diff --git a/public/client.js b/public/client.js index 35c2f75f9..70afb0d48 100644 --- a/public/client.js +++ b/public/client.js @@ -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; }; } diff --git a/src/markdown.ts b/src/markdown.ts index 656e22ac6..964f288b3 100644 --- a/src/markdown.ts +++ b/src/markdown.ts @@ -13,6 +13,7 @@ import {readFile} from "node:fs/promises"; import {pathFromRoot} from "./files.js"; import {computeHash} from "./hash.js"; import {transpileJavaScript, type FileReference, type ImportReference, type Transpile} from "./javascript.js"; +import {transpileTag} from "./tag.js"; export interface ReadMarkdownResult { contents: string; @@ -77,26 +78,38 @@ function uniqueCodeId(context: ParseContext, content: string): string { return id; } +function getLiveSource(content, language, option) { + return option === "no-run" + ? undefined + : language === "js" + ? content + : language === "tex" + ? transpileTag(content, "tex.block", true) + : language === "dot" + ? transpileTag(content, "dot", false) + : language === "mermaid" + ? transpileTag(content, "await mermaid", false) + : undefined; +} + function makeFenceRenderer(root: string, baseRenderer: RenderRule): RenderRule { return (tokens, idx, options, context: ParseContext, self) => { const token = tokens[idx]; const [language, option] = token.info.split(" "); let result = ""; let count = 0; - if (language === "js" && option !== "no-run") { + const source = getLiveSource(token.content, language, option); + if (source != null) { const id = uniqueCodeId(context, token.content); - const transpile = transpileJavaScript(token.content, { - id, - root, - sourceLine: context.startLine + context.currentLine - }); + const sourceLine = context.startLine + context.currentLine; + const transpile = transpileJavaScript(source, {id, root, sourceLine}); extendPiece(context, {code: [transpile]}); if (transpile.files) context.files.push(...transpile.files); if (transpile.imports) context.imports.push(...transpile.imports); result += `
\n`; count++; } - if (language !== "js" || option === "show" || option === "no-run") { + if (source == null || option === "show") { result += baseRenderer(tokens, idx, options, context, self); count++; } diff --git a/src/render.ts b/src/render.ts index 7611ffbd9..2f89016ad 100644 --- a/src/render.ts +++ b/src/render.ts @@ -119,6 +119,8 @@ function getImportPreloads(parseResult: ParseResult): Iterable { if (inputs.has("Plot")) specifiers.add("npm:@observablehq/plot"); if (inputs.has("htl") || inputs.has("html") || inputs.has("svg") || inputs.has("Inputs")) specifiers.add("npm:htl"); if (inputs.has("Inputs")) specifiers.add("npm:@observablehq/inputs"); + if (inputs.has("dot")) specifiers.add("npm:@viz-js/viz"); + if (inputs.has("mermaid")) specifiers.add("npm:mermaid").add("npm:d3"); const preloads: string[] = []; for (const specifier of specifiers) { const resolved = resolveImport(specifier); diff --git a/src/tag.js b/src/tag.js new file mode 100644 index 000000000..9b6a55084 --- /dev/null +++ b/src/tag.js @@ -0,0 +1,170 @@ +import {Parser, TokContext, tokTypes as tt} from "acorn"; +import {Sourcemap} from "./sourcemap.js"; + +const CODE_DOLLAR = 36; +const CODE_BACKSLASH = 92; +const CODE_BACKTICK = 96; +const CODE_BRACEL = 123; + +export function transpileTag(input, tag = "", raw = false) { + const options = {ecmaVersion: 13, sourceType: "module"}; + const template = TemplateParser.parse(input, options); + const source = new Sourcemap(input); + escapeTemplateElements(source, template, raw); + source.insertLeft(template.start, tag + "`"); + source.insertRight(template.end, "`"); + return String(source); +} + +class TemplateParser extends Parser { + constructor(...args) { + super(...args); + // Initialize the type so that we're inside a backQuote + this.type = tt.backQuote; + this.exprAllowed = false; + } + initialContext() { + // Provide our custom TokContext + return [o_tmpl]; + } + parseTopLevel(body) { + // Fix for nextToken calling finishToken(tt.eof) + if (this.type === tt.eof) this.value = ""; + // Based on acorn.Parser.parseTemplate + const isTagged = true; + body.expressions = []; + let curElt = this.parseTemplateElement({isTagged}); + body.quasis = [curElt]; + while (this.type !== tt.eof) { + this.expect(tt.dollarBraceL); + body.expressions.push(this.parseExpression()); + this.expect(tt.braceR); + body.quasis.push((curElt = this.parseTemplateElement({isTagged}))); + } + curElt.tail = true; + this.next(); + this.finishNode(body, "TemplateLiteral"); + this.expect(tt.eof); + return body; + } +} + +// Based on acorn’s q_tmpl. We will use this to initialize the +// parser context so our `readTemplateToken` override is called. +// `readTemplateToken` is based on acorn's `readTmplToken` which +// is used inside template literals. Our version allows backQuotes. +const o_tmpl = new TokContext( + "`", // token + true, // isExpr + true, // preserveSpace + (parser) => readTemplateToken.call(parser) // override +); + +// This is our custom override for parsing a template that allows backticks. +// Based on acorn's readInvalidTemplateToken. +function readTemplateToken() { + out: for (; this.pos < this.input.length; this.pos++) { + switch (this.input.charCodeAt(this.pos)) { + case CODE_BACKSLASH: { + if (this.pos < this.input.length - 1) ++this.pos; // not a terminal slash + break; + } + case CODE_DOLLAR: { + if (this.input.charCodeAt(this.pos + 1) === CODE_BRACEL) { + if (this.pos === this.start && this.type === tt.invalidTemplate) { + this.pos += 2; + return this.finishToken(tt.dollarBraceL); + } + break out; + } + break; + } + } + } + return this.finishToken(tt.invalidTemplate, this.input.slice(this.start, this.pos)); +} + +function escapeTemplateElements(source, {quasis}, raw) { + for (const quasi of quasis) { + if (raw) { + interpolateBacktick(source, quasi); + } else { + escapeBacktick(source, quasi); + escapeBackslash(source, quasi); + } + } + if (raw) interpolateTerminalBackslash(source); +} + +function escapeBacktick(source, {start, end}) { + const input = source._input; + for (let i = start; i < end; ++i) { + if (input.charCodeAt(i) === CODE_BACKTICK) { + source.insertRight(i, "\\"); + } + } +} + +function interpolateBacktick(source, {start, end}) { + const input = source._input; + let oddBackslashes = false; + for (let i = start; i < end; ++i) { + switch (input.charCodeAt(i)) { + case CODE_BACKSLASH: { + oddBackslashes = !oddBackslashes; + break; + } + case CODE_BACKTICK: { + if (!oddBackslashes) { + let j = i + 1; + while (j < end && input.charCodeAt(j) === CODE_BACKTICK) ++j; + source.replaceRight(i, j, `\${'${"`".repeat(j - i)}'}`); + i = j - 1; + } + // fall through + } + default: { + oddBackslashes = false; + break; + } + } + } +} + +function escapeBackslash(source, {start, end}) { + const input = source._input; + let afterDollar = false; + let oddBackslashes = false; + for (let i = start; i < end; ++i) { + switch (input.charCodeAt(i)) { + case CODE_DOLLAR: { + afterDollar = true; + oddBackslashes = false; + break; + } + case CODE_BACKSLASH: { + oddBackslashes = !oddBackslashes; + if (afterDollar && input.charCodeAt(i + 1) === CODE_BRACEL) continue; + if (oddBackslashes && input.charCodeAt(i + 1) === CODE_DOLLAR && input.charCodeAt(i + 2) === CODE_BRACEL) + continue; + source.insertRight(i, "\\"); + break; + } + default: { + afterDollar = false; + oddBackslashes = false; + break; + } + } + } +} + +function interpolateTerminalBackslash(source) { + const input = source._input; + let oddBackslashes = false; + for (let i = input.length - 1; i >= 0; i--) { + if (input.charCodeAt(i) === CODE_BACKSLASH) oddBackslashes = !oddBackslashes; + else break; + } + if (oddBackslashes) source.replaceRight(input.length - 1, input.length, "${'\\\\'}"); +} diff --git a/test/input/dot-graphviz.md b/test/input/dot-graphviz.md new file mode 100644 index 000000000..ed5dbd469 --- /dev/null +++ b/test/input/dot-graphviz.md @@ -0,0 +1,13 @@ +```dot +digraph D { + + A [shape=diamond] + B [shape=box] + C [shape=circle] + + A -> B [style=dashed] + A -> C + A -> D [penwidth=5, arrowhead=none] + +} +``` \ No newline at end of file diff --git a/test/input/mermaid.md b/test/input/mermaid.md new file mode 100644 index 000000000..780c06f53 --- /dev/null +++ b/test/input/mermaid.md @@ -0,0 +1,7 @@ +```mermaid +graph TD; + A-->B; + A-->C; + B-->D; + C-->D; +``` diff --git a/test/input/tex-block.md b/test/input/tex-block.md new file mode 100644 index 000000000..bc3ff3b33 --- /dev/null +++ b/test/input/tex-block.md @@ -0,0 +1,3 @@ +```tex +\int_0^1 (x + y)dx +``` diff --git a/test/output/dot-graphviz.html b/test/output/dot-graphviz.html new file mode 100644 index 000000000..ab530f313 --- /dev/null +++ b/test/output/dot-graphviz.html @@ -0,0 +1 @@ +
diff --git a/test/output/dot-graphviz.json b/test/output/dot-graphviz.json new file mode 100644 index 000000000..25de78a28 --- /dev/null +++ b/test/output/dot-graphviz.json @@ -0,0 +1,27 @@ +{ + "data": null, + "title": null, + "files": [], + "imports": [], + "pieces": [ + { + "type": "html", + "id": "", + "cellIds": [ + "391a3a26" + ], + "html": "
\n" + } + ], + "cells": [ + { + "type": "cell", + "id": "391a3a26", + "inputs": [ + "dot", + "display" + ], + "body": "(dot,display) => {\ndisplay((\ndot`digraph D {\n\n A [shape=diamond]\n B [shape=box]\n C [shape=circle]\n\n A -> B [style=dashed]\n A -> C\n A -> D [penwidth=5, arrowhead=none]\n\n}\n`\n))\n}" + } + ] +} \ No newline at end of file diff --git a/test/output/mermaid.html b/test/output/mermaid.html new file mode 100644 index 000000000..6f875a10d --- /dev/null +++ b/test/output/mermaid.html @@ -0,0 +1 @@ +
diff --git a/test/output/mermaid.json b/test/output/mermaid.json new file mode 100644 index 000000000..4e17658f2 --- /dev/null +++ b/test/output/mermaid.json @@ -0,0 +1,27 @@ +{ + "data": null, + "title": null, + "files": [], + "imports": [], + "pieces": [ + { + "type": "html", + "id": "", + "cellIds": [ + "1ffa6d4f" + ], + "html": "
\n" + } + ], + "cells": [ + { + "type": "cell", + "id": "1ffa6d4f", + "inputs": [ + "mermaid", + "display" + ], + "body": "async (mermaid,display) => {\ndisplay((\nawait mermaid`graph TD;\n A-->B;\n A-->C;\n B-->D;\n C-->D;\n`\n))\n}" + } + ] +} \ No newline at end of file diff --git a/test/output/tex-block.html b/test/output/tex-block.html new file mode 100644 index 000000000..fe5200008 --- /dev/null +++ b/test/output/tex-block.html @@ -0,0 +1 @@ +
diff --git a/test/output/tex-block.json b/test/output/tex-block.json new file mode 100644 index 000000000..4fe25aaba --- /dev/null +++ b/test/output/tex-block.json @@ -0,0 +1,27 @@ +{ + "data": null, + "title": null, + "files": [], + "imports": [], + "pieces": [ + { + "type": "html", + "id": "", + "cellIds": [ + "5ceee90d" + ], + "html": "
\n" + } + ], + "cells": [ + { + "type": "cell", + "id": "5ceee90d", + "inputs": [ + "tex", + "display" + ], + "body": "(tex,display) => {\ndisplay((\ntex.block`\\int_0^1 (x + y)dx\n`\n))\n}" + } + ] +} \ No newline at end of file diff --git a/test/tag-test.js b/test/tag-test.js new file mode 100644 index 000000000..6ab3cc8e0 --- /dev/null +++ b/test/tag-test.js @@ -0,0 +1,17 @@ +import assert from "node:assert"; +import {transpileTag} from "../src/tag.js"; + +describe("transpileTag(input)", () => { + it("bare template literal", () => { + assert.strictEqual(transpileTag("1 + 2"), "`1 + 2`"); + }); + it("tagged template literal", () => { + assert.strictEqual(transpileTag("1 + 2", "tag"), "tag`1 + 2`"); + }); + it("embedded expression", () => { + assert.strictEqual(transpileTag("1 + ${2}"), "`1 + ${2}`"); + }); + it("escaped embedded expression", () => { + assert.strictEqual(transpileTag("1 + $\\{2}"), "`1 + $\\{2}`"); + }); +});