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}`");
+ });
+});