Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

file.size #1608

Merged
merged 6 commits into from
Aug 27, 2024
Merged
Show file tree
Hide file tree
Changes from all 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 @@ -69,8 +70,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 @@ -104,24 +105,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