Skip to content

Commit 429b1f3

Browse files
authored
normalize file path; deterministic resolution (#1076)
* normalize file path; deterministic file resolution * getSourceFileHash * fix archives.win32 snapshot?
1 parent 4721feb commit 429b1f3

File tree

18 files changed

+108
-76
lines changed

18 files changed

+108
-76
lines changed

src/dataloader.ts

Lines changed: 27 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -130,38 +130,47 @@ export class LoaderResolver {
130130
}
131131

132132
/**
133-
* Get the actual path of a file. For data loaders, it is the output if
134-
* already available (cached). In build this is always the case (unless the
135-
* corresponding data loader fails). However in preview we return the page
136-
* before running the data loaders (which will run on demand from the page),
137-
* so there might be a temporary discrepancy when a cache is stale.
133+
* Returns the path to the backing file during preview, which is the source
134+
* file for the associated data loader if the file is generated by a loader.
138135
*/
139-
private getFilePath(name: string): string {
136+
private getSourceFilePath(name: string): string {
140137
let path = name;
141138
if (!existsSync(join(this.root, path))) {
142139
const loader = this.find(path);
143-
if (loader) {
144-
path = relative(this.root, loader.path);
145-
if (name !== path) {
146-
const cachePath = join(".observablehq", "cache", name);
147-
if (existsSync(join(this.root, cachePath))) path = cachePath;
148-
}
149-
}
140+
if (loader) path = relative(this.root, loader.path);
141+
}
142+
return path;
143+
}
144+
145+
/**
146+
* Returns the path to the backing file during build, which is the cached
147+
* output file if the file is generated by a loader.
148+
*/
149+
private getOutputFilePath(name: string): string {
150+
let path = name;
151+
if (!existsSync(join(this.root, path))) {
152+
const loader = this.find(path);
153+
if (loader) path = join(".observablehq", "cache", name);
150154
}
151155
return path;
152156
}
153157

154-
getFileHash(name: string): string {
155-
return getFileHash(this.root, this.getFilePath(name));
158+
getSourceFileHash(name: string): string {
159+
return getFileHash(this.root, this.getSourceFilePath(name));
160+
}
161+
162+
getSourceLastModified(name: string): number | undefined {
163+
const entry = getFileInfo(this.root, this.getSourceFilePath(name));
164+
return entry && Math.floor(entry.mtimeMs);
156165
}
157166

158-
getLastModified(name: string): number | undefined {
159-
const entry = getFileInfo(this.root, this.getFilePath(name));
167+
getOutputLastModified(name: string): number | undefined {
168+
const entry = getFileInfo(this.root, this.getOutputFilePath(name));
160169
return entry && Math.floor(entry.mtimeMs);
161170
}
162171

163172
resolveFilePath(path: string): string {
164-
return `/${join("_file", path)}?sha=${this.getFileHash(path)}`;
173+
return `/${join("_file", path)}?sha=${this.getSourceFileHash(path)}`;
165174
}
166175
}
167176

src/javascript/transpile.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {simple} from "acorn-walk";
55
import {isPathImport, relativePath, resolvePath} from "../path.js";
66
import {getModuleResolver} from "../resolvers.js";
77
import {Sourcemap} from "../sourcemap.js";
8+
import type {FileExpression} from "./files.js";
89
import {findFiles} from "./files.js";
910
import type {ExportNode, ImportNode} from "./imports.js";
1011
import {hasImportDeclaration, isImportMetaResolve} from "./imports.js";
@@ -15,10 +16,11 @@ import {getStringLiteralValue, isStringLiteral} from "./source.js";
1516

1617
export interface TranspileOptions {
1718
id: string;
19+
path: string;
1820
resolveImport?: (specifier: string) => string;
1921
}
2022

21-
export function transpileJavaScript(node: JavaScriptNode, {id, resolveImport}: TranspileOptions): string {
23+
export function transpileJavaScript(node: JavaScriptNode, {id, path, resolveImport}: TranspileOptions): string {
2224
let async = node.async;
2325
const inputs = Array.from(new Set<string>(node.references.map((r) => r.name)));
2426
const outputs = Array.from(new Set<string>(node.declarations?.map((r) => r.name)));
@@ -28,6 +30,7 @@ export function transpileJavaScript(node: JavaScriptNode, {id, resolveImport}: T
2830
const output = new Sourcemap(node.input).trim();
2931
rewriteImportDeclarations(output, node.body, resolveImport);
3032
rewriteImportExpressions(output, node.body, resolveImport);
33+
rewriteFileExpressions(output, node.files, path);
3134
if (display) output.insertLeft(0, "display(await(\n").insertRight(node.input.length, "\n))");
3235
output.insertLeft(0, `, body: ${async ? "async " : ""}(${inputs}) => {\n`);
3336
if (outputs.length) output.insertLeft(0, `, outputs: ${JSON.stringify(outputs)}`);
@@ -100,6 +103,14 @@ export async function transpileModule(
100103
return String(output);
101104
}
102105

106+
function rewriteFileExpressions(output: Sourcemap, files: FileExpression[], path: string): void {
107+
for (const {name, node} of files) {
108+
const source = node.arguments[0];
109+
const resolved = relativePath(path, resolvePath(path, name));
110+
output.replaceLeft(source.start, source.end, JSON.stringify(resolved));
111+
}
112+
}
113+
103114
function rewriteImportExpressions(
104115
output: Sourcemap,
105116
body: Node,

src/render.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,13 @@ import ${preview || page.code.length ? `{${preview ? "open, " : ""}define} from
6565
: ""
6666
}${data?.sql ? `\nimport {registerTable} from ${JSON.stringify(resolveImport("npm:@observablehq/duckdb"))};` : ""}${
6767
files.size
68-
? `\n${renderFiles(files, resolveFile, (name: string) => loaders.getLastModified(resolvePath(path, name)))}`
68+
? `\n${renderFiles(
69+
files,
70+
resolveFile,
71+
preview
72+
? (name: string) => loaders.getSourceLastModified(resolvePath(path, name))
73+
: (name: string) => loaders.getOutputLastModified(resolvePath(path, name))
74+
)}`
6975
: ""
7076
}${
7177
data?.sql
@@ -75,7 +81,7 @@ import ${preview || page.code.length ? `{${preview ? "open, " : ""}define} from
7581
: ""
7682
}
7783
${preview ? `\nopen({hash: ${JSON.stringify(resolvers.hash)}, eval: (body) => eval(body)});\n` : ""}${page.code
78-
.map(({node, id}) => `\n${transpileJavaScript(node, {id, resolveImport})}`)
84+
.map(({node, id}) => `\n${transpileJavaScript(node, {id, path, resolveImport})}`)
7985
.join("")}`)}
8086
</script>${sidebar ? html`\n${await renderSidebar(title, pages, root, path, search, normalizeLink)}` : ""}${
8187
toc.show ? html`\n${renderToc(findHeaders(page), toc.label)}` : ""

src/resolvers.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {extractNpmSpecifier, populateNpmCache, resolveNpmImport, resolveNpmImpor
1212
import {isAssetPath, isPathImport, relativePath, resolveLocalPath, resolvePath} from "./path.js";
1313

1414
export interface Resolvers {
15+
path: string;
1516
hash: string;
1617
assets: Set<string>; // like files, but not registered for FileAttachment
1718
files: Set<string>;
@@ -115,10 +116,10 @@ export async function getResolvers(
115116
}
116117

117118
// Compute the content hash.
118-
for (const f of assets) hash.update(loaders.getFileHash(resolvePath(path, f)));
119-
for (const f of files) hash.update(loaders.getFileHash(resolvePath(path, f)));
119+
for (const f of assets) hash.update(loaders.getSourceFileHash(resolvePath(path, f)));
120+
for (const f of files) hash.update(loaders.getSourceFileHash(resolvePath(path, f)));
120121
for (const i of localImports) hash.update(getModuleHash(root, resolvePath(path, i)));
121-
if (page.style && isPathImport(page.style)) hash.update(loaders.getFileHash(resolvePath(path, page.style)));
122+
if (page.style && isPathImport(page.style)) hash.update(loaders.getSourceFileHash(resolvePath(path, page.style)));
122123

123124
// Collect transitively-attached files and local imports.
124125
for (const i of localImports) {
@@ -259,6 +260,7 @@ export async function getResolvers(
259260
}
260261

261262
return {
263+
path,
262264
hash: hash.digest("hex"),
263265
assets,
264266
files,

test/build-test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ describe("build", () => {
2626
if (isEmpty(path)) continue;
2727
const only = name.startsWith("only.");
2828
const skip = name.startsWith("skip.");
29-
const outname = only || skip ? name.slice(5) : name;
29+
const outname = name.replace(/^only\.|skip\./, "");
3030
(only
3131
? it.only
3232
: skip ||

test/dataloaders-test.ts

Lines changed: 20 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -99,38 +99,41 @@ describe("LoaderResolver.find(path, {useStale: true})", () => {
9999
});
100100
});
101101

102-
describe("LoaderResolver.getFileHash(path)", () => {
102+
describe("LoaderResolver.getSourceFileHash(path)", () => {
103103
it("returns the content hash for the specified file’s data loader", async () => {
104104
const loaders = new LoaderResolver({root: "test/input/build/archives.posix"});
105-
assert.strictEqual(loaders.getFileHash("dynamic.zip.sh"), "516cec2431ce8f1181a7a2a161db8bdfcaea132d3b2c37f863ea6f05d64d1d10"); // prettier-ignore
106-
assert.strictEqual(loaders.getFileHash("dynamic.zip"), "516cec2431ce8f1181a7a2a161db8bdfcaea132d3b2c37f863ea6f05d64d1d10"); // prettier-ignore
107-
assert.strictEqual(loaders.getFileHash("dynamic/file.txt"), "516cec2431ce8f1181a7a2a161db8bdfcaea132d3b2c37f863ea6f05d64d1d10"); // prettier-ignore
108-
assert.strictEqual(loaders.getFileHash("static.zip"), "e6afff224da77b900cfe3ab8789f2283883300e1497548c30af66dfe4c29b429"); // prettier-ignore
109-
assert.strictEqual(loaders.getFileHash("static/file.txt"), "e6afff224da77b900cfe3ab8789f2283883300e1497548c30af66dfe4c29b429"); // prettier-ignore
105+
assert.strictEqual(loaders.getSourceFileHash("dynamic.zip.sh"), "516cec2431ce8f1181a7a2a161db8bdfcaea132d3b2c37f863ea6f05d64d1d10"); // prettier-ignore
106+
assert.strictEqual(loaders.getSourceFileHash("dynamic.zip"), "516cec2431ce8f1181a7a2a161db8bdfcaea132d3b2c37f863ea6f05d64d1d10"); // prettier-ignore
107+
assert.strictEqual(loaders.getSourceFileHash("dynamic/file.txt"), "516cec2431ce8f1181a7a2a161db8bdfcaea132d3b2c37f863ea6f05d64d1d10"); // prettier-ignore
108+
assert.strictEqual(loaders.getSourceFileHash("static.zip"), "e6afff224da77b900cfe3ab8789f2283883300e1497548c30af66dfe4c29b429"); // prettier-ignore
109+
assert.strictEqual(loaders.getSourceFileHash("static/file.txt"), "e6afff224da77b900cfe3ab8789f2283883300e1497548c30af66dfe4c29b429"); // prettier-ignore
110110
});
111111
it("returns the empty hash if the specified file does not exist", async () => {
112112
const loaders = new LoaderResolver({root: "test/input/build/files"});
113-
assert.strictEqual(loaders.getFileHash("does-not-exist.csv"), "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"); // prettier-ignore
113+
assert.strictEqual(loaders.getSourceFileHash("does-not-exist.csv"), "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"); // prettier-ignore
114114
});
115115
});
116116

117-
describe("LoaderResolver.getLastModified(path)", () => {
117+
describe("LoaderResolver.get{Source,Output}LastModified(path)", () => {
118118
const time1 = new Date(Date.UTC(2023, 11, 1));
119119
const time2 = new Date(Date.UTC(2024, 2, 1));
120120
const loaders = new LoaderResolver({root: "test"});
121-
it("returns the last modification time for a simple file", async () => {
121+
it("both return the last modification time for a simple file", async () => {
122122
await utimes("test/input/loader/simple.txt", time1, time1);
123-
assert.strictEqual(loaders.getLastModified("input/loader/simple.txt"), +time1);
123+
assert.strictEqual(loaders.getSourceLastModified("input/loader/simple.txt"), +time1);
124+
assert.strictEqual(loaders.getOutputLastModified("input/loader/simple.txt"), +time1);
124125
});
125-
it("returns an undefined last modification time for a missing file", async () => {
126-
assert.strictEqual(loaders.getLastModified("input/loader/missing.txt"), undefined);
126+
it("both return an undefined last modification time for a missing file", async () => {
127+
assert.strictEqual(loaders.getSourceLastModified("input/loader/missing.txt"), undefined);
128+
assert.strictEqual(loaders.getOutputLastModified("input/loader/missing.txt"), undefined);
127129
});
128-
it("returns the last modification time for a cached data loader", async () => {
130+
it("returns the last modification time of the loader in preview, and of the cache, on build", async () => {
129131
await utimes("test/input/loader/cached.txt.sh", time1, time1);
130132
await mkdir("test/.observablehq/cache/input/loader/", {recursive: true});
131133
await writeFile("test/.observablehq/cache/input/loader/cached.txt", "2024-03-01 00:00:00");
132134
await utimes("test/.observablehq/cache/input/loader/cached.txt", time2, time2);
133-
assert.strictEqual(loaders.getLastModified("input/loader/cached.txt"), +time2);
135+
assert.strictEqual(loaders.getSourceLastModified("input/loader/cached.txt"), +time1);
136+
assert.strictEqual(loaders.getOutputLastModified("input/loader/cached.txt"), +time2);
134137
// clean up
135138
try {
136139
await unlink("test/.observablehq/cache/input/loader/cached.txt");
@@ -139,8 +142,9 @@ describe("LoaderResolver.getLastModified(path)", () => {
139142
// ignore;
140143
}
141144
});
142-
it("returns the last modification time for a data loader that has no cache", async () => {
145+
it("returns the last modification time of the data loader in preview, and null in build, when there is no cache", async () => {
143146
await utimes("test/input/loader/not-cached.txt.sh", time1, time1);
144-
assert.strictEqual(loaders.getLastModified("input/loader/not-cached.txt"), +time1);
147+
assert.strictEqual(loaders.getSourceLastModified("input/loader/not-cached.txt"), +time1);
148+
assert.strictEqual(loaders.getOutputLastModified("input/loader/not-cached.txt"), undefined);
145149
});
146150
});

test/javascript/transpile-test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ function runTests(inputRoot: string, outputRoot: string, filter: (name: string)
3333

3434
try {
3535
const node = parseJavaScript(input, {path: name});
36-
actual = transpileJavaScript(node, {id: "0", resolveImport: mockResolveImport});
36+
actual = transpileJavaScript(node, {id: "0", path: name, resolveImport: mockResolveImport});
3737
} catch (error) {
3838
if (!(error instanceof SyntaxError)) throw error;
3939
actual = `define({id: "0", body: () => { throw new SyntaxError(${JSON.stringify(error.message)}); }});\n`;
@@ -75,7 +75,7 @@ describe("transpileJavaScript(input, options)", () => {
7575
runTests("test/input/imports", "test/output/imports", (name) => name.endsWith("-import.js"));
7676
it("trims leading and trailing newlines", async () => {
7777
const node = parseJavaScript("\ntest\n", {path: "index.js"});
78-
const body = transpileJavaScript(node, {id: "0"});
78+
const body = transpileJavaScript(node, {id: "0", path: "index.js"});
7979
assert.strictEqual(body, 'define({id: "0", inputs: ["test","display"], body: async (test,display) => {\ndisplay(await(\ntest\n))\n}});\n'); // prettier-ignore
8080
});
8181
});

test/output/build/archives.posix/tar.html

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -15,53 +15,53 @@
1515
import {define} from "./_observablehq/client.js";
1616
import {registerFile} from "./_observablehq/stdlib.js";
1717

18-
registerFile("./dynamic-tar-gz/does-not-exist.txt", {"name":"./dynamic-tar-gz/does-not-exist.txt","mimeType":"text/plain","path":"./dynamic-tar-gz/does-not-exist.txt","lastModified":/* ts */1706742000000});
18+
registerFile("./dynamic-tar-gz/does-not-exist.txt", {"name":"./dynamic-tar-gz/does-not-exist.txt","mimeType":"text/plain","path":"./dynamic-tar-gz/does-not-exist.txt"});
1919
registerFile("./dynamic-tar-gz/file.txt", {"name":"./dynamic-tar-gz/file.txt","mimeType":"text/plain","path":"./_file/dynamic-tar-gz/file.c93138d8.txt","lastModified":/* ts */1706742000000});
20-
registerFile("./dynamic-tar/does-not-exist.txt", {"name":"./dynamic-tar/does-not-exist.txt","mimeType":"text/plain","path":"./dynamic-tar/does-not-exist.txt","lastModified":/* ts */1706742000000});
20+
registerFile("./dynamic-tar/does-not-exist.txt", {"name":"./dynamic-tar/does-not-exist.txt","mimeType":"text/plain","path":"./dynamic-tar/does-not-exist.txt"});
2121
registerFile("./dynamic-tar/file.txt", {"name":"./dynamic-tar/file.txt","mimeType":"text/plain","path":"./_file/dynamic-tar/file.c93138d8.txt","lastModified":/* ts */1706742000000});
22-
registerFile("./static-tar/does-not-exist.txt", {"name":"./static-tar/does-not-exist.txt","mimeType":"text/plain","path":"./static-tar/does-not-exist.txt","lastModified":/* ts */1706742000000});
22+
registerFile("./static-tar/does-not-exist.txt", {"name":"./static-tar/does-not-exist.txt","mimeType":"text/plain","path":"./static-tar/does-not-exist.txt"});
2323
registerFile("./static-tar/file.txt", {"name":"./static-tar/file.txt","mimeType":"text/plain","path":"./_file/static-tar/file.c93138d8.txt","lastModified":/* ts */1706742000000});
2424
registerFile("./static-tgz/file.txt", {"name":"./static-tgz/file.txt","mimeType":"text/plain","path":"./_file/static-tgz/file.c93138d8.txt","lastModified":/* ts */1706742000000});
2525

2626
define({id: "d5134368", inputs: ["FileAttachment","display"], body: async (FileAttachment,display) => {
2727
display(await(
28-
await FileAttachment("static-tar/file.txt").text()
28+
await FileAttachment("./static-tar/file.txt").text()
2929
))
3030
}});
3131

3232
define({id: "a0c06958", inputs: ["FileAttachment","display"], body: async (FileAttachment,display) => {
3333
display(await(
34-
await FileAttachment("static-tgz/file.txt").text()
34+
await FileAttachment("./static-tgz/file.txt").text()
3535
))
3636
}});
3737

3838
define({id: "d84cd7fb", inputs: ["FileAttachment","display"], body: async (FileAttachment,display) => {
3939
display(await(
40-
await FileAttachment("static-tar/does-not-exist.txt").text()
40+
await FileAttachment("./static-tar/does-not-exist.txt").text()
4141
))
4242
}});
4343

4444
define({id: "86bd51aa", inputs: ["FileAttachment","display"], body: async (FileAttachment,display) => {
4545
display(await(
46-
await FileAttachment("dynamic-tar/file.txt").text()
46+
await FileAttachment("./dynamic-tar/file.txt").text()
4747
))
4848
}});
4949

5050
define({id: "95938c22", inputs: ["FileAttachment","display"], body: async (FileAttachment,display) => {
5151
display(await(
52-
await FileAttachment("dynamic-tar/does-not-exist.txt").text()
52+
await FileAttachment("./dynamic-tar/does-not-exist.txt").text()
5353
))
5454
}});
5555

5656
define({id: "7e5740fd", inputs: ["FileAttachment","display"], body: async (FileAttachment,display) => {
5757
display(await(
58-
await FileAttachment("dynamic-tar-gz/file.txt").text()
58+
await FileAttachment("./dynamic-tar-gz/file.txt").text()
5959
))
6060
}});
6161

6262
define({id: "d0a58efd", inputs: ["FileAttachment","display"], body: async (FileAttachment,display) => {
6363
display(await(
64-
await FileAttachment("dynamic-tar-gz/does-not-exist.txt").text()
64+
await FileAttachment("./dynamic-tar-gz/does-not-exist.txt").text()
6565
))
6666
}});
6767

test/output/build/archives.posix/zip.html

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,31 +16,31 @@
1616
import {registerFile} from "./_observablehq/stdlib.js";
1717

1818
registerFile("./dynamic/file.txt", {"name":"./dynamic/file.txt","mimeType":"text/plain","path":"./_file/dynamic/file.c93138d8.txt","lastModified":/* ts */1706742000000});
19-
registerFile("./dynamic/not-found.txt", {"name":"./dynamic/not-found.txt","mimeType":"text/plain","path":"./dynamic/not-found.txt","lastModified":/* ts */1706742000000});
19+
registerFile("./dynamic/not-found.txt", {"name":"./dynamic/not-found.txt","mimeType":"text/plain","path":"./dynamic/not-found.txt"});
2020
registerFile("./static/file.txt", {"name":"./static/file.txt","mimeType":"text/plain","path":"./_file/static/file.d9014c46.txt","lastModified":/* ts */1706742000000});
21-
registerFile("./static/not-found.txt", {"name":"./static/not-found.txt","mimeType":"text/plain","path":"./static/not-found.txt","lastModified":/* ts */1706742000000});
21+
registerFile("./static/not-found.txt", {"name":"./static/not-found.txt","mimeType":"text/plain","path":"./static/not-found.txt"});
2222

2323
define({id: "d3b9d0ee", inputs: ["FileAttachment","display"], body: async (FileAttachment,display) => {
2424
display(await(
25-
await FileAttachment("static/file.txt").text()
25+
await FileAttachment("./static/file.txt").text()
2626
))
2727
}});
2828

2929
define({id: "bab54217", inputs: ["FileAttachment","display"], body: async (FileAttachment,display) => {
3030
display(await(
31-
await FileAttachment("static/not-found.txt").text()
31+
await FileAttachment("./static/not-found.txt").text()
3232
))
3333
}});
3434

3535
define({id: "11eec300", inputs: ["FileAttachment","display"], body: async (FileAttachment,display) => {
3636
display(await(
37-
await FileAttachment("dynamic/file.txt").text()
37+
await FileAttachment("./dynamic/file.txt").text()
3838
))
3939
}});
4040

4141
define({id: "ee2310f3", inputs: ["FileAttachment","display"], body: async (FileAttachment,display) => {
4242
display(await(
43-
await FileAttachment("dynamic/not-found.txt").text()
43+
await FileAttachment("./dynamic/not-found.txt").text()
4444
))
4545
}});
4646

0 commit comments

Comments
 (0)