Conversation
- 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)
There was a problem hiding this comment.
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
renderD2renderer that shells out to thed2CLI and returns SVG (or a sentinel when unavailable). - Extended fenced diagram enhancement to recognize
d2blocks, 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. |
src/renderers/d2.ts
Outdated
| 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; |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Why the concern does not hold in practice:
The catch block receives two kinds of errors:
- The manually wrapped error rejected inside the
execFilecallback — used when d2
exits non-zero. Thecodeandmessageare copied from the original
NodeJS.ErrnoExceptionbefore rejecting, so the shape is well-defined and
controlled. - A Node.js system error from
fs.promises.writeFileorfs.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 viastderr || 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.
src/renderers/d2.ts
Outdated
| /cannot find/i.test(msg) || | ||
| /No such file or directory/i.test(msg); | ||
| if (isNotFound) return D2_NOT_FOUND; | ||
| const escaped = msg.replace(/</g, '<').replace(/>/g, '>'); |
There was a problem hiding this comment.
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).
| const escaped = msg.replace(/</g, '<').replace(/>/g, '>'); | |
| const escaped = msg | |
| .replace(/&/g, '&') | |
| .replace(/</g, '<') | |
| .replace(/>/g, '>'); |
There was a problem hiding this comment.
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.
src/renderers/d2.ts
Outdated
| } 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' || |
There was a problem hiding this comment.
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.
| } 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' || |
There was a problem hiding this comment.
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.
src/renderers/d2.ts
Outdated
| // On Windows with shell:true, cmd.exe exits with code 1 and prints | ||
| // "is not recognized..." instead of an OS-level ENOENT. |
There was a problem hiding this comment.
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.
| // 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. |
There was a problem hiding this comment.
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.| const braceContent = rest.slice(braceStart + 1, i - 1).trim(); | ||
| if (braceContent) attrParts.push(braceContent); | ||
| rest = rest.slice(i).trim(); |
There was a problem hiding this comment.
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.
| 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() : ''; |
There was a problem hiding this comment.
Why this is not worth fixing:
-
The transformer never produces unbalanced braces. The only way the new brace-path
inparseBlockInfois reached is when the transformer in
src/markdown-engine/transformer.tsappends{data-source-line="N"}to the fence
info string. That output is always well-formed. -
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 syntaxThere is no silent wrong output — d2 rejects invalid arguments loudly.
-
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.
| 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. |
There was a problem hiding this comment.
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).
There was a problem hiding this comment.
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"}
3f841f1 to
f1db54f
Compare
Add D2 diagram rendering support
Adds support for rendering D2 diagrams by shelling out to the
d2CLI.Changes
src/renderers/d2.ts— New renderer: writes diagram source to a temp file, invokes thed2CLI, reads back the SVG. Returns aD2_NOT_FOUNDsymbol when the binary is not installed (callers render as plain code block). Web/browser environments are guarded and also returnD2_NOT_FOUND.src/notebook/types.ts— Addedd2Path,d2Layout,d2Theme,d2SketchtoNotebookConfigwith defaults (d2,dagre,0,false).src/markdown-engine/index.ts— Passes d2 config fields through toenhanceWithFencedDiagrams.src/render-enhancers/fenced-diagrams.ts— Added'd2'to supported languages and acase '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— FixedparseBlockInfoto correctly handle fence info strings of the formlang space-attrs {brace-attrs}andlang {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— Addedcryptoto Node built-in externals (used by the new d2 renderer).Usage (in the host editor)
Per-block overrides: