Skip to content

Commit

Permalink
static import.meta.resolve
Browse files Browse the repository at this point in the history
  • Loading branch information
mbostock committed Mar 1, 2024
1 parent b4aa2ec commit 51dfd9a
Show file tree
Hide file tree
Showing 14 changed files with 219 additions and 39 deletions.
6 changes: 3 additions & 3 deletions src/client/stdlib/fileAttachment.js
Original file line number Diff line number Diff line change
@@ -1,18 +1,18 @@
const files = new Map();

export function registerFile(name, file) {
const url = String(new URL(name, location.href));
const url = new URL(name, location).href;
if (file == null) files.delete(url);
else files.set(url, file);
}

export function FileAttachment(name, base = location.href) {
if (new.target !== undefined) throw new TypeError("FileAttachment is not a constructor");
const url = String(new URL(name, base));
const url = new URL(name, base).href;
const file = files.get(url);
if (!file) throw new Error(`File not found: ${name}`);
const {path, mimeType} = file;
return new FileAttachmentImpl(String(new URL(path, base)), name.split("/").pop(), mimeType);
return new FileAttachmentImpl(new URL(path, base).href, name.split("/").pop(), mimeType);
}

async function remote_fetch(file) {
Expand Down
34 changes: 31 additions & 3 deletions src/javascript/imports.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type {Node} from "acorn";
import type {CallExpression} from "acorn";
import type {ExportAllDeclaration, ExportNamedDeclaration, ImportDeclaration, ImportExpression} from "acorn";
import {simple} from "acorn-walk";
import {isPathImport, relativePath, resolveLocalPath} from "../path.js";
Expand Down Expand Up @@ -62,12 +63,14 @@ export function findImports(body: Node, path: string, input: string): ImportRefe
ImportDeclaration: findImport,
ImportExpression: findImport,
ExportAllDeclaration: findImport,
ExportNamedDeclaration: findImport
ExportNamedDeclaration: findImport,
CallExpression: findImportMetaResolve
});

function findImport(node: ImportNode | ExportNode) {
if (!node.source || !isStringLiteral(node.source)) return;
const name = decodeURIComponent(getStringLiteralValue(node.source));
const source = node.source;
if (!source || !isStringLiteral(source)) return;
const name = decodeURIComponent(getStringLiteralValue(source));
const method = node.type === "ImportExpression" ? "dynamic" : "static";
if (isPathImport(name)) {
const localPath = resolveLocalPath(path, name);
Expand All @@ -78,5 +81,30 @@ export function findImports(body: Node, path: string, input: string): ImportRefe
}
}

function findImportMetaResolve(node: CallExpression) {
const source = node.arguments[0];
if (!isImportMetaResolve(node) || !isStringLiteral(source)) return;
const name = decodeURIComponent(getStringLiteralValue(source));
if (isPathImport(name)) {
const localPath = resolveLocalPath(path, name);
if (!localPath) throw syntaxError(`non-local import: ${name}`, node, input); // prettier-ignore
imports.push({name: relativePath(path, localPath), type: "local", method: "dynamic"});
} else {
imports.push({name, type: "global", method: "dynamic"});
}
}

return imports;
}

export function isImportMetaResolve(node: CallExpression): boolean {
return (
node.callee.type === "MemberExpression" &&
node.callee.object.type === "MetaProperty" &&
node.callee.object.meta.name === "import" &&
node.callee.object.property.name === "meta" &&
node.callee.property.type === "Identifier" &&
node.callee.property.name === "resolve" &&
node.arguments.length > 0
);
}
10 changes: 6 additions & 4 deletions src/javascript/source.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import type {Literal, Node, TemplateLiteral} from "acorn";

export type StringLiteral =
| {type: "Literal"; value: string} // FileAttachment("foo.csv")
| {type: "TemplateLiteral"; quasis: {value: {cooked: string}}[]}; // FileAttachment(`foo.csv`)
export type StringLiteral = (
| {type: "Literal"; value: string}
| {type: "TemplateLiteral"; quasis: {value: {cooked: string}}[]}
) &
Node;

export function isLiteral(node: Node): node is Literal {
return node.type === "Literal";
Expand All @@ -12,7 +14,7 @@ export function isTemplateLiteral(node: Node): node is TemplateLiteral {
return node.type === "TemplateLiteral";
}

export function isStringLiteral(node: Node): node is StringLiteral & Node {
export function isStringLiteral(node: Node): node is StringLiteral {
return isLiteral(node) ? /^['"]/.test(node.raw!) : isTemplateLiteral(node) ? node.expressions.length === 0 : false;
}

Expand Down
57 changes: 45 additions & 12 deletions src/javascript/transpile.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import {join} from "node:path/posix";
import type {ImportDeclaration, ImportDefaultSpecifier, ImportNamespaceSpecifier, ImportSpecifier, Node} from "acorn";
import type {CallExpression, Node} from "acorn";
import type {ImportDeclaration, ImportDefaultSpecifier, ImportNamespaceSpecifier, ImportSpecifier} from "acorn";
import {Parser} from "acorn";
import {simple} from "acorn-walk";
import {relativePath, resolvePath} from "../path.js";
import {isPathImport, relativePath, resolvePath} from "../path.js";
import {getModuleResolver} from "../resolvers.js";
import {Sourcemap} from "../sourcemap.js";
import {findFiles} from "./files.js";
import type {ExportNode, ImportNode} from "./imports.js";
import {hasImportDeclaration} from "./imports.js";
import {hasImportDeclaration, isImportMetaResolve} from "./imports.js";
import type {JavaScriptNode} from "./parse.js";
import {parseOptions} from "./parse.js";
import type {StringLiteral} from "./source.js";
Expand Down Expand Up @@ -54,27 +55,46 @@ export async function transpileModule(
const body = Parser.parse(input, parseOptions); // TODO ignore syntax error?
const output = new Sourcemap(input);
const imports: (ImportNode | ExportNode)[] = [];
const calls: CallExpression[] = [];

simple(body, {
ImportDeclaration: rewriteImport,
ImportExpression: rewriteImport,
ExportAllDeclaration: rewriteImport,
ExportNamedDeclaration: rewriteImport
ExportNamedDeclaration: rewriteImport,
CallExpression: rewriteCall
});

function rewriteImport(node: ImportNode | ExportNode) {
imports.push(node);
}

function rewriteCall(node: CallExpression) {
calls.push(node);
}

async function rewriteImportSource(source: StringLiteral) {
const specifier = getStringLiteralValue(source);
output.replaceLeft(source.start, source.end, JSON.stringify(await resolveImport(specifier)));
}

for (const {name, node} of findFiles(body, path, input)) {
const source = node.arguments[0];
const p = relativePath(servePath, resolvePath(path, name));
output.replaceLeft(node.arguments[0].start, node.arguments[0].end, `${JSON.stringify(p)}, import.meta.url`);
output.replaceLeft(source.start, source.end, `${JSON.stringify(p)}, import.meta.url`);
}

for (const node of imports) {
if (node.source && isStringLiteral(node.source)) {
const specifier = getStringLiteralValue(node.source);
output.replaceLeft(node.source.start, node.source.end, JSON.stringify(await resolveImport(specifier)));
const source = node.source;
if (source && isStringLiteral(source)) {
await rewriteImportSource(source);
}
}

for (const node of calls) {
const source = node.arguments[0];
if (isImportMetaResolve(node) && isStringLiteral(source)) {
await rewriteImportSource(source);
}
}

Expand All @@ -86,13 +106,26 @@ function rewriteImportExpressions(
body: Node,
resolve: (specifier: string) => string = String
): void {
function rewriteImportSource(source: StringLiteral) {
output.replaceLeft(source.start, source.end, JSON.stringify(resolve(getStringLiteralValue(source))));
}
simple(body, {
ImportExpression(node) {
if (isStringLiteral(node.source)) {
const source = node.source;
if (isStringLiteral(source)) {
rewriteImportSource(source);
}
},
CallExpression(node) {
const source = node.arguments[0];
if (isImportMetaResolve(node) && isStringLiteral(source)) {
const resolution = resolve(getStringLiteralValue(source));
output.replaceLeft(
node.source.start,
node.source.end,
JSON.stringify(resolve(getStringLiteralValue(node.source)))
node.start,
node.end,
isPathImport(resolution)
? `new URL(${JSON.stringify(resolution)}, location).href`
: JSON.stringify(resolution)
);
}
}
Expand Down
31 changes: 22 additions & 9 deletions src/npm.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import {existsSync} from "node:fs";
import {mkdir, readFile, readdir, writeFile} from "node:fs/promises";
import {dirname, join} from "node:path";
import type {CallExpression} from "acorn";
import {Parser} from "acorn";
import {simple} from "acorn-walk";
import {rsort, satisfies} from "semver";
import {isEnoent} from "./error.js";
import type {ExportNode, ImportNode, ImportReference} from "./javascript/imports.js";
import {findImports} from "./javascript/imports.js";
import {findImports, isImportMetaResolve} from "./javascript/imports.js";
import {parseOptions} from "./javascript/parse.js";
import type {StringLiteral} from "./javascript/source.js";
import {getStringLiteralValue, isStringLiteral} from "./javascript/source.js";
import {relativePath} from "./path.js";
import {Sourcemap} from "./sourcemap.js";
Expand Down Expand Up @@ -43,18 +45,29 @@ export function rewriteNpmImports(input: string, path: string): string {
ImportDeclaration: rewriteImport,
ImportExpression: rewriteImport,
ExportAllDeclaration: rewriteImport,
ExportNamedDeclaration: rewriteImport
ExportNamedDeclaration: rewriteImport,
CallExpression: rewriteImportMetaResolve
});

function rewriteImport(node: ImportNode | ExportNode) {
if (node.source && isStringLiteral(node.source)) {
let value = getStringLiteralValue(node.source);
if (value.startsWith("/npm/")) {
value = `/_npm/${value.slice("/npm/".length)}`;
if (value.endsWith("/+esm")) value += ".js";
value = relativePath(path, value);
output.replaceLeft(node.source.start, node.source.end, JSON.stringify(value));
}
rewriteImportSource(node.source);
}
}

function rewriteImportMetaResolve(node: CallExpression) {
if (isImportMetaResolve(node) && isStringLiteral(node.arguments[0])) {
rewriteImportSource(node.arguments[0]);
}
}

function rewriteImportSource(source: StringLiteral) {
let value = getStringLiteralValue(source);
if (value.startsWith("/npm/")) {
value = `/_npm/${value.slice("/npm/".length)}`;
if (value.endsWith("/+esm")) value += ".js";
value = relativePath(path, value);
output.replaceLeft(source.start, source.end, JSON.stringify(value));
}
}

Expand Down
6 changes: 3 additions & 3 deletions src/resolvers.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {createHash} from "node:crypto";
import {join} from "node:path/posix";
import {extname, join} from "node:path/posix";
import {findAssets} from "./html.js";
import {defaultGlobals} from "./javascript/globals.js";
import {getFileHash, getModuleHash, getModuleInfo} from "./javascript/module.js";
Expand Down Expand Up @@ -215,7 +215,7 @@ export async function getResolvers(page: MarkdownPage, {root, path}: {root: stri
: builtins.has(specifier)
? relativePath(path, builtins.get(specifier)!)
: specifier.startsWith("observablehq:")
? relativePath(path, `/_observablehq/${specifier.slice("observablehq:".length)}.js`)
? relativePath(path, `/_observablehq/${specifier.slice("observablehq:".length)}${extname(specifier) ? "" : ".js"}`) // prettier-ignore
: resolutions.has(specifier)
? relativePath(path, resolutions.get(specifier)!)
: specifier.startsWith("npm:")
Expand Down Expand Up @@ -267,7 +267,7 @@ export function getModuleResolver(root: string, path: string): (specifier: strin
: builtins.has(specifier)
? relativePath(servePath, builtins.get(specifier)!)
: specifier.startsWith("observablehq:")
? relativePath(servePath, `/_observablehq/${specifier.slice("observablehq:".length)}.js`)
? relativePath(servePath, `/_observablehq/${specifier.slice("observablehq:".length)}${extname(specifier) ? "" : ".js"}`) // prettier-ignore
: specifier.startsWith("npm:")
? relativePath(servePath, await resolveNpmImport(root, specifier.slice("npm:".length)))
: specifier;
Expand Down
3 changes: 2 additions & 1 deletion src/rollup.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import {extname} from "node:path/posix";
import {nodeResolve} from "@rollup/plugin-node-resolve";
import {type CallExpression} from "acorn";
import {simple} from "acorn-walk";
Expand Down Expand Up @@ -100,7 +101,7 @@ function importResolve(input: string, root: string, path: string): Plugin {
return typeof specifier !== "string" || specifier === input
? null
: specifier.startsWith("observablehq:")
? {id: relativePath(path, `/_observablehq/${specifier.slice("observablehq:".length)}.js`), external: true}
? {id: relativePath(path, `/_observablehq/${specifier.slice("observablehq:".length)}${extname(specifier) ? "" : ".js"}`), external: true} // prettier-ignore
: specifier === "npm:@observablehq/runtime"
? {id: relativePath(path, "/_observablehq/runtime.js"), external: true}
: specifier === "npm:@observablehq/stdlib" || specifier === "@observablehq/stdlib"
Expand Down
1 change: 1 addition & 0 deletions test/input/imports/npm-meta-resolve-import.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
const foo = import.meta.resolve("npm:d3");
6 changes: 5 additions & 1 deletion test/javascript/transpile-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ function isJsFile(inputRoot: string, fileName: string) {
return statSync(path).isFile();
}

function mockResolveImport(specifier: string): string {
return specifier.replace(/^npm:/, "https://cdn.jsdelivr.net/npm/");
}

function runTests(inputRoot: string, outputRoot: string, filter: (name: string) => boolean = () => true) {
for (const name of readdirSync(inputRoot)) {
if (!isJsFile(inputRoot, name) || !filter(name)) continue;
Expand All @@ -29,7 +33,7 @@ function runTests(inputRoot: string, outputRoot: string, filter: (name: string)

try {
const node = parseJavaScript(input, {path: name});
actual = transpileJavaScript(node, {id: "0"});
actual = transpileJavaScript(node, {id: "0", resolveImport: mockResolveImport});
} catch (error) {
if (!(error instanceof SyntaxError)) throw error;
actual = `define({id: "0", body: () => { throw new SyntaxError(${JSON.stringify(error.message)}); }});\n`;
Expand Down
2 changes: 1 addition & 1 deletion test/output/imports/dynamic-npm-import.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
define({id: "0", outputs: ["confetti"], body: async () => {
const {default: confetti} = await import("npm:canvas-confetti");
const {default: confetti} = await import("https://cdn.jsdelivr.net/npm/canvas-confetti");

confetti();
return {confetti};
Expand Down
4 changes: 4 additions & 0 deletions test/output/imports/npm-meta-resolve-import.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
define({id: "0", outputs: ["foo"], body: () => {
const foo = "https://cdn.jsdelivr.net/npm/d3";
return {foo};
}});
Loading

0 comments on commit 51dfd9a

Please sign in to comment.