Skip to content

Commit b7c352c

Browse files
committed
dot & tex blocks
1 parent 5d6d4cb commit b7c352c

File tree

6 files changed

+244
-10
lines changed

6 files changed

+244
-10
lines changed

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,8 @@ TODO
4242
- output name for SQL cells
4343
- source database for SQL cells
4444
- HTML fenced code blocks?
45-
- TeX fenced code blocks?
46-
- Graphviz/dot fenced code blocks?
45+
- TeX fenced code blocks?
46+
- Graphviz/dot fenced code blocks?
4747
- ✅ routing to different notebooks
4848
- detect broken socket and reconnect
4949
- detect server restart and reload

public/client.js

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,41 @@ function recommendedLibraries() {
3333
d3: () => import("npm:d3"),
3434
htl: () => import("npm:htl"),
3535
Plot: () => import("npm:@observablehq/plot"),
36+
dot: async () => {
37+
// TODO Incorporate this into the standard library.
38+
const viz = await import("npm:@viz-js/viz").then(({instance}) => instance());
39+
return function dot(strings) {
40+
let string = strings[0] + "";
41+
let i = 0;
42+
let n = arguments.length;
43+
while (++i < n) string += arguments[i] + "" + strings[i];
44+
const svg = viz.renderSVGElement(string, {
45+
graphAttributes: {
46+
bgcolor: "none"
47+
},
48+
nodeAttributes: {
49+
color: "#00000101",
50+
fontcolor: "#00000101",
51+
fontname: "var(--sans-serif)",
52+
fontsize: "12"
53+
},
54+
edgeAttributes: {
55+
color: "#00000101"
56+
}
57+
});
58+
for (const e of svg.querySelectorAll("[stroke='#000001'][stroke-opacity='0.003922']")) {
59+
e.setAttribute("stroke", "currentColor");
60+
e.removeAttribute("stroke-opacity");
61+
}
62+
for (const e of svg.querySelectorAll("[fill='#000001'][fill-opacity='0.003922']")) {
63+
e.setAttribute("fill", "currentColor");
64+
e.removeAttribute("fill-opacity");
65+
}
66+
svg.remove();
67+
svg.style = "max-width: 100%; height: auto;";
68+
return svg;
69+
};
70+
},
3671
Inputs: () => {
3772
// TODO Observable Inputs needs to include the CSS in the dist folder
3873
// published to npm, and we should replace the __ns__ namespace with

src/markdown.ts

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,9 @@ import {type default as Renderer, type RenderRule} from "markdown-it/lib/rendere
1111
import mime from "mime";
1212
import {join} from "path";
1313
import {canReadSync} from "./files.js";
14-
import {transpileJavaScript, type FileReference, type ImportReference, type Transpile} from "./javascript.js";
1514
import {computeHash} from "./hash.js";
15+
import {transpileJavaScript, type FileReference, type ImportReference, type Transpile} from "./javascript.js";
16+
import {transpileTag} from "./tag.js";
1617

1718
export interface HtmlPiece {
1819
type: "html";
@@ -78,26 +79,36 @@ function uniqueCodeId(context: ParseContext, content: string): string {
7879
return id;
7980
}
8081

82+
function isLive(language) {
83+
return language === "js" || language === "dot" || language === "tex";
84+
}
85+
86+
function getSource(content, language) {
87+
return language === "tex"
88+
? transpileTag(content, "tex.block", true)
89+
: language === "dot"
90+
? transpileTag(content, "dot", false)
91+
: content;
92+
}
93+
8194
function makeFenceRenderer(root: string, baseRenderer: RenderRule): RenderRule {
8295
return (tokens, idx, options, context: ParseContext, self) => {
8396
const token = tokens[idx];
8497
const [language, option] = token.info.split(" ");
8598
let result = "";
8699
let count = 0;
87-
if (language === "js" && option !== "no-run") {
100+
if (isLive(language) && option !== "no-run") {
88101
const id = uniqueCodeId(context, token.content);
89-
const transpile = transpileJavaScript(token.content, {
90-
id,
91-
root,
92-
sourceLine: context.startLine + context.currentLine
93-
});
102+
const source = getSource(token.content, language);
103+
const sourceLine = context.startLine + context.currentLine;
104+
const transpile = transpileJavaScript(source, {id, root, sourceLine});
94105
extendPiece(context, {code: [transpile]});
95106
if (transpile.files) context.files.push(...transpile.files);
96107
if (transpile.imports) context.imports.push(...transpile.imports);
97108
result += `<div id="cell-${id}" class="observablehq observablehq--block"></div>\n`;
98109
count++;
99110
}
100-
if (language !== "js" || option === "show" || option === "no-run") {
111+
if (!isLive(language) || option === "show" || option === "no-run") {
101112
result += baseRenderer(tokens, idx, options, context, self);
102113
count++;
103114
}

src/render.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@ ${parseResult.html}</main>
106106

107107
function getImportMap(parseResult: ParseResult): [name: string, href: string][] {
108108
const modules = new Set(["npm:@observablehq/runtime"]);
109+
if (parseResult.cells.some((c) => c.inputs?.includes("dot"))) modules.add("npm:@viz-js/viz");
109110
if (parseResult.cells.some((c) => c.inputs?.includes("d3") || c.inputs?.includes("Plot"))) modules.add("npm:d3");
110111
if (parseResult.cells.some((c) => c.inputs?.includes("Plot"))) modules.add("npm:@observablehq/plot");
111112
if (parseResult.cells.some((c) => c.inputs?.includes("htl") || c.inputs?.includes("Inputs"))) modules.add("npm:htl");

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/tag-test.js

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import assert from "node:assert";
2+
import {transpileTag} from "../src/tag.js";
3+
4+
describe("transpileTag(input)", () => {
5+
it("bare template literal", () => {
6+
assert.strictEqual(transpileTag("1 + 2"), "`1 + 2`");
7+
});
8+
it("tagged template literal", () => {
9+
assert.strictEqual(transpileTag("1 + 2", "tag"), "tag`1 + 2`");
10+
});
11+
it("embedded expression", () => {
12+
assert.strictEqual(transpileTag("1 + ${2}"), "`1 + ${2}`");
13+
});
14+
it("escaped embedded expression", () => {
15+
assert.strictEqual(transpileTag("1 + $\\{2}"), "`1 + $\\{2}`");
16+
});
17+
});

0 commit comments

Comments
 (0)