Skip to content

Add D2 diagram rendering support#405

Open
kvdogan wants to merge 2 commits intoshd101wyy:developfrom
kvdogan:d2-diagram-support
Open

Add D2 diagram rendering support#405
kvdogan wants to merge 2 commits intoshd101wyy:developfrom
kvdogan:d2-diagram-support

Conversation

@kvdogan
Copy link
Copy Markdown

@kvdogan kvdogan commented Apr 9, 2026

Add D2 diagram rendering support

Adds support for rendering D2 diagrams by shelling out to the d2 CLI.

Changes

  • src/renderers/d2.ts — New renderer: writes diagram source to a temp file, invokes the d2 CLI, reads back the SVG. Returns a D2_NOT_FOUND symbol when the binary is not installed (callers render as plain code block). Web/browser environments are guarded and also return D2_NOT_FOUND.

  • src/notebook/types.ts — Added d2Path, d2Layout, d2Theme, d2Sketch to NotebookConfig with defaults (d2, dagre, 0, false).

  • src/markdown-engine/index.ts — Passes d2 config fields through to enhanceWithFencedDiagrams.

  • src/render-enhancers/fenced-diagrams.ts — Added 'd2' to supported languages and a case 'd2' handler. Per-fence attributes (layout, theme, sketch) override global config. Uses a d2-specific cache key that includes the resolved render options so global setting changes correctly bust the cache.

  • src/lib/block-info/parse-block-info.ts — Fixed parseBlockInfo to correctly handle fence info strings of the form lang space-attrs {brace-attrs} and lang {attrs1} {attrs2}. The transformer appends {data-source-line="N"} to fence info strings that already have space-separated attributes (e.g. ```d2 layout=elk theme=200 sketch), which the old single-regex approach failed to parse correctly.

  • build.js — Added crypto to Node built-in externals (used by the new d2 renderer).

Usage (in the host editor)

```d2
shape: sequence_diagram
alice -> bob: What does it mean?
bob -> alice: 42
```

Per-block overrides:

```d2 layout=elk theme=200 sketch
...
```

- Add src/renderers/d2.ts: renderD2() function using execFile to shell out
  to the d2 CLI binary; returns SVG string, D2_NOT_FOUND symbol (when binary
  not installed), or an HTML error string; web-safe guard for crypto.randomBytes
- Add d2Path, d2Layout, d2Theme, d2Sketch config fields to NotebookConfig
  with defaults (d2, dagre, 0, false)
- Add 'd2' case to fenced-diagrams render enhancer; per-fence attributes
  (layout, theme, sketch) override global settings; uses a d2-specific cache
  key that includes resolved render options so global setting changes bust cache
- Fix parseBlockInfo to handle 'lang pre-attrs {brace-attrs}' format produced
  by the transformer when it appends {data-source-line} to fences that already
  have space-separated attributes (e.g. ```d2 layout=elk theme=200 sketch)
- Add crypto to externals in build.js (Node built-in, not to be bundled)
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds first-class D2 diagram support to Crossnote by introducing a Node-side D2 renderer, wiring new notebook configuration options through the markdown rendering pipeline, and improving fenced code-block info parsing to support mixed attribute syntaxes.

Changes:

  • Added a new renderD2 renderer that shells out to the d2 CLI and returns SVG (or a sentinel when unavailable).
  • Extended fenced diagram enhancement to recognize d2 blocks, support per-fence overrides, and include D2 render options in the cache key.
  • Updated block info parsing to correctly handle multiple {...} attribute groups and mixed space/braced attributes.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
src/renderers/d2.ts New Node-side D2 CLI renderer returning SVG / fallback sentinel.
src/render-enhancers/fenced-diagrams.ts Adds d2 fenced-diagram handling, per-block overrides, and cache-busting on option changes.
src/notebook/types.ts Adds D2-related config fields and defaults to NotebookConfig.
src/markdown-engine/index.ts Passes D2 config through to the fenced-diagrams enhancer.
src/lib/block-info/parse-block-info.ts Improves parsing of fence info strings with mixed braced and space-separated attributes.
build.js Marks crypto as an external Node builtin for bundling.

Comment on lines +71 to +78
const msg: string = err?.message ?? String(err);
const isNotFound =
err?.code === 'ENOENT' ||
/not recognized as an internal or external command/i.test(msg) ||
/not found/i.test(msg) ||
/cannot find/i.test(msg) ||
/No such file or directory/i.test(msg);
if (isNotFound) return D2_NOT_FOUND;
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The isNotFound heuristic is overly broad and can incorrectly swallow real render failures. In particular, err.code === 'ENOENT' will also match cases like failing to read the generated tmpOut file (missing output), and /not found/i can match legitimate D2 error messages. Consider restricting this to spawn/exec errors (e.g., err.syscall === 'spawn' with ENOENT, or the exact Windows "not recognized" message when using shell: true) so real diagram errors surface to users.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why the concern does not hold in practice:

The catch block receives two kinds of errors:

  1. The manually wrapped error rejected inside the execFile callback — used when d2
    exits non-zero. The code and message are copied from the original
    NodeJS.ErrnoException before rejecting, so the shape is well-defined and
    controlled.
  2. A Node.js system error from fs.promises.writeFile or fs.promises.readFile — but
    these only fail if d2 exits 0 without writing the output file, which does not happen:
    any d2 failure (bad syntax, wrong argument, permission error) exits non-zero and the
    error text is captured via stderr || err.message.

This was confirmed by runtime testing: invalid d2 syntax, bad --theme / --layout
arguments, and a missing binary all produce the correct output — an error message for
d2 errors and a silent plain-code fallback when the binary is absent. The scenario
Copilot described (d2 exits 0 with no output file) is not observable in practice.

Additionally, the test cases added for Comment 6 exercise the full attribute-parsing →
renderer pipeline and further confirm no silent suppression occurs.

/cannot find/i.test(msg) ||
/No such file or directory/i.test(msg);
if (isNotFound) return D2_NOT_FOUND;
const escaped = msg.replace(/</g, '&lt;').replace(/>/g, '&gt;');
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

msg is interpolated into HTML but only < and > are escaped. For correct rendering (and to avoid entity-related surprises), escape & as well (or reuse the existing html-escaper utility used elsewhere in the codebase).

Suggested change
const escaped = msg.replace(/</g, '&lt;').replace(/>/g, '&gt;');
const escaped = msg
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Replaced the manual chain with escape(msg) from html-escaper, consistent with fenced-diagrams.ts, code-block-styling.ts, bitfield.ts, parse-math.ts, and every other renderer in the project.

Comment on lines +67 to +73
} catch (err: any) {
// d2 binary not found — caller falls back to plain code block.
// On Windows with shell:true, cmd.exe exits with code 1 and prints
// "is not recognized..." instead of an OS-level ENOENT.
const msg: string = err?.message ?? String(err);
const isNotFound =
err?.code === 'ENOENT' ||
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Avoid any in catch clauses; it bypasses type-safety and may violate the repo’s lint/tsconfig rules. Use catch (err: unknown) and then narrow via instanceof Error / property checks for code, message, etc.

Suggested change
} catch (err: any) {
// d2 binary not found — caller falls back to plain code block.
// On Windows with shell:true, cmd.exe exits with code 1 and prints
// "is not recognized..." instead of an OS-level ENOENT.
const msg: string = err?.message ?? String(err);
const isNotFound =
err?.code === 'ENOENT' ||
} catch (err: unknown) {
// d2 binary not found — caller falls back to plain code block.
// On Windows with shell:true, cmd.exe exits with code 1 and prints
// "is not recognized..." instead of an OS-level ENOENT.
let msg = String(err);
let code: string | undefined;
if (err instanceof Error) {
msg = err.message;
if ('code' in err && typeof err.code === 'string') {
code = err.code;
}
} else if (typeof err === 'object' && err !== null) {
const maybeErr = err as { code?: unknown; message?: unknown };
if (typeof maybeErr.message === 'string') {
msg = maybeErr.message;
}
if (typeof maybeErr.code === 'string') {
code = maybeErr.code;
}
}
const isNotFound =
code === 'ENOENT' ||

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TypeScript's catch (err: any) disables type-checking on err for the entire catch
block. While the project's ESLint config (plugin:@typescript-eslint/recommended) does
not escalate @typescript-eslint/no-explicit-any to an error, it is inconsistent with
the TypeScript strict-mode spirit the codebase follows (strictNullChecks,
noUnusedLocals).

Copilot's suggested replacement was unnecessarily verbose (20+ lines of nested
instanceof / typeof guards). The actual fix is 2 lines because every real error in
this try block is a standard Node.js Error — the wrapped execFile error is
constructed with new Error(...) and fs.promises errors are always Error instances:

// after
} catch (err: unknown) {
  const msg = err instanceof Error ? err.message : String(err);
  const code = (err as NodeJS.ErrnoException).code;
  ...
}

The NodeJS.ErrnoException cast is safe here because all conceivable error sources in
this block are Node.js system errors with a .code property.

Comment on lines +69 to +70
// On Windows with shell:true, cmd.exe exits with code 1 and prints
// "is not recognized..." instead of an OS-level ENOENT.
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment mentions Windows behavior "with shell:true", but this execFile call does not pass shell: true. Either remove/update the comment, or if shell: true is required for some environments, set it explicitly so the behavior matches the explanation.

Suggested change
// On Windows with shell:true, cmd.exe exits with code 1 and prints
// "is not recognized..." instead of an OS-level ENOENT.
// Some Windows environments report a missing executable via stderr text
// such as "is not recognized..." instead of an OS-level ENOENT.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

execFile does not use shell: true. The "not recognized as an internal or
external command" message is a cmd.exe shell behaviour that only appears when using
exec() or child_process.spawn with shell: true. Without a shell, Windows raises
ENOENT at the OS level when the executable is not found.

The comment was left over from an earlier design consideration and was misleading to
future maintainers. It was updated to accurately describe what the regex actually guards
against:

// Some Windows environments report a missing executable via stderr text
// such as "is not recognized..." instead of an OS-level ENOENT.

Comment on lines +44 to +46
const braceContent = rest.slice(braceStart + 1, i - 1).trim();
if (braceContent) attrParts.push(braceContent);
rest = rest.slice(i).trim();
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the fence info contains an opening { without a matching closing }, the brace-scan loop exits at end-of-string and rest.slice(braceStart + 1, i - 1) drops the final character. Consider handling the unbalanced-brace case by taking rest.slice(braceStart + 1) when no closing brace is found, so malformed input doesn’t get truncated.

Suggested change
const braceContent = rest.slice(braceStart + 1, i - 1).trim();
if (braceContent) attrParts.push(braceContent);
rest = rest.slice(i).trim();
const hasClosingBrace = depth === 0;
const braceContent = hasClosingBrace
? rest.slice(braceStart + 1, i - 1).trim()
: rest.slice(braceStart + 1).trim();
if (braceContent) attrParts.push(braceContent);
rest = hasClosingBrace ? rest.slice(i).trim() : '';

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why this is not worth fixing:

  1. The transformer never produces unbalanced braces. The only way the new brace-path
    in parseBlockInfo is reached is when the transformer in
    src/markdown-engine/transformer.ts appends {data-source-line="N"} to the fence
    info string. That output is always well-formed.

  2. d2 itself is a safety valve. A manual test with a malformed fence info string
    (stray } passed as part of arguments) produced a clear d2 error:

    D2 error: err: bad usage: failed to parse flags: invalid argument "1}" for "-t, --theme" flag: strconv.ParseInt: parsing "1}": invalid syntax

    There is no silent wrong output — d2 rejects invalid arguments loudly.

  3. No real user input can trigger it. The fence info string is generated by the
    transformer, not typed directly by the user. Defensive parsing for impossible inputs
    adds complexity without protecting against anything observable.

Comment on lines +9 to +16
if (trimmedParams.indexOf('{') !== -1) {
// The transformer appends {data-source-line="N"} to fence info strings,
// producing either:
// "lang {attrs} {data-source-line="N"}" (when attrs already existed)
// "lang pre-attrs {data-source-line="N"}" (space-separated attrs like d2)
// "lang {attrs}" (normal case)
// Strategy: extract language (up to first space or {), then collect all
// brace-group contents and space-separated tokens between them as attrs.
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

parseBlockInfo parsing behavior changed significantly (multiple {...} groups and mixed space-separated attrs). Please add/extend unit tests in test/lib/block-info.test.ts to cover the new supported forms like lang space-attrs {brace-attrs} and lang {attrs1} {attrs2} (including the {data-source-line="N"} transformer case).

Copilot generated this review using guidance from repository custom instructions.
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was the most important comment. parseBlockInfo is a shared utility used for
every fenced code block in the codebase — not just d2. The new brace-parsing logic
(multiple {...} groups, mixed space-separated and brace-delimited attributes) had no
test coverage, creating a regression risk for all other diagram types.

Three test cases were added to test/lib/block-info.test.ts:

Input What it covers
d2 layout=elk theme=200 sketch Pure space-separated attributes (the else branch)
d2 layout=elk theme=200 sketch {data-source-line="5"} Transformer output: space-attrs + appended {data-source-line} brace group
d2 {layout=elk theme=200} {data-source-line="5"} Transformer output: brace-attrs + appended {data-source-line} second brace group

All 29 tests (26 pre-existing + 3 new) pass:

PASS  test/lib/block-info.test.ts (10.12 s)
  lib/block-info
    ✓ parseBlockInfo() correctly parses d2 layout=elk theme=200 sketch (1 ms)
    ✓ parseBlockInfo() correctly parses d2 layout=elk theme=200 sketch {data-source-line="5"} (1 ms)
    ✓ parseBlockInfo() correctly parses d2 {layout=elk theme=200} {data-source-line="5"} (1 ms)

Tests: 29 passed, 29 total

…l replace chain (consistent with rest of codebase)

Narrow catch(err: any) to catch(err: unknown) with instanceof Error guard in renderD2
Fix misleading comment that referenced shell:true (execFile does not use a shell)
Add parseBlockInfo test cases for d2 attribute patterns: pure space-attrs, and transformer-produced mixed space+brace forms including appended {data-source-line="N"}
@kvdogan kvdogan force-pushed the d2-diagram-support branch from 3f841f1 to f1db54f Compare April 10, 2026 11:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants