Skip to content

Commit 6586877

Browse files
Filmbostock
andauthored
lastModified (#1051)
* tests * document * fileAttachment knows about lastModified * build tests know about lastModified * lastModified * lastModified and content hash are now based on a data loader's cached payload * minimize diff * lowercase comment * Apply suggestions from code review Co-authored-by: Mike Bostock <[email protected]> * fix LoaderResolver.getLastModified logic & add tests * okay this is a fake data loader, but let's keep it consistent * restore default mime-type but on render instead of in the client/stdlib * move the default mime-type back to the client; the test doesn't have it anymore * javadoc comment --------- Co-authored-by: Mike Bostock <[email protected]>
1 parent 1971b56 commit 6586877

File tree

22 files changed

+129
-53
lines changed

22 files changed

+129
-53
lines changed

docs/javascript/files.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ Load files — whether static or generated dynamically by a [data loader](../loa
1010
import {FileAttachment} from "npm:@observablehq/stdlib";
1111
```
1212

13-
The `FileAttachment` function takes a path and returns a file handle. This handle exposes the file’s name and [MIME type](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types).
13+
The `FileAttachment` function takes a path and returns a file handle. This handle exposes the file’s name, [MIME type](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types), and last modification date as the number of milliseconds since UNIX epoch.
1414

1515
```js echo
1616
FileAttachment("volcano.json")
@@ -32,7 +32,7 @@ volcano
3232

3333
### Static analysis
3434

35-
The `FileAttachment` function can _only_ be passed a static string literal; constructing a dynamic path such as `FileAttachment("my" + "file.csv")` is invalid syntax. Static analysis is used to invoke [data loaders](../loaders) at build time, and ensures that only referenced files are included in the generated output during build. In the future [#260](https://github.com/observablehq/framework/issues/260), it will also allow content hashes for cache breaking during deploy.
35+
The `FileAttachment` function can _only_ be passed a static string literal; constructing a dynamic path such as `FileAttachment("my" + "file.csv")` is invalid syntax. Static analysis is used to invoke [data loaders](../loaders) at build time, and ensures that only referenced files are included in the generated output during build. This also allows a content hash in the file name for cache breaking during deploy.
3636

3737
If you have multiple files, you can enumerate them explicitly like so:
3838

@@ -52,6 +52,8 @@ const frames = [
5252

5353
None of the files in `frames` above are loaded until a [content method](#supported-formats) is invoked, for example by saying `frames[0].image()`.
5454

55+
For missing files, `file.lastModified` is undefined. The `file.mimeType` is determined by checking the file extension against the [`mime-db` media type database](https://github.com/jshttp/mime-db); it defaults to `application/octet-stream`.
56+
5557
## Supported formats
5658

5759
`FileAttachment` supports a variety of methods for loading file contents:

src/client/stdlib/fileAttachment.js

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@ export function FileAttachment(name, base = location.href) {
1111
const url = new URL(name, base).href;
1212
const file = files.get(url);
1313
if (!file) throw new Error(`File not found: ${name}`);
14-
const {path, mimeType} = file;
15-
return new FileAttachmentImpl(new URL(path, location).href, name.split("/").pop(), mimeType);
14+
const {path, mimeType, lastModified} = file;
15+
return new FileAttachmentImpl(new URL(path, location).href, name.split("/").pop(), mimeType, lastModified);
1616
}
1717

1818
async function remote_fetch(file) {
@@ -28,9 +28,10 @@ async function dsv(file, delimiter, {array = false, typed = false} = {}) {
2828
}
2929

3030
export class AbstractFile {
31-
constructor(name, mimeType = "application/octet-stream") {
32-
Object.defineProperty(this, "name", {value: `${name}`, enumerable: true});
31+
constructor(name, mimeType = "application/octet-stream", lastModified) {
3332
Object.defineProperty(this, "mimeType", {value: `${mimeType}`, enumerable: true});
33+
Object.defineProperty(this, "name", {value: `${name}`, enumerable: true});
34+
if (lastModified !== undefined) Object.defineProperty(this, "lastModified", {value: Number(lastModified), enumerable: true}); // prettier-ignore
3435
}
3536
async blob() {
3637
return (await remote_fetch(this)).blob();
@@ -95,8 +96,8 @@ export class AbstractFile {
9596
}
9697

9798
class FileAttachmentImpl extends AbstractFile {
98-
constructor(url, name, mimeType) {
99-
super(name, mimeType);
99+
constructor(url, name, mimeType, lastModified) {
100+
super(name, mimeType, lastModified);
100101
Object.defineProperty(this, "_url", {value: url});
101102
}
102103
async url() {

src/dataloader.ts

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import JSZip from "jszip";
77
import {extract} from "tar-stream";
88
import {maybeStat, prepareOutput} from "./files.js";
99
import {FileWatchers} from "./fileWatchers.js";
10-
import {getFileHash} from "./javascript/module.js";
10+
import {getFileHash, getFileInfo} from "./javascript/module.js";
1111
import type {Logger, Writer} from "./logger.js";
1212
import {cyan, faint, green, red, yellow} from "./tty.js";
1313

@@ -129,12 +129,35 @@ export class LoaderResolver {
129129
return FileWatchers.of(this, path, watchPaths, callback);
130130
}
131131

132-
getFileHash(path: string): string {
132+
/**
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.
138+
*/
139+
private getFilePath(name: string): string {
140+
let path = name;
133141
if (!existsSync(join(this.root, path))) {
134142
const loader = this.find(path);
135-
if (loader) path = relative(this.root, loader.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+
}
136150
}
137-
return getFileHash(this.root, path);
151+
return path;
152+
}
153+
154+
getFileHash(name: string): string {
155+
return getFileHash(this.root, this.getFilePath(name));
156+
}
157+
158+
getLastModified(name: string): number | undefined {
159+
const entry = getFileInfo(this.root, this.getFilePath(name));
160+
return entry && Math.floor(entry.mtimeMs);
138161
}
139162

140163
resolveFilePath(path: string): string {

src/render.ts

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import {transpileJavaScript} from "./javascript/transpile.js";
88
import type {MarkdownPage} from "./markdown.js";
99
import type {PageLink} from "./pager.js";
1010
import {findLink, normalizePath} from "./pager.js";
11-
import {relativePath} from "./path.js";
11+
import {relativePath, resolvePath} from "./path.js";
1212
import type {Resolvers} from "./resolvers.js";
1313
import {getResolvers} from "./resolvers.js";
1414
import {rollupClient} from "./rollup.js";
@@ -25,7 +25,8 @@ type RenderInternalOptions =
2525

2626
export async function renderPage(page: MarkdownPage, options: RenderOptions & RenderInternalOptions): Promise<string> {
2727
const {data} = page;
28-
const {root, md, base, path, pages, title, preview, search, resolvers = await getResolvers(page, options)} = options;
28+
const {root, md, base, path, pages, title, preview, search} = options;
29+
const {loaders, resolvers = await getResolvers(page, options)} = options;
2930
const {normalizeLink} = md;
3031
const sidebar = data?.sidebar !== undefined ? Boolean(data.sidebar) : options.sidebar;
3132
const toc = mergeToc(data?.toc, options.toc);
@@ -63,7 +64,9 @@ import ${preview || page.code.length ? `{${preview ? "open, " : ""}define} from
6364
)};`
6465
: ""
6566
}${data?.sql ? `\nimport {registerTable} from ${JSON.stringify(resolveImport("npm:@observablehq/duckdb"))};` : ""}${
66-
files.size ? `\n${renderFiles(files, resolveFile)}` : ""
67+
files.size
68+
? `\n${renderFiles(files, resolveFile, (name: string) => loaders.getLastModified(resolvePath(path, name)))}`
69+
: ""
6770
}${
6871
data?.sql
6972
? `\n${Object.entries<string>(data.sql)
@@ -84,18 +87,19 @@ ${html.unsafe(rewriteHtml(page.html, resolvers))}</main>${renderFooter(path, opt
8487
`);
8588
}
8689

87-
function renderFiles(files: Iterable<string>, resolve: (name: string) => string): string {
90+
function renderFiles(files: Iterable<string>, resolve: (name: string) => string, getLastModified): string {
8891
return Array.from(files)
8992
.sort()
90-
.map((f) => renderFile(f, resolve))
93+
.map((f) => renderFile(f, resolve, getLastModified))
9194
.join("");
9295
}
9396

94-
function renderFile(name: string, resolve: (name: string) => string): string {
97+
function renderFile(name: string, resolve: (name: string) => string, getLastModified): string {
9598
return `\nregisterFile(${JSON.stringify(name)}, ${JSON.stringify({
9699
name,
97100
mimeType: mime.getType(name) ?? undefined,
98-
path: resolve(name)
101+
path: resolve(name),
102+
lastModified: getLastModified(name)
99103
})});`;
100104
}
101105

src/resolvers.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -114,8 +114,7 @@ export async function getResolvers(
114114
}
115115
}
116116

117-
// Compute the content hash. TODO In build, this needs to consider the output
118-
// of data loaders, rather than the source of data loaders.
117+
// Compute the content hash.
119118
for (const f of assets) hash.update(loaders.getFileHash(resolvePath(path, f)));
120119
for (const f of files) hash.update(loaders.getFileHash(resolvePath(path, f)));
121120
for (const i of localImports) hash.update(getModuleHash(root, resolvePath(path, i)));

test/build-test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ class TestEffects extends FileBuildEffects {
101101
async writeFile(outputPath: string, contents: string | Buffer): Promise<void> {
102102
if (typeof contents === "string" && outputPath.endsWith(".html")) {
103103
contents = contents.replace(/^(\s*<script>\{).*(\}<\/script>)$/gm, "$1/* redacted init script */$2");
104+
contents = contents.replace(/^(registerFile\(.*,"lastModified":)\d+(\}\);)$/gm, "$1/* ts */1706742000000$2");
104105
}
105106
return super.writeFile(outputPath, contents);
106107
}

test/dataloaders-test.ts

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import assert from "node:assert";
2-
import {readFile, stat, unlink, utimes} from "node:fs/promises";
2+
import {mkdir, readFile, rmdir, stat, unlink, utimes, writeFile} from "node:fs/promises";
33
import os from "node:os";
44
import type {LoadEffects} from "../src/dataloader.js";
55
import {LoaderResolver} from "../src/dataloader.js";
@@ -113,3 +113,34 @@ describe("LoaderResolver.getFileHash(path)", () => {
113113
assert.strictEqual(loaders.getFileHash("does-not-exist.csv"), "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"); // prettier-ignore
114114
});
115115
});
116+
117+
describe("LoaderResolver.getLastModified(path)", () => {
118+
const time1 = new Date(Date.UTC(2023, 11, 1));
119+
const time2 = new Date(Date.UTC(2024, 2, 1));
120+
const loaders = new LoaderResolver({root: "test"});
121+
it("returns the last modification time for a simple file", async () => {
122+
await utimes("test/input/loader/simple.txt", time1, time1);
123+
assert.strictEqual(loaders.getLastModified("input/loader/simple.txt"), +time1);
124+
});
125+
it("returns an undefined last modification time for a missing file", async () => {
126+
assert.strictEqual(loaders.getLastModified("input/loader/missing.txt"), undefined);
127+
});
128+
it("returns the last modification time for a cached data loader", async () => {
129+
await utimes("test/input/loader/cached.txt.sh", time1, time1);
130+
await mkdir("test/.observablehq/cache/input/loader/", {recursive: true});
131+
await writeFile("test/.observablehq/cache/input/loader/cached.txt", "2024-03-01 00:00:00");
132+
await utimes("test/.observablehq/cache/input/loader/cached.txt", time2, time2);
133+
assert.strictEqual(loaders.getLastModified("input/loader/cached.txt"), +time2);
134+
// clean up
135+
try {
136+
await unlink("test/.observablehq/cache/input/loader/cached.txt");
137+
await rmdir("test/.observablehq/cache/input/loader", {recursive: true});
138+
} catch {
139+
// ignore;
140+
}
141+
});
142+
it("returns the last modification time for a data loader that has no cache", async () => {
143+
await utimes("test/input/loader/not-cached.txt.sh", time1, time1);
144+
assert.strictEqual(loaders.getLastModified("input/loader/not-cached.txt"), +time1);
145+
});
146+
});

test/input/build/files/files.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@ FileAttachment("subsection/file-sub.csv")
1414
FileAttachment("observable logo.png")
1515
```
1616

17+
```js
18+
FileAttachment("unknown-mime-extension.really")
19+
```
20+
1721
![](observable%20logo%20small.png)
1822

1923

test/input/loader/cached.txt.sh

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
date -u "+%Y-%m-%d %H:%M:%S"

test/input/loader/not-cached.txt.sh

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
date -u "+%Y-%m-%d %H:%M:%S"

0 commit comments

Comments
 (0)