Skip to content

Commit e08f0b9

Browse files
authored
embedded assets (#1681)
* embedded assets * test asset embeds * avoid recomputing config.paths * deterministic file order
1 parent 6a29b31 commit e08f0b9

File tree

7 files changed

+63
-17
lines changed

7 files changed

+63
-17
lines changed

docs/markdown.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ The front matter supports the following options:
1818
- **title** - the page title; defaults to the (first) first-level heading of the page, if any
1919
- **index** - whether to index this page if [search](./search) is enabled; defaults to true for listed pages
2020
- **keywords** <a href="https://github.com/observablehq/framework/releases/tag/v1.1.0" class="observablehq-version-badge" data-version="^1.1.0" title="Added in v1.1.0"></a> - additional words to index for [search](./search); boosted at the same weight as the title
21-
- **draft** <a href="https://github.com/observablehq/framework/releases/tag/v1.1.0" class="observablehq-version-badge" data-version="^1.1.0" title="Added in v1.1.0"></a> - whether to skip this page during build; drafts are also not listed in the default sidebar
21+
- **draft** <a href="https://github.com/observablehq/framework/releases/tag/v1.1.0" class="observablehq-version-badge" data-version="^1.1.0" title="Added in v1.1.0"></a> - whether to skip this page during build; drafts are also not listed in the default sidebar nor searchable
2222
- **sql** <a href="https://github.com/observablehq/framework/releases/tag/v1.2.0" class="observablehq-version-badge" data-version="^1.2.0" title="Added in v1.2.0"></a> - table definitions for [SQL code blocks](./sql)
2323

2424
The front matter can also override the following [app-level configuration](./config) options:

src/build.ts

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,9 @@ export async function build(
7171
const addStylesheet = (path: string, s: string) => stylesheets.add(/^\w+:/.test(s) ? s : resolvePath(path, s));
7272

7373
// Load pages, building a list of additional assets as we go.
74+
let assetCount = 0;
75+
let pageCount = 0;
76+
const pagePaths = new Set<string>();
7477
for await (const path of config.paths()) {
7578
effects.output.write(`${faint("load")} ${path} `);
7679
const start = performance.now();
@@ -86,9 +89,18 @@ export async function build(
8689
for (const s of resolvers.stylesheets) addStylesheet(path, s);
8790
effects.output.write(`${faint("in")} ${(elapsed >= 100 ? yellow : faint)(`${elapsed}ms`)}\n`);
8891
outputs.set(path, {type: "module", resolvers});
92+
++assetCount;
8993
continue;
9094
}
9195
}
96+
const file = loaders.find(path);
97+
if (file) {
98+
effects.output.write(`${faint("copy")} ${join(root, path)} ${faint("→")} `);
99+
const sourcePath = join(root, await file.load({useStale: true}, effects));
100+
await effects.copyFile(sourcePath, path);
101+
++assetCount;
102+
continue;
103+
}
92104
const page = await loaders.loadPage(path, options, effects);
93105
if (page.data.draft) {
94106
effects.logger.log(faint("(skipped)"));
@@ -102,13 +114,16 @@ export async function build(
102114
for (const i of resolvers.globalImports) addGlobalImport(path, resolvers.resolveImport(i));
103115
for (const s of resolvers.stylesheets) addStylesheet(path, s);
104116
effects.output.write(`${faint("in")} ${(elapsed >= 100 ? yellow : faint)(`${elapsed}ms`)}\n`);
117+
pagePaths.add(path);
105118
outputs.set(path, {type: "page", page, resolvers});
119+
++pageCount;
106120
}
107121

108122
// Check that there’s at least one output.
109-
const outputCount = outputs.size;
123+
const outputCount = pageCount + assetCount;
110124
if (!outputCount) throw new CliError(`Nothing to build: no pages found in your ${root} directory.`);
111-
effects.logger.log(`${faint("built")} ${outputCount} ${faint(`page${outputCount === 1 ? "" : "s"} in`)} ${root}`);
125+
if (pageCount) effects.logger.log(`${faint("built")} ${pageCount} ${faint(`page${pageCount === 1 ? "" : "s"} in`)} ${root}`); // prettier-ignore
126+
if (assetCount) effects.logger.log(`${faint("built")} ${assetCount} ${faint(`asset${assetCount === 1 ? "" : "s"} in`)} ${root}`); // prettier-ignore
112127

113128
// For cache-breaking we rename most assets to include content hashes.
114129
const aliases = new Map<string, string>();
@@ -117,7 +132,7 @@ export async function build(
117132
// Add the search bundle and data, if needed.
118133
if (config.search) {
119134
globalImports.add("/_observablehq/search.js").add("/_observablehq/minisearch.json");
120-
const contents = await searchIndex(config, effects);
135+
const contents = await searchIndex(config, pagePaths, effects);
121136
effects.output.write(`${faint("index →")} `);
122137
const cachePath = join(cacheRoot, "_observablehq", "minisearch.json");
123138
await prepareOutput(cachePath);
@@ -349,7 +364,7 @@ export async function build(
349364
}
350365
effects.logger.log("");
351366

352-
Telemetry.record({event: "build", step: "finish", pageCount: outputCount});
367+
Telemetry.record({event: "build", step: "finish", pageCount});
353368
}
354369

355370
function applyHash(path: string, hash: string): string {

src/search.ts

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -29,15 +29,19 @@ const indexOptions = {
2929

3030
type MiniSearchResult = Omit<SearchResult, "path" | "keywords"> & {id: string; keywords: string};
3131

32-
export async function searchIndex(config: Config, effects = defaultEffects): Promise<string> {
32+
export async function searchIndex(
33+
config: Config,
34+
paths: Iterable<string> | AsyncIterable<string> = getDefaultSearchPaths(config),
35+
effects = defaultEffects
36+
): Promise<string> {
3337
const {pages, search, normalizePath} = config;
3438
if (!search) return "{}";
3539
const cached = indexCache.get(pages);
3640
if (cached && cached.freshUntil > Date.now()) return cached.json;
3741

3842
// Index the pages
3943
const index = new MiniSearch<MiniSearchResult>(indexOptions);
40-
for await (const result of indexPages(config, effects)) index.add(normalizeResult(result, normalizePath));
44+
for await (const result of indexPages(config, paths, effects)) index.add(normalizeResult(result, normalizePath));
4145
if (search.index) for await (const result of search.index()) index.add(normalizeResult(result, normalizePath));
4246

4347
// Pass the serializable index options to the client.
@@ -57,8 +61,12 @@ export async function searchIndex(config: Config, effects = defaultEffects): Pro
5761
return json;
5862
}
5963

60-
async function* indexPages(config: Config, effects: SearchIndexEffects): AsyncIterable<SearchResult> {
61-
const {root, pages, loaders} = config;
64+
async function* indexPages(
65+
config: Config,
66+
paths: Iterable<string> | AsyncIterable<string>,
67+
effects: SearchIndexEffects
68+
): AsyncIterable<SearchResult> {
69+
const {pages, loaders} = config;
6270

6371
// Get all the listed pages (which are indexed by default)
6472
const pagePaths = new Set(["/index"]);
@@ -67,8 +75,7 @@ async function* indexPages(config: Config, effects: SearchIndexEffects): AsyncIt
6775
if ("pages" in p) for (const {path} of p.pages) pagePaths.add(path);
6876
}
6977

70-
for await (const path of config.paths()) {
71-
if (path.endsWith(".js") && findModule(root, path)) continue;
78+
for await (const path of paths) {
7279
const {body, title, data} = await loaders.loadPage(path, {...config, path});
7380

7481
// Skip pages that opt-out of indexing, and skip unlisted pages unless
@@ -97,6 +104,15 @@ async function* indexPages(config: Config, effects: SearchIndexEffects): AsyncIt
97104
}
98105
}
99106

107+
async function* getDefaultSearchPaths(config: Config): AsyncGenerator<string> {
108+
const {root, loaders} = config;
109+
for await (const path of config.paths()) {
110+
if (path.endsWith(".js") && findModule(root, path)) continue; // ignore modules
111+
if (loaders.find(path)) continue; // ignore assets
112+
yield path;
113+
}
114+
}
115+
100116
function normalizeResult(
101117
{path, keywords, ...rest}: SearchResult,
102118
normalizePath: Config["normalizePath"]

test/files-test.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -55,26 +55,26 @@ describe("visitFiles(root)", () => {
5555
"files.md",
5656
"observable logo small.png",
5757
"observable logo.png",
58-
"unknown-mime-extension.really",
5958
"subsection/additional-styles.css",
6059
"subsection/file-sub.csv",
61-
"subsection/subfiles.md"
60+
"subsection/subfiles.md",
61+
"unknown-mime-extension.really"
6262
]);
6363
});
6464
it("handles circular symlinks, visiting files only once", function () {
6565
if (os.platform() === "win32") this.skip(); // symlinks are not the same on Windows
6666
assert.deepStrictEqual(collect(visitFiles("test/input/circular-files")), ["a/a.txt", "b/b.txt"]);
6767
});
6868
it("ignores .observablehq at any level", function () {
69-
assert.deepStrictEqual(collect(visitFiles("test/files")), ["visible.txt", "sub/visible.txt"]);
69+
assert.deepStrictEqual(collect(visitFiles("test/files")), ["sub/visible.txt", "visible.txt"]);
7070
});
7171
});
7272

7373
describe("visitFiles(root, test)", () => {
7474
it("skips directories and files that don’t pass the specified test", () => {
7575
assert.deepStrictEqual(
7676
collect(visitFiles("test/input/build/params", (name) => isParameterized(name) || extname(name) !== "")),
77-
["observablehq.config.js", "[dir]/index.md", "[dir]/loaded.md.js"]
77+
["[dir]/index.md", "[dir]/loaded.md.js", "[name]-icon.svg.js", "observablehq.config.js"]
7878
);
7979
assert.deepStrictEqual(collect(visitFiles("test/input/build/params", (name) => !isParameterized(name))), [
8080
"observablehq.config.js"
@@ -88,5 +88,5 @@ function collect(generator: Generator<string>): string[] {
8888
if (value.startsWith(".observablehq/cache/")) continue;
8989
values.push(value);
9090
}
91-
return values;
91+
return values.sort();
9292
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import {parseArgs} from "node:util";
2+
3+
const {
4+
values: {name}
5+
} = parseArgs({
6+
options: {name: {type: "string"}}
7+
});
8+
9+
process.stdout.write(`<svg width="200" height="40" fill="#000000" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
10+
<text x="50%" y="50%" dy="0.35em" text-anchor="middle">${name}</text>
11+
</svg>
12+
`);
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
export default {
22
async *dynamicPaths() {
3-
yield* ["/bar/index", "/bar/loaded", "/foo/bar", "/foo/index"];
3+
yield* ["/bar/index", "/bar/loaded", "/foo/bar", "/foo/index", "/observable-icon.svg"];
44
}
55
};
Lines changed: 3 additions & 0 deletions
Loading

0 commit comments

Comments
 (0)