Skip to content

Support remote and local assets in custom CSS #1372

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

Open
wants to merge 6 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 1 commit
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
28 changes: 20 additions & 8 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>();

// 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 Down Expand Up @@ -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);
await bundleStyles({path: join(root, specifier), files});
}
}
}
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
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
22 changes: 20 additions & 2 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,16 +37,33 @@ function rewriteInputsNamespace(code: string) {
export async function bundleStyles({
minify = false,
path,
theme
theme,
files,
aliases
}: {
minify?: boolean;
path?: string;
theme?: string[];
files?: Set<string>;
aliases?: Map<string, string>;
}): Promise<string> {
const assets = {
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;
if (files) files.add(args.path); // /!\ modifies files as a side effect
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
});
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>