Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support remote and local assets in custom CSS #1372

Open
wants to merge 6 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 22 additions & 10 deletions src/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ export async function build(

// For cache-breaking we rename most assets to include content hashes.
const aliases = new Map<string, string>();
const plainaliases = new Map<string, string>();
Fil marked this conversation as resolved.
Show resolved Hide resolved
Fil marked this conversation as resolved.
Show resolved Hide resolved

// Add the search bundle and data, if needed.
if (config.search) {
Expand All @@ -106,6 +107,7 @@ export async function build(

// Generate the client bundles (JavaScript and styles). TODO Use a content
// hash, or perhaps the Framework version number for built-in modules.
const delayedStylesheets = new Set<string>();
if (addPublic) {
for (const path of globalImports) {
if (path.startsWith("/_observablehq/") && path.endsWith(".js")) {
Expand All @@ -123,11 +125,11 @@ export async function build(
effects.output.write(`${faint("build")} ${specifier} ${faint("→")} `);
if (specifier.startsWith("observablehq:theme-")) {
const match = /^observablehq:theme-(?<theme>[\w-]+(,[\w-]+)*)?\.css$/.exec(specifier);
const contents = await bundleStyles({theme: match!.groups!.theme?.split(",") ?? [], minify: true});
const {contents} = await bundleStyles({theme: match!.groups!.theme?.split(",") ?? [], minify: true});
await effects.writeFile(path, contents);
} else {
const clientPath = getClientPath(path.slice("/_observablehq/".length));
const contents = await bundleStyles({path: clientPath, minify: true});
const {contents} = await bundleStyles({path: clientPath, minify: true});
await effects.writeFile(`/_observablehq/${specifier.slice("observablehq:".length)}`, contents);
}
} else if (specifier.startsWith("npm:")) {
Expand All @@ -136,14 +138,9 @@ export async function build(
const sourcePath = await populateNpmCache(root, path); // TODO effects
await effects.copyFile(sourcePath, path);
} else if (!/^\w+:/.test(specifier)) {
const sourcePath = join(root, specifier);
effects.output.write(`${faint("build")} ${sourcePath} ${faint("→")} `);
const contents = await bundleStyles({path: sourcePath, minify: true});
const hash = createHash("sha256").update(contents).digest("hex").slice(0, 8);
const ext = extname(specifier);
const alias = `/${join("_import", dirname(specifier), `${basename(specifier, ext)}.${hash}${ext}`)}`;
aliases.set(resolveStylesheetPath(root, specifier), alias);
await effects.writeFile(alias, contents);
// Uses a side effect to register file assets on custom stylesheets
delayedStylesheets.add(specifier);
Fil marked this conversation as resolved.
Show resolved Hide resolved
for (const file of (await bundleStyles({path: join(root, specifier)})).files) files.add(file);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It might be cleaner to have two separate methods (that use the same underlying implementation) since we always call bundleStyles in one of two ways: bundleStyles could return the contents as before, and resolveStyleFiles could return the set of files. But low priority…

}
}
}
Expand All @@ -170,9 +167,24 @@ export async function build(
const ext = extname(file);
const alias = `/${join("_file", dirname(file), `${basename(file, ext)}.${hash}${ext}`)}`;
aliases.set(loaders.resolveFilePath(file), alias);
plainaliases.set(file, alias);
await effects.writeFile(alias, contents);
}

// Write delayed stylesheets
Fil marked this conversation as resolved.
Show resolved Hide resolved
if (addPublic) {
for (const specifier of delayedStylesheets) {
const sourcePath = join(root, specifier);
effects.output.write(`${faint("build")} ${sourcePath} ${faint("→")} `);
const {contents} = await bundleStyles({path: sourcePath, minify: true, aliases: plainaliases});
const hash = createHash("sha256").update(contents).digest("hex").slice(0, 8);
const ext = extname(specifier);
const alias = `/${join("_import", dirname(specifier), `${basename(specifier, ext)}.${hash}${ext}`)}`;
aliases.set(resolveStylesheetPath(root, specifier), alias);
await effects.writeFile(alias, contents);
}
}

// Download npm imports. TODO It might be nice to use content hashes for
// these, too, but it would involve rewriting the files since populateNpmCache
// doesn’t let you pass in a resolver.
Expand Down
6 changes: 3 additions & 3 deletions src/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,13 +120,13 @@ export class PreviewServer {
} else if (pathname === "/_observablehq/minisearch.json") {
end(req, res, await searchIndex(config), "application/json");
} else if ((match = /^\/_observablehq\/theme-(?<theme>[\w-]+(,[\w-]+)*)?\.css$/.exec(pathname))) {
end(req, res, await bundleStyles({theme: match.groups!.theme?.split(",") ?? []}), "text/css");
end(req, res, (await bundleStyles({theme: match.groups!.theme?.split(",") ?? []})).contents, "text/css");
} else if (pathname.startsWith("/_observablehq/") && pathname.endsWith(".js")) {
const path = getClientPath(pathname.slice("/_observablehq/".length));
end(req, res, await rollupClient(path, root, pathname), "text/javascript");
} else if (pathname.startsWith("/_observablehq/") && pathname.endsWith(".css")) {
const path = getClientPath(pathname.slice("/_observablehq/".length));
end(req, res, await bundleStyles({path}), "text/css");
end(req, res, (await bundleStyles({path})).contents, "text/css");
} else if (pathname.startsWith("/_node/")) {
send(req, pathname, {root: join(root, ".observablehq", "cache")}).pipe(res);
} else if (pathname.startsWith("/_npm/")) {
Expand All @@ -138,7 +138,7 @@ export class PreviewServer {
try {
if (pathname.endsWith(".css")) {
await access(filepath, constants.R_OK);
end(req, res, await bundleStyles({path: filepath}), "text/css");
end(req, res, (await bundleStyles({path: filepath})).contents, "text/css");
return;
} else if (pathname.endsWith(".js")) {
const input = await readFile(join(root, path), "utf-8");
Expand Down
26 changes: 22 additions & 4 deletions src/rollup.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import {extname} from "node:path/posix";
import {extname, join} from "node:path/posix";
import {nodeResolve} from "@rollup/plugin-node-resolve";
import type {CallExpression} from "acorn";
import {simple} from "acorn-walk";
import type {PluginBuild} from "esbuild";
import {build} from "esbuild";
import type {AstNode, OutputChunk, Plugin, ResolveIdResult} from "rollup";
import {rollup} from "rollup";
Expand Down Expand Up @@ -36,21 +37,38 @@ function rewriteInputsNamespace(code: string) {
export async function bundleStyles({
minify = false,
path,
theme
theme,
aliases
}: {
minify?: boolean;
path?: string;
theme?: string[];
}): Promise<string> {
aliases?: Map<string, string>;
}): Promise<{contents: string; files: Set<string>}> {
const files = new Set<string>();
const assets = {
Fil marked this conversation as resolved.
Show resolved Hide resolved
name: "resolve CSS assets",
setup(build: PluginBuild) {
build.onResolve({filter: /^\w+:\/\//}, (args) => ({path: args.path, external: true}));
build.onResolve({filter: /./}, (args) => {
if (args.path.endsWith(".css") || args.path.match(/^[#.]/)) return;
files.add(args.path);
const path = join("..", aliases?.get(args.path) ?? join("_file", args.path));
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another big advantage of the resolve hook is that bundleStyles won’t have to know about the _file directory — that logic can be supplied by the build command.

And for the preview command, ideally we’d supply a resolve hook that does ?sha=… so that module replacement works when you update a file referenced by a local stylesheet?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now we pass the marked URL, but I wasn't able to positively test hot module replacement.

return {path, external: true};
});
}
};
const result = await build({
bundle: true,
...(path ? {entryPoints: [path]} : {stdin: {contents: renderTheme(theme!), loader: "css"}}),
write: false,
plugins: [assets],
minify,
alias: STYLE_MODULES
});
const text = result.outputFiles[0].text;
return rewriteInputsNamespace(text); // TODO only for inputs
const contents = rewriteInputsNamespace(text); // TODO only for inputs
return {contents, files};
}

export async function rollupClient(
Expand Down
Binary file added test/input/build/css-public/horse.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
12 changes: 12 additions & 0 deletions test/input/build/css-public/index.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
style: style.css
---

# CSS assets

Atkinson Hyperlegible font is named after Braille Institute founder, J. Robert Atkinson. What makes it different from traditional typography design is that it focuses on letterform distinction to increase character recognition, ultimately improving readability. [We are making it free for anyone to use!](https://brailleinstitute.org/freefont)

<figure>
<div class="bg" style="height: 518px;"></div>
<figcaption>This image is set with CSS.</figcaption>
</figure>
20 changes: 20 additions & 0 deletions test/input/build/css-public/style.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
@import url("observablehq:default.css");
@import url("observablehq:theme-air.css");

:root {
--serif: "Atkinson Hyperlegible";
}

div.bg {
background-image: url("horse.jpg");
}

div.dont-break-hashes {
offset-path: url(#path);
}

@font-face {
font-family: "Atkinson Hyperlegible";
src: url(https://fonts.gstatic.com/s/atkinsonhyperlegible/v11/9Bt23C1KxNDXMspQ1lPyU89-1h6ONRlW45G04pIoWQeCbA.woff2)
format("woff2");
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions test/output/build/css-public/_import/style.a31bcaf4.css

Large diffs are not rendered by default.

Empty file.
Empty file.
Empty file.
34 changes: 34 additions & 0 deletions test/output/build/css-public/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<!DOCTYPE html>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
<title>CSS assets</title>
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="preload" as="style" href="https://fonts.googleapis.com/css2?family=Source+Serif+Pro:ital,wght@0,400;0,600;0,700;1,400;1,600;1,700&amp;display=swap" crossorigin>
<link rel="preload" as="style" href="./_import/style.a31bcaf4.css">
<link rel="stylesheet" type="text/css" href="https://fonts.googleapis.com/css2?family=Source+Serif+Pro:ital,wght@0,400;0,600;0,700;1,400;1,600;1,700&amp;display=swap" crossorigin>
<link rel="stylesheet" type="text/css" href="./_import/style.a31bcaf4.css">
<link rel="modulepreload" href="./_observablehq/client.js">
<link rel="modulepreload" href="./_observablehq/runtime.js">
<link rel="modulepreload" href="./_observablehq/stdlib.js">
<script type="module">

import "./_observablehq/client.js";

</script>
<aside id="observablehq-toc" data-selector="h1:not(:first-of-type)[id], h2:first-child[id], :not(h1) + h2[id]">
<nav>
</nav>
</aside>
<div id="observablehq-center">
<main id="observablehq-main" class="observablehq">
<h1 id="css-assets" tabindex="-1"><a class="observablehq-header-anchor" href="#css-assets">CSS assets</a></h1>
<p>Atkinson Hyperlegible font is named after Braille Institute founder, J. Robert Atkinson. What makes it different from traditional typography design is that it focuses on letterform distinction to increase character recognition, ultimately improving readability. <a href="https://brailleinstitute.org/freefont" target="_blank" rel="noopener noreferrer">We are making it free for anyone to use!</a></p>
<figure>
<div class="bg" style="height: 518px;"></div>
<figcaption>This image is set with CSS.</figcaption>
</figure>
</main>
<footer id="observablehq-footer">
<div>Built with <a href="https://observablehq.com/" target="_blank" rel="noopener noreferrer">Observable</a> on <a title="2024-01-10T16:00:00">Jan 10, 2024</a>.</div>
</footer>
</div>