Skip to content

Commit 3295682

Browse files
fix file path resolution (#120)
* Add build command test * Add a second build test * fix file path resolution * don’t treat imports as files * normalize top-level import path * findFiles; fix snapshot generation * fix tests * fold execute into build * fix import preloads, again * restore leading slash * fix early exit * remove obsolete comment * quiet build during test * fix crash without sidebar * include build changed in failure artifacts --------- Co-authored-by: Wiltse Carpenter <[email protected]>
1 parent a6cef5e commit 3295682

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

52 files changed

+539
-134
lines changed

.github/workflows/test.yml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,4 +24,6 @@ jobs:
2424
if: failure()
2525
with:
2626
name: test-output-changes
27-
path: test/output/*-changed.*
27+
path: |
28+
test/output/*-changed.*
29+
test/output/build/*-changed/

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,7 @@
22
dist/
33
docs/.observablehq/cache
44
node_modules/
5+
test/input/build/*/.observablehq/cache
56
test/output/*-changed.*
7+
test/output/build/*-changed/
68
yarn-error.log

bin/observable.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,14 @@ const command = process.argv.splice(2, 1)[0];
55
switch (command) {
66
case "-v":
77
case "--version": {
8-
import("../package.json").then(({version}: any) => console.log(version));
8+
await import("../package.json").then(({version}: any) => console.log(version));
99
break;
1010
}
1111
case "build":
12-
import("../src/build.js");
12+
await import("../src/build.js").then((build) => build.build());
1313
break;
1414
case "preview":
15-
import("../src/preview.js");
15+
await import("../src/preview.js");
1616
break;
1717
default:
1818
console.error(`Usage: observable <command>`);

package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,8 @@
3030
},
3131
"dependencies": {
3232
"@observablehq/runtime": "^5.9.4",
33-
"acorn": "^8.10.0",
34-
"acorn-walk": "^8.2.0",
33+
"acorn": "^8.11.2",
34+
"acorn-walk": "^8.3.0",
3535
"fast-array-diff": "^1.1.0",
3636
"fast-deep-equal": "^3.1.3",
3737
"gray-matter": "^4.0.3",
@@ -53,6 +53,7 @@
5353
"@types/ws": "^8.5.6",
5454
"@typescript-eslint/eslint-plugin": "^6.7.3",
5555
"@typescript-eslint/parser": "^6.7.3",
56+
"d3-array": "^3.2.4",
5657
"eslint": "^8.50.0",
5758
"eslint-config-prettier": "^9.0.0",
5859
"eslint-plugin-prettier": "^5.0.0",

public/client.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -191,7 +191,7 @@ export function define(cell) {
191191
v.define(outputs.length ? `cell ${id}` : null, inputs, body);
192192
variables.push(v);
193193
for (const o of outputs) variables.push(main.define(o, [`cell ${id}`], (exports) => exports[o]));
194-
for (const f of files) attachedFiles.set(f.name, {url: String(new URL(`/_file/${f.name}`, location)), mimeType: f.mimeType}); // prettier-ignore
194+
for (const f of files) attachedFiles.set(f.name, {url: `/_file${(new URL(f.name, location)).pathname}`, mimeType: f.mimeType}); // prettier-ignore
195195
for (const d of databases) databaseTokens.set(d.name, d);
196196
}
197197

@@ -300,8 +300,8 @@ export function open({hash} = {}) {
300300
}
301301
}
302302

303-
{
304-
const toggle = document.querySelector("#observablehq-sidebar-toggle");
303+
const toggle = document.querySelector("#observablehq-sidebar-toggle");
304+
if (toggle) {
305305
let indeterminate = toggle.indeterminate;
306306
toggle.onclick = () => {
307307
const matches = matchMedia("(min-width: calc(640px + 4rem + 0.5rem + 240px + 2rem))").matches;

src/build.ts

Lines changed: 35 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,26 @@
1+
import {existsSync} from "node:fs";
12
import {access, constants, copyFile, readFile, writeFile} from "node:fs/promises";
23
import {basename, dirname, join, normalize, relative} from "node:path";
34
import {cwd} from "node:process";
45
import {fileURLToPath} from "node:url";
56
import {parseArgs} from "node:util";
67
import {Loader} from "./dataloader.js";
7-
import {maybeStat, prepareOutput, visitFiles, visitMarkdownFiles} from "./files.js";
8+
import {prepareOutput, visitFiles, visitMarkdownFiles} from "./files.js";
89
import {readPages} from "./navigation.js";
910
import {renderServerless} from "./render.js";
1011
import {makeCLIResolver} from "./resolver.js";
1112

1213
const EXTRA_FILES = new Map([["node_modules/@observablehq/runtime/dist/runtime.js", "_observablehq/runtime.js"]]);
1314

14-
async function build(context: CommandContext) {
15-
const {sourceRoot, outputRoot} = context;
15+
export interface CommandContext {
16+
sourceRoot: string;
17+
outputRoot: string;
18+
verbose?: boolean;
19+
addPublic?: boolean;
20+
}
21+
22+
export async function build(context: CommandContext = makeCommandContext()) {
23+
const {sourceRoot, outputRoot, verbose = true, addPublic = true} = context;
1624

1725
// Make sure all files are readable before starting to write output files.
1826
for await (const sourceFile of visitMarkdownFiles(sourceRoot)) {
@@ -25,67 +33,66 @@ async function build(context: CommandContext) {
2533
const resolver = await makeCLIResolver();
2634
for await (const sourceFile of visitMarkdownFiles(sourceRoot)) {
2735
const sourcePath = join(sourceRoot, sourceFile);
28-
const outputPath = join(outputRoot, join(dirname(sourceFile), basename(sourceFile, ".md") + ".html"));
29-
console.log("render", sourcePath, "→", outputPath);
36+
const outputPath = join(outputRoot, dirname(sourceFile), basename(sourceFile, ".md") + ".html");
37+
if (verbose) console.log("render", sourcePath, "→", outputPath);
3038
const path = `/${join(dirname(sourceFile), basename(sourceFile, ".md"))}`;
3139
const render = renderServerless(await readFile(sourcePath, "utf-8"), {
3240
root: sourceRoot,
3341
path,
3442
pages,
3543
resolver
3644
});
37-
files.push(...render.files.map((f) => f.name));
45+
files.push(...render.files.map((f) => join(dirname(sourceFile), f.name)));
46+
files.push(...render.imports.map((f) => join(dirname(sourceFile), f.name)));
3847
await prepareOutput(outputPath);
3948
await writeFile(outputPath, render.html);
4049
}
4150

4251
// Copy over the public directory.
43-
const publicRoot = join(dirname(relative(cwd(), fileURLToPath(import.meta.url))), "..", "public");
44-
for await (const publicFile of visitFiles(publicRoot)) {
45-
const sourcePath = join(publicRoot, publicFile);
46-
const outputPath = join(outputRoot, "_observablehq", publicFile);
47-
console.log("copy", sourcePath, "→", outputPath);
48-
await prepareOutput(outputPath);
49-
await copyFile(sourcePath, outputPath);
52+
if (addPublic) {
53+
const publicRoot = join(dirname(relative(cwd(), fileURLToPath(import.meta.url))), "..", "public");
54+
for await (const publicFile of visitFiles(publicRoot)) {
55+
const sourcePath = join(publicRoot, publicFile);
56+
const outputPath = join(outputRoot, "_observablehq", publicFile);
57+
if (verbose) console.log("copy", sourcePath, "→", outputPath);
58+
await prepareOutput(outputPath);
59+
await copyFile(sourcePath, outputPath);
60+
}
5061
}
5162

5263
// Copy over the referenced files.
5364
for (const file of files) {
5465
let sourcePath = join(sourceRoot, file);
5566
const outputPath = join(outputRoot, "_file", file);
56-
const stats = await maybeStat(sourcePath);
57-
if (!stats) {
67+
if (!existsSync(sourcePath)) {
5868
const loader = Loader.find(sourceRoot, file);
5969
if (!loader) {
6070
console.error("missing referenced file", sourcePath);
6171
continue;
6272
}
63-
process.stdout.write(`generate ${loader.path} → `);
73+
if (verbose) process.stdout.write(`generate ${loader.path} → `);
6474
sourcePath = join(sourceRoot, await loader.load());
65-
console.log(sourcePath);
75+
if (verbose) console.log(sourcePath);
6676
}
67-
console.log("copy", sourcePath, "→", outputPath);
77+
if (verbose) console.log("copy", sourcePath, "→", outputPath);
6878
await prepareOutput(outputPath);
6979
await copyFile(sourcePath, outputPath);
7080
}
7181

7282
// Copy over required distribution files from node_modules.
7383
// TODO: Note that this requires that the build command be run relative to the node_modules directory.
74-
for (const [sourcePath, targetFile] of EXTRA_FILES) {
75-
const outputPath = join(outputRoot, targetFile);
76-
console.log("copy", sourcePath, "→", outputPath);
77-
await prepareOutput(outputPath);
78-
await copyFile(sourcePath, outputPath);
84+
if (addPublic) {
85+
for (const [sourcePath, targetFile] of EXTRA_FILES) {
86+
const outputPath = join(outputRoot, targetFile);
87+
if (verbose) console.log("copy", sourcePath, "→", outputPath);
88+
await prepareOutput(outputPath);
89+
await copyFile(sourcePath, outputPath);
90+
}
7991
}
8092
}
8193

8294
const USAGE = `Usage: observable build [--root dir] [--output dir]`;
8395

84-
interface CommandContext {
85-
sourceRoot: string;
86-
outputRoot: string;
87-
}
88-
8996
function makeCommandContext(): CommandContext {
9097
const {values} = parseArgs({
9198
options: {
@@ -110,9 +117,3 @@ function makeCommandContext(): CommandContext {
110117
outputRoot: normalize(values.output).replace(/\/$/, "")
111118
};
112119
}
113-
114-
await (async function () {
115-
const context = makeCommandContext();
116-
await build(context);
117-
process.exit(0);
118-
})();

src/dataloader.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ export class Loader {
8080
const loaderStat = await maybeStat(this.path);
8181
const cacheStat = await maybeStat(cachePath);
8282
if (cacheStat && cacheStat.mtimeMs > loaderStat!.mtimeMs) return outputPath;
83-
const tempPath = join(".observablehq", "cache", `${this.targetPath}.${process.pid}`);
83+
const tempPath = join(this.sourceRoot, ".observablehq", "cache", `${this.targetPath}.${process.pid}`);
8484
await prepareOutput(tempPath);
8585
const tempFd = await open(tempPath, "w");
8686
const tempFileStream = tempFd.createWriteStream({highWaterMark: 1024 * 1024});

src/javascript.ts

Lines changed: 22 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,15 @@ export interface ImportReference {
2323
type: "global" | "local";
2424
}
2525

26+
export interface Feature {
27+
type: "FileAttachment" | "DatabaseClient" | "Secret";
28+
name: string;
29+
}
30+
31+
export interface Identifier {
32+
name: string;
33+
}
34+
2635
export interface Transpile {
2736
id: string;
2837
inputs?: string[];
@@ -47,10 +56,12 @@ export function transpileJavaScript(input: string, options: ParseOptions): Trans
4756
const {id, root, sourcePath} = options;
4857
try {
4958
const node = parseJavaScript(input, options);
50-
const databases = node.features.filter((f) => f.type === "DatabaseClient").map((f) => ({name: f.name}));
59+
const databases = node.features
60+
.filter((f) => f.type === "DatabaseClient")
61+
.map((f): DatabaseReference => ({name: f.name}));
5162
const files = node.features
5263
.filter((f) => f.type === "FileAttachment")
53-
.map((f) => ({name: f.name, mimeType: mime.getType(f.name)}));
64+
.map((f): FileReference => ({name: f.name, mimeType: mime.getType(f.name)}));
5465
const inputs = Array.from(new Set<string>(node.references.map((r) => r.name)));
5566
const output = new Sourcemap(input);
5667
trim(output, input);
@@ -103,12 +114,12 @@ export const parseOptions: Options = {ecmaVersion: 13, sourceType: "module"};
103114

104115
export interface JavaScriptNode {
105116
body: Node;
106-
declarations: {name: string}[] | null;
107-
references: {name: string}[];
108-
features: {type: unknown; name: string}[];
109-
imports: {type: "global" | "local"; name: string}[];
110-
expression: boolean;
111-
async: boolean;
117+
declarations: Identifier[] | null; // null for expressions that can’t declare top-level variables, a.k.a outputs
118+
references: Identifier[]; // the unbound references, a.k.a. inputs
119+
features: Feature[];
120+
imports: ImportReference[];
121+
expression: boolean; // is this an expression or a program cell?
122+
async: boolean; // does this use top-level await?
112123
}
113124

114125
function parseJavaScript(input: string, options: ParseOptions): JavaScriptNode {
@@ -121,8 +132,8 @@ function parseJavaScript(input: string, options: ParseOptions): JavaScriptNode {
121132
const body = expression ?? (Parser.parse(input, parseOptions) as any);
122133
const references = findReferences(body, globals, input);
123134
const declarations = expression ? null : findDeclarations(body, globals, input);
124-
const {imports, features: importFeatures} = findImports(body, root, sourcePath);
125-
const features = [...importFeatures, ...findFeatures(body, root, sourcePath, references, input)];
135+
const imports = findImports(body, root, sourcePath);
136+
const features = findFeatures(body, root, sourcePath, references, input);
126137
return {
127138
body,
128139
declarations,
@@ -137,7 +148,7 @@ function parseJavaScript(input: string, options: ParseOptions): JavaScriptNode {
137148
// Parses a single expression; like parseExpressionAt, but returns null if
138149
// additional input follows the expression.
139150
function maybeParseExpression(input, options) {
140-
const parser = new Parser(options, input, 0);
151+
const parser = new (Parser as any)(options, input, 0); // private constructor
141152
parser.nextToken();
142153
try {
143154
const node = (parser as any).parseExpression();

src/javascript/features.js renamed to src/javascript/features.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
import {simple} from "acorn-walk";
2+
import {type Feature} from "../javascript.js";
3+
import {isLocalImport} from "./imports.js";
24
import {syntaxError} from "./syntaxError.js";
3-
import {isLocalImport} from "./imports.ts";
4-
import {dirname, join} from "node:path";
55

66
export function findFeatures(node, root, sourcePath, references, input) {
7-
const features = [];
7+
const features: Feature[] = [];
88

99
simple(node, {
1010
CallExpression(node) {
@@ -13,9 +13,10 @@ export function findFeatures(node, root, sourcePath, references, input) {
1313
arguments: args,
1414
arguments: [arg]
1515
} = node;
16+
1617
// Promote fetches with static literals to file attachment references.
1718
if (isLocalFetch(node, references, root, sourcePath)) {
18-
features.push({type: "FileAttachment", name: join(dirname(sourcePath), getStringLiteralValue(arg))});
19+
features.push({type: "FileAttachment", name: getStringLiteralValue(arg)});
1920
return;
2021
}
2122

@@ -34,6 +35,7 @@ export function findFeatures(node, root, sourcePath, references, input) {
3435
if (args.length !== 1 || !isStringLiteral(arg)) {
3536
throw syntaxError(`${callee.name} requires a single literal string argument`, node, input);
3637
}
38+
3739
features.push({type: callee.name, name: getStringLiteralValue(arg)});
3840
}
3941
});
File renamed without changes.
File renamed without changes.

src/javascript/imports.ts

Lines changed: 11 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,12 @@
1-
import {Parser} from "acorn";
2-
import type {Node} from "acorn";
1+
import {Parser, type Node} from "acorn";
32
import {simple} from "acorn-walk";
43
import {readFileSync} from "node:fs";
5-
import {dirname, join} from "node:path";
6-
import {type JavaScriptNode, parseOptions} from "../javascript.js";
4+
import {dirname, join, normalize} from "node:path";
5+
import {parseOptions, type ImportReference, type JavaScriptNode} from "../javascript.js";
76
import {getStringLiteralValue, isStringLiteral} from "./features.js";
87

98
export function findImports(body: Node, root: string, sourcePath: string) {
10-
const imports: {name: string; type: "global" | "local"}[] = [];
11-
const features: {name: string; type: string}[] = [];
9+
const imports: ImportReference[] = [];
1210
const paths = new Set<string>();
1311

1412
simple(body, {
@@ -22,23 +20,22 @@ export function findImports(body: Node, root: string, sourcePath: string) {
2220
if (isStringLiteral(node.source)) {
2321
const value = getStringLiteralValue(node.source);
2422
if (isLocalImport(value, root, sourcePath)) {
25-
findLocalImports(join(dirname(sourcePath), value));
23+
findLocalImports(normalize(value));
2624
} else {
2725
imports.push({name: value, type: "global"});
2826
}
2927
}
3028
}
3129

3230
// If this is an import of a local ES module, recursively parse the module to
33-
// find transitive imports.
34-
// path is the full URI path without /_file
31+
// find transitive imports. The path is always relative to the source path of
32+
// the Markdown file, even across transitive imports.
3533
function findLocalImports(path) {
3634
if (paths.has(path)) return;
3735
paths.add(path);
3836
imports.push({type: "local", name: path});
39-
features.push({type: "FileAttachment", name: path});
4037
try {
41-
const input = readFileSync(join(root, path), "utf-8");
38+
const input = readFileSync(join(root, dirname(sourcePath), path), "utf-8");
4239
const program = Parser.parse(input, parseOptions);
4340
simple(program, {
4441
ImportDeclaration: findLocalImport,
@@ -52,9 +49,8 @@ export function findImports(body: Node, root: string, sourcePath: string) {
5249
function findLocalImport(node) {
5350
if (isStringLiteral(node.source)) {
5451
const value = getStringLiteralValue(node.source);
55-
if (isLocalImport(value, root, path)) {
56-
const subpath = join(dirname(path), value);
57-
findLocalImports(subpath);
52+
if (isLocalImport(value, root, sourcePath)) {
53+
findLocalImports(join(dirname(path), value));
5854
} else {
5955
imports.push({name: value, type: "global"});
6056
// non-local imports don't need to be promoted to file attachments
@@ -63,7 +59,7 @@ export function findImports(body: Node, root: string, sourcePath: string) {
6359
}
6460
}
6561

66-
return {imports, features};
62+
return imports;
6763
}
6864

6965
// TODO parallelize multiple static imports

0 commit comments

Comments
 (0)