From 3daa32a75eddeabeae2af6a1f1ab730d3146a9c5 Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Sun, 25 Jan 2026 10:31:03 -0800 Subject: [PATCH] shadow, globals options --- src/index.ts | 1 + src/javascript/assignments.test.ts | 6 ++---- src/javascript/assignments.ts | 5 ++--- src/javascript/files.ts | 10 +++++++--- src/javascript/parse.ts | 27 +++++++++++++++++++++------ src/javascript/references.ts | 21 ++++++--------------- src/javascript/transpile.ts | 5 +++-- src/runtime/define.ts | 8 +++++--- 8 files changed, 47 insertions(+), 36 deletions(-) diff --git a/src/index.ts b/src/index.ts index 783324e..8b758e6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,4 @@ +export * from "./javascript/globals.js"; export * from "./javascript/parse.js"; export * from "./javascript/template.js"; export * from "./javascript/transpile.js"; diff --git a/src/javascript/assignments.test.ts b/src/javascript/assignments.test.ts index 5d8c7ec..8b76fff 100644 --- a/src/javascript/assignments.test.ts +++ b/src/javascript/assignments.test.ts @@ -1,10 +1,8 @@ import {assert, test} from "vitest"; import {parseJavaScript} from "./parse.js"; -import {findReferences} from "./references.js"; -function check(input: string) { - const cell = parseJavaScript(input); - findReferences(cell.body, {input}); +function check(input: string): void { + parseJavaScript(input); } test("allows non-external assignments", () => { diff --git a/src/javascript/assignments.ts b/src/javascript/assignments.ts index 997ee15..1a4040e 100644 --- a/src/javascript/assignments.ts +++ b/src/javascript/assignments.ts @@ -1,5 +1,4 @@ import type {Expression, Identifier, Node, Pattern, VariableDeclaration} from "acorn"; -import {defaultGlobals} from "./globals.js"; import {syntaxError} from "./syntaxError.js"; import {ancestor} from "./walk.js"; @@ -11,12 +10,12 @@ export function checkAssignments( input, locals, references, - globals = defaultGlobals + globals }: { input: string; locals: Map>; - globals?: Set; references: Identifier[]; + globals: Set; } ): void { function isLocal({name}: Identifier, parents: Node[]): boolean { diff --git a/src/javascript/files.ts b/src/javascript/files.ts index 41410b5..178c69b 100644 --- a/src/javascript/files.ts +++ b/src/javascript/files.ts @@ -1,14 +1,18 @@ -import type {Node} from "acorn"; +import type {Identifier, Node} from "acorn"; import {findReferences} from "./references.js"; import {Sourcemap} from "./sourcemap.js"; import {simple} from "./walk.js"; +function isFileAttachment({name}: Identifier): boolean { + return name === "FileAttachment"; +} + export function rewriteFileExpressions(output: Sourcemap, body: Node): void { - const files = new Set(findReferences(body, {filterReference: ({name}) => name === "FileAttachment"})); + const {references} = findReferences(body, {filterReference: isFileAttachment}); simple(body, { CallExpression(node) { const {callee} = node; - if (callee.type !== "Identifier" || !files.has(callee)) return; + if (callee.type !== "Identifier" || !references.includes(callee)) return; const args = node.arguments; if (args.length === 0) return; const [arg] = args; diff --git a/src/javascript/parse.ts b/src/javascript/parse.ts index 11c24fe..d116b16 100644 --- a/src/javascript/parse.ts +++ b/src/javascript/parse.ts @@ -1,14 +1,21 @@ import {Parser, tokTypes} from "acorn"; import type {Expression, Identifier, Options, Program} from "acorn"; -import {findReferences} from "./references.js"; -import {findDeclarations} from "./declarations.js"; +import {checkAssignments} from "./assignments.js"; import {findAwaits} from "./awaits.js"; +import {findDeclarations} from "./declarations.js"; +import {checkExports} from "./imports.js"; +import {defaultGlobals} from "./globals.js"; +import {findReferences} from "./references.js"; export const acornOptions: Options = { ecmaVersion: "latest", sourceType: "module" }; +export type ParseOptions = { + globals?: Set; +}; + // TODO files export interface JavaScriptCell { body: Program | Expression; @@ -18,29 +25,37 @@ export interface JavaScriptCell { async: boolean; // does this use top-level await? } -export function maybeParseJavaScript(input: string): JavaScriptCell | undefined { +export function maybeParseJavaScript(input: string, options?: ParseOptions): JavaScriptCell | undefined { try { - return parseJavaScript(input); + return parseJavaScript(input, options); } catch (error) { if (!(error instanceof SyntaxError)) throw error; return; } } -export function parseJavaScript(input: string): JavaScriptCell { +export function parseJavaScript(input: string, options: ParseOptions = {}): JavaScriptCell { let expression = maybeParseExpression(input); // first attempt to parse as expression if (expression?.type === "ClassExpression" && expression.id) expression = null; // treat named class as program if (expression?.type === "FunctionExpression" && expression.id) expression = null; // treat named function as program const body = expression ?? parseProgram(input); // otherwise parse as a program + const {globals = defaultGlobals} = options; + const {locals, references} = findReferences(body, {filterReference: excludeNames(globals)}); + checkAssignments(body, {locals, references, globals, input}); + checkExports(body, {input}); return { body, declarations: expression ? null : findDeclarations(body as Program, input), - references: findReferences(body, {input}), + references, expression: !!expression, async: findAwaits(body).length > 0 }; } +function excludeNames(excludes: Set): (identifier: Identifier) => boolean { + return (identifier: Identifier) => !excludes.has(identifier.name); +} + function parseProgram(input: string): Program { return Parser.parse(input, acornOptions); } diff --git a/src/javascript/references.ts b/src/javascript/references.ts index 14c3900..456dc0a 100644 --- a/src/javascript/references.ts +++ b/src/javascript/references.ts @@ -3,9 +3,6 @@ import type {BlockStatement, CatchClause, Class} from "acorn"; import type {ForInStatement, ForOfStatement, ForStatement} from "acorn"; import type {FunctionDeclaration, FunctionExpression} from "acorn"; import type {Identifier, Node, Pattern, Program} from "acorn"; -import {checkAssignments} from "./assignments.js"; -import {defaultGlobals} from "./globals.js"; -import {checkExports} from "./imports.js"; import {ancestor} from "./walk.js"; // Based on https://github.com/ForbesLindesay/acorn-globals @@ -42,17 +39,16 @@ function isBlockScope(node: Node): node is FunctionNode | Program | BlockStateme export function findReferences( node: Node, { - input, - globals = defaultGlobals, - filterReference = (identifier: Identifier) => !globals.has(identifier.name), + filterReference = () => true, filterDeclaration = () => true }: { - input?: string; - globals?: Set; filterReference?: (identifier: Identifier) => boolean; filterDeclaration?: (identifier: {name: string}) => boolean; } = {} -): Identifier[] { +): { + locals: Map>; + references: Identifier[]; +} { const locals = new Map>(); const references: Identifier[] = []; @@ -163,10 +159,5 @@ export function findReferences( Identifier: identifier }); - if (input !== undefined) { - checkAssignments(node, {locals, references, globals, input}); - checkExports(node, {input}); - } - - return references; + return {locals, references}; } diff --git a/src/javascript/transpile.ts b/src/javascript/transpile.ts index 7c3bd72..9eaac51 100644 --- a/src/javascript/transpile.ts +++ b/src/javascript/transpile.ts @@ -4,6 +4,7 @@ import {rewriteFileExpressions} from "./files.js"; import {hasImportDeclaration} from "./imports.js"; import {rewriteImportDeclarations, rewriteImportExpressions} from "./imports.js"; import {transpileObservable} from "./observable.js"; +import type {ParseOptions} from "./parse.js"; import {parseJavaScript} from "./parse.js"; import {Sourcemap} from "./sourcemap.js"; import {transpileTemplate} from "./template.js"; @@ -26,7 +27,7 @@ export type TranspiledJavaScript = { automutable?: boolean; }; -export type TranspileOptions = { +export type TranspileOptions = ParseOptions & { /** If true, resolve local imports paths relative to document.baseURI. */ resolveLocalImports?: boolean; /** If true, resolve file using import.meta.url (so Vite treats it as an asset). */ @@ -77,7 +78,7 @@ export function transpileJavaScript( input: string, options?: TranspileOptions ): TranspiledJavaScript { - const cell = parseJavaScript(input); + const cell = parseJavaScript(input, options); let async = cell.async; const inputs = Array.from(new Set(cell.references.map((r) => r.name))); if (hasImportDeclaration(cell.body)) async = true; diff --git a/src/runtime/define.ts b/src/runtime/define.ts index 48ca650..42e5340 100644 --- a/src/runtime/define.ts +++ b/src/runtime/define.ts @@ -1,4 +1,4 @@ -import type {Module, Variable, VariableDefinition} from "@observablehq/runtime"; +import type {Module, Variable, VariableDefinition, VariableOptions} from "@observablehq/runtime"; import type {DisplayState} from "./display.js"; import {clear, display, observe} from "./display.js"; import {input} from "./stdlib/generators/index.js"; @@ -14,6 +14,8 @@ export type Definition = { id: number; /** the cell’s definition function */ body: VariableDefinition; + /** any shadowed variables */ + shadow?: VariableOptions["shadow"]; /** the names of this cell’s inputs (unbound references), if any */ inputs?: string[]; /** the names of this cell’s outputs (top-level declarations), if any */ @@ -31,9 +33,9 @@ export type Definition = { }; export function define(main: Module, state: DefineState, definition: Definition, observer = observe): void { - const {id, body, inputs = [], outputs = [], output, autodisplay, autoview, automutable} = definition; + const {id, body, shadow = {}, inputs = [], outputs = [], output, autodisplay, autoview, automutable} = definition; const variables = state.variables; - const v = main.variable(observer(state, definition), {shadow: {}}); + const v = main.variable(observer(state, definition), {shadow}); const vid = output ?? (outputs.length ? `cell ${id}` : null); state.autoclear = true; if (inputs.includes("display") || inputs.includes("view")) {