diff --git a/src/javascript/__snapshots__/transpile.test.ts.snap b/src/javascript/__snapshots__/transpile.test.ts.snap index 4461a86..7f66fbb 100644 --- a/src/javascript/__snapshots__/transpile.test.ts.snap +++ b/src/javascript/__snapshots__/transpile.test.ts.snap @@ -1,5 +1,85 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html +exports[`ignores non-bare Observable JavaScript dynamic imports 1`] = ` +{ + "autodisplay": true, + "automutable": false, + "autoview": false, + "body": "function(){return( +import("./local.js") +)}", + "databases": Set {}, + "files": Set {}, + "inputs": [], + "output": undefined, + "secrets": Set {}, +} +`; + +exports[`ignores non-bare Observable JavaScript dynamic imports 2`] = ` +{ + "autodisplay": true, + "automutable": false, + "autoview": false, + "body": "function(){return( +import("../sibling.js") +)}", + "databases": Set {}, + "files": Set {}, + "inputs": [], + "output": undefined, + "secrets": Set {}, +} +`; + +exports[`ignores non-bare Observable JavaScript dynamic imports 3`] = ` +{ + "autodisplay": true, + "automutable": false, + "autoview": false, + "body": "function(){return( +import("/abs.js") +)}", + "databases": Set {}, + "files": Set {}, + "inputs": [], + "output": undefined, + "secrets": Set {}, +} +`; + +exports[`ignores non-bare Observable JavaScript dynamic imports 4`] = ` +{ + "autodisplay": true, + "automutable": false, + "autoview": false, + "body": "function(){return( +import("https://cdn.skypack.dev/canvas-confetti") +)}", + "databases": Set {}, + "files": Set {}, + "inputs": [], + "output": undefined, + "secrets": Set {}, +} +`; + +exports[`ignores non-bare Observable JavaScript dynamic imports 5`] = ` +{ + "autodisplay": true, + "automutable": false, + "autoview": false, + "body": "function(){return( +import("npm:d3") +)}", + "databases": Set {}, + "files": Set {}, + "inputs": [], + "output": undefined, + "secrets": Set {}, +} +`; + exports[`transpiles JavaScript expressions 1`] = ` { "autodisplay": true, @@ -129,6 +209,102 @@ await z; } `; +exports[`transpiles Observable JavaScript dynamic imports 1`] = ` +{ + "autodisplay": true, + "automutable": false, + "autoview": false, + "body": "function(){return( +import("https://cdn.jsdelivr.net/npm/d3/+esm") +)}", + "databases": Set {}, + "files": Set {}, + "inputs": [], + "output": undefined, + "secrets": Set {}, +} +`; + +exports[`transpiles Observable JavaScript dynamic imports 2`] = ` +{ + "autodisplay": true, + "automutable": false, + "autoview": false, + "body": "function(){return( +import("https://cdn.jsdelivr.net/npm/d3@7/+esm") +)}", + "databases": Set {}, + "files": Set {}, + "inputs": [], + "output": undefined, + "secrets": Set {}, +} +`; + +exports[`transpiles Observable JavaScript dynamic imports 3`] = ` +{ + "autodisplay": true, + "automutable": false, + "autoview": false, + "body": "function(){return( +import("https://cdn.jsdelivr.net/npm/@observablehq/plot/+esm") +)}", + "databases": Set {}, + "files": Set {}, + "inputs": [], + "output": undefined, + "secrets": Set {}, +} +`; + +exports[`transpiles Observable JavaScript dynamic imports 4`] = ` +{ + "autodisplay": true, + "automutable": false, + "autoview": false, + "body": "function(){return( +import("https://cdn.jsdelivr.net/npm/lodash/fp/+esm") +)}", + "databases": Set {}, + "files": Set {}, + "inputs": [], + "output": undefined, + "secrets": Set {}, +} +`; + +exports[`transpiles Observable JavaScript dynamic imports 5`] = ` +{ + "autodisplay": true, + "automutable": false, + "autoview": false, + "body": "function(){return( +import("https://cdn.jsdelivr.net/npm/three@0.150.1/examples/jsm/controls/OrbitControls.js/+esm") +)}", + "databases": Set {}, + "files": Set {}, + "inputs": [], + "output": undefined, + "secrets": Set {}, +} +`; + +exports[`transpiles Observable JavaScript dynamic imports 6`] = ` +{ + "autodisplay": true, + "automutable": false, + "autoview": false, + "body": "function(){return( +import("https://cdn.jsdelivr.net/npm/leaflet/dist/leaflet.css") +)}", + "databases": Set {}, + "files": Set {}, + "inputs": [], + "output": undefined, + "secrets": Set {}, +} +`; + exports[`transpiles Observable JavaScript import-withs 1`] = ` { "autodisplay": false, diff --git a/src/javascript/features.ts b/src/javascript/features.ts index d448e3d..0a7f930 100644 --- a/src/javascript/features.ts +++ b/src/javascript/features.ts @@ -1,5 +1,5 @@ import type {Node, CallExpression} from "acorn"; -import {getStringLiteralValue, isStringLiteral} from "./literal.js"; +import {getStringLiteralValue, isStringLiteral} from "./strings.js"; import {findReferences} from "./references.js"; import {syntaxError} from "./syntaxError.js"; import {simple} from "./walk.js"; diff --git a/src/javascript/imports/npm.test.ts b/src/javascript/imports/npm.test.ts index 0dcb824..f313ed3 100644 --- a/src/javascript/imports/npm.test.ts +++ b/src/javascript/imports/npm.test.ts @@ -34,6 +34,14 @@ test("adds /+esm as expected", () => { resolveNpmImport("npm:prettier/plugins/acorn.js/+esm"), "https://cdn.jsdelivr.net/npm/prettier/plugins/acorn.js/+esm" // preserve existing /+esm ); + assert.strictEqual( + resolveNpmImport("npm:leaflet/dist/leaflet.css"), + "https://cdn.jsdelivr.net/npm/leaflet/dist/leaflet.css" // no /+esm because non-JS file + ); + assert.strictEqual( + resolveNpmImport("npm:foo/data.json"), + "https://cdn.jsdelivr.net/npm/foo/data.json" // no /+esm because non-JS file + ); assert.strictEqual( resolveNpmImport("npm:prettier/plugins/estree"), "https://cdn.jsdelivr.net/npm/prettier/plugins/estree/+esm" diff --git a/src/javascript/literal.ts b/src/javascript/literal.ts deleted file mode 100644 index dc970e1..0000000 --- a/src/javascript/literal.ts +++ /dev/null @@ -1,55 +0,0 @@ -import type {BinaryExpression, Literal, MemberExpression, Node, TemplateLiteral} from "acorn"; - -export type StringLiteral = (Literal & {value: string}) | TemplateLiteral | BinaryExpression; - -export function isLiteral(node: Node): node is Literal { - return node.type === "Literal"; -} - -export function isTemplateLiteral(node: Node): node is TemplateLiteral { - return node.type === "TemplateLiteral"; -} - -export function isStringLiteral(node: Node): node is StringLiteral { - return isLiteral(node) - ? /^['"]/.test(node.raw!) - : isTemplateLiteral(node) - ? node.expressions.every(isStringLiteral) - : isBinaryExpression(node) - ? node.operator === "+" && isStringLiteral(node.left) && isStringLiteral(node.right) - : isMemberExpression(node) - ? "value" in node // param - : false; -} - -export function getStringLiteralValue(node: StringLiteral): string { - return node.type === "TemplateLiteral" - ? getTemplateLiteralValue(node) - : node.type === "BinaryExpression" - ? getBinaryExpressionValue(node) - : node.value; // Literal or ParamReference -} - -function getTemplateLiteralValue(node: TemplateLiteral): string { - let value = node.quasis[0].value.cooked!; - for (let i = 0; i < node.expressions.length; ++i) { - value += getStringLiteralValue(node.expressions[i] as StringLiteral); - value += node.quasis[i + 1].value.cooked!; - } - return value; -} - -function getBinaryExpressionValue(node: BinaryExpression): string { - return ( - getStringLiteralValue(node.left as StringLiteral) + - getStringLiteralValue(node.right as StringLiteral) - ); -} - -function isMemberExpression(node: Node): node is MemberExpression { - return node.type === "MemberExpression"; -} - -function isBinaryExpression(node: Node): node is BinaryExpression { - return node.type === "BinaryExpression"; -} diff --git a/src/javascript/observable.ts b/src/javascript/observable.ts index 6e8ddeb..59b6073 100644 --- a/src/javascript/observable.ts +++ b/src/javascript/observable.ts @@ -4,7 +4,9 @@ import {parseCell} from "@observablehq/parser"; import type {Identifier, ImportDeclaration, Node} from "acorn"; import {rewriteFileExpressions} from "./files.js"; import {flatMapImportSpecifiers, rewriteImportDeclarations} from "./imports.js"; +import {resolveNpmImport} from "./imports/npm.js"; import {Sourcemap} from "./sourcemap.js"; +import {getStringLiteralValue, isStringLiteral} from "./strings.js"; import type {TranspiledJavaScript, TranspileOptions} from "./transpile.js"; import {transpileJavaScript} from "./transpile.js"; import {simple} from "./walk.js"; @@ -19,6 +21,7 @@ export function transpileObservable( if (cell.tag) throw new Error("tagged ojs cells are not supported"); const output = new Sourcemap(input).trim(); rewriteSpecialReferences(output, cell.body); + rewriteDynamicImports(output, cell.body); if (options?.resolveFiles) rewriteFileExpressions(output, cell.body); const inputs = Array.from(new Set(cell.references.map(asReference))); let start = ""; @@ -92,6 +95,24 @@ function transformObservableImport(body: ImportDeclaration): void { ]; } +/** + * Rewrite a bare dynamic import such as import("d3") to a CDN URL, like + * import("https://cdn.jsdelivr.net/npm/d3/+esm"), using the same resolution as + * static imports. Protocol (npm:, https:, …) and local imports are left alone. + */ +function rewriteDynamicImports(output: Sourcemap, body: Node): void { + simple(body, { + ImportExpression({source}) { + if (!isStringLiteral(source)) return; + let value = getStringLiteralValue(source); + if (/^(\w+:|\.?\.?\/)/.test(value)) return; + if (/\.(js|mjs|cjs)$/.test(value)) value += "/+esm"; + const resolution = resolveNpmImport(`npm:${value}`); + output.replaceLeft(source.start, source.end, JSON.stringify(resolution)); + } + }); +} + /** Rewrite viewof x ↦ viewof$x, and mutable x ↦ mutable$x.value. */ function rewriteSpecialReferences(output: Sourcemap, body: Node): void { simple(body, { diff --git a/src/javascript/transpile.test.ts b/src/javascript/transpile.test.ts index 5378085..2c2dec4 100644 --- a/src/javascript/transpile.test.ts +++ b/src/javascript/transpile.test.ts @@ -84,6 +84,23 @@ it("transpiles Observable JavaScript import-withs of views and mutables", () => expect(transpile('import {color} with {foo, viewof bar, mutable baz as qux} from "2d6bf7be248d66f3"', "ojs")).toMatchSnapshot(); }); +it("transpiles Observable JavaScript dynamic imports", () => { + expect(transpile('import("d3")', "ojs")).toMatchSnapshot(); + expect(transpile('import("d3@7")', "ojs")).toMatchSnapshot(); + expect(transpile('import("@observablehq/plot")', "ojs")).toMatchSnapshot(); + expect(transpile('import("lodash/fp")', "ojs")).toMatchSnapshot(); + expect(transpile('import("three@0.150.1/examples/jsm/controls/OrbitControls.js")', "ojs")).toMatchSnapshot(); + expect(transpile('import("leaflet/dist/leaflet.css")', "ojs")).toMatchSnapshot(); +}); + +it("ignores non-bare Observable JavaScript dynamic imports", () => { + expect(transpile('import("./local.js")', "ojs")).toMatchSnapshot(); + expect(transpile('import("../sibling.js")', "ojs")).toMatchSnapshot(); + expect(transpile('import("/abs.js")', "ojs")).toMatchSnapshot(); + expect(transpile('import("https://cdn.skypack.dev/canvas-confetti")', "ojs")).toMatchSnapshot(); + expect(transpile('import("npm:d3")', "ojs")).toMatchSnapshot(); +}); + it("transpiles import.meta.resolve", () => { expect(transpile('import.meta.resolve("npm:d3")', "js")).toMatchSnapshot(); expect(transpile('import.meta.resolve("./test")', "js", {resolveLocalImports: true})).toMatchSnapshot();