Skip to content

Commit

Permalink
vizdom
Browse files Browse the repository at this point in the history
  • Loading branch information
stereobooster committed Oct 13, 2024
1 parent 38f3a84 commit 6a17604
Show file tree
Hide file tree
Showing 14 changed files with 621 additions and 23 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ I have implemented core packages and added some examples. However, I still need
| Graphviz | [@beoe/rehype-graphviz](/packages/rehype-graphviz/) | |
| Mermaid | [@beoe/rehype-mermaid](/packages/rehype-mermaid/) | |
| Gnuplot | [@beoe/rehype-gnuplot](/packages/rehype-gnuplot/) | |
| Vizdom | [@beoe/rehype-vizdom](/packages/rehype-vizdom/) | |
| Penrose | | |
| ... | | |

Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
"playwright": "^1.48.0",
"turbo": "^2.1.3",
"typescript": "^5.6.3",
"vite-plugin-top-level-await": "^1.4.4",
"vite-plugin-wasm": "^3.3.0",
"vite-tsconfig-paths": "^5.0.1",
"vitest": "^2.1.2"
},
Expand Down
2 changes: 1 addition & 1 deletion packages/rehype-mermaid/test/fixtures/a.html
Original file line number Diff line number Diff line change
@@ -1 +1 @@
<figure class="beoe mermaid"><svg xmlns="http://www.w3.org/2000/svg" id="m01-0" width="100%" aria-roledescription="flowchart-v2" style="font-family:arial,sans-serif;font-size:16px;fill:#333;max-width:41.671875px" viewBox="-8 -8 41.672 49"><style>#m01-0 .marker{fill:#333;stroke:#333}#m01-0 .marker.cross{stroke:#333}#m01-0 svg{font-family:arial,sans-serif;font-size:16px}#m01-0 .node circle,#m01-0 .node path,#m01-0 .node rect{fill:#ececff;stroke:#9370db;stroke-width:1px}#m01-0 :root{--mermaid-font-family:arial,sans-serif}</style><marker id="m01-0_flowchart-pointEnd" class="marker flowchart" markerHeight="12" markerUnits="userSpaceOnUse" markerWidth="12" orient="auto" refX="6" refY="5" viewBox="0 0 10 10"><path d="m0 0 10 5-10 5z" class="arrowMarkerPath" style="stroke-width:1;stroke-dasharray:1,0"></path></marker><marker id="m01-0_flowchart-pointStart" class="marker flowchart" markerHeight="12" markerUnits="userSpaceOnUse" markerWidth="12" orient="auto" refX="4.5" refY="5" viewBox="0 0 10 10"><path d="m0 5 10 5V0z" class="arrowMarkerPath" style="stroke-width:1;stroke-dasharray:1,0"></path></marker><marker id="m01-0_flowchart-circleEnd" class="marker flowchart" markerHeight="11" markerUnits="userSpaceOnUse" markerWidth="11" orient="auto" refX="11" refY="5" viewBox="0 0 10 10"><circle cx="5" cy="5" r="5" class="arrowMarkerPath" style="stroke-width:1;stroke-dasharray:1,0"></circle></marker><marker id="m01-0_flowchart-circleStart" class="marker flowchart" markerHeight="11" markerUnits="userSpaceOnUse" markerWidth="11" orient="auto" refX="-1" refY="5" viewBox="0 0 10 10"><circle cx="5" cy="5" r="5" class="arrowMarkerPath" style="stroke-width:1;stroke-dasharray:1,0"></circle></marker><marker id="m01-0_flowchart-crossEnd" class="marker cross flowchart" markerHeight="11" markerUnits="userSpaceOnUse" markerWidth="11" orient="auto" refX="12" refY="5.2" viewBox="0 0 11 11"><path d="m1 1 9 9m0-9-9 9" class="arrowMarkerPath" style="stroke-width:2;stroke-dasharray:1,0"></path></marker><marker id="m01-0_flowchart-crossStart" class="marker cross flowchart" markerHeight="11" markerUnits="userSpaceOnUse" markerWidth="11" orient="auto" refX="-1" refY="5.2" viewBox="0 0 11 11"><path d="m1 1 9 9m0-9-9 9" class="arrowMarkerPath" style="stroke-width:2;stroke-dasharray:1,0"></path></marker><g class="root"><g class="nodes"><g id="flowchart-A-0" class="node default default flowchart-label" data-id="A" data-node="true" transform="translate(12.836 16.5)"><rect width="25.672" height="33" x="-12.836" y="-16.5" class="basic label-container" rx="0" ry="0"></rect><g class="label" style="font-family:arial,sans-serif;color:#333;text-align:center" transform="translate(-5.336 -9)"><rect></rect><foreignObject width="10.672" height="18"><div xmlns="http://www.w3.org/1999/xhtml" style="display:inline-block;white-space:nowrap"><span class="nodeLabel" style="fill:#333;color:#333">A</span></div></foreignObject></g></g></g></g></svg></figure>
<figure class="beoe mermaid"><svg xmlns="http://www.w3.org/2000/svg" id="m01-0" width="100%" aria-roledescription="flowchart-v2" style="font-family:arial,sans-serif;font-size:16px;fill:#333;max-width:41.671875px" viewBox="-8 -8 41.672 49.5"><style>#m01-0 .marker{fill:#333;stroke:#333}#m01-0 .marker.cross{stroke:#333}#m01-0 svg{font-family:arial,sans-serif;font-size:16px}#m01-0 .node circle,#m01-0 .node path,#m01-0 .node rect{fill:#ececff;stroke:#9370db;stroke-width:1px}#m01-0 :root{--mermaid-font-family:arial,sans-serif}</style><marker id="m01-0_flowchart-pointEnd" class="marker flowchart" markerHeight="12" markerUnits="userSpaceOnUse" markerWidth="12" orient="auto" refX="6" refY="5" viewBox="0 0 10 10"><path d="m0 0 10 5-10 5z" class="arrowMarkerPath" style="stroke-width:1;stroke-dasharray:1,0"></path></marker><marker id="m01-0_flowchart-pointStart" class="marker flowchart" markerHeight="12" markerUnits="userSpaceOnUse" markerWidth="12" orient="auto" refX="4.5" refY="5" viewBox="0 0 10 10"><path d="m0 5 10 5V0z" class="arrowMarkerPath" style="stroke-width:1;stroke-dasharray:1,0"></path></marker><marker id="m01-0_flowchart-circleEnd" class="marker flowchart" markerHeight="11" markerUnits="userSpaceOnUse" markerWidth="11" orient="auto" refX="11" refY="5" viewBox="0 0 10 10"><circle cx="5" cy="5" r="5" class="arrowMarkerPath" style="stroke-width:1;stroke-dasharray:1,0"></circle></marker><marker id="m01-0_flowchart-circleStart" class="marker flowchart" markerHeight="11" markerUnits="userSpaceOnUse" markerWidth="11" orient="auto" refX="-1" refY="5" viewBox="0 0 10 10"><circle cx="5" cy="5" r="5" class="arrowMarkerPath" style="stroke-width:1;stroke-dasharray:1,0"></circle></marker><marker id="m01-0_flowchart-crossEnd" class="marker cross flowchart" markerHeight="11" markerUnits="userSpaceOnUse" markerWidth="11" orient="auto" refX="12" refY="5.2" viewBox="0 0 11 11"><path d="m1 1 9 9m0-9-9 9" class="arrowMarkerPath" style="stroke-width:2;stroke-dasharray:1,0"></path></marker><marker id="m01-0_flowchart-crossStart" class="marker cross flowchart" markerHeight="11" markerUnits="userSpaceOnUse" markerWidth="11" orient="auto" refX="-1" refY="5.2" viewBox="0 0 11 11"><path d="m1 1 9 9m0-9-9 9" class="arrowMarkerPath" style="stroke-width:2;stroke-dasharray:1,0"></path></marker><g class="root"><g class="nodes"><g id="flowchart-A-0" class="node default default flowchart-label" data-id="A" data-node="true" transform="translate(12.836 16.75)"><rect width="25.672" height="33.5" x="-12.836" y="-16.75" class="basic label-container" rx="0" ry="0"></rect><g class="label" style="font-family:arial,sans-serif;color:#333;text-align:center" transform="translate(-5.336 -9.25)"><rect></rect><foreignObject width="10.672" height="18.5"><div xmlns="http://www.w3.org/1999/xhtml" style="display:inline-block;white-space:nowrap"><span class="nodeLabel" style="fill:#333;color:#333">A</span></div></foreignObject></g></g></g></g></svg></figure>
101 changes: 101 additions & 0 deletions packages/rehype-vizdom/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
# @beoe/rehype-vizdom

> [!WARNING]
> Doesn't work because `@vizdom/vizdom-ts-esm` uses [WebAssembly/ES Module Integration](https://github.com/WebAssembly/esm-integration/tree/main/proposals/esm-integration) proposal, which is not supported by [Vite](https://github.com/vitejs/vite/discussions/7763) and the plugin [doesn't seem to work with Vitest](https://github.com/Menci/vite-plugin-wasm/issues/56#issuecomment-2253169420)
Rehype plugin to generate [Vizdom](https://github.com/vizdom-dev/vizdom) diagrams (as inline SVGs) in place of code fences. This

````md
```dot
digraph G { Hello -> World }
```
````

will be converted to

```html
<figure class="beoe vizdom">
<svg>...</svg>
</figure>
```

which can look like this:

**TODO**: add screenshot

## Usage

```js
import rehypeGraphviz from "@beoe/rehype-vizdom";

const html = await unified()
.use(remarkParse)
.use(remarkRehype)
.use(rehypeGraphviz)
.use(rehypeStringify)
.process(`markdown`);
```

It support caching the same way as [@beoe/rehype-code-hook](/packages/rehype-code-hook/) does.

## Tips

### Styling and dark mode

You can add dark mode with something like this:

```css
:root {
--color-variable: #000;
}
@media (prefers-color-scheme: dark) {
:root {
--color-variable: #fff;
}
}
.vizdom {
text {
fill: var(--color-variable);
}
[fill="black"] {
fill: var(--color-variable);
}
[stroke="black"] {
stroke: var(--color-variable);
}
}
```

Plus you can pass [class](https://vizdom.org/docs/attrs/class/) to Edges and Nodes to implement advanced styling.

### Transparent background

To remove background use:

```dot
digraph G {
bgcolor="transparent"
}
```

### To remove title

To remove `title` (which shows as tooltip when you hover mouse) use:

```dot
digraph G {
node[tooltip=" "]
}
```

### You can add links

Inline SVG can contain HTML links:

```dot
digraph G {
node[URL="https://example.com"]
}
```

## TODO
47 changes: 47 additions & 0 deletions packages/rehype-vizdom/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
{
"name": "@beoe/rehype-vizdom",
"type": "module",
"version": "0.0.2",
"description": "rehype vizdom plugin",
"keywords": [
"rehype",
"vizdom"
],
"author": "stereobooster",
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/stereobooster/beoe.git",
"directory": "packages/rehype-vizdom"
},
"sideEffects": false,
"exports": {
"types": "./dist/index.d.js",
"default": "./dist/index.js"
},
"main": "./dist/index.js",
"module": "./dist/index.js",
"files": [
"dist"
],
"types": "./dist/index.d.js",
"scripts": {
"test": "vitest",
"build": "rm -rf dist && tsc",
"dev": "tsc --watch",
"clean": "rm -rf dist"
},
"dependencies": {
"@beoe/rehype-code-hook": "workspace:*",
"@ts-graphviz/ast": "^2.0.5",
"@vizdom/vizdom-ts-esm": "^0.1.7",
"svgo": "^3.2.0"
},
"devDependencies": {
"@types/hast": "^3.0.4",
"rehype-stringify": "^10.0.0",
"remark-parse": "^11.0.0",
"remark-rehype": "^11.1.0",
"unified": "^11.0.4"
}
}
93 changes: 93 additions & 0 deletions packages/rehype-vizdom/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import type { Plugin } from "unified";
import type { Root } from "hast";
import { rehypeCodeHook, type MapLike } from "@beoe/rehype-code-hook";
import { type Config as SvgoConfig } from "svgo";
import { parse, toModel } from "@ts-graphviz/ast";
import { processVizdomSvg } from "./vizdom.js";
import { DirectedGraph, VertexWeakRef } from "@vizdom/vizdom-ts-esm";

export async function getSvg(code: string) {
const graph = new DirectedGraph();
const ast = parse(code);
const model = toModel(ast);

const nodes: Record<string, VertexWeakRef> = {};
model.nodes.forEach((node) => {
nodes[node.id] = graph.new_vertex({
render: {
id: node.id,
label: node.attributes.get("label"),
tooltip: node.attributes.get("tooltip"),
fill_color: node.attributes.get("fillcolor") as string,
font_color: node.attributes.get("fontcolor") as string,
color: node.attributes.get("color") as string,
font_size: node.attributes.get("fontsize"),
pen_width: node.attributes.get("penwidth"),
shape: node.attributes.get("shape") as any,
style: node.attributes.get("style") as any,
},
});
});

model.edges.forEach((edge) => {
const from = edge.targets[0];
const to = edge.targets[0];

if (Array.isArray(from) || Array.isArray(to)) {
throw new Error("what is it?");
}

graph.new_edge(nodes[from.id], nodes[to.id], {
render: {
pen_width: edge.attributes.get("penwidth"),
font_size: edge.attributes.get("fontsize"),
style: edge.attributes.get("style") as any,
// shape: edge.attributes.get("shape") as any,
// curve: edge.attributes.get("curve") as any,
arrow_head: edge.attributes.get("arrowhead") as any,
dir: edge.attributes.get("dir") as any,
color: edge.attributes.get("color") as string,
font_color: edge.attributes.get("fontcolor") as string,
fill_color: edge.attributes.get("fillcolor") as string,
// id?: string;
label: edge.attributes.get("label"),
tooltip: edge.attributes.get("tooltip"),
},
});
});

const positioned = graph.layout();
return await positioned.to_svg().to_string();
}

export type RenderVizdomOptions = {
code: string;
class?: string;
svgo?: SvgoConfig | boolean;
};

export { processVizdomSvg };

export type RehypeVizdomConfig = {
cache?: MapLike;
class?: string;
svgo?: SvgoConfig | boolean;
};

export const rehypeVizdom: Plugin<[RehypeVizdomConfig?], Root> = (
options = {}
) => {
const salt = { class: options.class, svgo: options.svgo };
// @ts-expect-error
return rehypeCodeHook({
...options,
salt,
language: "dot",
code: ({ code }) =>
getSvg(code).then((str) =>
processVizdomSvg(str, options.class, options.svgo)
),
});
};

export default rehypeVizdom;
44 changes: 44 additions & 0 deletions packages/rehype-vizdom/src/vizdom.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// SVGO is an experiment. I'm not sure it can compress a lot, plus it can break some diagrams
import { optimize, type Config as SvgoConfig } from "svgo";

export type VizdomSvgOptions = {
class?: string;
};

const svgoConfig: SvgoConfig = {
plugins: [
{
name: "preset-default",
params: {
overrides: {
// disable a default plugin
removeViewBox: false,
},
},
},
],
};

/**
* removes `<?xml version="1.0" encoding="UTF-8" standalone="no"?>`
* removes `<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"`
* removes `width="..." height="..."` from svg tag
* minifies SVG with `SVGO`
* wraps in a figure with class `beoe vizdom`
*/
export const processVizdomSvg = (
svg: string,
className?: string,
config?: SvgoConfig | boolean
) => {
svg = svg.split("\n").slice(6).join("\n");
if (config !== false) {
svg = optimize(
svg,
config === undefined || config === true ? svgoConfig : config
).data;
}
svg = svg.replace(/width="\d+[^"]+"\s+/, "");
svg = svg.replace(/height="\d+[^"]+"\s+/, "");
return `<figure class="beoe vizdom ${className || ""}">${svg}</figure>`;
};
1 change: 1 addition & 0 deletions packages/rehype-vizdom/test/fixtures/a.html
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<figure class="beoe vizdom"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 79.41 116"><g class="graph" transform="translate(4 112)"><path fill="#fff" d="M-4 4v-116h79.41V4z"></path><g class="node"><ellipse cx="35.71" cy="-90" fill="none" stroke="#000" rx="32.49" ry="18"></ellipse><text x="35.71" y="-85.8" font-family="Times,serif" font-size="14" text-anchor="middle">Hello</text></g><g class="node"><ellipse cx="35.71" cy="-18" fill="none" stroke="#000" rx="35.71" ry="18"></ellipse><text x="35.71" y="-13.8" font-family="Times,serif" font-size="14" text-anchor="middle">World</text></g><g class="edge"><path fill="none" stroke="#000" d="M35.71-71.7v24.16"></path><path stroke="#000" d="m39.21-47.62-3.5 10-3.5-10z"></path></g></g></svg></figure>
3 changes: 3 additions & 0 deletions packages/rehype-vizdom/test/fixtures/a.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
```dot
digraph G { Hello -> World }
```
19 changes: 19 additions & 0 deletions packages/rehype-vizdom/test/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import fs from "node:fs/promises";
import { unified } from "unified";
import remarkParse from "remark-parse";
import remarkRehype from "remark-rehype";
import rehypeStringify from "rehype-stringify";
import { expect, it } from "vitest";

import rehypeGraphviz from "../src";

it("renders diagram", async () => {
const file = await unified()
.use(remarkParse)
.use(remarkRehype)
.use(rehypeGraphviz)
.use(rehypeStringify)
.process(await fs.readFile(new URL("./fixtures/a.md", import.meta.url)));

expect(file.toString()).toMatchFileSnapshot("./fixtures/a.html");
});
45 changes: 45 additions & 0 deletions packages/rehype-vizdom/test/vizdom.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { expect, it } from "vitest";

import { processVizdomSvg } from "../src/vizdom";

const svg = `<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Generated by vizdom version 10.0.1 (0)
-->
<!-- Title: G Pages: 1 -->
<svg width="79pt" height="116pt"
viewBox="0.00 0.00 79.41 116.00" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<g id="graph0" class="graph" transform="scale(1 1) rotate(0) translate(4 112)">
<title>G</title>
<polygon fill="white" stroke="none" points="-4,4 -4,-112 75.41,-112 75.41,4 -4,4"/>
<!-- Hello -->
</g>
</svg>`;

it("removes xml doctype", async () => {
const result = processVizdomSvg(svg);

expect(result).toMatchInlineSnapshot(
`"<figure class="beoe vizdom "><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 79.41 116"><g class="graph" transform="translate(4 112)"><path fill="#fff" d="M-4 4v-116h79.41V4z"/></g></svg></figure>"`
);
});

it("removes width and height", async () => {
const result = processVizdomSvg(svg);

expect(result).not.toContain(`width=`);
expect(result).not.toContain(`height=`);
});

it("wraps in a figure with classes", async () => {
const result = processVizdomSvg(svg);

expect(result).toContain(`<figure class="beoe vizdom`);
});

it("is possible to add class", async () => {
const result = processVizdomSvg(svg, "not-content");

expect(result).toContain(`<figure class="beoe vizdom not-content`);
});
11 changes: 11 additions & 0 deletions packages/rehype-vizdom/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "./dist",
"declaration": true,
"noEmit": false
},
"include": ["./src"],
"rootDir": "./src"
}
Loading

0 comments on commit 6a17604

Please sign in to comment.