From ed3d171fafa6812780d265b0668ba1068ca23329 Mon Sep 17 00:00:00 2001 From: Gordon Smith Date: Sat, 9 Aug 2025 18:38:03 +0100 Subject: [PATCH] Add support for creating body function at runtime Signed-off-by: Gordon Smith --- src/javascript/transpile.test.ts | 12 +++++-- src/javascript/transpile.ts | 56 +++++++++++++++++++++++++++++--- 2 files changed, 62 insertions(+), 6 deletions(-) diff --git a/src/javascript/transpile.test.ts b/src/javascript/transpile.test.ts index f57e566..480b355 100644 --- a/src/javascript/transpile.test.ts +++ b/src/javascript/transpile.test.ts @@ -1,17 +1,22 @@ import {expect, it} from "vitest"; import {transpile} from "./transpile.js"; -it("transpiles JavaScript expressions", () => { +it("transpiles JavaScript expressions", async () => { expect(transpile("1 + 2", "js")).toMatchSnapshot(); + expect(transpile("1 + 2", "js", {concreteBody: true}).body()).toEqual(3); expect(transpile("x + y", "js")).toMatchSnapshot(); + expect(transpile("x + y", "js", {concreteBody: true}).body(3, 5)).toEqual(8); expect(transpile("await z", "js")).toMatchSnapshot(); + expect(await transpile("await z", "js", {concreteBody: true}).body(Promise.resolve(7))).toEqual(7); expect(transpile("display(1), display(2)", "js")).toMatchSnapshot(); }); -it("transpiles JavaScript programs", () => { +it("transpiles JavaScript programs", async () => { expect(transpile("const x = 1, y = 2;", "js")).toMatchSnapshot(); + expect(transpile("const x = 1, y = 2;", "js", {concreteBody: true}).body()).toEqual({x: 1, y: 2}); expect(transpile("x + y;", "js")).toMatchSnapshot(); expect(transpile("await z;", "js")).toMatchSnapshot(); + expect(await transpile("await z", "js", {concreteBody: true}).body(Promise.resolve(7))).toEqual(7); }); it("transpiles static npm: imports", () => { @@ -41,6 +46,9 @@ it("transpiles Observable JavaScript imports", () => { it("transpiles import.meta.resolve", () => { expect(transpile('import.meta.resolve("npm:d3")', "js")).toMatchSnapshot(); + expect(transpile('import.meta.resolve("npm:d3")', "js",{concreteBody: true}).body()).toEqual("https://cdn.jsdelivr.net/npm/d3/+esm"); expect(transpile('import.meta.resolve("./test")', "js", {resolveLocalImports: true})).toMatchSnapshot(); expect(transpile('import.meta.resolve("./test")', "js", {resolveLocalImports: false})).toMatchSnapshot(); + expect(transpile('import.meta.resolve("./test")', "js",{resolveLocalImports: false, concreteBody: true}).body()).toEqual("./test"); }); + diff --git a/src/javascript/transpile.ts b/src/javascript/transpile.ts index ec75129..694c0f6 100644 --- a/src/javascript/transpile.ts +++ b/src/javascript/transpile.ts @@ -6,9 +6,18 @@ import {parseJavaScript} from "./parse.js"; import {Sourcemap} from "./sourcemap.js"; import {transpileTemplate} from "./template.js"; +const FunctionConstructors = { + regular: Object.getPrototypeOf(function () { }).constructor, + async: Object.getPrototypeOf(async function () { }).constructor, + generator: Object.getPrototypeOf(function* () { }).constructor, + asyncGenerator: Object.getPrototypeOf(async function* () { }).constructor, +} as const; + +type ConcreteFunction = (...args: unknown[]) => unknown; + export type TranspiledJavaScript = { - /** the source code of a JavaScript function defining the primary variable */ - body: string; + /** the source code of a JavaScript function defining the primary variable, or the function itself when concreteBody is true */ + body: string | ConcreteFunction; /** any unbound references in body; corresponds to the body arguments, in order */ inputs?: string[]; /** if present, the body returns an object of named outputs; alternative to output */ @@ -28,8 +37,21 @@ export type TranspileOptions = { resolveLocalImports?: boolean; /** If true, resolve file using import.meta.url (so Vite treats it as an asset). */ resolveFiles?: boolean; + /** If true, creates a function instance rather than a string for the body. */ + concreteBody?: boolean; }; +// Overloads for better typing of body depending on concreteBody +export function transpile( + input: string, + mode: Cell["mode"], + options: TranspileOptions & { concreteBody: true } +): Omit & { body: ConcreteFunction }; +export function transpile( + input: string, + mode: Cell["mode"], + options?: TranspileOptions +): Omit & { body: string }; export function transpile( input: string, mode: Cell["mode"], @@ -47,6 +69,29 @@ function transpileMode(input: string, mode: Exclude): strin return transpileTemplate(input, tag, raw); } +function constructFunction(bodyStr: string) { + const { body } = parseJavaScript(bodyStr); + if (body.type !== "FunctionExpression" && body.type !== "ArrowFunctionExpression") { + throw new Error(`Unsupported function type: ${body.type}`); + } + + const func = body.async && body.generator ? + FunctionConstructors.asyncGenerator : + body.async ? + FunctionConstructors.async : + body.generator ? + FunctionConstructors.generator : + FunctionConstructors.regular; + + const params = body.params?.map((param) => bodyStr.slice(param.start, param.end)).join(", ") ?? ""; + const isBlock = body.body.type === "BlockStatement"; + const { start, end } = body.body; + const inner = isBlock + ? bodyStr.slice(start + 1, end - 1) + : `return ${bodyStr.slice(start, end)}`; + return func(params, inner); +} + export function transpileJavaScript( input: string, options?: TranspileOptions @@ -65,7 +110,10 @@ export function transpileJavaScript( if (outputs.length > 0) output.insertRight(input.length, `\nreturn {${outputs}};`); if (cell.expression) output.insertRight(input.length, `\n)`); output.insertRight(input.length, "\n}"); - const body = String(output); + let body: string | ConcreteFunction = String(output); const autodisplay = cell.expression && !(inputs.includes("display") || inputs.includes("view")); - return {body, inputs, outputs, autodisplay}; + if (options?.concreteBody) { + body = constructFunction(body as string); + } + return { body, inputs, outputs, autodisplay }; }