Skip to content

Commit

Permalink
Merge branch 'main' of https://github.com/observablehq/cli into wilts…
Browse files Browse the repository at this point in the history
…e/attrs
  • Loading branch information
wiltsecarpenter committed Nov 8, 2023
2 parents 8b15610 + d8f5710 commit 39af3e2
Show file tree
Hide file tree
Showing 39 changed files with 607 additions and 89 deletions.
53 changes: 53 additions & 0 deletions public/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,60 @@ function recommendedLibraries() {
link.href = "https://cdn.jsdelivr.net/gh/observablehq/inputs/src/style.css";
document.head.append(link);
return inputs;
},
dot,
mermaid
};
}

// TODO Incorporate this into the standard library.
async function dot() {
const {instance} = await import("https://cdn.jsdelivr.net/npm/@viz-js/viz/+esm");
const viz = await instance();
return function dot(strings) {
let string = strings[0] + "";
let i = 0;
let n = arguments.length;
while (++i < n) string += arguments[i] + "" + strings[i];
const svg = viz.renderSVGElement(string, {
graphAttributes: {
bgcolor: "none"
},
nodeAttributes: {
color: "#00000101",
fontcolor: "#00000101",
fontname: "var(--sans-serif)",
fontsize: "12"
},
edgeAttributes: {
color: "#00000101"
}
});
for (const e of svg.querySelectorAll("[stroke='#000001'][stroke-opacity='0.003922']")) {
e.setAttribute("stroke", "currentColor");
e.removeAttribute("stroke-opacity");
}
for (const e of svg.querySelectorAll("[fill='#000001'][fill-opacity='0.003922']")) {
e.setAttribute("fill", "currentColor");
e.removeAttribute("fill-opacity");
}
svg.remove();
svg.style = "max-width: 100%; height: auto;";
return svg;
};
}

// TODO Incorporate this into the standard library.
async function mermaid() {
let nextId = 0;
const {default: mer} = await import("https://cdn.jsdelivr.net/npm/mermaid/+esm");
mer.initialize({startOnLoad: false, securityLevel: "loose", theme: "neutral"});
return async function mermaid() {
const div = document.createElement("div");
div.innerHTML = (await mer.render(`mermaid-${++nextId}`, String.raw.apply(String, arguments))).svg;
const svg = div.firstChild;
svg.remove();
return svg;
};
}

Expand Down
2 changes: 1 addition & 1 deletion src/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ async function build(context: CommandContext) {
pages,
resolver
});
files.push(...render.files.map((f) => join(sourceFile, "..", f.name)));
files.push(...render.files.map((f) => f.name));
await prepareOutput(outputPath);
await writeFile(outputPath, render.html);
}
Expand Down
28 changes: 20 additions & 8 deletions src/javascript.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {Parser, tokTypes, type Options} from "acorn";
import {Parser, tokTypes, type Node, type Options} from "acorn";
import mime from "mime";
import {findAwaits} from "./javascript/awaits.js";
import {findDeclarations} from "./javascript/declarations.js";
Expand All @@ -20,6 +20,7 @@ export interface FileReference {

export interface ImportReference {
name: string;
type: "global" | "local";
}

export interface Transpile {
Expand All @@ -36,13 +37,14 @@ export interface Transpile {
export interface ParseOptions {
id: string;
root: string;
sourcePath: string;
inline?: boolean;
sourceLine?: number;
globals?: Set<string>;
}

export function transpileJavaScript(input: string, options: ParseOptions): Transpile {
const {id} = options;
const {id, root, sourcePath} = options;
try {
const node = parseJavaScript(input, options);
const databases = node.features.filter((f) => f.type === "DatabaseClient").map((f) => ({name: f.name}));
Expand All @@ -57,8 +59,8 @@ export function transpileJavaScript(input: string, options: ParseOptions): Trans
output.insertRight(input.length, "\n))");
inputs.push("display");
}
rewriteImports(output, node);
rewriteFetches(output, node);
rewriteImports(output, node, root, sourcePath);
rewriteFetches(output, node, root, sourcePath);
return {
id,
...(inputs.length ? {inputs} : null),
Expand Down Expand Up @@ -99,8 +101,18 @@ function trim(output: Sourcemap, input: string): void {

export const parseOptions: Options = {ecmaVersion: 13, sourceType: "module"};

export function parseJavaScript(input: string, options: ParseOptions) {
const {globals = defaultGlobals, inline = false, root} = options;
export interface JavaScriptNode {
body: Node;
declarations: {name: string}[] | null;
references: {name: string}[];
features: {type: unknown; name: string}[];
imports: {type: "global" | "local"; name: string}[];
expression: boolean;
async: boolean;
}

function parseJavaScript(input: string, options: ParseOptions): JavaScriptNode {
const {globals = defaultGlobals, inline = false, root, sourcePath} = options;
// First attempt to parse as an expression; if this fails, parse as a program.
let expression = maybeParseExpression(input, parseOptions);
if (expression?.type === "ClassExpression" && expression.id) expression = null; // treat named class as program
Expand All @@ -109,8 +121,8 @@ export function parseJavaScript(input: string, options: ParseOptions) {
const body = expression ?? (Parser.parse(input, parseOptions) as any);
const references = findReferences(body, globals, input);
const declarations = expression ? null : findDeclarations(body, globals, input);
const features = findFeatures(body, references, input);
const imports = findImports(body, root);
const {imports, features: importFeatures} = findImports(body, root, sourcePath);
const features = [...importFeatures, ...findFeatures(body, root, sourcePath, references, input)];
return {
body,
declarations,
Expand Down
28 changes: 8 additions & 20 deletions src/javascript/features.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import {simple} from "acorn-walk";
import {syntaxError} from "./syntaxError.js";
import {isLocalImport} from "./imports.ts";
import {dirname, join} from "node:path";

export function findFeatures(node, references, input) {
export function findFeatures(node, root, sourcePath, references, input) {
const features = [];

simple(node, {
Expand All @@ -11,10 +13,9 @@ export function findFeatures(node, references, input) {
arguments: args,
arguments: [arg]
} = node;

// Promote fetches with static literals to file attachment references.
if (isLocalFetch(node, references)) {
features.push({type: "FileAttachment", name: getStringLiteralValue(arg)});
if (isLocalFetch(node, references, root, sourcePath)) {
features.push({type: "FileAttachment", name: join(dirname(sourcePath), getStringLiteralValue(arg))});
return;
}

Expand All @@ -33,27 +34,14 @@ export function findFeatures(node, references, input) {
if (args.length !== 1 || !isStringLiteral(arg)) {
throw syntaxError(`${callee.name} requires a single literal string argument`, node, input);
}

features.push({type: callee.name, name: getStringLiteralValue(arg)});
},
// Promote dynamic imports with static literals to file attachment references.
ImportExpression: findImport,
ImportDeclaration: findImport
});

function findImport(node) {
if (isStringLiteral(node.source)) {
const value = getStringLiteralValue(node.source);
if (value.startsWith("./")) {
features.push({type: "FileAttachment", name: value});
}
}
}
});

return features;
}

export function isLocalFetch(node, references) {
export function isLocalFetch(node, references, root, sourcePath) {
if (node.type !== "CallExpression") return false;
const {
callee,
Expand All @@ -65,7 +53,7 @@ export function isLocalFetch(node, references) {
!references.includes(callee) &&
arg &&
isStringLiteral(arg) &&
getStringLiteralValue(arg).startsWith("./")
isLocalImport(getStringLiteralValue(arg), root, sourcePath)
);
}

Expand Down
6 changes: 3 additions & 3 deletions src/javascript/fetches.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import {simple} from "acorn-walk";
import {isLocalFetch} from "./features.js";

export function rewriteFetches(output, root) {
simple(root.body, {
export function rewriteFetches(output, rootNode, root, sourcePath) {
simple(rootNode.body, {
CallExpression(node) {
if (isLocalFetch(node, root.references)) {
if (isLocalFetch(node, rootNode.references, root, sourcePath)) {
output.insertLeft(node.arguments[0].start + 3, "_file/");
}
}
Expand Down
56 changes: 38 additions & 18 deletions src/javascript/imports.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import {Parser} from "acorn";
import type {Node} from "acorn";
import {simple} from "acorn-walk";
import {readFileSync} from "node:fs";
import {dirname, join, relative, resolve} from "node:path";
import {parseOptions} from "../javascript.js";
import {dirname, join} from "node:path";
import {type JavaScriptNode, parseOptions} from "../javascript.js";
import {getStringLiteralValue, isStringLiteral} from "./features.js";

export function findImports(body, root) {
const imports: {name: string}[] = [];
export function findImports(body: Node, root: string, sourcePath: string) {
const imports: {name: string; type: "global" | "local"}[] = [];
const features: {name: string; type: string}[] = [];
const paths = new Set<string>();

simple(body, {
Expand All @@ -19,18 +21,24 @@ export function findImports(body, root) {
function findImport(node) {
if (isStringLiteral(node.source)) {
const value = getStringLiteralValue(node.source);
if (value.startsWith("./")) findLocalImports(join(root, value));
imports.push({name: value});
if (isLocalImport(value, root, sourcePath)) {
findLocalImports(join(dirname(sourcePath), value));
} 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.
// path is the full URI path without /_file
function findLocalImports(path) {
if (paths.has(path)) return;
paths.add(path);
imports.push({type: "local", name: path});
features.push({type: "FileAttachment", name: path});
try {
const input = readFileSync(path, "utf-8");
const input = readFileSync(join(root, path), "utf-8");
const program = Parser.parse(input, parseOptions);
simple(program, {
ImportDeclaration: findLocalImport,
Expand All @@ -44,38 +52,41 @@ export function findImports(body, root) {
function findLocalImport(node) {
if (isStringLiteral(node.source)) {
const value = getStringLiteralValue(node.source);
if (value.startsWith("./")) {
const subpath = resolve(dirname(path), value);
if (isLocalImport(value, root, path)) {
const subpath = join(dirname(path), value);
findLocalImports(subpath);
imports.push({name: `./${relative(root, subpath)}`});
} else {
imports.push({name: value});
imports.push({name: value, type: "global"});
// non-local imports don't need to be promoted to file attachments
}
}
}
}

return imports;
return {imports, features};
}

// TODO parallelize multiple static imports
// TODO need to know the local path of the importing notebook; this assumes it’s in the root
export function rewriteImports(output, root) {
simple(root.body, {
export function rewriteImports(output: any, rootNode: JavaScriptNode, root: string, sourcePath: string) {
simple(rootNode.body, {
ImportExpression(node: any) {
if (isStringLiteral(node.source)) {
const value = getStringLiteralValue(node.source);
output.replaceLeft(
node.source.start,
node.source.end,
JSON.stringify(value.startsWith("./") ? `/_file/${value.slice(2)}` : resolveImport(value))
JSON.stringify(
isLocalImport(value, root, sourcePath)
? join("/_file/", join(dirname(sourcePath), value))
: resolveImport(value)
)
);
}
},
ImportDeclaration(node: any) {
if (isStringLiteral(node.source)) {
const value = getStringLiteralValue(node.source);
root.async = true;
rootNode.async = true;
output.replaceLeft(
node.start,
node.end,
Expand All @@ -86,7 +97,9 @@ export function rewriteImports(output, root) {
? node.specifiers.find(isNamespaceSpecifier).local.name
: "{}"
} = await import(${JSON.stringify(
value.startsWith("./") ? `/_file/${value.slice(2)}` : resolveImport(value)
isLocalImport(value, root, sourcePath)
? join("/_file/", join(dirname(sourcePath), value))
: resolveImport(value)
)});`
);
}
Expand All @@ -102,6 +115,13 @@ function rewriteImportSpecifier(node) {
: `${node.imported.name}: ${node.local.name}`;
}

export function isLocalImport(value: string, root: string, sourcePath: string): boolean {
return (
["./", "../", "/"].some((prefix) => value.startsWith(prefix)) &&
join(root + "/", dirname(sourcePath), value).startsWith(root)
);
}

function isNamespaceSpecifier(node) {
return node.type === "ImportNamespaceSpecifier";
}
Expand Down
Loading

0 comments on commit 39af3e2

Please sign in to comment.