@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
package/src/display.ts ADDED
@@ -0,0 +1,201 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { createRequire } from "node:module";
3
+ import { pathToFileURL } from "node:url";
4
+
5
+ /**
6
+ * Turns a diagram result into terminal components. Both the image and the text are drawn here
7
+ * rather than by the host, so neither has to travel back through the model's context. The image is
8
+ * read from the temp store at display time.
9
+ */
10
+
11
+ /** Wide enough for an architecture diagram, short enough to leave the transcript readable. */
12
+ const MAX_WIDTH_CELLS = 80;
13
+ /** Without a bound the library reserves a square, about 40 rows, drawn or not. */
14
+ const MAX_HEIGHT_CELLS = 30;
15
+ const MAX_IMAGE_BYTES = 4 * 1024 * 1024;
16
+
17
+ export interface Component {
18
+ render(width: number): string[];
19
+ }
20
+
21
+ export interface DisplayTheme {
22
+ fg(color: string, text: string): string;
23
+ }
24
+
25
+ export interface DisplayContext {
26
+ readonly showImages: boolean;
27
+ readonly expanded: boolean;
28
+ /** Per-row scratch space from the host, used to read each image only once. */
29
+ readonly state: Record<string, unknown>;
30
+ }
31
+
32
+ interface DisplayImage {
33
+ readonly path: string;
34
+ readonly widthPx: number;
35
+ readonly heightPx: number;
36
+ }
37
+
38
+ interface TuiModule {
39
+ readonly getCapabilities: () => { readonly images: "kitty" | "iterm2" | null };
40
+ readonly Text: new (text?: string, paddingX?: number, paddingY?: number) => Component;
41
+ readonly Container: new () => Component & { addChild(child: Component): void };
42
+ readonly Image: new (
43
+ base64Data: string,
44
+ mimeType: string,
45
+ theme: { fallbackColor: (text: string) => string },
46
+ options?: { maxWidthCells?: number; maxHeightCells?: number; filename?: string },
47
+ dimensions?: { widthPx: number; heightPx: number },
48
+ ) => Component;
49
+ }
50
+
51
+ let tui: TuiModule | undefined;
52
+
53
+ /**
54
+ * The library keeps image placement state in module scope, so the host and this package have to
55
+ * use the same copy. A local checkout has its own, which wins by bare name.
56
+ */
57
+ export function tuiSpecifier(entry: string | undefined): string {
58
+ if (entry !== undefined) {
59
+ try {
60
+ return pathToFileURL(createRequire(entry).resolve("@earendil-works/pi-tui")).href;
61
+ } catch {
62
+ // Not resolvable from the host, so fall back to whatever this package can see.
63
+ }
64
+ }
65
+ return "@earendil-works/pi-tui";
66
+ }
67
+
68
+ /** Loaded once at registration, since a render cannot wait on an import. */
69
+ export function primeDisplay(): Promise<void> {
70
+ if (tui !== undefined) {
71
+ return Promise.resolve();
72
+ }
73
+ return import(tuiSpecifier(process.argv[1])).then(
74
+ (module) => {
75
+ tui = module as unknown as TuiModule;
76
+ },
77
+ () => {
78
+ // Text rendering needs none of this, so a missing library is not worth reporting.
79
+ },
80
+ );
81
+ }
82
+
83
+ /** Whether the terminal speaks an image protocol, or `undefined` until the library has loaded. */
84
+ export function imagesSupported(): boolean | undefined {
85
+ return tui === undefined ? undefined : tui.getCapabilities().images !== null;
86
+ }
87
+
88
+ /** Whether this package can draw a result row at all, or the host has to print the text. */
89
+ export function displayLoaded(): boolean {
90
+ return tui !== undefined;
91
+ }
92
+
93
+ /** Throws when the library is missing, which the host takes as a request to draw the row itself. */
94
+ function loadedModule(): TuiModule {
95
+ if (tui === undefined) {
96
+ primeDisplay();
97
+ throw new Error("The TUI library is not loaded.");
98
+ }
99
+ return tui;
100
+ }
101
+
102
+ export interface DiagramCallView {
103
+ /** What is being drawn: the title, or a line count when there is no title. */
104
+ readonly subject: string;
105
+ /** The profile, and the directory when the call also saves files. */
106
+ readonly note: string;
107
+ }
108
+
109
+ /** The row while D2 runs. The source would fill the transcript, so it is not shown. */
110
+ export function renderDiagramCall(view: DiagramCallView, theme: DisplayTheme): Component {
111
+ const module = loadedModule();
112
+ const text = [
113
+ theme.fg("toolTitle", "diagram "),
114
+ theme.fg("accent", view.subject),
115
+ " ",
116
+ theme.fg("muted", view.note),
117
+ ].join("");
118
+ return new module.Text(text, 0, 0);
119
+ }
120
+
121
+ export function renderDiagramResult(
122
+ view: DiagramView,
123
+ theme: DisplayTheme,
124
+ context: DisplayContext,
125
+ ): Component {
126
+ const module = loadedModule();
127
+ const container = new module.Container();
128
+ const line = (text: string): void => {
129
+ container.addChild(new module.Text(theme.fg("toolOutput", text), 0, 0));
130
+ };
131
+ if (view.title !== undefined) {
132
+ line(view.title);
133
+ }
134
+
135
+ const image = drawable(module, view.image, context) ? view.image : undefined;
136
+ if (image === undefined) {
137
+ line(view.text);
138
+ } else {
139
+ try {
140
+ container.addChild(
141
+ new module.Image(
142
+ read(image, context),
143
+ "image/png",
144
+ { fallbackColor: (text: string) => theme.fg("toolOutput", text) },
145
+ {
146
+ maxWidthCells: MAX_WIDTH_CELLS,
147
+ maxHeightCells: MAX_HEIGHT_CELLS,
148
+ filename: image.path,
149
+ },
150
+ { widthPx: image.widthPx, heightPx: image.heightPx },
151
+ ),
152
+ );
153
+ } catch {
154
+ // The picture is gone from the temp store, so show the text instead.
155
+ line(view.text);
156
+ }
157
+ }
158
+
159
+ const footer = [...view.notes, ...(context.expanded ? view.details : [])];
160
+ if (footer.length > 0) {
161
+ line(footer.join("\n"));
162
+ }
163
+ return container;
164
+ }
165
+
166
+ function drawable(
167
+ module: TuiModule,
168
+ image: DisplayImage | undefined,
169
+ context: DisplayContext,
170
+ ): boolean {
171
+ return image !== undefined && context.showImages && module.getCapabilities().images !== null;
172
+ }
173
+
174
+ export interface DiagramView {
175
+ readonly image: DisplayImage | undefined;
176
+ readonly title: string | undefined;
177
+ /** Drawn when there is no image, or when the one there is cannot be shown. */
178
+ readonly text: string;
179
+ readonly notes: readonly string[];
180
+ /** Render mode, paths, diagnostics, and source. Shown only in the expanded row. */
181
+ readonly details: readonly string[];
182
+ }
183
+
184
+ /** Reads the image once per result row: the host calls the renderer again on every redraw. */
185
+ function read(image: DisplayImage, context: DisplayContext): string {
186
+ const cached = context.state["diagramImage"];
187
+ if (typeof cached === "object" && cached !== null) {
188
+ const { path, encoded } = cached as { path?: unknown; encoded?: unknown };
189
+ if (path === image.path && typeof encoded === "string") {
190
+ return encoded;
191
+ }
192
+ }
193
+
194
+ const bytes = readFileSync(image.path);
195
+ if (bytes.length === 0 || bytes.length > MAX_IMAGE_BYTES) {
196
+ throw new Error(`The image at ${image.path} is ${bytes.length} bytes.`);
197
+ }
198
+ const encoded = bytes.toString("base64");
199
+ context.state["diagramImage"] = { path: image.path, encoded };
200
+ return encoded;
201
+ }
package/src/index.ts ADDED
@@ -0,0 +1,5 @@
1
+ import { type DiagramExtensionApi, registerDiagramTools } from "./tools.js";
2
+
3
+ export default function piDiagram(pi: DiagramExtensionApi): void {
4
+ registerDiagramTools(pi);
5
+ }
@@ -0,0 +1,119 @@
1
+ import { createHash } from "node:crypto";
2
+ import { DiagramSourceError } from "./d2/diagnostics.js";
3
+
4
+ /** Byte-level enforcement of the schema's character limit in `tools.ts`. */
5
+ const MAX_SOURCE_BYTES = 20 * 1024;
6
+ const MAX_TITLE_LENGTH = 120;
7
+
8
+ const TAB = 0x09;
9
+ const LINE_FEED = 0x0a;
10
+ const FIRST_PRINTABLE = 0x20;
11
+ const DELETE = 0x7f;
12
+ const BYTE_ORDER_MARK = 0xfeff;
13
+
14
+ declare const normalizedSourceBrand: unique symbol;
15
+ declare const safeTitleBrand: unique symbol;
16
+
17
+ /**
18
+ * Source in canonical form: no byte-order mark, LF endings, trimmed, no control characters,
19
+ * within the size cap. Only `normalizeSource` can produce one.
20
+ */
21
+ export type NormalizedD2Source = string & { readonly [normalizedSourceBrand]: true };
22
+
23
+ /** A single-line title of bounded length, safe to show beside the diagram. */
24
+ export type SafeTitle = string & { readonly [safeTitleBrand]: true };
25
+
26
+ export interface NormalizedSource {
27
+ readonly text: NormalizedD2Source;
28
+ readonly hash: string;
29
+ readonly lineCount: number;
30
+ }
31
+
32
+ interface FoundControl {
33
+ readonly offset: number;
34
+ readonly codePoint: number;
35
+ }
36
+
37
+ /** Characters a terminal would act on rather than print. */
38
+ function isControl(codePoint: number): boolean {
39
+ if (codePoint === TAB || codePoint === LINE_FEED) {
40
+ return false;
41
+ }
42
+ return codePoint < FIRST_PRINTABLE || codePoint === DELETE;
43
+ }
44
+
45
+ function findControl(text: string): FoundControl | undefined {
46
+ let offset = 0;
47
+ for (const character of text) {
48
+ const codePoint = character.codePointAt(0) ?? 0;
49
+ if (isControl(codePoint)) {
50
+ return { offset, codePoint };
51
+ }
52
+ offset += character.length;
53
+ }
54
+ return undefined;
55
+ }
56
+
57
+ /** Runs before anything inspects or renders, so the scanner and D2 see the same bytes. */
58
+ export function normalizeSource(raw: unknown): NormalizedSource {
59
+ if (typeof raw !== "string") {
60
+ throw new DiagramSourceError("Diagram source must be a string.", [
61
+ { code: "D2_SOURCE", message: `Received ${raw === null ? "null" : typeof raw}.` },
62
+ ]);
63
+ }
64
+
65
+ const text = stripByteOrderMark(raw).replace(/\r\n?/gu, "\n").trim();
66
+ if (text.length === 0) {
67
+ throw new DiagramSourceError("Diagram source is empty.", [
68
+ { code: "D2_SOURCE", message: "Send D2 source such as `client -> gateway: request`." },
69
+ ]);
70
+ }
71
+
72
+ const control = findControl(text);
73
+ if (control) {
74
+ const label = control.codePoint.toString(16).padStart(4, "0").toUpperCase();
75
+ throw new DiagramSourceError("Diagram source contains a control character.", [
76
+ {
77
+ code: "D2_SOURCE",
78
+ message: `U+${label} at offset ${control.offset} is not allowed in diagram source.`,
79
+ },
80
+ ]);
81
+ }
82
+
83
+ const bytes = Buffer.byteLength(text, "utf8");
84
+ if (bytes > MAX_SOURCE_BYTES) {
85
+ throw new DiagramSourceError("Diagram source is too large.", [
86
+ {
87
+ code: "D2_TOO_LARGE",
88
+ message: `${bytes} bytes is above the ${MAX_SOURCE_BYTES} byte limit.`,
89
+ hint: "Split it into smaller diagrams.",
90
+ },
91
+ ]);
92
+ }
93
+
94
+ return {
95
+ text: text as NormalizedD2Source,
96
+ hash: createHash("sha256").update(text, "utf8").digest("hex"),
97
+ lineCount: text.split("\n").length,
98
+ };
99
+ }
100
+
101
+ export function parseTitle(raw: unknown): SafeTitle | undefined {
102
+ if (typeof raw !== "string") {
103
+ return undefined;
104
+ }
105
+ const printable = Array.from(raw, (character) =>
106
+ isControl(character.codePointAt(0) ?? 0) ? " " : character,
107
+ ).join("");
108
+ const title = printable.replace(/\s+/gu, " ").trim();
109
+ if (title.length === 0) {
110
+ return undefined;
111
+ }
112
+ const capped =
113
+ title.length > MAX_TITLE_LENGTH ? `${title.slice(0, MAX_TITLE_LENGTH - 3)}...` : title;
114
+ return capped as SafeTitle;
115
+ }
116
+
117
+ function stripByteOrderMark(raw: string): string {
118
+ return raw.codePointAt(0) === BYTE_ORDER_MARK ? raw.slice(1) : raw;
119
+ }
package/src/process.ts ADDED
@@ -0,0 +1,134 @@
1
+ import { execFile } from "node:child_process";
2
+
3
+ const DEFAULT_MAX_OUTPUT_BYTES = 1024 * 1024;
4
+
5
+ export interface CommandResult {
6
+ readonly command: string;
7
+ readonly args: readonly string[];
8
+ readonly exitCode: number;
9
+ readonly stdout: string;
10
+ readonly stderr: string;
11
+ }
12
+
13
+ interface RunCommandOptions {
14
+ readonly cwd: string;
15
+ readonly signal?: AbortSignal | undefined;
16
+ readonly env?: NodeJS.ProcessEnv | undefined;
17
+ readonly timeoutMs?: number | undefined;
18
+ readonly maxOutputBytes?: number | undefined;
19
+ }
20
+
21
+ export type CommandRunner = (
22
+ command: string,
23
+ args: readonly string[],
24
+ options: RunCommandOptions,
25
+ ) => Promise<CommandResult>;
26
+
27
+ /** The command never started, which is what a missing D2 install looks like. */
28
+ export class CommandInvocationError extends Error {
29
+ readonly command: string;
30
+ readonly code: string | undefined;
31
+
32
+ constructor(command: string, message: string, code: string | undefined, options?: ErrorOptions) {
33
+ super(message, options);
34
+ this.name = "CommandInvocationError";
35
+ this.command = command;
36
+ this.code = code;
37
+ }
38
+ }
39
+
40
+ export class CommandCancelledError extends Error {
41
+ constructor(command: string) {
42
+ super(`${command} was cancelled.`);
43
+ this.name = "CommandCancelledError";
44
+ }
45
+ }
46
+
47
+ export class CommandTimeoutError extends Error {
48
+ readonly command: string;
49
+ readonly timeoutMs: number;
50
+
51
+ constructor(command: string, timeoutMs: number) {
52
+ super(`${command} did not finish within ${timeoutMs} ms and was stopped.`);
53
+ this.name = "CommandTimeoutError";
54
+ this.command = command;
55
+ this.timeoutMs = timeoutMs;
56
+ }
57
+ }
58
+
59
+ /** A `maxBuffer` kill is a size limit, not a broken executable, so it is not a spawn failure. */
60
+ export class CommandOutputLimitError extends Error {
61
+ readonly command: string;
62
+ readonly maxOutputBytes: number;
63
+
64
+ constructor(command: string, maxOutputBytes: number, options?: ErrorOptions) {
65
+ super(`${command} produced more than ${maxOutputBytes} bytes of output and was stopped.`, {
66
+ ...options,
67
+ });
68
+ this.name = "CommandOutputLimitError";
69
+ this.command = command;
70
+ this.maxOutputBytes = maxOutputBytes;
71
+ }
72
+ }
73
+
74
+ export const runCommand: CommandRunner = (command, args, options) => {
75
+ if (options.signal?.aborted) {
76
+ return Promise.reject(new CommandCancelledError(command));
77
+ }
78
+
79
+ const maxOutputBytes = options.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
80
+ const timeoutMs = options.timeoutMs ?? 0;
81
+ const { promise, resolve, reject } = Promise.withResolvers<CommandResult>();
82
+ execFile(
83
+ command,
84
+ [...args],
85
+ {
86
+ cwd: options.cwd,
87
+ encoding: "utf8",
88
+ maxBuffer: maxOutputBytes,
89
+ signal: options.signal,
90
+ timeout: timeoutMs,
91
+ env: options.env,
92
+ windowsHide: true,
93
+ },
94
+ (error, stdout, stderr) => {
95
+ if (options.signal?.aborted || error?.name === "AbortError") {
96
+ reject(new CommandCancelledError(command));
97
+ return;
98
+ }
99
+
100
+ if (error?.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER") {
101
+ reject(new CommandOutputLimitError(command, maxOutputBytes, { cause: error }));
102
+ return;
103
+ }
104
+
105
+ // `timeout` kills the child with a signal, which arrives without a numeric exit code.
106
+ if (timeoutMs > 0 && error?.signal && typeof error.code !== "number") {
107
+ reject(new CommandTimeoutError(command, timeoutMs));
108
+ return;
109
+ }
110
+
111
+ if (error && typeof error.code !== "number") {
112
+ const detail = error.message.trim();
113
+ reject(
114
+ new CommandInvocationError(
115
+ command,
116
+ `Unable to execute ${command}: ${detail}`,
117
+ typeof error.code === "string" ? error.code : undefined,
118
+ { cause: error },
119
+ ),
120
+ );
121
+ return;
122
+ }
123
+
124
+ resolve({
125
+ command,
126
+ args: [...args],
127
+ exitCode: typeof error?.code === "number" ? error.code : 0,
128
+ stdout,
129
+ stderr,
130
+ });
131
+ },
132
+ );
133
+ return promise;
134
+ };