Skip to content

Commit 84ebb50

Browse files
committed
resolve npm imports
1 parent baa7357 commit 84ebb50

29 files changed

+196
-136
lines changed

src/build.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ export async function build(
112112
continue;
113113
}
114114
effects.output.write(`${faint("copy")} ${sourcePath} ${faint("→")} `);
115-
const contents = rewriteModule(await readFile(sourcePath, "utf-8"), file, importResolver);
115+
const contents = await rewriteModule(await readFile(sourcePath, "utf-8"), file, importResolver);
116116
await effects.writeFile(outputPath, contents);
117117
}
118118

src/javascript.ts

Lines changed: 29 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ import {findDeclarations} from "./javascript/declarations.js";
88
import {findFeatures} from "./javascript/features.js";
99
import {rewriteFetches} from "./javascript/fetches.js";
1010
import {defaultGlobals} from "./javascript/globals.js";
11-
import {createImportResolver, findExports, findImports, rewriteImports} from "./javascript/imports.js";
11+
import {findExports, findImportDeclarations, findImports} from "./javascript/imports.js";
12+
import {createImportResolver, rewriteImports} from "./javascript/imports.js";
1213
import {findReferences} from "./javascript/references.js";
1314
import {syntaxError} from "./javascript/syntaxError.js";
1415
import {Sourcemap} from "./sourcemap.js";
@@ -35,17 +36,24 @@ export interface Feature {
3536
name: string;
3637
}
3738

38-
export interface Transpile {
39+
export interface BaseTranspile {
3940
id: string;
4041
inputs?: string[];
4142
outputs?: string[];
4243
inline?: boolean;
43-
body: string;
4444
databases?: DatabaseReference[];
4545
files?: FileReference[];
4646
imports?: ImportReference[];
4747
}
4848

49+
export interface PendingTranspile extends BaseTranspile {
50+
body: Promise<string>;
51+
}
52+
53+
export interface Transpile extends BaseTranspile {
54+
body: string;
55+
}
56+
4957
export interface ParseOptions {
5058
id: string;
5159
root: string;
@@ -56,7 +64,7 @@ export interface ParseOptions {
5664
verbose?: boolean;
5765
}
5866

59-
export function transpileJavaScript(input: string, options: ParseOptions): Transpile {
67+
export function transpileJavaScript(input: string, options: ParseOptions): PendingTranspile {
6068
const {id, root, sourcePath, verbose = true} = options;
6169
try {
6270
const node = parseJavaScript(input, options);
@@ -67,25 +75,30 @@ export function transpileJavaScript(input: string, options: ParseOptions): Trans
6775
.filter((f) => f.type === "FileAttachment")
6876
.map(({name}) => fileReference(name, sourcePath));
6977
const inputs = Array.from(new Set<string>(node.references.map((r) => r.name)));
70-
const output = new Sourcemap(input);
71-
trim(output, input);
72-
if (node.expression && !inputs.includes("display") && !inputs.includes("view")) {
73-
output.insertLeft(0, "display((\n");
74-
output.insertRight(input.length, "\n))");
75-
inputs.push("display");
76-
}
77-
rewriteImports(output, node, sourcePath, createImportResolver(root, "_import"));
78-
rewriteFetches(output, node, sourcePath);
78+
const implicitDisplay = node.expression && !inputs.includes("display") && !inputs.includes("view");
79+
if (implicitDisplay) inputs.push("display");
80+
if (findImportDeclarations(node).length > 0) node.async = true;
7981
return {
8082
id,
8183
...(inputs.length ? {inputs} : null),
8284
...(options.inline ? {inline: true} : null),
8385
...(node.declarations?.length ? {outputs: node.declarations.map(({name}) => name)} : null),
8486
...(databases.length ? {databases: resolveDatabases(databases)} : null),
8587
...(files.length ? {files} : null),
86-
body: `${node.async ? "async " : ""}(${inputs}) => {
88+
body: (async () => {
89+
const output = new Sourcemap(input);
90+
trim(output, input);
91+
if (implicitDisplay) {
92+
output.insertLeft(0, "display((\n");
93+
output.insertRight(input.length, "\n))");
94+
}
95+
await rewriteImports(output, node, sourcePath, createImportResolver(root, "_import"));
96+
rewriteFetches(output, node, sourcePath);
97+
const result = `${node.async ? "async " : ""}(${inputs}) => {
8798
${String(output)}${node.declarations?.length ? `\nreturn {${node.declarations.map(({name}) => name)}};` : ""}
88-
}`,
99+
}`;
100+
return result;
101+
})(),
89102
...(node.imports.length ? {imports: node.imports} : null)
90103
};
91104
} catch (error) {
@@ -104,7 +117,7 @@ ${String(output)}${node.declarations?.length ? `\nreturn {${node.declarations.ma
104117
if (verbose) console.error(red(`${error.name}: ${message}`));
105118
return {
106119
id: `${id}`,
107-
body: `() => { throw new SyntaxError(${JSON.stringify(error.message)}); }`
120+
body: Promise.resolve(`() => { throw new SyntaxError(${JSON.stringify(error.message)}); }`)
108121
};
109122
}
110123
}

src/javascript/imports.ts

Lines changed: 69 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -145,10 +145,11 @@ export function parseLocalImports(root: string, paths: string[]): ImportsAndFetc
145145
}
146146

147147
/** Rewrites import specifiers in the specified ES module source. */
148-
export function rewriteModule(input: string, sourcePath: string, resolver: ImportResolver): string {
148+
export async function rewriteModule(input: string, sourcePath: string, resolver: ImportResolver): Promise<string> {
149149
const body = Parser.parse(input, parseOptions) as Program;
150150
const references: Identifier[] = findReferences(body, defaultGlobals);
151151
const output = new Sourcemap(input);
152+
const imports: (ImportDeclaration | ImportExpression | ExportAllDeclaration | ExportNamedDeclaration)[] = [];
152153

153154
simple(body, {
154155
ImportDeclaration: rewriteImport,
@@ -161,59 +162,87 @@ export function rewriteModule(input: string, sourcePath: string, resolver: Impor
161162
});
162163

163164
function rewriteImport(node: ImportDeclaration | ImportExpression | ExportAllDeclaration | ExportNamedDeclaration) {
165+
imports.push(node);
166+
}
167+
168+
for (const node of imports) {
164169
if (isStringLiteral(node.source)) {
165170
output.replaceLeft(
166171
node.source.start,
167172
node.source.end,
168-
JSON.stringify(resolver(sourcePath, getStringLiteralValue(node.source)))
173+
JSON.stringify(await resolver(sourcePath, getStringLiteralValue(node.source)))
169174
);
170175
}
171176
}
172177

173178
return String(output);
174179
}
175180

181+
export function findImportDeclarations(cell: JavaScriptNode): ImportDeclaration[] {
182+
const declarations: ImportDeclaration[] = [];
183+
184+
simple(cell.body, {
185+
ImportDeclaration(node) {
186+
if (isStringLiteral(node.source)) {
187+
declarations.push(node);
188+
}
189+
}
190+
});
191+
192+
return declarations;
193+
}
194+
176195
/**
177196
* Rewrites import specifiers in the specified JavaScript fenced code block or
178197
* inline expression. TODO parallelize multiple static imports.
179198
*/
180-
export function rewriteImports(
199+
export async function rewriteImports(
181200
output: Sourcemap,
182201
cell: JavaScriptNode,
183202
sourcePath: string,
184203
resolver: ImportResolver
185-
): void {
204+
): Promise<void> {
205+
const expressions: ImportExpression[] = [];
206+
const declarations: ImportDeclaration[] = [];
207+
186208
simple(cell.body, {
187209
ImportExpression(node) {
188210
if (isStringLiteral(node.source)) {
189-
output.replaceLeft(
190-
node.source.start,
191-
node.source.end,
192-
JSON.stringify(resolver(sourcePath, getStringLiteralValue(node.source)))
193-
);
211+
expressions.push(node);
194212
}
195213
},
196214
ImportDeclaration(node) {
197215
if (isStringLiteral(node.source)) {
198-
cell.async = true;
199-
output.replaceLeft(
200-
node.start,
201-
node.end,
202-
`const ${
203-
node.specifiers.some(isNotNamespaceSpecifier)
204-
? `{${node.specifiers.filter(isNotNamespaceSpecifier).map(rewriteImportSpecifier).join(", ")}}`
205-
: node.specifiers.find(isNamespaceSpecifier)?.local.name ?? "{}"
206-
} = await import(${JSON.stringify(resolver(sourcePath, getStringLiteralValue(node.source)))});`
207-
);
216+
declarations.push(node);
208217
}
209218
}
210219
});
220+
221+
for (const node of expressions) {
222+
output.replaceLeft(
223+
node.source.start,
224+
node.source.end,
225+
JSON.stringify(await resolver(sourcePath, getStringLiteralValue(node.source)))
226+
);
227+
}
228+
229+
for (const node of declarations) {
230+
output.replaceLeft(
231+
node.start,
232+
node.end,
233+
`const ${
234+
node.specifiers.some(isNotNamespaceSpecifier)
235+
? `{${node.specifiers.filter(isNotNamespaceSpecifier).map(rewriteImportSpecifier).join(", ")}}`
236+
: node.specifiers.find(isNamespaceSpecifier)?.local.name ?? "{}"
237+
} = await import(${JSON.stringify(await resolver(sourcePath, getStringLiteralValue(node.source)))});`
238+
);
239+
}
211240
}
212241

213-
export type ImportResolver = (path: string, specifier: string) => string;
242+
export type ImportResolver = (path: string, specifier: string) => Promise<string>;
214243

215244
export function createImportResolver(root: string, base: "." | "_import" = "."): ImportResolver {
216-
return (path, specifier) => {
245+
return async (path, specifier) => {
217246
return isLocalImport(specifier, path)
218247
? relativeUrl(path, resolvePath(base, path, resolveImportHash(root, path, specifier)))
219248
: specifier === "npm:@observablehq/runtime"
@@ -233,11 +262,29 @@ export function createImportResolver(root: string, base: "." | "_import" = "."):
233262
: specifier === "npm:@observablehq/xslx"
234263
? resolveBuiltin(base, path, "stdlib/xslx.js") // TODO publish to npm
235264
: specifier.startsWith("npm:")
236-
? `https://cdn.jsdelivr.net/npm/${specifier.slice("npm:".length)}/+esm`
265+
? await resolveNpmImport(specifier.slice("npm:".length))
237266
: specifier;
238267
};
239268
}
240269

270+
// Like import, don’t fetch the same package more than once to ensure
271+
// consistency; restart the server if you want to clear the cache.
272+
const npmCache = new Map<string, Promise<string>>();
273+
274+
export async function resolveNpmImport(specifier: string): Promise<string> {
275+
let promise = npmCache.get(specifier);
276+
if (promise) return promise;
277+
promise = (async () => {
278+
const response = await fetch(`https://data.jsdelivr.com/v1/packages/npm/${specifier}/resolved`);
279+
if (!response.ok) throw new Error(`unable to resolve npm specifier: ${specifier}`);
280+
const body = await response.json();
281+
return `https://cdn.jsdelivr.net/npm/${specifier}@${body.version}/+esm`;
282+
})();
283+
promise.catch(() => npmCache.delete(specifier)); // try again on error
284+
npmCache.set(specifier, promise);
285+
return promise;
286+
}
287+
241288
function resolveBuiltin(base: "." | "_import", path: string, specifier: string): string {
242289
return relativeUrl(join(base === "." ? "_import" : ".", path), join("_observablehq", specifier));
243290
}

src/markdown.ts

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@ import {isEnoent} from "./error.js";
1515
import {fileReference, getLocalPath} from "./files.js";
1616
import {computeHash} from "./hash.js";
1717
import {parseInfo} from "./info.js";
18-
import {type FileReference, type ImportReference, type Transpile, transpileJavaScript} from "./javascript.js";
18+
import type {FileReference, ImportReference, PendingTranspile, Transpile} from "./javascript.js";
19+
import {transpileJavaScript} from "./javascript.js";
1920
import {transpileTag} from "./tag.js";
2021
import {resolvePath} from "./url.js";
2122

@@ -50,7 +51,7 @@ export interface ParseResult {
5051

5152
interface RenderPiece {
5253
html: string;
53-
code: Transpile[];
54+
code: PendingTranspile[];
5455
}
5556

5657
interface ParseContext {
@@ -407,16 +408,13 @@ function toParsePieces(pieces: RenderPiece[]): HtmlPiece[] {
407408
}));
408409
}
409410

410-
function toParseCells(pieces: RenderPiece[]): CellPiece[] {
411+
async function toParseCells(pieces: RenderPiece[]): Promise<CellPiece[]> {
411412
const cellPieces: CellPiece[] = [];
412-
pieces.forEach((piece) =>
413-
piece.code.forEach((code) =>
414-
cellPieces.push({
415-
type: "cell",
416-
...code
417-
})
418-
)
419-
);
413+
for (const piece of pieces) {
414+
for (const {body, ...rest} of piece.code) {
415+
cellPieces.push({type: "cell", body: await body, ...rest});
416+
}
417+
}
420418
return cellPieces;
421419
}
422420

@@ -435,14 +433,16 @@ export async function parseMarkdown(source: string, root: string, sourcePath: st
435433
const context: ParseContext = {files: [], imports: [], pieces: [], startLine: 0, currentLine: 0};
436434
const tokens = md.parse(parts.content, context);
437435
const html = md.renderer.render(tokens, md.options, context); // Note: mutates context.pieces, context.files!
436+
const pieces = toParsePieces(context.pieces);
437+
const cells = await toParseCells(context.pieces);
438438
return {
439439
html,
440440
data: isEmpty(parts.data) ? null : parts.data,
441441
title: parts.data?.title ?? findTitle(tokens) ?? null,
442442
files: context.files,
443443
imports: context.imports,
444-
pieces: toParsePieces(context.pieces),
445-
cells: toParseCells(context.pieces),
444+
pieces,
445+
cells,
446446
hash: await computeMarkdownHash(source, root, sourcePath, context.imports)
447447
};
448448
}

src/preview.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ export class PreviewServer {
9999
if (!isEnoent(error)) throw error;
100100
throw new HttpError(`Not found: ${pathname}`, 404);
101101
}
102-
end(req, res, rewriteModule(js, file, createImportResolver(this.root)), "text/javascript");
102+
end(req, res, await rewriteModule(js, file, createImportResolver(this.root)), "text/javascript");
103103
} else if (pathname.startsWith("/_file/")) {
104104
const path = pathname.slice("/_file".length);
105105
const filepath = join(this.root, path);

src/render.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import {parseHTML} from "linkedom";
22
import {type Config, type Page, type Section, mergeToc} from "./config.js";
33
import {type Html, html} from "./html.js";
44
import {type ImportResolver, createImportResolver} from "./javascript/imports.js";
5-
import {type FileReference, type ImportReference} from "./javascript.js";
5+
import type {FileReference, ImportReference, Transpile} from "./javascript.js";
66
import {addImplicitSpecifiers, addImplicitStylesheets} from "./libraries.js";
77
import {type ParseResult, parseMarkdown} from "./markdown.js";
88
import {type PageLink, findLink} from "./pager.js";
@@ -22,7 +22,7 @@ export interface RenderOptions extends Config {
2222
export async function renderPreview(source: string, options: RenderOptions): Promise<Render> {
2323
const parseResult = await parseMarkdown(source, options.root, options.path);
2424
return {
25-
html: render(parseResult, {...options, preview: true}),
25+
html: await render(parseResult, {...options, preview: true}),
2626
files: parseResult.files,
2727
imports: parseResult.imports
2828
};
@@ -31,13 +31,13 @@ export async function renderPreview(source: string, options: RenderOptions): Pro
3131
export async function renderServerless(source: string, options: RenderOptions): Promise<Render> {
3232
const parseResult = await parseMarkdown(source, options.root, options.path);
3333
return {
34-
html: render(parseResult, options),
34+
html: await render(parseResult, options),
3535
files: parseResult.files,
3636
imports: parseResult.imports
3737
};
3838
}
3939

40-
export function renderDefineCell(cell): string {
40+
export function renderDefineCell(cell: Transpile): string {
4141
const {id, inline, inputs, outputs, files, body, databases} = cell;
4242
return `define({${Object.entries({id, inline, inputs, outputs, files, databases})
4343
.filter((arg) => arg[1] !== undefined)
@@ -49,7 +49,7 @@ type RenderInternalOptions =
4949
| {preview?: false} // serverless
5050
| {preview: true}; // preview
5151

52-
function render(parseResult: ParseResult, options: RenderOptions & RenderInternalOptions): string {
52+
async function render(parseResult: ParseResult, options: RenderOptions & RenderInternalOptions): Promise<string> {
5353
const {root, path, pages, title, preview} = options;
5454
const toc = mergeToc(parseResult.data?.toc, options.toc);
5555
const headers = toc.show ? findHeaders(parseResult) : [];
@@ -62,7 +62,7 @@ ${
6262
.filter((title): title is string => !!title)
6363
.join(" | ")}</title>\n`
6464
: ""
65-
}${renderLinks(parseResult, path, createImportResolver(root, "_import"))}
65+
}${await renderLinks(parseResult, path, createImportResolver(root, "_import"))}
6666
<script type="module">${html.unsafe(`
6767
6868
import {${preview ? "open, " : ""}define} from ${JSON.stringify(relativeUrl(path, "/_observablehq/client.js"))};
@@ -150,15 +150,15 @@ function prettyPath(path: string): string {
150150
return path.replace(/\/index$/, "/") || "/";
151151
}
152152

153-
function renderLinks(parseResult: ParseResult, path: string, resolver: ImportResolver): Html {
153+
async function renderLinks(parseResult: ParseResult, path: string, resolver: ImportResolver): Promise<Html> {
154154
const stylesheets = new Set<string>([relativeUrl(path, "/_observablehq/style.css"), "https://fonts.googleapis.com/css2?family=Source+Serif+Pro:ital,wght@0,400;0,600;0,700;1,400;1,600;1,700&display=swap"]); // prettier-ignore
155155
const specifiers = new Set<string>(["npm:@observablehq/runtime", "npm:@observablehq/stdlib"]);
156156
for (const {name} of parseResult.imports) specifiers.add(name);
157157
const inputs = new Set(parseResult.cells.flatMap((cell) => cell.inputs ?? []));
158158
addImplicitSpecifiers(specifiers, inputs);
159159
addImplicitStylesheets(stylesheets, specifiers);
160160
const preloads = new Set<string>();
161-
for (const specifier of specifiers) preloads.add(resolver(path, specifier));
161+
for (const specifier of specifiers) preloads.add(await resolver(path, specifier));
162162
if (parseResult.cells.some((cell) => cell.databases?.length)) preloads.add(relativeUrl(path, "/_observablehq/database.js")); // prettier-ignore
163163
return html`<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>${
164164
Array.from(stylesheets).sort().map(renderStylesheet) // <link rel=stylesheet>

0 commit comments

Comments
 (0)