@markup-carve/carve-grammars 0.1.2 → 0.1.3

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.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Grammars for the [Carve](https://github.com/markup-carve/carve) markup language:
4
4
 
5
- - a **Tiptap** integration (editor kit + serializer) that turns a Tiptap/ProseMirror document into Carve markup;
5
+ - a **Tiptap** integration (editor kit, Carve loader and serializer) that converts between Carve markup and Tiptap/ProseMirror JSON;
6
6
  - **Prism** and **highlight.js** syntax-highlighting grammars for rendering Carve source on the web;
7
7
  - a **TextMate** grammar (`textmate/carve.tmLanguage.json`) for TextMate-based highlighters such as Shiki (used by VitePress).
8
8
 
@@ -22,8 +22,14 @@ npm install @markup-carve/carve-grammars
22
22
  ```
23
23
 
24
24
  All peer dependencies are optional - install only what you use:
25
- `@tiptap/core` + `@tiptap/starter-kit` (v2) for the editor, `prismjs` (v1) for
26
- Prism, `highlight.js` (v11) for highlight.js.
25
+ `@tiptap/core` + `@tiptap/starter-kit` (v2 or v3) for the editor, `prismjs` (v1)
26
+ for Prism, `highlight.js` (v11) for highlight.js. CI runs the suite against both
27
+ Tiptap majors.
28
+
29
+ On Tiptap 3, `CarveKit` disables StarterKit's bundled Underline and Link, since
30
+ it registers its own (underline carries Carve's `_text_` mapping). Pass
31
+ `starterKit: { underline: true }` to opt back in, at the cost of a duplicate
32
+ mark name.
27
33
 
28
34
  `CarveKit` also pulls in several standalone Tiptap marks/extensions (highlight,
29
35
  subscript, superscript, underline, link, image, table, task-list); install the
@@ -114,6 +120,53 @@ blocks (`` ``` lang ``), horizontal rules (`---`), tables (with `|=` header
114
120
  cells and `^` / `<` row / column spans), container divs (`::: class`), and
115
121
  definition lists.
116
122
 
123
+ ## Loading Carve Into Tiptap
124
+
125
+ Use the AST loader when opening Carve source in an editor. It parses Carve with
126
+ `@markup-carve/carve` and builds the ProseMirror JSON shape consumed by
127
+ `CarveKit`, avoiding the lossy HTML pivot where attributes disappear unless a
128
+ Tiptap extension happens to claim them during `parseHTML`.
129
+
130
+ ```js
131
+ import {
132
+ CarveKit,
133
+ carveToProseMirror,
134
+ serializeToCarve,
135
+ } from '@markup-carve/carve-grammars/tiptap'
136
+
137
+ const content = carveToProseMirror(source, { unsupported: 'preserve' })
138
+
139
+ const editor = new Editor({
140
+ extensions: [CarveKit],
141
+ content,
142
+ })
143
+
144
+ const saved = serializeToCarve(editor.getJSON())
145
+ ```
146
+
147
+ Entry points:
148
+
149
+ - `carveToProseMirror(source, options?)` parses Carve source and returns a
150
+ ProseMirror `doc`.
151
+ - `astToProseMirror(ast, options?)` converts an already parsed Carve
152
+ `document` AST.
153
+
154
+ Unsupported handling:
155
+
156
+ - `unsupported: 'throw'` is the default. The loader throws `UnsupportedNodeError`
157
+ instead of silently dropping content.
158
+ - `unsupported: 'preserve'` first builds the richest available document and
159
+ verifies that serializing it preserves the parsed AST. Unsupported subtrees
160
+ use opaque `carveUnsupported` blocks; if a mapped document is still lossy,
161
+ the loader falls back to one whole-document opaque block. `serializeToCarve`
162
+ writes its source back byte-for-byte, including edge whitespace.
163
+
164
+ All corpus documents are therefore load/save lossless in preservation mode.
165
+ Some constructs remain opaque rather than directly editable, including parts
166
+ of figures, advanced tables, comments, raw passthrough, and source-layout edge
167
+ cases. `tiptap/schema-map.json` is the public rich-mapping authority;
168
+ `tests/lib/coverage.js` records why structured conversion falls back.
169
+
117
170
  ## Syntax highlighting
118
171
 
119
172
  Render Carve source as highlighted HTML on the web. Both grammars cover the full
@@ -121,9 +174,104 @@ Carve token set: headings, lists, tables, blockquotes, fenced/raw blocks,
121
174
  container divs, front matter and comments, plus inline emphasis
122
175
  (`*bold*` `/italic/` `_underline_` `~strike~` `=highlight=`, braced
123
176
  `{^sup^}` `{,sub,}`),
124
- code, links, images, spans, attributes, footnotes, math (`` $`x`$ ``),
177
+ code, links, images, spans, attributes, footnotes, math (`` $`x` ``),
125
178
  CriticMarkup (`{+ins+}` `{-del-}`), mentions, tags and emoji.
126
179
 
180
+ ### Where the three grammars deliberately differ
181
+
182
+ The TextMate grammar is stricter than the Prism and highlight.js grammars about
183
+ **indented block openers at document level**, and that difference is a decision
184
+ rather than drift.
185
+
186
+ Carve opens a block at column 0, or at an enclosing container's content column -
187
+ nowhere in between. So at document level these are all ordinary paragraphs:
188
+
189
+ ````
190
+ # H
191
+ > q
192
+ *[HTML]: HyperText
193
+ ```js
194
+ x
195
+ ```
196
+ ````
197
+
198
+ while the same four openers at a list item's content column are real blocks:
199
+
200
+ ````
201
+ - item
202
+
203
+ # H
204
+
205
+ > quoted
206
+
207
+ ```js
208
+ x
209
+ ```
210
+ ````
211
+
212
+ Telling those two apart needs block context. Only the TextMate grammar has it:
213
+ its list-item rules track the item's actual content column, so a document-level
214
+ rule can be anchored at column 0 while an `_in_container` twin stays permissive
215
+ and is reachable only from inside a container. Its `heading`, `fenced_code`,
216
+ `blockquote` and `abbreviation` rules are therefore anchored at column 0, and
217
+ `heading_in_container`, `fenced_code_in_container`, `blockquote_in_container`
218
+ and `abbreviation_in_container` carry the indented forms.
219
+
220
+ Prism and highlight.js are line-based and have no container model, so they
221
+ cannot make that distinction. Anchoring their block rules at column 0 would not
222
+ buy accuracy - it would stop highlighting **every** legitimately indented
223
+ construct inside a list item or a block quote, which is a common valid shape,
224
+ in exchange for correcting a rare invalid one. So both keep their `^[ \t]*`
225
+ anchors and knowingly over-colour the indented-at-document-level case.
226
+
227
+ The practical consequence: a document that indents a heading, fence, blockquote
228
+ or abbreviation definition by one or two columns at top level is highlighted by
229
+ Prism and highlight.js and left as plain text by the TextMate grammar (Shiki,
230
+ VS Code). The TextMate answer is the one that agrees with the engines.
231
+
232
+ `tests/lib/constructs.js` is the shared construct inventory all three sweeps
233
+ read, and the same asymmetry is written down there as `skip` entries on the
234
+ column-sensitive cases; the TextMate-only column cases live in the `NEGATIVE`
235
+ list in `tests/textmate-sweep-test.js`.
236
+
237
+ ### One rule, three spellings: a leading byte order mark
238
+
239
+ A byte order mark at the **start of a document** is not content. The spec says
240
+ so ("Line endings and a byte order mark"), and carve-js, carve-rs and carve-php
241
+ all strip it before the block scanner runs. It is neither a space nor a tab, so
242
+ without an explicit allowance it sits between the line start and the marker and
243
+ defeats every line-anchored opener - a mark in front of a heading left the title
244
+ unscoped, and a mark in front of a fence handed the line to the inline code rule
245
+ instead.
246
+
247
+ All three grammars now allow it, and the restriction to the document's start is
248
+ load-bearing rather than pedantry. A mark anywhere else is an ordinary
249
+ zero-width character that opens nothing:
250
+
251
+ ```
252
+ # T
253
+
254
+ <a byte order mark here>- item
255
+ ```
256
+
257
+ renders as a paragraph holding literal text in carve-rs and in carve-php, and as
258
+ a list only in carve-js, whose own `\s` class is Unicode White_Space plus U+FEFF
259
+ (markup-carve/carve#806). Every rule here anchors with `^` under a multiline
260
+ flag, which matches at *every* line start, so the allowance has to carry its own
261
+ document-start assertion - and the three grammars do not share one:
262
+
263
+ | grammar | spelling | mechanism |
264
+ | --- | --- | --- |
265
+ | prism | `(?:(?<![\s\S])\uFEFF)?` | JavaScript lookbehind: nothing precedes offset 0 |
266
+ | highlightjs | `(?:(?<![\s\S])\uFEFF)?` | the same, and it survives highlight.js compilation |
267
+ | textmate | `(?:\A\x{FEFF})?` | Oniguruma `\A`, which vscode-textmate resolves against the first line only |
268
+
269
+ The codepoint is always written as an escape. No file in this repo holds a
270
+ literal byte order mark: it is invisible, and an editor or a normalizing filter
271
+ can drop the one character a rule is about. The spec corpus is the exception and
272
+ can afford to be - it marks `tests/corpus/**` as `-text`, so
273
+ `250-line-endings-and-a-byte-order-mark-3.crv` really does begin `ef bb bf`.
274
+
127
275
  ### Prism
128
276
 
129
277
  The grammar registers itself against the global `Prism`, so `Prism` must be
@@ -193,12 +341,120 @@ Named exports for other setups: `carveGrammar`, `carveLightExtras` /
193
341
  `carveDarkExtras`, `carveLightTheme` / `carveDarkTheme`, `extendTheme`,
194
342
  `carveStylingTransformer`.
195
343
 
344
+ ## Diagram rendering
345
+
346
+ Carve's `FencedRenderExtension` presets emit a `<pre class="LANG">source</pre>`
347
+ hydration element; something on the client turns it into a diagram. Mermaid,
348
+ WaveDrom, Vega-Lite and Chart each render once **you** load their browser
349
+ library. For the rest, `@markup-carve/carve-grammars/diagrams` ships renderers:
350
+
351
+ | Type | Renderer | Engine | Network |
352
+ |------|----------|--------|---------|
353
+ | `graphviz` (`dot`) | `renderGraphvizDiagrams` | `@viz-js/viz` (WASM) | **offline** |
354
+ | `d2` | `renderD2Diagrams` | `@terrastruct/d2` (WASM) | **offline** |
355
+ | `plantuml` (`puml`) | `renderKrokiDiagrams` | a Kroki server | **network** |
356
+
357
+ Graphviz and D2 render **entirely in the browser** - no server, no external
358
+ call, works offline (in an IDE, behind a firewall, ...). The rendered SVG is
359
+ placed in an inert `<img>` data URI (like the Kroki path), so even untrusted
360
+ diagram source cannot run script or expose a `javascript:` link. The WASM
361
+ libraries are optional peer dependencies, imported lazily only when a matching
362
+ block is on the page:
363
+
364
+ ```js
365
+ import { renderGraphvizDiagrams } from '@markup-carve/carve-grammars/diagrams/graphviz'
366
+ import { renderD2Diagrams } from '@markup-carve/carve-grammars/diagrams/d2'
367
+
368
+ await renderGraphvizDiagrams(container)
369
+ await renderD2Diagrams(container)
370
+ ```
371
+
372
+ `renderDiagrams` runs both (and PlantUML, when you opt in) in one call; each
373
+ no-ops when its blocks are absent, so you pay nothing for the types not present:
374
+
375
+ ```js
376
+ import { renderDiagrams } from '@markup-carve/carve-grammars/diagrams'
377
+
378
+ await renderDiagrams(container) // graphviz + d2, offline
379
+ await renderDiagrams(container, { kroki: {} }) // + PlantUML via kroki.io
380
+ await renderDiagrams(container, { kroki: { server: 'https://kroki.internal' } })
381
+ ```
382
+
383
+ ### PlantUML (Kroki)
384
+
385
+ PlantUML is the one preset with no practical in-browser renderer - its only
386
+ pure-JS build is a multi-megabyte JVM-in-WASM. `renderKrokiDiagrams` renders it
387
+ by POSTing the source to a [Kroki](https://kroki.io) server; the returned SVG
388
+ rides in an `<img>` data URI (which cannot execute script). Idempotent, and
389
+ dependency-free (plain-text POST, no deflate/base64).
390
+
391
+ > ⚠️ **Privacy / GDPR.** The default server is the **public `https://kroki.io`**,
392
+ > so the diagram source is sent to a **third party outside your domain**. For
393
+ > anything sensitive, or to stay offline, point `server` at a **self-hosted or
394
+ > localhost Kroki** so no data leaves your control - and disclose the external
395
+ > call to end users where required. Because of this, `renderDiagrams` leaves the
396
+ > Kroki step **off unless you pass `kroki`**.
397
+
398
+ ```js
399
+ import { renderKrokiDiagrams } from '@markup-carve/carve-grammars/diagrams/kroki'
400
+
401
+ await renderKrokiDiagrams(container, { server: 'https://kroki.internal' })
402
+ ```
403
+
404
+ Options: `server` (default `https://kroki.io`), `types` (class → Kroki-type map,
405
+ default `KROKI_DIAGRAM_TYPES` = `plantuml`/`puml` only; extend it to Kroki-render
406
+ graphviz/d2 against a self-hosted server), `onError`, `fetch`.
407
+
408
+ > When the diagram is rendered at build time (SSG) rather than in the browser,
409
+ > prefer the engine's static-render hook (carve-js `renderers.plantuml`,
410
+ > carve-php's own render pipeline) so the page ships finished SVG and needs no
411
+ > client JS at all.
412
+
196
413
  ## API
197
414
 
415
+ - `renderDiagrams(container, options?)` - render Graphviz + D2 (offline), and
416
+ PlantUML via Kroki when `options.kroki` is set. See
417
+ [Diagram rendering](#diagram-rendering).
418
+ - `renderGraphvizDiagrams(container, options?)` / `renderD2Diagrams(container, options?)` -
419
+ render `graphviz`/`d2` blocks with the offline WASM engines.
420
+ - `renderKrokiDiagrams(container, options?)` - render PlantUML (and any opted-in
421
+ type) via a Kroki server; `KROKI_DIAGRAM_TYPES` is the default class→type map.
422
+ - `carveToProseMirror(source, options?)` - parse Carve source and convert it to
423
+ ProseMirror JSON. `options.unsupported` is `'throw'` by default or
424
+ `'preserve'` for opaque source-preserving blocks.
425
+ - `astToProseMirror(ast, options?)` - convert an existing `@markup-carve/carve`
426
+ AST to ProseMirror JSON.
198
427
  - `serializeToCarve(doc)` - serialize an `editor.getJSON()` document to Carve markup.
199
428
  - `escapeCarve(text)` - contextually escape literal Carve syntax in a plain-text run so it round-trips as text (used internally by `serializeToCarve`).
200
429
  - `CarveKit` - the bundled Tiptap extension set.
201
- - Individual extensions: `CarveInsert`, `CarveDelete`, `CarveDiv`, `CarveSpan`, `CarveFootnote`, `CarveFootnoteDefinition`, `CarveMath`, `CarveEmbed`, `CarveAbbreviation`, `CarveDefinitionList`.
430
+ - Individual extensions: `CarveInsert`, `CarveDelete`, `CarveCriticComment`, `CarveDiv`, `CarveSpan`, `CarveFootnote`, `CarveFootnoteDefinition`, `CarveMath`, `CarveEmbed`, `CarveAbbreviation`, `CarveDefinitionList`, `CarveUnsupported`.
431
+
432
+ ## Schema map (for other engines)
433
+
434
+ `tiptap/schema-map.json` publishes the Carve-to-ProseMirror vocabulary as data, so
435
+ an engine building a bridge in another language reads it instead of restating it:
436
+
437
+ ```js
438
+ import map from '@markup-carve/carve-grammars/tiptap/schema-map.json'
439
+
440
+ map.types.strong // { kind: 'mark', pm: 'bold' }
441
+ map.types.list // { kind: 'node', pm: ['bulletList', 'orderedList', 'taskList'], ... }
442
+ map.unmapped.figure // 'figure / caption blocks are not modeled'
443
+ ```
444
+
445
+ Every Carve node type appears exactly once, either in `types` with its
446
+ ProseMirror name(s) or in `unmapped` with the reason it has none - the negative
447
+ space is part of the contract, because a bridge that silently drops table
448
+ alignment or figure captions is worse than one that says it cannot carry them.
449
+
450
+ `tests/schema-map-test.js` keeps it honest: every ProseMirror name must exist in
451
+ the `CarveKit` schema with the declared node/mark kind, and every type in the
452
+ pinned spec vocabulary must have a decision. Types the map covers ahead of the
453
+ `spec/` pin are declared explicitly and must be removed once the pin catches up.
454
+
455
+ Restating this mapping per engine is what the spec's own node-vocabulary test was
456
+ written to prevent: carve-php once emitted `citation-group` while every other
457
+ implementation spelled it with underscores.
202
458
 
203
459
  ## Attributes, math and footnotes
204
460
 
@@ -207,8 +463,10 @@ Named exports for other setups: `carveGrammar`, `carveLightExtras` /
207
463
  `[text]{#me .note}`, `![alt](src){.wide}`. Inline attrs trail their target;
208
464
  block attrs (headings) sit on the **preceding** line (strict djot), e.g.
209
465
  `{#slug}` then `# Title`.
210
- - **Math** - `CarveMath` (inline atom) serializes to `` $`x`$ `` and, with
211
- `display: true`, `` $$`x`$$ ``.
466
+ - **Math** - `CarveMath` (inline atom) serializes to `` $`x` `` and, with
467
+ `display: true`, `` $$`x` ``. Math has no closing `$` sentinel (grammar.ebnf
468
+ PART 9 §18): the `$` / `$$` prefix opens a verbatim span and the backtick run
469
+ ends it, which is what keeps currency like `$5` literal.
212
470
  - **Footnotes** - `CarveFootnote` is the inline `[^label]` reference;
213
471
  `CarveFootnoteDefinition` is the matching body block, serialized as
214
472
  `[^label]: body`.
package/diagrams/d2.js ADDED
@@ -0,0 +1,55 @@
1
+ /**
2
+ * D2 diagram renderer for Carve fenced-render output.
3
+ *
4
+ * `FencedRenderExtension::d2()` emits `<pre class="d2">SOURCE</pre>`. D2 has a
5
+ * WebAssembly build (`@terrastruct/d2`), so this renders in the browser with no
6
+ * server and no external call - it works offline. The WASM is large, so the
7
+ * library is imported lazily (only when a D2 block is on the page) and the D2
8
+ * instance is reused across blocks and calls.
9
+ *
10
+ * @example
11
+ * import { renderD2Diagrams } from '@markup-carve/carve-grammars/diagrams/d2'
12
+ * await renderD2Diagrams(document.querySelector('.carve-output'))
13
+ */
14
+ import { renderBlocks } from './render-blocks.js';
15
+
16
+ /** CSS classes claimed as D2 (the FencedRenderExtension cssClass). */
17
+ export const D2_CLASSES = ['d2'];
18
+
19
+ let d2Instance = null;
20
+
21
+ /**
22
+ * Render every `<pre class="d2">` under `container` to inline SVG.
23
+ *
24
+ * @param {ParentNode} container
25
+ * @param {object} [options]
26
+ * @param {string[]} [options.classes=D2_CLASSES]
27
+ * @param {object} [options.compileOptions] - Passed to `d2.compile` (e.g. `{ sketch: true }`).
28
+ * @param {() => Promise<{ compile: Function, render: Function }>} [options.load]
29
+ * Resolve the D2 instance; overridable for tests / a self-hosted build.
30
+ * Defaults to lazy-importing `@terrastruct/d2`.
31
+ * @param {(el: Element, message: string) => void} [options.onError]
32
+ * @returns {Promise<number>} How many blocks were rendered.
33
+ */
34
+ export async function renderD2Diagrams(container, options = {}) {
35
+ const classes = options.classes ?? D2_CLASSES;
36
+ const load = options.load ?? defaultLoad;
37
+
38
+ return renderBlocks(container, classes, async (source) => {
39
+ const d2 = await load();
40
+ const result = await d2.compile(source, options.compileOptions);
41
+
42
+ return d2.render(result.diagram, result.renderOptions);
43
+ }, { flag: 'd2', onError: options.onError });
44
+ }
45
+
46
+ async function defaultLoad() {
47
+ if (!d2Instance) {
48
+ const { D2 } = await import('@terrastruct/d2');
49
+ d2Instance = new D2();
50
+ }
51
+
52
+ return d2Instance;
53
+ }
54
+
55
+ export default renderD2Diagrams;
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Graphviz diagram renderer for Carve fenced-render output.
3
+ *
4
+ * `FencedRenderExtension::graphviz()` emits `<pre class="graphviz">DOT</pre>`.
5
+ * Graphviz has a mature, self-contained WebAssembly build (`@viz-js/viz`), so
6
+ * this renders entirely in the browser - no server, no external call, works
7
+ * offline. The library is imported lazily (only when a graphviz block is on
8
+ * the page) and the Viz instance is reused across blocks and calls.
9
+ *
10
+ * @example
11
+ * import { renderGraphvizDiagrams } from '@markup-carve/carve-grammars/diagrams/graphviz'
12
+ * await renderGraphvizDiagrams(document.querySelector('.carve-output'))
13
+ */
14
+ import { renderBlocks } from './render-blocks.js';
15
+
16
+ /**
17
+ * CSS classes claimed as Graphviz DOT. `graphviz` is the FencedRenderExtension
18
+ * cssClass; `dot` covers a block configured with that class directly.
19
+ */
20
+ export const GRAPHVIZ_CLASSES = ['graphviz', 'dot'];
21
+
22
+ let vizInstance = null;
23
+
24
+ /**
25
+ * Render every `<pre class="graphviz">` under `container` to inline SVG.
26
+ *
27
+ * @param {ParentNode} container
28
+ * @param {object} [options]
29
+ * @param {string[]} [options.classes=GRAPHVIZ_CLASSES]
30
+ * @param {() => Promise<{ renderString(dot: string, opts?: object): string }>} [options.load]
31
+ * Resolve the Viz instance; overridable for tests / a self-hosted build.
32
+ * Defaults to lazy-importing `@viz-js/viz`.
33
+ * @param {(el: Element, message: string) => void} [options.onError]
34
+ * @returns {Promise<number>} How many blocks were rendered.
35
+ */
36
+ export async function renderGraphvizDiagrams(container, options = {}) {
37
+ const classes = options.classes ?? GRAPHVIZ_CLASSES;
38
+ const load = options.load ?? defaultLoad;
39
+
40
+ return renderBlocks(container, classes, async (source) => {
41
+ const viz = await load();
42
+ // renderString (not renderSVGElement) so it works in any host: the
43
+ // latter needs a browser DOMParser, the former just returns the SVG
44
+ // string, which renderBlocks wraps. Strip the XML prolog so the markup
45
+ // starts at <svg>.
46
+ return viz.renderString(source, { format: 'svg' }).replace(/^\s*<\?xml[^>]*\?>\s*/, '');
47
+ }, { flag: 'graphviz', onError: options.onError });
48
+ }
49
+
50
+ async function defaultLoad() {
51
+ if (!vizInstance) {
52
+ const { instance } = await import('@viz-js/viz');
53
+ vizInstance = await instance();
54
+ }
55
+
56
+ return vizInstance;
57
+ }
58
+
59
+ export default renderGraphvizDiagrams;
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Carve diagram renderers.
3
+ *
4
+ * Turns the `FencedRenderExtension` diagram hydration elements
5
+ * (`<pre class="LANG">source</pre>`) into rendered diagrams in the browser:
6
+ *
7
+ * - `renderGraphvizDiagrams` - Graphviz via `@viz-js/viz` (offline WASM)
8
+ * - `renderD2Diagrams` - D2 via `@terrastruct/d2` (offline WASM)
9
+ * - `renderKrokiDiagrams` - PlantUML (and any opted-in type) via a Kroki server
10
+ *
11
+ * Mermaid, WaveDrom, Vega-Lite and Chart already have their own browser
12
+ * libraries; load and run those yourself.
13
+ *
14
+ * `renderDiagrams` is a convenience that runs all three. Each self-loads its
15
+ * library lazily and no-ops when its blocks are absent, so calling it costs
16
+ * nothing for the types not present on the page. The Kroki step is off unless
17
+ * you opt in (`options.kroki`), because it may call an external server - see
18
+ * `renderKrokiDiagrams` for the privacy/GDPR note.
19
+ */
20
+ export { renderGraphvizDiagrams, GRAPHVIZ_CLASSES } from './graphviz.js';
21
+ export { renderD2Diagrams, D2_CLASSES } from './d2.js';
22
+ export { renderKrokiDiagrams, KROKI_DIAGRAM_TYPES } from './kroki.js';
23
+
24
+ import { renderGraphvizDiagrams } from './graphviz.js';
25
+ import { renderD2Diagrams } from './d2.js';
26
+ import { renderKrokiDiagrams } from './kroki.js';
27
+
28
+ /**
29
+ * Render the offline diagram types (Graphviz, D2) under `container`, and -
30
+ * only when `options.kroki` is set - PlantUML via Kroki.
31
+ *
32
+ * @param {ParentNode} container
33
+ * @param {object} [options]
34
+ * @param {object} [options.graphviz] - Options for `renderGraphvizDiagrams` (omit to enable with defaults; `false` to skip).
35
+ * @param {object} [options.d2] - Options for `renderD2Diagrams` (omit to enable with defaults; `false` to skip).
36
+ * @param {object} [options.kroki] - Options for `renderKrokiDiagrams`; OFF unless provided (opt-in, may call an external server).
37
+ * @returns {Promise<number>} Total blocks rendered.
38
+ */
39
+ export async function renderDiagrams(container, options = {}) {
40
+ const tasks = [];
41
+ if (options.graphviz !== false) {
42
+ tasks.push(renderGraphvizDiagrams(container, options.graphviz || {}));
43
+ }
44
+ if (options.d2 !== false) {
45
+ tasks.push(renderD2Diagrams(container, options.d2 || {}));
46
+ }
47
+ if (options.kroki) {
48
+ tasks.push(renderKrokiDiagrams(container, options.kroki));
49
+ }
50
+ const counts = await Promise.all(tasks);
51
+
52
+ return counts.reduce((a, b) => a + b, 0);
53
+ }
54
+
55
+ export default renderDiagrams;
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Kroki diagram renderer for Carve fenced-render output.
3
+ *
4
+ * The one diagram preset with no practical in-browser renderer is **PlantUML**
5
+ * (its only pure-JS build is a multi-megabyte JVM-in-WASM). This helper renders
6
+ * it - and any other Kroki-supported type you opt in - by POSTing the source to
7
+ * a Kroki server. Graphviz and D2 do NOT need this: they have self-contained
8
+ * WebAssembly renderers (`renderGraphvizDiagrams`, `renderD2Diagrams`) that run
9
+ * offline, so they are not in the default type map.
10
+ *
11
+ * ⚠️ PRIVACY / GDPR: the default server is the PUBLIC `https://kroki.io`, so the
12
+ * diagram source is sent to a third party outside your control. For anything
13
+ * sensitive - or to keep rendering offline - point `server` at a self-hosted
14
+ * Kroki (or a localhost instance) so no data leaves your domain, and disclose
15
+ * the external call to end users where required.
16
+ *
17
+ * It is dependency-free: the diagram source is POSTed to Kroki as plain text
18
+ * (no deflate/base64 step, so no pako dependency), and the returned SVG is
19
+ * shown in an `<img>` via a data URI. An `<img>` cannot execute script, so
20
+ * Kroki's SVG output is inert on the page regardless of its content.
21
+ *
22
+ * SVG only, by design: the output rides in a `data:image/svg+xml` URI, which
23
+ * is text-based and scales cleanly. Binary Kroki formats (PNG, PDF) would need
24
+ * a different transport and are out of scope for an inline browser renderer.
25
+ *
26
+ * @example
27
+ * import { renderKrokiDiagrams } from '@markup-carve/carve-grammars/diagrams/kroki'
28
+ * await renderKrokiDiagrams(document.querySelector('.carve-output'))
29
+ *
30
+ * @example <caption>Self-hosted Kroki + only PlantUML</caption>
31
+ * await renderKrokiDiagrams(container, {
32
+ * server: 'https://kroki.internal',
33
+ * types: { plantuml: 'plantuml', puml: 'plantuml' },
34
+ * })
35
+ */
36
+
37
+ /**
38
+ * Default map of fenced-block CSS class (the `FencedRenderExtension` cssClass)
39
+ * to the Kroki diagram type. PlantUML only, because it is the one preset with
40
+ * no practical in-browser renderer. Graphviz and D2 have offline WASM renderers
41
+ * (`renderGraphvizDiagrams`, `renderD2Diagrams`); Mermaid, WaveDrom, Vega-Lite
42
+ * and Chart have their own browser libraries. To Kroki-render one of those
43
+ * anyway (e.g. against a self-hosted server), pass an extended `types` map.
44
+ *
45
+ * @type {Record<string, string>}
46
+ */
47
+ export const KROKI_DIAGRAM_TYPES = {
48
+ plantuml: 'plantuml',
49
+ puml: 'plantuml',
50
+ };
51
+
52
+ /**
53
+ * Render every Kroki-supported fenced-render block inside `container`.
54
+ *
55
+ * Each matching `<pre class="LANG">` is replaced with an `<img>` of the Kroki
56
+ * output. Processing is idempotent (a rendered or errored block is marked and
57
+ * skipped on a later call), so it is safe to run after every content update.
58
+ *
59
+ * @param {ParentNode} container - Root to search for diagram blocks.
60
+ * @param {object} [options]
61
+ * @param {string} [options.server='https://kroki.io'] - Kroki base URL.
62
+ * @param {Record<string, string>} [options.types=KROKI_DIAGRAM_TYPES] - CSS
63
+ * class to Kroki-type map. Also selects which blocks are claimed.
64
+ * @param {(el: Element, message: string) => void} [options.onError] - Called
65
+ * for a block that fails to render; by default the `<pre>` is left in place
66
+ * with a `data-kroki-error` attribute.
67
+ * @param {typeof fetch} [options.fetch=globalThis.fetch] - Fetch implementation
68
+ * (overridable for tests / non-browser hosts).
69
+ * @returns {Promise<number>} How many blocks were rendered in this call.
70
+ */
71
+ export async function renderKrokiDiagrams(container, options = {}) {
72
+ if (!container) {
73
+ return 0;
74
+ }
75
+ const server = (options.server ?? 'https://kroki.io').replace(/\/+$/, '');
76
+ const types = options.types ?? KROKI_DIAGRAM_TYPES;
77
+ const fetchImpl = options.fetch ?? globalThis.fetch;
78
+ const onError = options.onError;
79
+ if (typeof fetchImpl !== 'function') {
80
+ throw new Error('renderKrokiDiagrams: no fetch implementation available');
81
+ }
82
+
83
+ const classes = Object.keys(types);
84
+ if (classes.length === 0) {
85
+ return 0;
86
+ }
87
+ const selector = classes.map((cls) => `pre.${cls}`).join(', ');
88
+ const blocks = [...container.querySelectorAll(selector)];
89
+
90
+ const results = await Promise.all(blocks.map(async (el) => {
91
+ if (el.dataset.krokiProcessed) {
92
+ return 0;
93
+ }
94
+ el.dataset.krokiProcessed = 'true';
95
+ // First matching class wins, mirroring the single cssClass a
96
+ // FencedRenderExtension block carries.
97
+ const cls = classes.find((c) => el.classList.contains(c));
98
+ const krokiType = types[cls];
99
+ const source = el.textContent.trim();
100
+ try {
101
+ const response = await fetchImpl(`${server}/${krokiType}/svg`, {
102
+ method: 'POST',
103
+ headers: { 'Content-Type': 'text/plain' },
104
+ body: source,
105
+ });
106
+ if (!response.ok) {
107
+ throw new Error(`Kroki responded ${response.status}`);
108
+ }
109
+ const rendered = await response.text();
110
+ const doc = el.ownerDocument;
111
+ const img = doc.createElement('img');
112
+ img.src = svgDataUri(rendered);
113
+ img.alt = `${krokiType} diagram`;
114
+ img.loading = 'lazy';
115
+ img.className = `carve-diagram carve-diagram-${krokiType}`;
116
+ el.replaceWith(img);
117
+
118
+ return 1;
119
+ } catch (e) {
120
+ const message = e instanceof Error ? e.message : String(e);
121
+ el.dataset.krokiError = message;
122
+ if (typeof onError === 'function') {
123
+ onError(el, message);
124
+ }
125
+
126
+ return 0;
127
+ }
128
+ }));
129
+
130
+ return results.reduce((a, b) => a + b, 0);
131
+ }
132
+
133
+ /**
134
+ * Encode an SVG string as a base64 `data:` URI for an `<img src>`.
135
+ *
136
+ * @param {string} svg
137
+ * @returns {string}
138
+ */
139
+ function svgDataUri(svg) {
140
+ // btoa needs Latin-1; percent-encode first so any UTF-8 in the diagram
141
+ // (labels, etc.) survives.
142
+ const base64 = btoa(unescape(encodeURIComponent(svg)));
143
+
144
+ return `data:image/svg+xml;base64,${base64}`;
145
+ }
146
+
147
+ export default renderKrokiDiagrams;