Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/convert.md
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,7 @@ The Framework standard library also includes several new methods that are not av
Framework’s [`FileAttachment`](./files) includes a few new features:

- `file.href`
- `file.size`
- `file.lastModified`
- `file.mimeType` is always defined
- `file.text` now supports an `encoding` option
Expand Down
4 changes: 2 additions & 2 deletions docs/files.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ Load files — whether static or generated dynamically by a [data loader](./load
import {FileAttachment} from "npm:@observablehq/stdlib";
```

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 modification time <a href="https://github.com/observablehq/framework/releases/tag/v1.4.0" class="observablehq-version-badge" data-version="^1.4.0" title="Added in 1.4.0"></a> (represented as the number of milliseconds since UNIX epoch).
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), size in bytes <a href="https://github.com/observablehq/framework/pulls/1608" class="observablehq-version-badge" data-version="prerelease" title="Added in #1608"></a>, and modification time <a href="https://github.com/observablehq/framework/releases/tag/v1.4.0" class="observablehq-version-badge" data-version="^1.4.0" title="Added in 1.4.0"></a> (represented as the number of milliseconds since UNIX epoch).

```js echo
FileAttachment("volcano.json")
Expand Down Expand Up @@ -52,7 +52,7 @@ const frames = [

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

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`.
For missing files, `file.size` and `file.lastModified` are 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`.

## Supported formats

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@
},
"dependencies": {
"@clack/prompts": "^0.7.0",
"@observablehq/inputs": "^0.11.0",
"@observablehq/inputs": "^0.12.0",
"@observablehq/runtime": "^5.9.4",
"@rollup/plugin-commonjs": "^25.0.7",
"@rollup/plugin-json": "^6.1.0",
Expand Down
19 changes: 13 additions & 6 deletions src/client/stdlib/fileAttachment.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,14 @@ export function registerFile(name, info) {
if (info == null) {
files.delete(href);
} else {
const {path, mimeType, lastModified} = info;
const file = new FileAttachmentImpl(new URL(path, location).href, name.split("/").pop(), mimeType, lastModified);
const {path, mimeType, lastModified, size} = info;
const file = new FileAttachmentImpl(
new URL(path, location).href,
name.split("/").pop(),
mimeType,
lastModified,
size
);
files.set(href, file);
}
}
Expand All @@ -25,11 +31,12 @@ async function remote_fetch(file) {
}

export class AbstractFile {
constructor(name, mimeType = "application/octet-stream", lastModified) {
constructor(name, mimeType = "application/octet-stream", lastModified, size) {
Object.defineProperties(this, {
name: {value: `${name}`, enumerable: true},
mimeType: {value: `${mimeType}`, enumerable: true},
lastModified: {value: +lastModified, enumerable: true}
lastModified: {value: +lastModified, enumerable: true},
size: {value: +size, enumerable: true}
});
}
async blob() {
Expand Down Expand Up @@ -131,8 +138,8 @@ export class AbstractFile {
}

class FileAttachmentImpl extends AbstractFile {
constructor(href, name, mimeType, lastModified) {
super(name, mimeType, lastModified);
constructor(href, name, mimeType, lastModified, size) {
super(name, mimeType, lastModified, size);
Object.defineProperty(this, "href", {value: href});
}
async url() {
Expand Down
61 changes: 58 additions & 3 deletions src/client/stdlib/inputs.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,60 @@
import {fileOf} from "@observablehq/inputs";
import {file as _file} from "@observablehq/inputs";
import {AbstractFile} from "npm:@observablehq/stdlib";

export * from "@observablehq/inputs";
export const file = fileOf(AbstractFile);
export {
button,
checkbox,
radio,
toggle,
color,
date,
datetime,
form,
range,
number,
search,
searchFilter,
select,
table,
text,
email,
tel,
url,
password,
textarea,
input,
bind,
disposal,
formatDate,
formatLocaleAuto,
formatLocaleNumber,
formatTrim,
formatAuto,
formatNumber
} from "@observablehq/inputs";
Comment on lines +4 to +34
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Repeating this is unfortunate but ESLint doesn’t like us exporting file twice. 😞


export const file = (options) => _file({...options, transform: localFile});

function localFile(file) {
return new LocalFile(file);
}

class LocalFile extends AbstractFile {
constructor(file) {
super(file.name, file.type, file.lastModified, file.size);
Object.defineProperty(this, "_", {value: file});
Object.defineProperty(this, "_url", {writable: true});
}
get href() {
return (this._url ??= URL.createObjectURL(this._));
}
async url() {
return this.href;
}
async blob() {
return this._;
}
async stream() {
return this._.stream();
}
}
11 changes: 5 additions & 6 deletions src/dataloader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {extract} from "tar-stream";
import {maybeStat, prepareOutput} from "./files.js";
import {FileWatchers} from "./fileWatchers.js";
import {formatByteSize} from "./format.js";
import type {FileInfo} from "./javascript/module.js";
import {getFileInfo} from "./javascript/module.js";
import type {Logger, Writer} from "./logger.js";
import {cyan, faint, green, red, yellow} from "./tty.js";
Expand Down Expand Up @@ -178,14 +179,12 @@ export class LoaderResolver {
return path === name ? hash : createHash("sha256").update(hash).update(String(info.mtimeMs)).digest("hex");
}

getSourceLastModified(name: string): number | undefined {
const entry = getFileInfo(this.root, this.getSourceFilePath(name));
return entry && Math.floor(entry.mtimeMs);
getSourceInfo(name: string): FileInfo | undefined {
return getFileInfo(this.root, this.getSourceFilePath(name));
}

getOutputLastModified(name: string): number | undefined {
const entry = getFileInfo(this.root, this.getOutputFilePath(name));
return entry && Math.floor(entry.mtimeMs);
getOutputInfo(name: string): FileInfo | undefined {
return getFileInfo(this.root, this.getOutputFilePath(name));
}

resolveFilePath(path: string): string {
Expand Down
10 changes: 7 additions & 3 deletions src/javascript/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ import {parseProgram} from "./parse.js";
export type FileInfo = {
/** The last-modified time of the file; used to invalidate the cache. */
mtimeMs: number;
/** The size of the file in bytes. */
size: number;
/** The SHA-256 content hash of the file contents. */
hash: string;
};
Expand Down Expand Up @@ -119,7 +121,7 @@ export function getModuleInfo(root: string, path: string): ModuleInfo | undefine
const key = join(root, path);
let mtimeMs: number;
try {
({mtimeMs} = statSync(resolveJsx(key) ?? key));
mtimeMs = Math.floor(statSync(resolveJsx(key) ?? key).mtimeMs);
} catch {
moduleInfoCache.delete(key); // delete stale entry
return; // ignore missing file
Expand Down Expand Up @@ -186,11 +188,13 @@ export function getFileHash(root: string, path: string): string {
export function getFileInfo(root: string, path: string): FileInfo | undefined {
const key = join(root, path);
let mtimeMs: number;
let size: number;
try {
const stat = statSync(key);
if (!stat.isFile()) return; // ignore non-files
accessSync(key, constants.R_OK); // verify that file is readable
({mtimeMs} = stat);
mtimeMs = Math.floor(stat.mtimeMs);
size = stat.size;
} catch {
fileInfoCache.delete(key); // delete stale entry
return; // ignore missing, non-readable file
Expand All @@ -199,7 +203,7 @@ export function getFileInfo(root: string, path: string): FileInfo | undefined {
if (!entry || entry.mtimeMs < mtimeMs) {
const contents = readFileSync(key);
const hash = createHash("sha256").update(contents).digest("hex");
fileInfoCache.set(key, (entry = {mtimeMs, hash}));
fileInfoCache.set(key, (entry = {mtimeMs, size, hash}));
}
return entry;
}
Expand Down
15 changes: 9 additions & 6 deletions src/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {HttpError, isEnoent, isHttpError, isSystemError} from "./error.js";
import {getClientPath} from "./files.js";
import type {FileWatchers} from "./fileWatchers.js";
import {isComment, isElement, isText, parseHtml, rewriteHtml} from "./html.js";
import type {FileInfo} from "./javascript/module.js";
import {readJavaScript} from "./javascript/module.js";
import {transpileJavaScript, transpileModule} from "./javascript/transpile.js";
import {parseMarkdown} from "./markdown.js";
Expand Down Expand Up @@ -354,7 +355,7 @@ function handleWatch(socket: WebSocket, req: IncomingMessage, configPromise: Pro
type: "update",
html: diffHtml(previousHtml, html),
code: diffCode(previousCode, code),
files: diffFiles(previousFiles, files, getLastModifiedResolver(loaders, path)),
files: diffFiles(previousFiles, files, getInfoResolver(loaders, path)),
tables: diffTables(previousTables, tables, previousFiles, files),
stylesheets: diffStylesheets(previousStylesheets, stylesheets),
hash: {previous: previousHash, current: hash}
Expand Down Expand Up @@ -486,13 +487,13 @@ function diffCode(oldCode: Map<string, string>, newCode: Map<string, string>): C
return patch;
}

type FileDeclaration = {name: string; mimeType: string; lastModified: number; path: string};
type FileDeclaration = {name: string; mimeType: string; lastModified: number; size: number; path: string};
type FilePatch = {removed: string[]; added: FileDeclaration[]};

function diffFiles(
oldFiles: Map<string, string>,
newFiles: Map<string, string>,
getLastModified: (name: string) => number | undefined
getInfo: (name: string) => FileInfo | undefined
): FilePatch {
const patch: FilePatch = {removed: [], added: []};
for (const [name, path] of oldFiles) {
Expand All @@ -502,19 +503,21 @@ function diffFiles(
}
for (const [name, path] of newFiles) {
if (oldFiles.get(name) !== path) {
const info = getInfo(name);
patch.added.push({
name,
mimeType: mime.getType(name) ?? "application/octet-stream",
lastModified: getLastModified(name) ?? NaN,
lastModified: info?.mtimeMs ?? NaN,
size: info?.size ?? NaN,
path
});
}
}
return patch;
}

function getLastModifiedResolver(loaders: LoaderResolver, path: string): (name: string) => number | undefined {
return (name) => loaders.getSourceLastModified(resolvePath(path, name));
function getInfoResolver(loaders: LoaderResolver, path: string): (name: string) => FileInfo | undefined {
return (name) => loaders.getSourceInfo(resolvePath(path, name));
}

type TableDeclaration = {name: string; path: string};
Expand Down
15 changes: 9 additions & 6 deletions src/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {getClientPath} from "./files.js";
import type {Html, HtmlResolvers} from "./html.js";
import {html, parseHtml, rewriteHtml} from "./html.js";
import {isJavaScript} from "./javascript/imports.js";
import type {FileInfo} from "./javascript/module.js";
import {transpileJavaScript} from "./javascript/transpile.js";
import type {MarkdownPage} from "./markdown.js";
import type {PageLink} from "./pager.js";
Expand Down Expand Up @@ -68,8 +69,8 @@ import ${preview || page.code.length ? `{${preview ? "open, " : ""}define} from
files,
resolveFile,
preview
? (name) => loaders.getSourceLastModified(resolvePath(path, name))
: (name) => loaders.getOutputLastModified(resolvePath(path, name))
? (name) => loaders.getSourceInfo(resolvePath(path, name))
: (name) => loaders.getOutputInfo(resolvePath(path, name))
)}`
: ""
}${data?.sql ? `\n${registerTables(data.sql, options)}` : ""}
Expand Down Expand Up @@ -103,24 +104,26 @@ function registerTable(name: string, source: string, {path}: RenderOptions): str
function registerFiles(
files: Iterable<string>,
resolve: (name: string) => string,
getLastModified: (name: string) => number | undefined
getInfo: (name: string) => FileInfo | undefined
): string {
return Array.from(files)
.sort()
.map((f) => registerFile(f, resolve, getLastModified))
.map((f) => registerFile(f, resolve, getInfo))
.join("");
}

function registerFile(
name: string,
resolve: (name: string) => string,
getLastModified: (name: string) => number | undefined
getInfo: (name: string) => FileInfo | undefined
): string {
const info = getInfo(name);
return `\nregisterFile(${JSON.stringify(name)}, ${JSON.stringify({
name,
mimeType: mime.getType(name) ?? undefined,
path: resolve(name),
lastModified: getLastModified(name)
lastModified: info?.mtimeMs,
size: info?.size
})});`;
}

Expand Down
2 changes: 1 addition & 1 deletion test/build-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@ class TestEffects extends FileBuildEffects {
async writeFile(outputPath: string, contents: string | Buffer): Promise<void> {
if (typeof contents === "string" && outputPath.endsWith(".html")) {
contents = contents.replace(/^(\s*<script>\{).*(\}<\/script>)$/gm, "$1/* redacted init script */$2");
contents = contents.replace(/^(registerFile\(.*,"lastModified":)\d+(\}\);)$/gm, "$1/* ts */1706742000000$2");
contents = contents.replace(/^(registerFile\(.*,"lastModified":)\d+(,"size":\d+\}\);)$/gm, "$1/* ts */1706742000000$2"); // prettier-ignore
}
return super.writeFile(outputPath, contents);
}
Expand Down
18 changes: 9 additions & 9 deletions test/dataloaders-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,26 +117,26 @@ describe("LoaderResolver.getSourceFileHash(path)", () => {
});
});

describe("LoaderResolver.get{Source,Output}LastModified(path)", () => {
describe("LoaderResolver.get{Source,Output}Info(path)", () => {
const time1 = new Date(Date.UTC(2023, 11, 1));
const time2 = new Date(Date.UTC(2024, 2, 1));
const loaders = new LoaderResolver({root: "test"});
it("both return the last modification time for a simple file", async () => {
await utimes("test/input/loader/simple.txt", time1, time1);
assert.strictEqual(loaders.getSourceLastModified("input/loader/simple.txt"), +time1);
assert.strictEqual(loaders.getOutputLastModified("input/loader/simple.txt"), +time1);
assert.deepStrictEqual(loaders.getSourceInfo("input/loader/simple.txt"), {hash: "3b09aeb6f5f5336beb205d7f720371bc927cd46c21922e334d47ba264acb5ba4", mtimeMs: +time1, size: 6}); // prettier-ignore
assert.deepStrictEqual(loaders.getOutputInfo("input/loader/simple.txt"), {hash: "3b09aeb6f5f5336beb205d7f720371bc927cd46c21922e334d47ba264acb5ba4", mtimeMs: +time1, size: 6}); // prettier-ignore
});
it("both return an undefined last modification time for a missing file", async () => {
assert.strictEqual(loaders.getSourceLastModified("input/loader/missing.txt"), undefined);
assert.strictEqual(loaders.getOutputLastModified("input/loader/missing.txt"), undefined);
assert.deepStrictEqual(loaders.getSourceInfo("input/loader/missing.txt"), undefined);
assert.deepStrictEqual(loaders.getOutputInfo("input/loader/missing.txt"), undefined);
});
it("returns the last modification time of the loader in preview, and of the cache, on build", async () => {
await utimes("test/input/loader/cached.txt.sh", time1, time1);
await mkdir("test/.observablehq/cache/input/loader/", {recursive: true});
await writeFile("test/.observablehq/cache/input/loader/cached.txt", "2024-03-01 00:00:00");
await utimes("test/.observablehq/cache/input/loader/cached.txt", time2, time2);
assert.strictEqual(loaders.getSourceLastModified("input/loader/cached.txt"), +time1);
assert.strictEqual(loaders.getOutputLastModified("input/loader/cached.txt"), +time2);
assert.deepStrictEqual(loaders.getSourceInfo("input/loader/cached.txt"), {hash: "6493b08929c0ff92d9cf9ea9a03a2c8c74b03800f63c1ec986c40c8bd9a48405", mtimeMs: +time1, size: 29}); // prettier-ignore
assert.deepStrictEqual(loaders.getOutputInfo("input/loader/cached.txt"), {hash: "1174b3f8f206b9be09f89eceea3799f60389d7d62897e8b2767847b2bc259a8c", mtimeMs: +time2, size: 19}); // prettier-ignore
// clean up
try {
await unlink("test/.observablehq/cache/input/loader/cached.txt");
Expand All @@ -147,7 +147,7 @@ describe("LoaderResolver.get{Source,Output}LastModified(path)", () => {
});
it("returns the last modification time of the data loader in preview, and null in build, when there is no cache", async () => {
await utimes("test/input/loader/not-cached.txt.sh", time1, time1);
assert.strictEqual(loaders.getSourceLastModified("input/loader/not-cached.txt"), +time1);
assert.strictEqual(loaders.getOutputLastModified("input/loader/not-cached.txt"), undefined);
assert.deepStrictEqual(loaders.getSourceInfo("input/loader/not-cached.txt"), {hash: "6493b08929c0ff92d9cf9ea9a03a2c8c74b03800f63c1ec986c40c8bd9a48405", mtimeMs: +time1, size: 29}); // prettier-ignore
assert.deepStrictEqual(loaders.getOutputInfo("input/loader/not-cached.txt"), undefined);
});
});
6 changes: 3 additions & 3 deletions test/javascript/module-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,9 +116,9 @@ describe("getFileHash(root, path)", () => {

describe("getFileInfo(root, path)", () => {
it("returns the info for the specified file", () => {
assert.deepStrictEqual(redactFileInfo("test/input/build/files", "file-top.csv"), {hash: "01a7ce0aea79f9cddb22e772b2cc9a9f3229a64a5fd941eec8d8ddc41fb07c34"}); // prettier-ignore
assert.deepStrictEqual(redactFileInfo("test/input/build/archives.posix", "dynamic.zip.sh"), {hash: "516cec2431ce8f1181a7a2a161db8bdfcaea132d3b2c37f863ea6f05d64d1d10"}); // prettier-ignore
assert.deepStrictEqual(redactFileInfo("test/input/build/archives.posix", "static.zip"), {hash: "e6afff224da77b900cfe3ab8789f2283883300e1497548c30af66dfe4c29b429"}); // prettier-ignore
assert.deepStrictEqual(redactFileInfo("test/input/build/files", "file-top.csv"), {hash: "01a7ce0aea79f9cddb22e772b2cc9a9f3229a64a5fd941eec8d8ddc41fb07c34", size: 16}); // prettier-ignore
assert.deepStrictEqual(redactFileInfo("test/input/build/archives.posix", "dynamic.zip.sh"), {hash: "516cec2431ce8f1181a7a2a161db8bdfcaea132d3b2c37f863ea6f05d64d1d10", size: 51}); // prettier-ignore
assert.deepStrictEqual(redactFileInfo("test/input/build/archives.posix", "static.zip"), {hash: "e6afff224da77b900cfe3ab8789f2283883300e1497548c30af66dfe4c29b429", size: 180}); // prettier-ignore
});
it("returns undefined if the specified file is created by a data loader", () => {
assert.strictEqual(getFileInfo("test/input/build/archives.posix", "dynamic.zip"), undefined);
Expand Down
Loading