Skip to content

Commit 98a9fb0

Browse files
committed
d2 experiment
1 parent 6aa6a54 commit 98a9fb0

File tree

12 files changed

+479
-24
lines changed

12 files changed

+479
-24
lines changed

experiments/rehype-d2/README.md

+38
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# @beoe/rehype-d2
2+
3+
Rehype plugin to generate [d2](https://d2lang.com) diagrams (as inline SVGs) in place of code fences. This:
4+
5+
````md
6+
```d2
7+
x -> y: hello world
8+
```
9+
````
10+
11+
will be converted to
12+
13+
```html
14+
<figure class="beoe d2">
15+
<svg>...</svg>
16+
</figure>
17+
```
18+
19+
which can look like this:
20+
21+
<img width="" height="" src="./example.svg" alt="example of how generated diagram looks">
22+
23+
## Usage
24+
25+
You need to install Java and Graphviz in order to use this plugin.
26+
27+
```js
28+
import rehypeD2 from "@beoe/rehype-d2";
29+
30+
const html = await unified()
31+
.use(remarkParse)
32+
.use(remarkRehype)
33+
.use(rehypeD2)
34+
.use(rehypeStringify)
35+
.process(`markdown`);
36+
```
37+
38+
It support caching the same way as [@beoe/rehype-code-hook](/packages/rehype-code-hook/) does.

experiments/rehype-d2/example.svg

+177
Loading

experiments/rehype-d2/package.json

+48
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
{
2+
"name": "@beoe/rehype-d2",
3+
"type": "module",
4+
"version": "0.0.1",
5+
"description": "rehype d2 plugin",
6+
"keywords": [
7+
"rehype",
8+
"d2"
9+
],
10+
"author": "stereobooster",
11+
"license": "MIT",
12+
"repository": {
13+
"type": "git",
14+
"url": "git+https://github.com/stereobooster/beoe.git",
15+
"directory": "packages/rehype-d2"
16+
},
17+
"sideEffects": false,
18+
"exports": {
19+
"types": "./dist/index.d.js",
20+
"default": "./dist/index.js"
21+
},
22+
"main": "./dist/index.js",
23+
"module": "./dist/index.js",
24+
"files": [
25+
"dist"
26+
],
27+
"types": "./dist/index.d.js",
28+
"scripts": {
29+
"test": "vitest",
30+
"build": "rm -rf dist && tsc",
31+
"dev": "tsc --watch",
32+
"clean": "rm -rf dist"
33+
},
34+
"dependencies": {
35+
"@beoe/rehype-code-hook": "workspace:*",
36+
"hastscript": "^9.0.0",
37+
"mini-svg-data-uri": "^1.4.4",
38+
"d2": "^0.0.2",
39+
"svgo": "^3.2.0"
40+
},
41+
"devDependencies": {
42+
"@types/hast": "^3.0.4",
43+
"rehype-stringify": "^10.0.0",
44+
"remark-parse": "^11.0.0",
45+
"remark-rehype": "^11.1.0",
46+
"unified": "^11.0.4"
47+
}
48+
}

experiments/rehype-d2/src/d2.ts

+67
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { spawn } from "node:child_process";
2+
3+
// https://github.com/stereobooster/venn-nodejs/blob/main/index.js
4+
export function exec(
5+
command: string,
6+
args: string[],
7+
stdin?: string,
8+
cwd?: string
9+
) {
10+
return new Promise<string[]>((resolve, reject) => {
11+
const child = spawn(command, args, {
12+
cwd,
13+
stdio: [],
14+
});
15+
16+
const output: string[] = [];
17+
let errorMessage = `Unable to run command: '${command} ${args.join(" ")}'.`;
18+
19+
child.stdout.on("data", (data: Buffer) => {
20+
const lines = data
21+
.toString()
22+
.split("\n")
23+
.filter((line) => line.length > 0);
24+
25+
output.push(...lines);
26+
});
27+
28+
child.stderr.on("data", (data: Buffer) => {
29+
errorMessage += `\n${data.toString()}`;
30+
});
31+
32+
child.on("error", (error) => {
33+
reject(new Error(errorMessage, { cause: error }));
34+
});
35+
36+
child.on("close", (code) => {
37+
if (code !== 0) {
38+
reject(new Error(errorMessage));
39+
40+
return;
41+
}
42+
43+
resolve(output);
44+
});
45+
46+
if (stdin) {
47+
child.stdin.write(stdin);
48+
child.stdin.end();
49+
}
50+
});
51+
}
52+
53+
// extraArgs.push(`--dark-theme=${attributes.darkTheme ?? config.theme.dark}`);
54+
// await exec(
55+
// "d2",
56+
// [
57+
// `--layout=${attributes.layout ?? config.layout}`,
58+
// `--theme=${attributes.theme ?? config.theme.default}`,
59+
// `--sketch=${attributes.sketch ?? config.sketch}`,
60+
// `--pad=${attributes.pad ?? config.pad}`,
61+
// ...extraArgs,
62+
// "-",
63+
// outputPath,
64+
// ],
65+
// input,
66+
// cwd
67+
// );

experiments/rehype-d2/src/index.ts

+98
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import type { Plugin } from "unified";
2+
import type { Root } from "hast";
3+
import { rehypeCodeHook, type MapLike } from "@beoe/rehype-code-hook";
4+
// @ts-ignore
5+
import d2 from "./d2";
6+
import svgToMiniDataURI from "mini-svg-data-uri";
7+
import { h } from "hastscript";
8+
// SVGO is an experiment. I'm not sure it can compress a lot, plus it can break some diagrams
9+
import { optimize, type Config as SvgoConfig } from "svgo";
10+
11+
const svgoConfig: SvgoConfig = {
12+
plugins: [
13+
{
14+
name: "preset-default",
15+
params: {
16+
overrides: {
17+
// we need viewbox for inline SVGs
18+
removeViewBox: false,
19+
// this breaks statediagram
20+
convertShapeToPath: false,
21+
},
22+
},
23+
},
24+
],
25+
};
26+
27+
export type RehypeD2Config = {
28+
cache?: MapLike;
29+
class?: string;
30+
/**
31+
* be carefull. It may break some diagrams. For example, stateDiagram-v2
32+
*/
33+
svgo?: SvgoConfig | boolean;
34+
strategy?: "inline" | "img";
35+
};
36+
37+
export const rehypeD2: Plugin<[RehypeD2Config?], Root> = (
38+
options = {}
39+
) => {
40+
const { svgo, strategy, ...rest } = options;
41+
42+
const salt = options;
43+
44+
const render = async (code: string) => {
45+
let svg: string = await d2(code);
46+
if (svgo !== false) {
47+
svg = optimize(
48+
svg,
49+
svgo === undefined || svgo === true ? svgoConfig : svgo
50+
).data;
51+
}
52+
53+
const widthMatch = svg.match(/width="(\d+[^"]+)"\s+/);
54+
const width = widthMatch ? widthMatch[1] : undefined;
55+
56+
const heightMatch = svg.match(/height="(\d+[^"]+)"\s+/);
57+
const height = heightMatch ? heightMatch[1] : undefined;
58+
59+
svg = svg.replace(/width="\d+[^"]+"\s+/, "");
60+
svg = svg.replace(/height="\d+[^"]+"\s+/, "");
61+
return { svg, width, height };
62+
};
63+
64+
// @ts-expect-error
65+
return rehypeCodeHook({
66+
...rest,
67+
salt,
68+
language: "d2",
69+
code: async ({ code }) => {
70+
switch (strategy) {
71+
case "img": {
72+
const { svg, width, height } = await render(code);
73+
return h(
74+
"figure",
75+
{
76+
class: `beoe d2 ${rest.class || ""}`,
77+
},
78+
// wrapp in additional div for svg-pan-zoom
79+
h("img", {
80+
width,
81+
height,
82+
alt: "",
83+
src: svgToMiniDataURI(svg),
84+
})
85+
);
86+
}
87+
default: {
88+
const { svg } = await render(code);
89+
return `<figure class="beoe d2 ${
90+
rest.class || ""
91+
}">${svg}</figure>`;
92+
}
93+
}
94+
},
95+
});
96+
};
97+
98+
export default rehypeD2;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
<figure class="beoe mermaid"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 115 121"><line x1="25" x2="25" y1="36.488" y2="85.799" style="stroke:#181818;stroke-width:.5;stroke-dasharray:5,5"></line><line x1="85.5" x2="85.5" y1="36.488" y2="85.799" style="stroke:#181818;stroke-width:.5;stroke-dasharray:5,5"></line><rect width="40" height="30.488" x="5" y="5" fill="#E2E2F0" rx="2.5" ry="2.5" style="stroke:#181818;stroke-width:.5"></rect><text x="12" y="25.535" font-family="sans-serif" font-size="14" textLength="26">Bob</text><rect width="40" height="30.488" x="5" y="84.799" fill="#E2E2F0" rx="2.5" ry="2.5" style="stroke:#181818;stroke-width:.5"></rect><text x="12" y="105.334" font-family="sans-serif" font-size="14" textLength="26">Bob</text><rect width="47" height="30.488" x="62.5" y="5" fill="#E2E2F0" rx="2.5" ry="2.5" style="stroke:#181818;stroke-width:.5"></rect><text x="69.5" y="25.535" font-family="sans-serif" font-size="14" textLength="33">Alice</text><rect width="47" height="30.488" x="62.5" y="84.799" fill="#E2E2F0" rx="2.5" ry="2.5" style="stroke:#181818;stroke-width:.5"></rect><text x="69.5" y="105.334" font-family="sans-serif" font-size="14" textLength="33">Alice</text><polygon fill="#181818" points="74,63.7988,84,67.7988,74,71.7988,78,67.7988" style="stroke:#181818;stroke-width:1"></polygon><line x1="25" x2="80" y1="67.799" y2="67.799" style="stroke:#181818;stroke-width:1"></line><text x="32" y="63.057" font-family="sans-serif" font-size="13" textLength="37">Hello!</text></svg></figure>
+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
```d2
2+
Bob->Alice : Hello!
3+
```
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
<figure class="beoe mermaid"><img width="115" height="121" alt="" src="data:image/svg+xml,%3csvg xmlns=&#x27;http://www.w3.org/2000/svg&#x27; viewBox=&#x27;0 0 115 121&#x27;%3e%3cline x1=&#x27;25&#x27; x2=&#x27;25&#x27; y1=&#x27;36.488&#x27; y2=&#x27;85.799&#x27; style=&#x27;stroke:%23181818%3bstroke-width:.5%3bstroke-dasharray:5%2c5&#x27;/%3e%3cline x1=&#x27;85.5&#x27; x2=&#x27;85.5&#x27; y1=&#x27;36.488&#x27; y2=&#x27;85.799&#x27; style=&#x27;stroke:%23181818%3bstroke-width:.5%3bstroke-dasharray:5%2c5&#x27;/%3e%3crect width=&#x27;40&#x27; height=&#x27;30.488&#x27; x=&#x27;5&#x27; y=&#x27;5&#x27; fill=&#x27;%23E2E2F0&#x27; rx=&#x27;2.5&#x27; ry=&#x27;2.5&#x27; style=&#x27;stroke:%23181818%3bstroke-width:.5&#x27;/%3e%3ctext x=&#x27;12&#x27; y=&#x27;25.535&#x27; font-family=&#x27;sans-serif&#x27; font-size=&#x27;14&#x27; textLength=&#x27;26&#x27;%3eBob%3c/text%3e%3crect width=&#x27;40&#x27; height=&#x27;30.488&#x27; x=&#x27;5&#x27; y=&#x27;84.799&#x27; fill=&#x27;%23E2E2F0&#x27; rx=&#x27;2.5&#x27; ry=&#x27;2.5&#x27; style=&#x27;stroke:%23181818%3bstroke-width:.5&#x27;/%3e%3ctext x=&#x27;12&#x27; y=&#x27;105.334&#x27; font-family=&#x27;sans-serif&#x27; font-size=&#x27;14&#x27; textLength=&#x27;26&#x27;%3eBob%3c/text%3e%3crect width=&#x27;47&#x27; height=&#x27;30.488&#x27; x=&#x27;62.5&#x27; y=&#x27;5&#x27; fill=&#x27;%23E2E2F0&#x27; rx=&#x27;2.5&#x27; ry=&#x27;2.5&#x27; style=&#x27;stroke:%23181818%3bstroke-width:.5&#x27;/%3e%3ctext x=&#x27;69.5&#x27; y=&#x27;25.535&#x27; font-family=&#x27;sans-serif&#x27; font-size=&#x27;14&#x27; textLength=&#x27;33&#x27;%3eAlice%3c/text%3e%3crect width=&#x27;47&#x27; height=&#x27;30.488&#x27; x=&#x27;62.5&#x27; y=&#x27;84.799&#x27; fill=&#x27;%23E2E2F0&#x27; rx=&#x27;2.5&#x27; ry=&#x27;2.5&#x27; style=&#x27;stroke:%23181818%3bstroke-width:.5&#x27;/%3e%3ctext x=&#x27;69.5&#x27; y=&#x27;105.334&#x27; font-family=&#x27;sans-serif&#x27; font-size=&#x27;14&#x27; textLength=&#x27;33&#x27;%3eAlice%3c/text%3e%3cpolygon fill=&#x27;%23181818&#x27; points=&#x27;74%2c63.7988%2c84%2c67.7988%2c74%2c71.7988%2c78%2c67.7988&#x27; style=&#x27;stroke:%23181818%3bstroke-width:1&#x27;/%3e%3cline x1=&#x27;25&#x27; x2=&#x27;80&#x27; y1=&#x27;67.799&#x27; y2=&#x27;67.799&#x27; style=&#x27;stroke:%23181818%3bstroke-width:1&#x27;/%3e%3ctext x=&#x27;32&#x27; y=&#x27;63.057&#x27; font-family=&#x27;sans-serif&#x27; font-size=&#x27;13&#x27; textLength=&#x27;37&#x27;%3eHello!%3c/text%3e%3c/svg%3e"></figure>
+32
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import fs from "node:fs/promises";
2+
import { unified } from "unified";
3+
import remarkParse from "remark-parse";
4+
import remarkRehype from "remark-rehype";
5+
import rehypeStringify from "rehype-stringify";
6+
import { expect, it } from "vitest";
7+
8+
import rehypeD2 from "../src";
9+
10+
// snapshots are different in CI and locally
11+
12+
it.skip("renders diagram", async () => {
13+
const file = await unified()
14+
.use(remarkParse)
15+
.use(remarkRehype)
16+
.use(rehypeD2)
17+
.use(rehypeStringify)
18+
.process(await fs.readFile(new URL("./fixtures/a.md", import.meta.url)));
19+
20+
expect(file.toString()).toMatchFileSnapshot("./fixtures/a-inline.html");
21+
});
22+
23+
it.skip("renders diagram", async () => {
24+
const file = await unified()
25+
.use(remarkParse)
26+
.use(remarkRehype)
27+
.use(rehypeD2, { strategy: "img" })
28+
.use(rehypeStringify)
29+
.process(await fs.readFile(new URL("./fixtures/a.md", import.meta.url)));
30+
31+
expect(file.toString()).toMatchFileSnapshot("./fixtures/a1-datauri.html");
32+
});

experiments/rehype-d2/tsconfig.json

+11
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"$schema": "https://json.schemastore.org/tsconfig",
3+
"extends": "../../tsconfig.json",
4+
"compilerOptions": {
5+
"outDir": "./dist",
6+
"declaration": true,
7+
"noEmit": false
8+
},
9+
"include": ["./src"],
10+
"rootDir": "./src"
11+
}

packages/rehype-plantuml/README.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ will be converted to
1818

1919
which can look like this:
2020

21-
<img width="115" height="121" src="./example.svg" alt="example of how generated graph looks">
21+
<img width="115" height="121" src="./example.svg" alt="example of how generated diagram looks">
2222

2323
## PlantUML installation options
2424

packages/rehype-plantuml/test/index.test.ts

+2-23
Original file line numberDiff line numberDiff line change
@@ -3,29 +3,10 @@ import { unified } from "unified";
33
import remarkParse from "remark-parse";
44
import remarkRehype from "remark-rehype";
55
import rehypeStringify from "rehype-stringify";
6-
import { afterAll, beforeAll, expect, it, vi } from "vitest";
6+
import { expect, it } from "vitest";
77

88
import rehypePlantuml from "../src";
99

10-
beforeAll(() => {
11-
const m = {
12-
random: vi.fn(() => 0.1),
13-
trunc: Math.trunc,
14-
abs: Math.abs,
15-
max: Math.max,
16-
floor: Math.floor,
17-
pow: Math.pow,
18-
round: Math.round,
19-
min: Math.min,
20-
};
21-
22-
vi.stubGlobal("Math", m);
23-
});
24-
25-
afterAll(() => {
26-
vi.unstubAllGlobals();
27-
});
28-
2910
// snapshots are different in CI and locally
3011

3112
it.skip("renders diagram", async () => {
@@ -45,9 +26,7 @@ it.skip("renders diagram", async () => {
4526
.use(remarkRehype)
4627
.use(rehypePlantuml, { strategy: "img" })
4728
.use(rehypeStringify)
48-
.process(
49-
await fs.readFile(new URL("./fixtures/a.md", import.meta.url))
50-
);
29+
.process(await fs.readFile(new URL("./fixtures/a.md", import.meta.url)));
5130

5231
expect(file.toString()).toMatchFileSnapshot("./fixtures/a1-datauri.html");
5332
});

0 commit comments

Comments
 (0)