@mcuste/pi-diagram 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +240 -0
  3. package/dist/artifacts.d.ts +59 -0
  4. package/dist/artifacts.d.ts.map +1 -0
  5. package/dist/artifacts.js +274 -0
  6. package/dist/artifacts.js.map +1 -0
  7. package/dist/cache.d.ts +43 -0
  8. package/dist/cache.d.ts.map +1 -0
  9. package/dist/cache.js +0 -0
  10. package/dist/cache.js.map +1 -0
  11. package/dist/d2/diagnostics.d.ts +25 -0
  12. package/dist/d2/diagnostics.d.ts.map +1 -0
  13. package/dist/d2/diagnostics.js +77 -0
  14. package/dist/d2/diagnostics.js.map +1 -0
  15. package/dist/d2/fonts.d.ts +23 -0
  16. package/dist/d2/fonts.d.ts.map +1 -0
  17. package/dist/d2/fonts.js +255 -0
  18. package/dist/d2/fonts.js.map +1 -0
  19. package/dist/d2/preflight.d.ts +20 -0
  20. package/dist/d2/preflight.d.ts.map +1 -0
  21. package/dist/d2/preflight.js +217 -0
  22. package/dist/d2/preflight.js.map +1 -0
  23. package/dist/d2/profiles.d.ts +34 -0
  24. package/dist/d2/profiles.d.ts.map +1 -0
  25. package/dist/d2/profiles.js +118 -0
  26. package/dist/d2/profiles.js.map +1 -0
  27. package/dist/d2/runner.d.ts +95 -0
  28. package/dist/d2/runner.d.ts.map +1 -0
  29. package/dist/d2/runner.js +350 -0
  30. package/dist/d2/runner.js.map +1 -0
  31. package/dist/display.d.ts +48 -0
  32. package/dist/display.d.ts.map +1 -0
  33. package/dist/display.js +120 -0
  34. package/dist/display.js.map +1 -0
  35. package/dist/index.d.ts +3 -0
  36. package/dist/index.d.ts.map +1 -0
  37. package/dist/index.js +5 -0
  38. package/dist/index.js.map +1 -0
  39. package/dist/normalize.d.ts +23 -0
  40. package/dist/normalize.d.ts.map +1 -0
  41. package/dist/normalize.js +83 -0
  42. package/dist/normalize.js.map +1 -0
  43. package/dist/process.d.ts +38 -0
  44. package/dist/process.d.ts.map +1 -0
  45. package/dist/process.js +87 -0
  46. package/dist/process.js.map +1 -0
  47. package/dist/raster.d.ts +40 -0
  48. package/dist/raster.d.ts.map +1 -0
  49. package/dist/raster.js +193 -0
  50. package/dist/raster.js.map +1 -0
  51. package/dist/render.d.ts +55 -0
  52. package/dist/render.d.ts.map +1 -0
  53. package/dist/render.js +229 -0
  54. package/dist/render.js.map +1 -0
  55. package/dist/tools.d.ts +58 -0
  56. package/dist/tools.d.ts.map +1 -0
  57. package/dist/tools.js +284 -0
  58. package/dist/tools.js.map +1 -0
  59. package/package.json +101 -0
  60. package/src/artifacts.ts +418 -0
  61. package/src/cache.ts +0 -0
  62. package/src/d2/diagnostics.ts +114 -0
  63. package/src/d2/fonts.ts +289 -0
  64. package/src/d2/preflight.ts +270 -0
  65. package/src/d2/profiles.ts +157 -0
  66. package/src/d2/runner.ts +513 -0
  67. package/src/display.ts +201 -0
  68. package/src/index.ts +5 -0
  69. package/src/normalize.ts +119 -0
  70. package/src/process.ts +134 -0
  71. package/src/raster.ts +258 -0
  72. package/src/render.ts +338 -0
  73. package/src/tools.ts +455 -0
@@ -0,0 +1,157 @@
1
+ import { DiagramSourceError } from "./diagnostics.js";
2
+
3
+ /**
4
+ * What a diagram is for decides how it looks. The model names the purpose, this table sets the
5
+ * engine, the theme, and the spacing, so diagrams stay consistent between calls.
6
+ *
7
+ * The policy reaches D2 as CLI flags, which take precedence over anything the source sets.
8
+ */
9
+
10
+ /** Neutral Default: light containers under near-white nodes, which keeps the nesting readable. */
11
+ const NEUTRAL_THEME = 0;
12
+ /** Neutral Grey: no colour to lose, so it still reads when a document is printed. */
13
+ const GREY_THEME = 1;
14
+ /** C4, so a diagram drawn in that convention is coloured the way its readers expect. */
15
+ const C4_THEME = 303;
16
+ /** Dark Mauve, D2's neutral dark theme, so a saved SVG follows the reader into dark mode. */
17
+ const DARK_THEME = 200;
18
+
19
+ export const PROFILE_NAMES = [
20
+ "explain",
21
+ "architecture",
22
+ "data",
23
+ "docs",
24
+ "tree",
25
+ "c4",
26
+ "dependency",
27
+ ] as const;
28
+
29
+ export type ProfileName = (typeof PROFILE_NAMES)[number];
30
+
31
+ /** The engines expose different spacing options, so a profile states only its own engine's. */
32
+ export type LayoutPolicy =
33
+ | {
34
+ readonly engine: "elk";
35
+ /** Pixels between one row of nodes and the next. */
36
+ readonly layerGapPx: number;
37
+ /** Pixels between a node and an edge routed past it. */
38
+ readonly edgeGapPx: number;
39
+ /** Pixels between a container's border and what it holds. */
40
+ readonly containerPadPx: number;
41
+ }
42
+ | {
43
+ readonly engine: "dagre";
44
+ /** Pixels between one node and the next across a row. */
45
+ readonly nodeGapPx: number;
46
+ /** Pixels between two edges running side by side. */
47
+ readonly edgeGapPx: number;
48
+ };
49
+
50
+ export interface RenderProfile {
51
+ readonly name: ProfileName;
52
+ /** D2 theme id used in light mode. */
53
+ readonly theme: number;
54
+ /** D2 theme id a viewer in dark mode gets instead. */
55
+ readonly darkTheme: number;
56
+ /** Pixels around the whole drawing. */
57
+ readonly padPx: number;
58
+ /** Hand-drawn strokes and a handwriting font. */
59
+ readonly sketch: boolean;
60
+ readonly layout: LayoutPolicy;
61
+ }
62
+
63
+ const PROFILES: Readonly<Record<ProfileName, RenderProfile>> = {
64
+ /**
65
+ * The default. Drawn by hand because an answer in a conversation is a rough model, and a crisp
66
+ * diagram claims more precision than the explanation has.
67
+ */
68
+ explain: {
69
+ name: "explain",
70
+ theme: NEUTRAL_THEME,
71
+ darkTheme: DARK_THEME,
72
+ padPx: 30,
73
+ sketch: true,
74
+ layout: { engine: "elk", layerGapPx: 60, edgeGapPx: 40, containerPadPx: 40 },
75
+ },
76
+ // Room between the ranks, so the edges crossing between containers stay separable.
77
+ architecture: {
78
+ name: "architecture",
79
+ theme: NEUTRAL_THEME,
80
+ darkTheme: DARK_THEME,
81
+ padPx: 60,
82
+ sketch: false,
83
+ layout: { engine: "elk", layerGapPx: 90, edgeGapPx: 50, containerPadPx: 60 },
84
+ },
85
+ // Tables and classes are tall already, so the space around them is kept tight.
86
+ data: {
87
+ name: "data",
88
+ theme: NEUTRAL_THEME,
89
+ darkTheme: DARK_THEME,
90
+ padPx: 30,
91
+ sketch: false,
92
+ layout: { engine: "elk", layerGapPx: 50, edgeGapPx: 30, containerPadPx: 30 },
93
+ },
94
+ // Sized for a page rather than a transcript row, with a theme that survives greyscale.
95
+ docs: {
96
+ name: "docs",
97
+ theme: GREY_THEME,
98
+ darkTheme: DARK_THEME,
99
+ padPx: 100,
100
+ sketch: false,
101
+ layout: { engine: "elk", layerGapPx: 80, edgeGapPx: 40, containerPadPx: 50 },
102
+ },
103
+ /**
104
+ * The one profile that changes engine: dagre fans children out under their parent, which is how
105
+ * a hierarchy is normally drawn.
106
+ */
107
+ tree: {
108
+ name: "tree",
109
+ theme: NEUTRAL_THEME,
110
+ darkTheme: DARK_THEME,
111
+ padPx: 40,
112
+ sketch: false,
113
+ layout: { engine: "dagre", nodeGapPx: 40, edgeGapPx: 20 },
114
+ },
115
+ // Architecture spacing. The palette is the whole difference.
116
+ c4: {
117
+ name: "c4",
118
+ theme: C4_THEME,
119
+ darkTheme: DARK_THEME,
120
+ padPx: 60,
121
+ sketch: false,
122
+ layout: { engine: "elk", layerGapPx: 90, edgeGapPx: 50, containerPadPx: 60 },
123
+ },
124
+ /**
125
+ * For a graph with more nodes than usual. Edges routed past nodes fill most of a large graph, so
126
+ * that gap is cut first.
127
+ */
128
+ dependency: {
129
+ name: "dependency",
130
+ theme: NEUTRAL_THEME,
131
+ darkTheme: DARK_THEME,
132
+ padPx: 20,
133
+ sketch: false,
134
+ layout: { engine: "elk", layerGapPx: 40, edgeGapPx: 20, containerPadPx: 20 },
135
+ },
136
+ };
137
+
138
+ /** Diagrams that explain something in passing, which is most of them. */
139
+ export const DEFAULT_PROFILE: RenderProfile = PROFILES.explain;
140
+
141
+ export function parseProfile(raw: unknown): RenderProfile {
142
+ if (raw === undefined) {
143
+ return DEFAULT_PROFILE;
144
+ }
145
+ // Matched against the names, not looked up on the table, so `toString` cannot become a profile.
146
+ const name = PROFILE_NAMES.find((candidate) => candidate === raw);
147
+ if (name === undefined) {
148
+ throw new DiagramSourceError("Unsupported diagram profile.", [
149
+ {
150
+ code: "D2_SOURCE",
151
+ message: `${JSON.stringify(raw)} is not a profile.`,
152
+ hint: `Use ${PROFILE_NAMES.join(", ")}.`,
153
+ },
154
+ ]);
155
+ }
156
+ return PROFILES[name];
157
+ }
@@ -0,0 +1,513 @@
1
+ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { cacheKey, FileCache, type RenderCache } from "../cache.js";
5
+ import {
6
+ CommandCancelledError,
7
+ CommandInvocationError,
8
+ CommandOutputLimitError,
9
+ type CommandResult,
10
+ type CommandRunner,
11
+ CommandTimeoutError,
12
+ runCommand,
13
+ } from "../process.js";
14
+ import { type Diagnostic, DiagramSourceError, parseD2Diagnostics } from "./diagnostics.js";
15
+ import type { SafeD2Source } from "./preflight.js";
16
+ import type { LayoutPolicy, RenderProfile } from "./profiles.js";
17
+
18
+ /**
19
+ * Runs the D2 CLI. The source reaches D2 only as a file in a fresh temporary directory: no
20
+ * model-written string ever enters the argument list, which is built from the literals below.
21
+ */
22
+
23
+ /**
24
+ * 0.7.x draws `shape: sql_table` as an empty box, losing every column. The prebuilt GitHub
25
+ * release binaries stop at 0.7.1, so the pinned path is the module tag, not a release asset.
26
+ */
27
+ const MINIMUM_D2_VERSION = "0.8.0";
28
+ const D2_TIMEOUT_SECONDS = 10;
29
+ /** Above D2's own timeout, so D2 reports it first and this only catches a hung process. */
30
+ const PROCESS_TIMEOUT_MS = (D2_TIMEOUT_SECONDS + 5) * 1000;
31
+ const MAX_RENDER_BYTES = 1024 * 1024;
32
+ const INPUT_FILE = "input.d2";
33
+
34
+ const INSTALL_HINT =
35
+ "Install the D2 CLI with `brew install d2` or " +
36
+ "`go install github.com/d2lang/d2@v0.8.1`, or point D2_BIN at an existing one. Version " +
37
+ `${MINIMUM_D2_VERSION} or newer is required.`;
38
+
39
+ export type AsciiMode = "extended" | "standard";
40
+
41
+ declare const d2ArgumentBrand: unique symbol;
42
+ declare const supportedVersionBrand: unique symbol;
43
+ declare const renderedTextBrand: unique symbol;
44
+ declare const renderedSvgBrand: unique symbol;
45
+
46
+ /** Built from this module's literals and the profile table, never from model input. */
47
+ type D2Argument = string & { readonly [d2ArgumentBrand]: true };
48
+
49
+ /** Confirmed at or above `MINIMUM_D2_VERSION`. */
50
+ export type SupportedD2Version = string & { readonly [supportedVersionBrand]: true };
51
+
52
+ /** Output that passed `parseRenderedText`, so it is safe to print. */
53
+ export type RenderedDiagramText = string & { readonly [renderedTextBrand]: true };
54
+
55
+ /** Output that passed `parseRenderedSvg`, so it is safe to write into the workspace. */
56
+ export type RenderedSvg = string & { readonly [renderedSvgBrand]: true };
57
+
58
+ export interface D2TextRequest {
59
+ readonly source: SafeD2Source;
60
+ readonly asciiMode: AsciiMode;
61
+ readonly signal: AbortSignal | undefined;
62
+ }
63
+
64
+ export interface D2Text {
65
+ readonly text: RenderedDiagramText;
66
+ readonly version: SupportedD2Version;
67
+ }
68
+
69
+ export interface D2SvgRequest {
70
+ readonly source: SafeD2Source;
71
+ readonly profile: RenderProfile;
72
+ readonly signal: AbortSignal | undefined;
73
+ }
74
+
75
+ export interface D2Svg {
76
+ readonly svg: RenderedSvg;
77
+ readonly version: SupportedD2Version;
78
+ }
79
+
80
+ export interface D2FormatRequest {
81
+ readonly source: SafeD2Source;
82
+ readonly signal: AbortSignal | undefined;
83
+ }
84
+
85
+ export interface D2Renderer {
86
+ renderText(request: D2TextRequest): Promise<D2Text>;
87
+ renderSvg(request: D2SvgRequest): Promise<D2Svg>;
88
+ /** The same source as `d2 fmt` writes it. Throws when D2 will not format it. */
89
+ formatSource(request: D2FormatRequest): Promise<string>;
90
+ }
91
+
92
+ /** The user has to fix this by installing D2. Retrying the call will not help. */
93
+ class D2UnavailableError extends Error {
94
+ constructor(message: string, options?: ErrorOptions) {
95
+ super(`${message} ${INSTALL_HINT}`, { ...options });
96
+ this.name = "D2UnavailableError";
97
+ }
98
+ }
99
+
100
+ /** D2 accepted the source but drew nothing usable. Not the model's mistake to correct. */
101
+ export class TextRenderUnavailableError extends Error {
102
+ readonly diagnostics: readonly Diagnostic[];
103
+
104
+ constructor(message: string, diagnostics: readonly Diagnostic[] = []) {
105
+ super(message);
106
+ this.name = "TextRenderUnavailableError";
107
+ this.diagnostics = diagnostics;
108
+ }
109
+ }
110
+
111
+ function args(...values: readonly string[]): readonly D2Argument[] {
112
+ return values as readonly D2Argument[];
113
+ }
114
+
115
+ /** No theme or spacing: D2 draws text in character cells, which neither one changes. */
116
+ function renderArguments(mode: AsciiMode): readonly D2Argument[] {
117
+ return args(
118
+ "--layout",
119
+ "elk",
120
+ "--timeout",
121
+ String(D2_TIMEOUT_SECONDS),
122
+ "--ascii-mode",
123
+ mode,
124
+ "--stdout-format",
125
+ "ascii",
126
+ INPUT_FILE,
127
+ "-",
128
+ );
129
+ }
130
+
131
+ function svgArguments(profile: RenderProfile): readonly D2Argument[] {
132
+ return args(
133
+ "--layout",
134
+ profile.layout.engine,
135
+ ...spacingArguments(profile.layout),
136
+ ...(profile.sketch ? ["--sketch"] : []),
137
+ "--theme",
138
+ String(profile.theme),
139
+ "--dark-theme",
140
+ String(profile.darkTheme),
141
+ "--pad",
142
+ String(profile.padPx),
143
+ "--timeout",
144
+ String(D2_TIMEOUT_SECONDS),
145
+ "--stdout-format",
146
+ "svg",
147
+ INPUT_FILE,
148
+ "-",
149
+ );
150
+ }
151
+
152
+ /** `d2 fmt` rewrites the file it is given, so there is no output format to ask for. */
153
+ const FORMAT_ARGUMENTS = args("fmt", INPUT_FILE);
154
+
155
+ function spacingArguments(layout: LayoutPolicy): readonly string[] {
156
+ if (layout.engine === "dagre") {
157
+ return [
158
+ "--dagre-nodesep",
159
+ String(layout.nodeGapPx),
160
+ "--dagre-edgesep",
161
+ String(layout.edgeGapPx),
162
+ ];
163
+ }
164
+ const pad = layout.containerPadPx;
165
+ return [
166
+ "--elk-nodeNodeBetweenLayers",
167
+ String(layout.layerGapPx),
168
+ "--elk-edgeNodeBetweenLayers",
169
+ String(layout.edgeGapPx),
170
+ "--elk-padding",
171
+ `[top=${pad},left=${pad},bottom=${pad},right=${pad}]`,
172
+ ];
173
+ }
174
+
175
+ /** D2 reports strings such as `v0.8.1-HEAD`, so only the three numbers are compared. */
176
+ export function parseD2Version(result: CommandResult): SupportedD2Version {
177
+ const raw = (result.stdout.trim() || result.stderr.trim()).split("\n")[0]?.trim() ?? "";
178
+ const match = /^v?(\d+)\.(\d+)\.(\d+)/u.exec(raw);
179
+ if (!match || result.exitCode !== 0) {
180
+ throw new D2UnavailableError(
181
+ `Could not read a version from D2 (${raw ? JSON.stringify(raw) : "no output"}).`,
182
+ );
183
+ }
184
+
185
+ const found = triple(match[1], match[2], match[3]);
186
+ if (found === undefined) {
187
+ throw new D2UnavailableError(`Could not read a version from D2 (${JSON.stringify(raw)}).`);
188
+ }
189
+ if (isBelow(found, MINIMUM_VERSION)) {
190
+ throw new D2UnavailableError(`D2 ${raw} is installed, which is too old.`);
191
+ }
192
+ return raw as SupportedD2Version;
193
+ }
194
+
195
+ const MINIMUM_VERSION = versionOf(MINIMUM_D2_VERSION);
196
+
197
+ type Version = readonly [number, number, number];
198
+
199
+ function versionOf(value: string): Version {
200
+ const parts = value.split(".");
201
+ const version = triple(parts[0], parts[1], parts[2]);
202
+ if (version === undefined) {
203
+ throw new Error(`Not a three-part version: ${JSON.stringify(value)}.`);
204
+ }
205
+ return version;
206
+ }
207
+
208
+ function triple(
209
+ major: string | undefined,
210
+ minor: string | undefined,
211
+ patch: string | undefined,
212
+ ): Version | undefined {
213
+ const numbers = [major, minor, patch].map((part) => Number(part));
214
+ const [first, second, third] = numbers;
215
+ if (
216
+ first === undefined ||
217
+ second === undefined ||
218
+ third === undefined ||
219
+ !numbers.every((part) => Number.isSafeInteger(part) && part >= 0)
220
+ ) {
221
+ return undefined;
222
+ }
223
+ return [first, second, third];
224
+ }
225
+
226
+ function isBelow(found: Version, minimum: Version): boolean {
227
+ const [foundMajor, foundMinor, foundPatch] = found;
228
+ const [minMajor, minMinor, minPatch] = minimum;
229
+ if (foundMajor !== minMajor) {
230
+ return foundMajor < minMajor;
231
+ }
232
+ if (foundMinor !== minMinor) {
233
+ return foundMinor < minMinor;
234
+ }
235
+ return foundPatch < minPatch;
236
+ }
237
+
238
+ /**
239
+ * D2 exiting zero does not mean the output is usable: the beta text renderer returns blank
240
+ * drawings, and a silently ignored mode flag would otherwise reach the terminal unnoticed.
241
+ */
242
+ export function parseRenderedText(raw: string, mode: AsciiMode): RenderedDiagramText {
243
+ const text = raw
244
+ .replace(/\r\n?/gu, "\n")
245
+ .replace(/[ \t]+$/gmu, "")
246
+ .replace(/\n+$/u, "");
247
+ if (text.trim().length === 0) {
248
+ throw new TextRenderUnavailableError("D2 produced an empty text diagram.");
249
+ }
250
+
251
+ for (const char of text) {
252
+ const code = char.codePointAt(0) ?? 0;
253
+ if (code < 32 && char !== "\n") {
254
+ throw new TextRenderUnavailableError(
255
+ `D2 text output contains a control character (U+${code.toString(16).padStart(4, "0").toUpperCase()}).`,
256
+ );
257
+ }
258
+ if (mode === "standard" && code > 126) {
259
+ throw new TextRenderUnavailableError(
260
+ `D2 returned a non-ASCII character (${JSON.stringify(char)}) in standard ASCII mode.`,
261
+ );
262
+ }
263
+ }
264
+
265
+ const drew =
266
+ mode === "standard" ? /[+\-|]/u.test(text) : /[\u2500-\u257F\u25A0-\u25FF]/u.test(text);
267
+ if (!drew) {
268
+ throw new TextRenderUnavailableError("D2 text output contains no diagram lines or boxes.");
269
+ }
270
+ return text as RenderedDiagramText;
271
+ }
272
+
273
+ /**
274
+ * D2 documents exported SVG as web content. The safe subset already rules out icons, images, and
275
+ * Markdown labels, so anything active or externally referenced here means it was bypassed.
276
+ */
277
+ export function parseRenderedSvg(raw: string): RenderedSvg {
278
+ const svg = raw.trim();
279
+ if (svg.length === 0) {
280
+ throw new TextRenderUnavailableError("D2 produced an empty SVG.");
281
+ }
282
+ if (!svg.startsWith("<?xml") && !svg.startsWith("<svg")) {
283
+ throw new TextRenderUnavailableError("D2 output does not start like an SVG document.");
284
+ }
285
+ if (!svg.endsWith("</svg>")) {
286
+ throw new TextRenderUnavailableError("D2 returned a truncated SVG document.");
287
+ }
288
+
289
+ const lowered = svg.toLowerCase();
290
+ for (const forbidden of ["<script", "<foreignobject", "<image", "<iframe", "<use"]) {
291
+ if (lowered.includes(forbidden)) {
292
+ throw new TextRenderUnavailableError(
293
+ `D2 SVG contains ${forbidden}>, which this tool does not write.`,
294
+ );
295
+ }
296
+ }
297
+ if (/(?:xlink:)?href\s*=\s*["']?(?:https?:|\/\/)/u.test(lowered)) {
298
+ throw new TextRenderUnavailableError("D2 SVG references a remote URL.");
299
+ }
300
+ return svg as RenderedSvg;
301
+ }
302
+
303
+ /** Bounds what one session remembers about sources D2 already accepted. */
304
+ const MAX_VALIDATED = 256;
305
+
306
+ export class D2Cli implements D2Renderer {
307
+ private readonly runner: CommandRunner;
308
+ private readonly binary: string;
309
+ private readonly cache: RenderCache;
310
+ private version: SupportedD2Version | undefined;
311
+ /** Cache keys of sources this process has compiled, so one call validates them once. */
312
+ private readonly validated = new Set<string>();
313
+
314
+ constructor(dependencies: { runner?: CommandRunner; binary?: string; cache?: RenderCache } = {}) {
315
+ this.runner = dependencies.runner ?? runCommand;
316
+ this.binary = dependencies.binary ?? parseBinaryName(process.env.D2_BIN);
317
+ this.cache = dependencies.cache ?? new FileCache();
318
+ }
319
+
320
+ async renderText(request: D2TextRequest): Promise<D2Text> {
321
+ const { output, version } = await this.compile(request.source, request.signal, {
322
+ argv: renderArguments(request.asciiMode),
323
+ parse: (stdout) => parseRenderedText(stdout, request.asciiMode),
324
+ failure: "D2 could not draw this diagram as text.",
325
+ });
326
+ return { text: output, version };
327
+ }
328
+
329
+ async renderSvg(request: D2SvgRequest): Promise<D2Svg> {
330
+ const { output, version } = await this.compile(request.source, request.signal, {
331
+ argv: svgArguments(request.profile),
332
+ parse: parseRenderedSvg,
333
+ failure: "D2 could not draw this diagram as an SVG.",
334
+ });
335
+ return { svg: output, version };
336
+ }
337
+
338
+ /** The formatted source is read back from the temp directory, since `fmt` writes no stdout. */
339
+ async formatSource(request: D2FormatRequest): Promise<string> {
340
+ const version = await this.ensureVersion(request.signal);
341
+ const key = cacheKey({
342
+ source: request.source,
343
+ language: "d2",
344
+ binary: this.binary,
345
+ version,
346
+ argv: FORMAT_ARGUMENTS,
347
+ });
348
+ const stored = await this.cache.read(key);
349
+ if (stored !== undefined) {
350
+ return stored;
351
+ }
352
+
353
+ const directory = await mkdtemp(join(tmpdir(), "pi-diagram-"));
354
+ try {
355
+ const path = join(directory, INPUT_FILE);
356
+ await writeFile(path, `${request.source}\n`, "utf8");
357
+ const result = await this.run(FORMAT_ARGUMENTS, directory, request.signal);
358
+ if (result.exitCode !== 0) {
359
+ throw new TextRenderUnavailableError(
360
+ "D2 could not format this source.",
361
+ parseD2Diagnostics(result.stderr, "D2_RENDER", [directory]),
362
+ );
363
+ }
364
+ const formatted = await readFile(path, "utf8");
365
+ await this.cache.write(key, formatted);
366
+ return formatted;
367
+ } finally {
368
+ await rm(directory, { recursive: true, force: true });
369
+ }
370
+ }
371
+
372
+ private async compile<TOutput>(
373
+ source: SafeD2Source,
374
+ signal: AbortSignal | undefined,
375
+ step: {
376
+ readonly argv: readonly D2Argument[];
377
+ readonly parse: (stdout: string) => TOutput;
378
+ readonly failure: string;
379
+ },
380
+ ): Promise<{ output: TOutput; version: SupportedD2Version }> {
381
+ const version = await this.ensureVersion(signal);
382
+ if (signal?.aborted === true) {
383
+ // A cached answer would otherwise come back after the call was given up on.
384
+ throw new CommandCancelledError("Drawing the diagram");
385
+ }
386
+
387
+ const identity = { source, language: "d2", binary: this.binary, version } as const;
388
+ const key = cacheKey({ ...identity, argv: step.argv });
389
+ // No arguments: what D2 accepts depends on the source and the version, not on the flags.
390
+ const compiles = cacheKey({ ...identity, argv: [] });
391
+ const stored = await this.cache.read(key);
392
+ if (stored !== undefined) {
393
+ try {
394
+ const output = step.parse(stored);
395
+ this.remember(compiles);
396
+ return { output, version };
397
+ } catch {
398
+ // An entry this build cannot read is no better than a missing one.
399
+ }
400
+ }
401
+
402
+ const directory = await mkdtemp(join(tmpdir(), "pi-diagram-"));
403
+ try {
404
+ await writeFile(join(directory, INPUT_FILE), `${source}\n`, "utf8");
405
+ if (!this.validated.has(compiles)) {
406
+ await this.validate(directory, signal);
407
+ }
408
+ const rendered = await this.run(step.argv, directory, signal);
409
+ if (rendered.exitCode !== 0) {
410
+ throw new TextRenderUnavailableError(
411
+ step.failure,
412
+ parseD2Diagnostics(rendered.stderr, "D2_RENDER", [directory]),
413
+ );
414
+ }
415
+ const output = step.parse(rendered.stdout);
416
+ this.remember(compiles);
417
+ await this.cache.write(key, rendered.stdout);
418
+ return { output, version };
419
+ } finally {
420
+ await rm(directory, { recursive: true, force: true });
421
+ }
422
+ }
423
+
424
+ private remember(key: string): void {
425
+ if (this.validated.size >= MAX_VALIDATED) {
426
+ this.validated.clear();
427
+ }
428
+ this.validated.add(key);
429
+ }
430
+
431
+ private async validate(directory: string, signal: AbortSignal | undefined): Promise<void> {
432
+ const result = await this.run(args("validate", INPUT_FILE), directory, signal);
433
+ if (result.exitCode !== 0) {
434
+ throw new DiagramSourceError(
435
+ "D2 could not compile this source.",
436
+ parseD2Diagnostics(result.stderr, "D2_SYNTAX", [directory]),
437
+ );
438
+ }
439
+ }
440
+
441
+ private async ensureVersion(signal: AbortSignal | undefined): Promise<SupportedD2Version> {
442
+ if (this.version !== undefined) {
443
+ return this.version;
444
+ }
445
+ // Deliberately not cached on failure, so installing D2 mid-session starts working.
446
+ const result = await this.run(args("--version"), tmpdir(), signal);
447
+ this.version = parseD2Version(result);
448
+ return this.version;
449
+ }
450
+
451
+ private async run(
452
+ argv: readonly D2Argument[],
453
+ cwd: string,
454
+ signal: AbortSignal | undefined,
455
+ ): Promise<CommandResult> {
456
+ try {
457
+ return await this.runner(this.binary, argv, {
458
+ cwd,
459
+ signal,
460
+ // D2 renders correctly with an empty environment. PATH is here only so a bare
461
+ // command name resolves.
462
+ env: { PATH: process.env.PATH ?? "" },
463
+ timeoutMs: PROCESS_TIMEOUT_MS,
464
+ maxOutputBytes: MAX_RENDER_BYTES,
465
+ });
466
+ } catch (error) {
467
+ throw translate(error, this.binary);
468
+ }
469
+ }
470
+ }
471
+
472
+ function translate(error: unknown, binary: string): unknown {
473
+ if (error instanceof CommandCancelledError) {
474
+ return error;
475
+ }
476
+ if (error instanceof CommandInvocationError) {
477
+ return new D2UnavailableError(`Could not run ${JSON.stringify(binary)}.`, { cause: error });
478
+ }
479
+ if (error instanceof CommandTimeoutError) {
480
+ return new DiagramSourceError("Rendering this diagram took too long.", [
481
+ {
482
+ code: "D2_TIMEOUT",
483
+ message: `D2 did not finish within ${D2_TIMEOUT_SECONDS} seconds.`,
484
+ hint: "Use fewer nodes, or split the diagram.",
485
+ },
486
+ ]);
487
+ }
488
+ if (error instanceof CommandOutputLimitError) {
489
+ return new DiagramSourceError("The rendered diagram is too large.", [
490
+ {
491
+ code: "D2_TOO_LARGE",
492
+ message: `D2 produced more than ${MAX_RENDER_BYTES} bytes.`,
493
+ hint: "Use fewer nodes, or split the diagram.",
494
+ },
495
+ ]);
496
+ }
497
+ return error;
498
+ }
499
+
500
+ /**
501
+ * `D2_BIN` is not model input, but it still lands in an exec call, so anything that could read
502
+ * as an option or split into extra arguments is refused.
503
+ */
504
+ export function parseBinaryName(value: string | undefined): string {
505
+ if (value === undefined || value.trim().length === 0) {
506
+ return "d2";
507
+ }
508
+ const binary = value.trim();
509
+ if (binary.startsWith("-") || /[\s"'`$;&|<>]/u.test(binary)) {
510
+ throw new D2UnavailableError(`D2_BIN is not a usable command: ${JSON.stringify(value)}.`);
511
+ }
512
+ return binary;
513
+ }