Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/javascript/bar.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const bar = "bar3";
3 changes: 3 additions & 0 deletions docs/javascript/baz.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import {bar} from "./bar.js";

export const baz = "baz" + bar;
7 changes: 7 additions & 0 deletions docs/javascript/import-test.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Testing imports

```js
import {baz} from "./baz.js";

display(baz);
```
5 changes: 3 additions & 2 deletions src/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {fileURLToPath} from "node:url";
import {readConfig} from "./config.js";
import {Loader} from "./dataloader.js";
import {prepareOutput, visitFiles, visitMarkdownFiles} from "./files.js";
import {resolveSources} from "./javascript/imports.js";
import {createModulePreviewResolver, rewriteModule} from "./javascript/imports.js";
import {renderServerless} from "./render.js";
import {makeCLIResolver} from "./resolver.js";

Expand Down Expand Up @@ -78,6 +78,7 @@ export async function build({sourceRoot, outputRoot, verbose = true, addPublic =
}

// Copy over the imported modules.
const importResolver = createModulePreviewResolver(sourceRoot);
for (const file of imports) {
const sourcePath = join(sourceRoot, file);
const outputPath = join(outputRoot, "_import", file);
Expand All @@ -87,7 +88,7 @@ export async function build({sourceRoot, outputRoot, verbose = true, addPublic =
}
if (verbose) console.log("copy", sourcePath, "→", outputPath);
await prepareOutput(outputPath);
await writeFile(outputPath, resolveSources(await readFile(sourcePath, "utf-8"), file));
await writeFile(outputPath, rewriteModule(await readFile(sourcePath, "utf-8"), file, importResolver));
}

// Copy over required distribution files from node_modules.
Expand Down
12 changes: 1 addition & 11 deletions src/files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,20 +18,10 @@ export function fileReference(name: string, sourcePath: string): FileReference {
return {
name,
mimeType: mime.getType(name),
path: normalizeRelativePath(relativeUrl(sourcePath, `/_file/${dirname(sourcePath)}/${name}`))
path: relativeUrl(sourcePath, `/_file/${dirname(sourcePath)}/${name}`)
Copy link
Copy Markdown
Member Author

@mbostock mbostock Nov 24, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this check whether the name starts with a slash, like we do elsewhere? And we should probably use join to normalize the path (though it doesn’t hurt to normalize again within relativeUrl).

Related #42 #193.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, this was a bug. Fixed!

};
}

function normalizeRelativePath(path) {
const parts = path.split("/").filter((d) => d !== ".");
for (let r = 1; r < parts.length; ) {
if (parts[r] === ".." && parts[r - 1] !== "..") parts.splice(--r, 2);
else ++r;
}
if (parts[0] !== "..") parts.unshift(".");
return parts.join("/");
}

export async function* visitMarkdownFiles(root: string): AsyncGenerator<string> {
for await (const file of visitFiles(root)) {
if (extname(file) !== ".md") continue;
Expand Down
6 changes: 3 additions & 3 deletions src/javascript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {findDeclarations} from "./javascript/declarations.js";
import {findFeatures} from "./javascript/features.js";
import {rewriteFetches} from "./javascript/fetches.js";
import {defaultGlobals} from "./javascript/globals.js";
import {findExports, findImports, rewriteImports} from "./javascript/imports.js";
import {createMarkdownPreviewResolver, findExports, findImports, rewriteImports} from "./javascript/imports.js";
import {findReferences} from "./javascript/references.js";
import {syntaxError} from "./javascript/syntaxError.js";
import {Sourcemap} from "./sourcemap.js";
Expand Down Expand Up @@ -58,7 +58,7 @@ export interface ParseOptions {
}

export function transpileJavaScript(input: string, options: ParseOptions): Transpile {
const {id, sourcePath, verbose = true} = options;
const {id, root, sourcePath, verbose = true} = options;
try {
const node = parseJavaScript(input, options);
const databases = node.features
Expand All @@ -75,7 +75,7 @@ export function transpileJavaScript(input: string, options: ParseOptions): Trans
output.insertRight(input.length, "\n))");
inputs.push("display");
}
rewriteImports(output, node, sourcePath);
rewriteImports(output, node, sourcePath, createMarkdownPreviewResolver(root));
rewriteFetches(output, node, sourcePath);
return {
id,
Expand Down
176 changes: 122 additions & 54 deletions src/javascript/imports.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
import {createHash} from "node:crypto";
import {readFileSync} from "node:fs";
import {dirname, join, normalize, relative} from "node:path";
import {dirname, join} from "node:path";
import {Parser} from "acorn";
import type {ExportAllDeclaration, ExportNamedDeclaration, ImportDeclaration, ImportExpression, Node} from "acorn";
import {simple} from "acorn-walk";
import {isEnoent} from "../error.js";
import {type ImportReference, type JavaScriptNode, parseOptions} from "../javascript.js";
import {Sourcemap} from "../sourcemap.js";
import {relativeUrl} from "../url.js";
import {getStringLiteralValue, isStringLiteral} from "./features.js";

export function findExports(body: Node) {
// Finds all export declarations in the specified node. (This is used to
// disallow exports within JavaScript code blocks.)
export function findExports(body: Node): (ExportAllDeclaration | ExportNamedDeclaration)[] {
const exports: (ExportAllDeclaration | ExportNamedDeclaration)[] = [];

simple(body, {
Expand All @@ -23,7 +27,10 @@ export function findExports(body: Node) {
return exports;
}

export function findImports(body: Node, root: string, sourcePath: string) {
// Finds all imports (both static and dynamic) in the specified node.
// Recursively processes any imported local ES modules. The returned transitive
// import paths are relative to the given source path.
export function findImports(body: Node, root: string, path: string): ImportReference[] {
const imports: ImportReference[] = [];
const paths = new Set<string>();

Expand All @@ -35,112 +42,173 @@ export function findImports(body: Node, root: string, sourcePath: string) {
function findImport(node) {
if (isStringLiteral(node.source)) {
const value = getStringLiteralValue(node.source);
if (isLocalImport(value, sourcePath)) {
findLocalImports(normalize(value));
if (isLocalImport(value, path)) {
addLocalImports(root, join(value.startsWith("/") ? "." : dirname(path), value), paths, imports);
} else {
imports.push({name: value, type: "global"});
}
}
}

// If this is an import of a local ES module, recursively parse the module to
// find transitive imports. The path is always relative to the source path of
// the Markdown file, even across transitive imports.
function findLocalImports(path) {
if (path.startsWith("/")) path = relative(dirname(sourcePath), path);
if (paths.has(path)) return;
paths.add(path);
imports.push({type: "local", name: path});
try {
const input = readFileSync(join(root, dirname(sourcePath), path), "utf-8");
const program = Parser.parse(input, parseOptions);
simple(program, {
ImportDeclaration: findLocalImport,
ImportExpression: findLocalImport,
ExportAllDeclaration: findLocalImport,
ExportNamedDeclaration: findLocalImport
});
} catch {
// ignore missing files and syntax errors
// Make all local paths relative to the source path.
for (const i of imports) {
if (i.type === "local") {
i.name = relativeUrl(path, i.name);
}
function findLocalImport(node) {
if (isStringLiteral(node.source)) {
const value = getStringLiteralValue(node.source);
if (isLocalImport(value, sourcePath)) {
findLocalImports(value.startsWith("/") ? normalize(value) : join(dirname(path), value));
} else {
imports.push({name: value, type: "global"});
// non-local imports don't need to be traversed
}
}

return imports;
}

// Parses the module at the specified path to find transitive imports,
// processing imported modules recursively. Accumulates visited paths, and
// appends to imports. The paths here are always relative to the root (unlike
// findImports above!).
function addLocalImports(
root: string,
path: string,
paths: Set<string> = new Set(),
imports: ImportReference[] = []
): ImportReference[] {
if (paths.has(path)) return imports;
paths.add(path);
imports.push({type: "local", name: path});
try {
const input = readFileSync(join(root, path), "utf-8");
const program = Parser.parse(input, parseOptions);
simple(program, {
ImportDeclaration: findImport,
ImportExpression: findImport,
ExportAllDeclaration: findImport,
ExportNamedDeclaration: findImport
});
} catch {
// ignore missing files and syntax errors
}
function findImport(node) {
if (isStringLiteral(node.source)) {
const value = getStringLiteralValue(node.source);
if (isLocalImport(value, path)) {
addLocalImports(root, join(value.startsWith("/") ? "." : dirname(path), value), paths, imports);
} else {
imports.push({name: value, type: "global"});
// non-local imports don't need to be traversed
}
}
}

return imports;
}

export function resolveSources(input: string, sourcePath: string) {
// Resolves the content hash for the module at the specified path within the
// given source root. This involves parsing the specified module to process
// transitive imports.
function getModuleHash(root: string, path: string): string {
const hash = createHash("sha256");
try {
hash.update(readFileSync(join(root, path), "utf-8"));
} catch (error) {
if (!isEnoent(error)) throw error;
}
// TODO can’t simply concatenate here; we need a delimiter
for (const i of addLocalImports(root, path)) {
if (i.type === "local") {
try {
hash.update(readFileSync(join(root, i.name), "utf-8"));
} catch (error) {
if (!isEnoent(error)) throw error;
continue;
}
}
}
return hash.digest("hex");
}

// If the given is a local import, applies the ?sha query string based on the
// content hash of the imported module and its transitively imported modules.
function resolveImportHash(root: string, path: string, specifier: string): string {
return isLocalImport(specifier, path)
? `${specifier}?sha=${getModuleHash(root, join(specifier.startsWith("/") ? "." : dirname(path), specifier))}`
: specifier;
}

// Rewrites import specifiers in the specified ES module source.
export function rewriteModule(input: string, sourcePath: string, resolver: ImportResolver): string {
const body = Parser.parse(input, parseOptions) as any;
const output = new Sourcemap(input);

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

function resolveSource(node: ImportDeclaration | ImportExpression | ExportAllDeclaration | ExportNamedDeclaration) {
function rewriteImport(node: ImportDeclaration | ImportExpression | ExportAllDeclaration | ExportNamedDeclaration) {
if (isStringLiteral(node.source)) {
const value = getStringLiteralValue(node.source);
output.replaceLeft(
node.source.start,
node.source.end,
JSON.stringify(value.startsWith("/") ? relativeImport(sourcePath, value) : resolveImport(value))
JSON.stringify(resolver(sourcePath, getStringLiteralValue(node.source)))
);
}
}

return String(output);
}

// TODO parallelize multiple static imports
export function rewriteImports(output: any, rootNode: JavaScriptNode, sourcePath: string) {
simple(rootNode.body, {
// Rewrites import specifiers in the specified JavaScript fenced code block or
// inline expression. TODO parallelize multiple static imports
export function rewriteImports(
output: Sourcemap,
cell: JavaScriptNode,
sourcePath: string,
resolver: ImportResolver
): void {
simple(cell.body, {
ImportExpression(node) {
if (isStringLiteral(node.source)) {
const value = getStringLiteralValue(node.source);
output.replaceLeft(
node.source.start,
node.source.end,
JSON.stringify(isLocalImport(value, sourcePath) ? relativeImport(sourcePath, value) : resolveImport(value))
JSON.stringify(resolver(sourcePath, getStringLiteralValue(node.source)))
);
}
},
ImportDeclaration(node) {
if (isStringLiteral(node.source)) {
const value = getStringLiteralValue(node.source);
rootNode.async = true;
cell.async = true;
output.replaceLeft(
node.start,
node.end,
`const ${
node.specifiers.some(isNotNamespaceSpecifier)
? `{${node.specifiers.filter(isNotNamespaceSpecifier).map(rewriteImportSpecifier).join(", ")}}`
: node.specifiers.find(isNamespaceSpecifier)?.local.name ?? "{}"
} = await import(${JSON.stringify(
isLocalImport(value, sourcePath) ? relativeImport(sourcePath, value) : resolveImport(value)
)});`
} = await import(${JSON.stringify(resolver(sourcePath, getStringLiteralValue(node.source)))});`
);
}
}
});
}

function relativeImport(sourcePath, value) {
return relativeUrl(sourcePath, join("/_import/", value.startsWith("/") ? "." : dirname(sourcePath), value));
export function createModulePreviewResolver(root: string): ImportResolver {
return (sourcePath, value) => {
value = resolveImportHash(root, sourcePath, value);
return value.startsWith("/") ? relativeUrl(sourcePath, value) : resolveImport(value);
};
}

export function createMarkdownPreviewResolver(root: string): ImportResolver {
return (sourcePath, value) => {
value = resolveImportHash(root, sourcePath, value);
return isLocalImport(value, sourcePath)
? relativeUrl(sourcePath, join("_import", value.startsWith("/") ? "." : dirname(sourcePath), value))
: resolveImport(value);
};
}

export type ImportResolver = (path: string, specifier: string) => string;

function rewriteImportSpecifier(node) {
return node.type === "ImportDefaultSpecifier"
? `default: ${node.local.name}`
Expand Down
29 changes: 27 additions & 2 deletions src/markdown.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {createHash} from "node:crypto";
import {readFile} from "node:fs/promises";
import {join} from "node:path";
import {dirname, join} from "node:path";
import {type Patch, type PatchItem, getPatch} from "fast-array-diff";
import equal from "fast-deep-equal";
import matter from "gray-matter";
Expand All @@ -10,6 +11,7 @@ import {type RuleCore} from "markdown-it/lib/parser_core.js";
import {type RuleInline} from "markdown-it/lib/parser_inline.js";
import {type RenderRule, type default as Renderer} from "markdown-it/lib/renderer.js";
import MarkdownItAnchor from "markdown-it-anchor";
import {isEnoent} from "./error.js";
import {fileReference, getLocalPath} from "./files.js";
import {computeHash} from "./hash.js";
import {parseInfo} from "./info.js";
Expand Down Expand Up @@ -477,5 +479,28 @@ export function diffMarkdown({parse: prevParse}: ReadMarkdownResult, {parse: nex

export async function readMarkdown(path: string, root: string): Promise<ReadMarkdownResult> {
const contents = await readFile(join(root, path), "utf-8");
return {contents, parse: parseMarkdown(contents, root, path), hash: computeHash(contents)};
const parse = parseMarkdown(contents, root, path);
const hash = await computeMarkdownHash(contents, root, path, parse);
return {contents, parse, hash};
}

export async function computeMarkdownHash(
contents: string,
root: string,
path: string,
parse: ParseResult
): Promise<string> {
const hash = createHash("sha256").update(contents);
// TODO can’t simply concatenate here; we need a delimiter
for (const i of parse.imports) {
if (i.type === "local") {
try {
hash.update(await readFile(join(root, dirname(path), i.name), "utf-8"));
} catch (error) {
if (!isEnoent(error)) throw error;
continue;
}
}
}
return hash.digest("hex");
}
Loading