Skip to content

Commit 1c7402a

Browse files
mbostockFil
andauthored
dot, tex, and mermaid blocks (#8)
* dot & tex blocks * add tests * getLiveSource * mermaid --------- Co-authored-by: Philippe Rivière <[email protected]>
1 parent 44a2934 commit 1c7402a

14 files changed

+369
-7
lines changed

public/client.js

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,60 @@ function recommendedLibraries() {
7474
link.href = "https://cdn.jsdelivr.net/gh/observablehq/inputs/src/style.css";
7575
document.head.append(link);
7676
return inputs;
77+
},
78+
dot,
79+
mermaid
80+
};
81+
}
82+
83+
// TODO Incorporate this into the standard library.
84+
async function dot() {
85+
const {instance} = await import("https://cdn.jsdelivr.net/npm/@viz-js/viz/+esm");
86+
const viz = await instance();
87+
return function dot(strings) {
88+
let string = strings[0] + "";
89+
let i = 0;
90+
let n = arguments.length;
91+
while (++i < n) string += arguments[i] + "" + strings[i];
92+
const svg = viz.renderSVGElement(string, {
93+
graphAttributes: {
94+
bgcolor: "none"
95+
},
96+
nodeAttributes: {
97+
color: "#00000101",
98+
fontcolor: "#00000101",
99+
fontname: "var(--sans-serif)",
100+
fontsize: "12"
101+
},
102+
edgeAttributes: {
103+
color: "#00000101"
104+
}
105+
});
106+
for (const e of svg.querySelectorAll("[stroke='#000001'][stroke-opacity='0.003922']")) {
107+
e.setAttribute("stroke", "currentColor");
108+
e.removeAttribute("stroke-opacity");
109+
}
110+
for (const e of svg.querySelectorAll("[fill='#000001'][fill-opacity='0.003922']")) {
111+
e.setAttribute("fill", "currentColor");
112+
e.removeAttribute("fill-opacity");
77113
}
114+
svg.remove();
115+
svg.style = "max-width: 100%; height: auto;";
116+
return svg;
117+
};
118+
}
119+
120+
// TODO Incorporate this into the standard library.
121+
async function mermaid() {
122+
let nextId = 0;
123+
const {default: mer} = await import("https://cdn.jsdelivr.net/npm/mermaid/+esm");
124+
mer.initialize({startOnLoad: false, securityLevel: "loose", theme: "neutral"});
125+
return async function mermaid() {
126+
const div = document.createElement("div");
127+
div.innerHTML = (await mer.render(`mermaid-${++nextId}`, String.raw.apply(String, arguments))).svg;
128+
const svg = div.firstChild;
129+
svg.remove();
130+
return svg;
78131
};
79132
}
80133

src/markdown.ts

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {readFile} from "node:fs/promises";
1313
import {pathFromRoot} from "./files.js";
1414
import {computeHash} from "./hash.js";
1515
import {transpileJavaScript, type FileReference, type ImportReference, type Transpile} from "./javascript.js";
16+
import {transpileTag} from "./tag.js";
1617

1718
export interface ReadMarkdownResult {
1819
contents: string;
@@ -77,26 +78,38 @@ function uniqueCodeId(context: ParseContext, content: string): string {
7778
return id;
7879
}
7980

81+
function getLiveSource(content, language, option) {
82+
return option === "no-run"
83+
? undefined
84+
: language === "js"
85+
? content
86+
: language === "tex"
87+
? transpileTag(content, "tex.block", true)
88+
: language === "dot"
89+
? transpileTag(content, "dot", false)
90+
: language === "mermaid"
91+
? transpileTag(content, "await mermaid", false)
92+
: undefined;
93+
}
94+
8095
function makeFenceRenderer(root: string, baseRenderer: RenderRule): RenderRule {
8196
return (tokens, idx, options, context: ParseContext, self) => {
8297
const token = tokens[idx];
8398
const [language, option] = token.info.split(" ");
8499
let result = "";
85100
let count = 0;
86-
if (language === "js" && option !== "no-run") {
101+
const source = getLiveSource(token.content, language, option);
102+
if (source != null) {
87103
const id = uniqueCodeId(context, token.content);
88-
const transpile = transpileJavaScript(token.content, {
89-
id,
90-
root,
91-
sourceLine: context.startLine + context.currentLine
92-
});
104+
const sourceLine = context.startLine + context.currentLine;
105+
const transpile = transpileJavaScript(source, {id, root, sourceLine});
93106
extendPiece(context, {code: [transpile]});
94107
if (transpile.files) context.files.push(...transpile.files);
95108
if (transpile.imports) context.imports.push(...transpile.imports);
96109
result += `<div id="cell-${id}" class="observablehq observablehq--block"></div>\n`;
97110
count++;
98111
}
99-
if (language !== "js" || option === "show" || option === "no-run") {
112+
if (source == null || option === "show") {
100113
result += baseRenderer(tokens, idx, options, context, self);
101114
count++;
102115
}

src/render.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,8 @@ function getImportPreloads(parseResult: ParseResult): Iterable<string> {
119119
if (inputs.has("Plot")) specifiers.add("npm:@observablehq/plot");
120120
if (inputs.has("htl") || inputs.has("html") || inputs.has("svg") || inputs.has("Inputs")) specifiers.add("npm:htl");
121121
if (inputs.has("Inputs")) specifiers.add("npm:@observablehq/inputs");
122+
if (inputs.has("dot")) specifiers.add("npm:@viz-js/viz");
123+
if (inputs.has("mermaid")) specifiers.add("npm:mermaid").add("npm:d3");
122124
const preloads: string[] = [];
123125
for (const specifier of specifiers) {
124126
const resolved = resolveImport(specifier);

src/tag.js

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
import {Parser, TokContext, tokTypes as tt} from "acorn";
2+
import {Sourcemap} from "./sourcemap.js";
3+
4+
const CODE_DOLLAR = 36;
5+
const CODE_BACKSLASH = 92;
6+
const CODE_BACKTICK = 96;
7+
const CODE_BRACEL = 123;
8+
9+
export function transpileTag(input, tag = "", raw = false) {
10+
const options = {ecmaVersion: 13, sourceType: "module"};
11+
const template = TemplateParser.parse(input, options);
12+
const source = new Sourcemap(input);
13+
escapeTemplateElements(source, template, raw);
14+
source.insertLeft(template.start, tag + "`");
15+
source.insertRight(template.end, "`");
16+
return String(source);
17+
}
18+
19+
class TemplateParser extends Parser {
20+
constructor(...args) {
21+
super(...args);
22+
// Initialize the type so that we're inside a backQuote
23+
this.type = tt.backQuote;
24+
this.exprAllowed = false;
25+
}
26+
initialContext() {
27+
// Provide our custom TokContext
28+
return [o_tmpl];
29+
}
30+
parseTopLevel(body) {
31+
// Fix for nextToken calling finishToken(tt.eof)
32+
if (this.type === tt.eof) this.value = "";
33+
// Based on acorn.Parser.parseTemplate
34+
const isTagged = true;
35+
body.expressions = [];
36+
let curElt = this.parseTemplateElement({isTagged});
37+
body.quasis = [curElt];
38+
while (this.type !== tt.eof) {
39+
this.expect(tt.dollarBraceL);
40+
body.expressions.push(this.parseExpression());
41+
this.expect(tt.braceR);
42+
body.quasis.push((curElt = this.parseTemplateElement({isTagged})));
43+
}
44+
curElt.tail = true;
45+
this.next();
46+
this.finishNode(body, "TemplateLiteral");
47+
this.expect(tt.eof);
48+
return body;
49+
}
50+
}
51+
52+
// Based on acorn’s q_tmpl. We will use this to initialize the
53+
// parser context so our `readTemplateToken` override is called.
54+
// `readTemplateToken` is based on acorn's `readTmplToken` which
55+
// is used inside template literals. Our version allows backQuotes.
56+
const o_tmpl = new TokContext(
57+
"`", // token
58+
true, // isExpr
59+
true, // preserveSpace
60+
(parser) => readTemplateToken.call(parser) // override
61+
);
62+
63+
// This is our custom override for parsing a template that allows backticks.
64+
// Based on acorn's readInvalidTemplateToken.
65+
function readTemplateToken() {
66+
out: for (; this.pos < this.input.length; this.pos++) {
67+
switch (this.input.charCodeAt(this.pos)) {
68+
case CODE_BACKSLASH: {
69+
if (this.pos < this.input.length - 1) ++this.pos; // not a terminal slash
70+
break;
71+
}
72+
case CODE_DOLLAR: {
73+
if (this.input.charCodeAt(this.pos + 1) === CODE_BRACEL) {
74+
if (this.pos === this.start && this.type === tt.invalidTemplate) {
75+
this.pos += 2;
76+
return this.finishToken(tt.dollarBraceL);
77+
}
78+
break out;
79+
}
80+
break;
81+
}
82+
}
83+
}
84+
return this.finishToken(tt.invalidTemplate, this.input.slice(this.start, this.pos));
85+
}
86+
87+
function escapeTemplateElements(source, {quasis}, raw) {
88+
for (const quasi of quasis) {
89+
if (raw) {
90+
interpolateBacktick(source, quasi);
91+
} else {
92+
escapeBacktick(source, quasi);
93+
escapeBackslash(source, quasi);
94+
}
95+
}
96+
if (raw) interpolateTerminalBackslash(source);
97+
}
98+
99+
function escapeBacktick(source, {start, end}) {
100+
const input = source._input;
101+
for (let i = start; i < end; ++i) {
102+
if (input.charCodeAt(i) === CODE_BACKTICK) {
103+
source.insertRight(i, "\\");
104+
}
105+
}
106+
}
107+
108+
function interpolateBacktick(source, {start, end}) {
109+
const input = source._input;
110+
let oddBackslashes = false;
111+
for (let i = start; i < end; ++i) {
112+
switch (input.charCodeAt(i)) {
113+
case CODE_BACKSLASH: {
114+
oddBackslashes = !oddBackslashes;
115+
break;
116+
}
117+
case CODE_BACKTICK: {
118+
if (!oddBackslashes) {
119+
let j = i + 1;
120+
while (j < end && input.charCodeAt(j) === CODE_BACKTICK) ++j;
121+
source.replaceRight(i, j, `\${'${"`".repeat(j - i)}'}`);
122+
i = j - 1;
123+
}
124+
// fall through
125+
}
126+
default: {
127+
oddBackslashes = false;
128+
break;
129+
}
130+
}
131+
}
132+
}
133+
134+
function escapeBackslash(source, {start, end}) {
135+
const input = source._input;
136+
let afterDollar = false;
137+
let oddBackslashes = false;
138+
for (let i = start; i < end; ++i) {
139+
switch (input.charCodeAt(i)) {
140+
case CODE_DOLLAR: {
141+
afterDollar = true;
142+
oddBackslashes = false;
143+
break;
144+
}
145+
case CODE_BACKSLASH: {
146+
oddBackslashes = !oddBackslashes;
147+
if (afterDollar && input.charCodeAt(i + 1) === CODE_BRACEL) continue;
148+
if (oddBackslashes && input.charCodeAt(i + 1) === CODE_DOLLAR && input.charCodeAt(i + 2) === CODE_BRACEL)
149+
continue;
150+
source.insertRight(i, "\\");
151+
break;
152+
}
153+
default: {
154+
afterDollar = false;
155+
oddBackslashes = false;
156+
break;
157+
}
158+
}
159+
}
160+
}
161+
162+
function interpolateTerminalBackslash(source) {
163+
const input = source._input;
164+
let oddBackslashes = false;
165+
for (let i = input.length - 1; i >= 0; i--) {
166+
if (input.charCodeAt(i) === CODE_BACKSLASH) oddBackslashes = !oddBackslashes;
167+
else break;
168+
}
169+
if (oddBackslashes) source.replaceRight(input.length - 1, input.length, "${'\\\\'}");
170+
}

test/input/dot-graphviz.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
```dot
2+
digraph D {
3+
4+
A [shape=diamond]
5+
B [shape=box]
6+
C [shape=circle]
7+
8+
A -> B [style=dashed]
9+
A -> C
10+
A -> D [penwidth=5, arrowhead=none]
11+
12+
}
13+
```

test/input/mermaid.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
```mermaid
2+
graph TD;
3+
A-->B;
4+
A-->C;
5+
B-->D;
6+
C-->D;
7+
```

test/input/tex-block.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
```tex
2+
\int_0^1 (x + y)dx
3+
```

test/output/dot-graphviz.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
<div id="cell-391a3a26" class="observablehq observablehq--block"></div>

test/output/dot-graphviz.json

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
{
2+
"data": null,
3+
"title": null,
4+
"files": [],
5+
"imports": [],
6+
"pieces": [
7+
{
8+
"type": "html",
9+
"id": "",
10+
"cellIds": [
11+
"391a3a26"
12+
],
13+
"html": "<div id=\"cell-391a3a26\" class=\"observablehq observablehq--block\"></div>\n"
14+
}
15+
],
16+
"cells": [
17+
{
18+
"type": "cell",
19+
"id": "391a3a26",
20+
"inputs": [
21+
"dot",
22+
"display"
23+
],
24+
"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}"
25+
}
26+
]
27+
}

test/output/mermaid.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
<div id="cell-1ffa6d4f" class="observablehq observablehq--block"></div>

test/output/mermaid.json

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
{
2+
"data": null,
3+
"title": null,
4+
"files": [],
5+
"imports": [],
6+
"pieces": [
7+
{
8+
"type": "html",
9+
"id": "",
10+
"cellIds": [
11+
"1ffa6d4f"
12+
],
13+
"html": "<div id=\"cell-1ffa6d4f\" class=\"observablehq observablehq--block\"></div>\n"
14+
}
15+
],
16+
"cells": [
17+
{
18+
"type": "cell",
19+
"id": "1ffa6d4f",
20+
"inputs": [
21+
"mermaid",
22+
"display"
23+
],
24+
"body": "async (mermaid,display) => {\ndisplay((\nawait mermaid`graph TD;\n A-->B;\n A-->C;\n B-->D;\n C-->D;\n`\n))\n}"
25+
}
26+
]
27+
}

test/output/tex-block.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
<div id="cell-5ceee90d" class="observablehq observablehq--block"></div>

0 commit comments

Comments
 (0)