Skip to content

Commit

Permalink
dot & tex blocks
Browse files Browse the repository at this point in the history
  • Loading branch information
mbostock committed Oct 15, 2023
1 parent 5d6d4cb commit b7c352c
Show file tree
Hide file tree
Showing 6 changed files with 244 additions and 10 deletions.
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,8 @@ TODO
- output name for SQL cells
- source database for SQL cells
- HTML fenced code blocks?
- TeX fenced code blocks?
- Graphviz/dot fenced code blocks?
- TeX fenced code blocks?
- Graphviz/dot fenced code blocks?
- ✅ routing to different notebooks
- detect broken socket and reconnect
- detect server restart and reload
Expand Down
35 changes: 35 additions & 0 deletions public/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,41 @@ function recommendedLibraries() {
d3: () => import("npm:d3"),
htl: () => import("npm:htl"),
Plot: () => import("npm:@observablehq/plot"),
dot: async () => {
// TODO Incorporate this into the standard library.
const viz = await import("npm:@viz-js/viz").then(({instance}) => 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;
};
},
Inputs: () => {
// TODO Observable Inputs needs to include the CSS in the dist folder
// published to npm, and we should replace the __ns__ namespace with
Expand Down
27 changes: 19 additions & 8 deletions src/markdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,9 @@ import {type default as Renderer, type RenderRule} from "markdown-it/lib/rendere
import mime from "mime";
import {join} from "path";
import {canReadSync} from "./files.js";
import {transpileJavaScript, type FileReference, type ImportReference, type Transpile} from "./javascript.js";
import {computeHash} from "./hash.js";
import {transpileJavaScript, type FileReference, type ImportReference, type Transpile} from "./javascript.js";
import {transpileTag} from "./tag.js";

export interface HtmlPiece {
type: "html";
Expand Down Expand Up @@ -78,26 +79,36 @@ function uniqueCodeId(context: ParseContext, content: string): string {
return id;
}

function isLive(language) {
return language === "js" || language === "dot" || language === "tex";
}

function getSource(content, language) {
return language === "tex"
? transpileTag(content, "tex.block", true)
: language === "dot"
? transpileTag(content, "dot", false)
: content;
}

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") {
if (isLive(language) && option !== "no-run") {
const id = uniqueCodeId(context, token.content);
const transpile = transpileJavaScript(token.content, {
id,
root,
sourceLine: context.startLine + context.currentLine
});
const source = getSource(token.content, language);
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 += `<div id="cell-${id}" class="observablehq observablehq--block"></div>\n`;
count++;
}
if (language !== "js" || option === "show" || option === "no-run") {
if (!isLive(language) || option === "show" || option === "no-run") {
result += baseRenderer(tokens, idx, options, context, self);
count++;
}
Expand Down
1 change: 1 addition & 0 deletions src/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ ${parseResult.html}</main>

function getImportMap(parseResult: ParseResult): [name: string, href: string][] {
const modules = new Set(["npm:@observablehq/runtime"]);
if (parseResult.cells.some((c) => c.inputs?.includes("dot"))) modules.add("npm:@viz-js/viz");
if (parseResult.cells.some((c) => c.inputs?.includes("d3") || c.inputs?.includes("Plot"))) modules.add("npm:d3");
if (parseResult.cells.some((c) => c.inputs?.includes("Plot"))) modules.add("npm:@observablehq/plot");
if (parseResult.cells.some((c) => c.inputs?.includes("htl") || c.inputs?.includes("Inputs"))) modules.add("npm:htl");
Expand Down
170 changes: 170 additions & 0 deletions src/tag.js
Original file line number Diff line number Diff line change
@@ -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, "${'\\\\'}");
}
17 changes: 17 additions & 0 deletions test/tag-test.js
Original file line number Diff line number Diff line change
@@ -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}`");
});
});

0 comments on commit b7c352c

Please sign in to comment.