Skip to content

Commit b03a1e0

Browse files
committed
Support remote and local assets in custom CSS
(This includes images and fonts using url().) Using onResolve (as suggested in #786 (comment)) closes #786 supersedes #788
1 parent 3135033 commit b03a1e0

File tree

11 files changed

+107
-10
lines changed

11 files changed

+107
-10
lines changed

src/build.ts

+20-8
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ export async function build(
9292

9393
// For cache-breaking we rename most assets to include content hashes.
9494
const aliases = new Map<string, string>();
95+
const plainaliases = new Map<string, string>();
9596

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

107108
// Generate the client bundles (JavaScript and styles). TODO Use a content
108109
// hash, or perhaps the Framework version number for built-in modules.
110+
const delayedStylesheets = new Set<string>();
109111
if (addPublic) {
110112
for (const path of globalImports) {
111113
if (path.startsWith("/_observablehq/") && path.endsWith(".js")) {
@@ -136,14 +138,9 @@ export async function build(
136138
const sourcePath = await populateNpmCache(root, path); // TODO effects
137139
await effects.copyFile(sourcePath, path);
138140
} else if (!/^\w+:/.test(specifier)) {
139-
const sourcePath = join(root, specifier);
140-
effects.output.write(`${faint("build")} ${sourcePath} ${faint("→")} `);
141-
const contents = await bundleStyles({path: sourcePath, minify: true});
142-
const hash = createHash("sha256").update(contents).digest("hex").slice(0, 8);
143-
const ext = extname(specifier);
144-
const alias = `/${join("_import", dirname(specifier), `${basename(specifier, ext)}.${hash}${ext}`)}`;
145-
aliases.set(resolveStylesheetPath(root, specifier), alias);
146-
await effects.writeFile(alias, contents);
141+
// Uses a side effect to register file assets on custom stylesheets
142+
delayedStylesheets.add(specifier);
143+
await bundleStyles({path: join(root, specifier), files});
147144
}
148145
}
149146
}
@@ -170,9 +167,24 @@ export async function build(
170167
const ext = extname(file);
171168
const alias = `/${join("_file", dirname(file), `${basename(file, ext)}.${hash}${ext}`)}`;
172169
aliases.set(loaders.resolveFilePath(file), alias);
170+
plainaliases.set(file, alias);
173171
await effects.writeFile(alias, contents);
174172
}
175173

174+
// Write delayed stylesheets
175+
if (addPublic) {
176+
for (const specifier of delayedStylesheets) {
177+
const sourcePath = join(root, specifier);
178+
effects.output.write(`${faint("build")} ${sourcePath} ${faint("→")} `);
179+
const contents = await bundleStyles({path: sourcePath, minify: true, aliases: plainaliases});
180+
const hash = createHash("sha256").update(contents).digest("hex").slice(0, 8);
181+
const ext = extname(specifier);
182+
const alias = `/${join("_import", dirname(specifier), `${basename(specifier, ext)}.${hash}${ext}`)}`;
183+
aliases.set(resolveStylesheetPath(root, specifier), alias);
184+
await effects.writeFile(alias, contents);
185+
}
186+
}
187+
176188
// Download npm imports. TODO It might be nice to use content hashes for
177189
// these, too, but it would involve rewriting the files since populateNpmCache
178190
// doesn’t let you pass in a resolver.

src/rollup.ts

+20-2
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
import {extname} from "node:path/posix";
1+
import {extname, join} from "node:path/posix";
22
import {nodeResolve} from "@rollup/plugin-node-resolve";
33
import type {CallExpression} from "acorn";
44
import {simple} from "acorn-walk";
5+
import type {PluginBuild} from "esbuild";
56
import {build} from "esbuild";
67
import type {AstNode, OutputChunk, Plugin, ResolveIdResult} from "rollup";
78
import {rollup} from "rollup";
@@ -36,16 +37,33 @@ function rewriteInputsNamespace(code: string) {
3637
export async function bundleStyles({
3738
minify = false,
3839
path,
39-
theme
40+
theme,
41+
files,
42+
aliases
4043
}: {
4144
minify?: boolean;
4245
path?: string;
4346
theme?: string[];
47+
files?: Set<string>;
48+
aliases?: Map<string, string>;
4449
}): Promise<string> {
50+
const assets = {
51+
name: "resolve CSS assets",
52+
setup(build: PluginBuild) {
53+
build.onResolve({filter: /^\w+:\/\//}, (args) => ({path: args.path, external: true}));
54+
build.onResolve({filter: /./}, (args) => {
55+
if (args.path.endsWith(".css") || args.path.match(/^[#.]/)) return;
56+
if (files) files.add(args.path); // /!\ modifies files as a side effect
57+
const path = join("..", aliases?.get(args.path) ?? join("_file", args.path));
58+
return {path, external: true};
59+
});
60+
}
61+
};
4562
const result = await build({
4663
bundle: true,
4764
...(path ? {entryPoints: [path]} : {stdin: {contents: renderTheme(theme!), loader: "css"}}),
4865
write: false,
66+
plugins: [assets],
4967
minify,
5068
alias: STYLE_MODULES
5169
});

test/input/build/css-public/horse.jpg

38.7 KB
Loading

test/input/build/css-public/index.md

+12
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
style: style.css
3+
---
4+
5+
# CSS assets
6+
7+
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)
8+
9+
<figure>
10+
<div class="bg" style="height: 518px;"></div>
11+
<figcaption>This image is set with CSS.</figcaption>
12+
</figure>

test/input/build/css-public/style.css

+20
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
@import url("observablehq:default.css");
2+
@import url("observablehq:theme-air.css");
3+
4+
:root {
5+
--serif: "Atkinson Hyperlegible";
6+
}
7+
8+
div.bg {
9+
background-image: url("horse.jpg");
10+
}
11+
12+
div.dont-break-hashes {
13+
offset-path: url(#path);
14+
}
15+
16+
@font-face {
17+
font-family: "Atkinson Hyperlegible";
18+
src: url(https://fonts.gstatic.com/s/atkinsonhyperlegible/v11/9Bt23C1KxNDXMspQ1lPyU89-1h6ONRlW45G04pIoWQeCbA.woff2)
19+
format("woff2");
20+
}
Loading

test/output/build/css-public/_import/style.a31bcaf4.css

+1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

test/output/build/css-public/_observablehq/client.js

Whitespace-only changes.

test/output/build/css-public/_observablehq/runtime.js

Whitespace-only changes.

test/output/build/css-public/_observablehq/stdlib.js

Whitespace-only changes.
+34
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
<!DOCTYPE html>
2+
<meta charset="utf-8">
3+
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
4+
<title>CSS assets</title>
5+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
6+
<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>
7+
<link rel="preload" as="style" href="./_import/style.a31bcaf4.css">
8+
<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>
9+
<link rel="stylesheet" type="text/css" href="./_import/style.a31bcaf4.css">
10+
<link rel="modulepreload" href="./_observablehq/client.js">
11+
<link rel="modulepreload" href="./_observablehq/runtime.js">
12+
<link rel="modulepreload" href="./_observablehq/stdlib.js">
13+
<script type="module">
14+
15+
import "./_observablehq/client.js";
16+
17+
</script>
18+
<aside id="observablehq-toc" data-selector="h1:not(:first-of-type)[id], h2:first-child[id], :not(h1) + h2[id]">
19+
<nav>
20+
</nav>
21+
</aside>
22+
<div id="observablehq-center">
23+
<main id="observablehq-main" class="observablehq">
24+
<h1 id="css-assets" tabindex="-1"><a class="observablehq-header-anchor" href="#css-assets">CSS assets</a></h1>
25+
<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>
26+
<figure>
27+
<div class="bg" style="height: 518px;"></div>
28+
<figcaption>This image is set with CSS.</figcaption>
29+
</figure>
30+
</main>
31+
<footer id="observablehq-footer">
32+
<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>
33+
</footer>
34+
</div>

0 commit comments

Comments
 (0)