@markup-carve/carve-grammars 0.1.2 → 0.1.4

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 (48) hide show
  1. package/README.md +311 -7
  2. package/diagrams/d2.js +55 -0
  3. package/diagrams/graphviz.js +59 -0
  4. package/diagrams/index.js +55 -0
  5. package/diagrams/kroki.js +147 -0
  6. package/diagrams/render-blocks.js +79 -0
  7. package/highlightjs/carve.js +816 -167
  8. package/package.json +69 -30
  9. package/prism/carve.js +739 -72
  10. package/textmate/carve.tmLanguage.json +1740 -928
  11. package/tiptap/carve-editor.d.ts +33 -0
  12. package/tiptap/carve-editor.js +109 -0
  13. package/tiptap/carve-kit.js +269 -10
  14. package/tiptap/carve-to-pm.js +1205 -0
  15. package/tiptap/extensions/carve-attribute-slots.js +80 -0
  16. package/tiptap/extensions/carve-citation-definition.js +32 -0
  17. package/tiptap/extensions/carve-citation.js +19 -0
  18. package/tiptap/extensions/carve-comment.js +33 -0
  19. package/tiptap/extensions/carve-critic-comment.js +51 -0
  20. package/tiptap/extensions/carve-crossref.js +17 -0
  21. package/tiptap/extensions/carve-div.js +13 -0
  22. package/tiptap/extensions/carve-empty-mark.js +66 -0
  23. package/tiptap/extensions/carve-figure.js +71 -0
  24. package/tiptap/extensions/carve-footnote-definition.js +4 -2
  25. package/tiptap/extensions/carve-footnote.js +6 -2
  26. package/tiptap/extensions/carve-frontmatter.js +17 -0
  27. package/tiptap/extensions/carve-heading.js +48 -0
  28. package/tiptap/extensions/carve-inline-extension.js +38 -0
  29. package/tiptap/extensions/carve-inline-note.js +17 -0
  30. package/tiptap/extensions/carve-line-block.js +13 -0
  31. package/tiptap/extensions/carve-link-ref-def.js +27 -0
  32. package/tiptap/extensions/carve-literal.js +17 -0
  33. package/tiptap/extensions/carve-math.js +51 -2
  34. package/tiptap/extensions/carve-raw-block.js +17 -0
  35. package/tiptap/extensions/carve-raw-inline.js +19 -0
  36. package/tiptap/extensions/carve-section.js +15 -0
  37. package/tiptap/extensions/carve-source-preservation.js +24 -0
  38. package/tiptap/extensions/carve-span.js +27 -5
  39. package/tiptap/extensions/carve-substitution.js +20 -0
  40. package/tiptap/extensions/carve-symbol.js +17 -0
  41. package/tiptap/extensions/carve-unsupported-inline.js +40 -0
  42. package/tiptap/extensions/carve-unsupported.js +47 -0
  43. package/tiptap/extensions/index.js +22 -0
  44. package/tiptap/index.d.ts +70 -0
  45. package/tiptap/index.js +14 -0
  46. package/tiptap/schema-map.json +538 -0
  47. package/tiptap/serializer.js +1044 -97
  48. package/tiptap/wire-fixtures.json +1467 -0
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,77 @@ 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
+
170
+ ## Framework-independent editor element
171
+
172
+ Applications that do not otherwise use Tiptap can mount the same lossless
173
+ bridge through a Web Component:
174
+
175
+ ```js
176
+ import { defineCarveEditor } from '@markup-carve/carve-grammars/editor'
177
+
178
+ defineCarveEditor()
179
+ const editor = document.querySelector('carve-editor')
180
+ editor.value = '# Hello'
181
+ editor.addEventListener('input', event => save(event.detail.value))
182
+ ```
183
+
184
+ ```html
185
+ <carve-editor></carve-editor>
186
+ ```
187
+
188
+ The element exposes a string `value`, emits bubbling and composed `input`
189
+ events, and uses `unsupported: 'preserve'` internally. Its editable surface is
190
+ available as the `editor` CSS part (`carve-editor::part(editor)`). Tiptap stays
191
+ an implementation detail of the element, although its peer packages must be
192
+ installed with `carve-grammars`.
193
+
117
194
  ## Syntax highlighting
118
195
 
119
196
  Render Carve source as highlighted HTML on the web. Both grammars cover the full
@@ -121,9 +198,104 @@ Carve token set: headings, lists, tables, blockquotes, fenced/raw blocks,
121
198
  container divs, front matter and comments, plus inline emphasis
122
199
  (`*bold*` `/italic/` `_underline_` `~strike~` `=highlight=`, braced
123
200
  `{^sup^}` `{,sub,}`),
124
- code, links, images, spans, attributes, footnotes, math (`` $`x`$ ``),
201
+ code, links, images, spans, attributes, footnotes, math (`` $`x` ``),
125
202
  CriticMarkup (`{+ins+}` `{-del-}`), mentions, tags and emoji.
126
203
 
204
+ ### Where the three grammars deliberately differ
205
+
206
+ The TextMate grammar is stricter than the Prism and highlight.js grammars about
207
+ **indented block openers at document level**, and that difference is a decision
208
+ rather than drift.
209
+
210
+ Carve opens a block at column 0, or at an enclosing container's content column -
211
+ nowhere in between. So at document level these are all ordinary paragraphs:
212
+
213
+ ````
214
+ # H
215
+ > q
216
+ *[HTML]: HyperText
217
+ ```js
218
+ x
219
+ ```
220
+ ````
221
+
222
+ while the same four openers at a list item's content column are real blocks:
223
+
224
+ ````
225
+ - item
226
+
227
+ # H
228
+
229
+ > quoted
230
+
231
+ ```js
232
+ x
233
+ ```
234
+ ````
235
+
236
+ Telling those two apart needs block context. Only the TextMate grammar has it:
237
+ its list-item rules track the item's actual content column, so a document-level
238
+ rule can be anchored at column 0 while an `_in_container` twin stays permissive
239
+ and is reachable only from inside a container. Its `heading`, `fenced_code`,
240
+ `blockquote` and `abbreviation` rules are therefore anchored at column 0, and
241
+ `heading_in_container`, `fenced_code_in_container`, `blockquote_in_container`
242
+ and `abbreviation_in_container` carry the indented forms.
243
+
244
+ Prism and highlight.js are line-based and have no container model, so they
245
+ cannot make that distinction. Anchoring their block rules at column 0 would not
246
+ buy accuracy - it would stop highlighting **every** legitimately indented
247
+ construct inside a list item or a block quote, which is a common valid shape,
248
+ in exchange for correcting a rare invalid one. So both keep their `^[ \t]*`
249
+ anchors and knowingly over-colour the indented-at-document-level case.
250
+
251
+ The practical consequence: a document that indents a heading, fence, blockquote
252
+ or abbreviation definition by one or two columns at top level is highlighted by
253
+ Prism and highlight.js and left as plain text by the TextMate grammar (Shiki,
254
+ VS Code). The TextMate answer is the one that agrees with the engines.
255
+
256
+ `tests/lib/constructs.js` is the shared construct inventory all three sweeps
257
+ read, and the same asymmetry is written down there as `skip` entries on the
258
+ column-sensitive cases; the TextMate-only column cases live in the `NEGATIVE`
259
+ list in `tests/textmate-sweep-test.js`.
260
+
261
+ ### One rule, three spellings: a leading byte order mark
262
+
263
+ A byte order mark at the **start of a document** is not content. The spec says
264
+ so ("Line endings and a byte order mark"), and carve-js, carve-rs and carve-php
265
+ all strip it before the block scanner runs. It is neither a space nor a tab, so
266
+ without an explicit allowance it sits between the line start and the marker and
267
+ defeats every line-anchored opener - a mark in front of a heading left the title
268
+ unscoped, and a mark in front of a fence handed the line to the inline code rule
269
+ instead.
270
+
271
+ All three grammars now allow it, and the restriction to the document's start is
272
+ load-bearing rather than pedantry. A mark anywhere else is an ordinary
273
+ zero-width character that opens nothing:
274
+
275
+ ```
276
+ # T
277
+
278
+ <a byte order mark here>- item
279
+ ```
280
+
281
+ renders as a paragraph holding literal text in carve-rs and in carve-php, and as
282
+ a list only in carve-js, whose own `\s` class is Unicode White_Space plus U+FEFF
283
+ (markup-carve/carve#806). Every rule here anchors with `^` under a multiline
284
+ flag, which matches at *every* line start, so the allowance has to carry its own
285
+ document-start assertion - and the three grammars do not share one:
286
+
287
+ | grammar | spelling | mechanism |
288
+ | --- | --- | --- |
289
+ | prism | `(?:(?<![\s\S])\uFEFF)?` | JavaScript lookbehind: nothing precedes offset 0 |
290
+ | highlightjs | `(?:(?<![\s\S])\uFEFF)?` | the same, and it survives highlight.js compilation |
291
+ | textmate | `(?:\A\x{FEFF})?` | Oniguruma `\A`, which vscode-textmate resolves against the first line only |
292
+
293
+ The codepoint is always written as an escape. No file in this repo holds a
294
+ literal byte order mark: it is invisible, and an editor or a normalizing filter
295
+ can drop the one character a rule is about. The spec corpus is the exception and
296
+ can afford to be - it marks `tests/corpus/**` as `-text`, so
297
+ `250-line-endings-and-a-byte-order-mark-3.crv` really does begin `ef bb bf`.
298
+
127
299
  ### Prism
128
300
 
129
301
  The grammar registers itself against the global `Prism`, so `Prism` must be
@@ -193,12 +365,127 @@ Named exports for other setups: `carveGrammar`, `carveLightExtras` /
193
365
  `carveDarkExtras`, `carveLightTheme` / `carveDarkTheme`, `extendTheme`,
194
366
  `carveStylingTransformer`.
195
367
 
368
+ ## Diagram rendering
369
+
370
+ Carve's `FencedRenderExtension` presets emit a `<pre class="LANG">source</pre>`
371
+ hydration element; something on the client turns it into a diagram. Mermaid,
372
+ WaveDrom, Vega-Lite and Chart each render once **you** load their browser
373
+ library. For the rest, `@markup-carve/carve-grammars/diagrams` ships renderers:
374
+
375
+ | Type | Renderer | Engine | Network |
376
+ |------|----------|--------|---------|
377
+ | `graphviz` (`dot`) | `renderGraphvizDiagrams` | `@viz-js/viz` (WASM) | **offline** |
378
+ | `d2` | `renderD2Diagrams` | `@terrastruct/d2` (WASM) | **offline** |
379
+ | `plantuml` (`puml`) | `renderKrokiDiagrams` | a Kroki server | **network** |
380
+
381
+ Graphviz and D2 render **entirely in the browser** - no server, no external
382
+ call, works offline (in an IDE, behind a firewall, ...). The rendered SVG is
383
+ placed in an inert `<img>` data URI (like the Kroki path), so even untrusted
384
+ diagram source cannot run script or expose a `javascript:` link. The WASM
385
+ libraries are optional peer dependencies, imported lazily only when a matching
386
+ block is on the page:
387
+
388
+ ```js
389
+ import { renderGraphvizDiagrams } from '@markup-carve/carve-grammars/diagrams/graphviz'
390
+ import { renderD2Diagrams } from '@markup-carve/carve-grammars/diagrams/d2'
391
+
392
+ await renderGraphvizDiagrams(container)
393
+ await renderD2Diagrams(container)
394
+ ```
395
+
396
+ `renderDiagrams` runs both (and PlantUML, when you opt in) in one call; each
397
+ no-ops when its blocks are absent, so you pay nothing for the types not present:
398
+
399
+ ```js
400
+ import { renderDiagrams } from '@markup-carve/carve-grammars/diagrams'
401
+
402
+ await renderDiagrams(container) // graphviz + d2, offline
403
+ await renderDiagrams(container, { kroki: {} }) // + PlantUML via kroki.io
404
+ await renderDiagrams(container, { kroki: { server: 'https://kroki.internal' } })
405
+ ```
406
+
407
+ ### PlantUML (Kroki)
408
+
409
+ PlantUML is the one preset with no practical in-browser renderer - its only
410
+ pure-JS build is a multi-megabyte JVM-in-WASM. `renderKrokiDiagrams` renders it
411
+ by POSTing the source to a [Kroki](https://kroki.io) server; the returned SVG
412
+ rides in an `<img>` data URI (which cannot execute script). Idempotent, and
413
+ dependency-free (plain-text POST, no deflate/base64).
414
+
415
+ > ⚠️ **Privacy / GDPR.** The default server is the **public `https://kroki.io`**,
416
+ > so the diagram source is sent to a **third party outside your domain**. For
417
+ > anything sensitive, or to stay offline, point `server` at a **self-hosted or
418
+ > localhost Kroki** so no data leaves your control - and disclose the external
419
+ > call to end users where required. Because of this, `renderDiagrams` leaves the
420
+ > Kroki step **off unless you pass `kroki`**.
421
+
422
+ ```js
423
+ import { renderKrokiDiagrams } from '@markup-carve/carve-grammars/diagrams/kroki'
424
+
425
+ await renderKrokiDiagrams(container, { server: 'https://kroki.internal' })
426
+ ```
427
+
428
+ Options: `server` (default `https://kroki.io`), `types` (class → Kroki-type map,
429
+ default `KROKI_DIAGRAM_TYPES` = `plantuml`/`puml` only; extend it to Kroki-render
430
+ graphviz/d2 against a self-hosted server), `onError`, `fetch`.
431
+
432
+ > When the diagram is rendered at build time (SSG) rather than in the browser,
433
+ > prefer the engine's static-render hook (carve-js `renderers.plantuml`,
434
+ > carve-php's own render pipeline) so the page ships finished SVG and needs no
435
+ > client JS at all.
436
+
196
437
  ## API
197
438
 
439
+ - `renderDiagrams(container, options?)` - render Graphviz + D2 (offline), and
440
+ PlantUML via Kroki when `options.kroki` is set. See
441
+ [Diagram rendering](#diagram-rendering).
442
+ - `renderGraphvizDiagrams(container, options?)` / `renderD2Diagrams(container, options?)` -
443
+ render `graphviz`/`d2` blocks with the offline WASM engines.
444
+ - `renderKrokiDiagrams(container, options?)` - render PlantUML (and any opted-in
445
+ type) via a Kroki server; `KROKI_DIAGRAM_TYPES` is the default class→type map.
446
+ - `carveToProseMirror(source, options?)` - parse Carve source and convert it to
447
+ ProseMirror JSON. `options.unsupported` is `'throw'` by default or
448
+ `'preserve'` for opaque source-preserving blocks.
449
+ - `astToProseMirror(ast, options?)` - convert an existing `@markup-carve/carve`
450
+ AST to ProseMirror JSON.
198
451
  - `serializeToCarve(doc)` - serialize an `editor.getJSON()` document to Carve markup.
199
452
  - `escapeCarve(text)` - contextually escape literal Carve syntax in a plain-text run so it round-trips as text (used internally by `serializeToCarve`).
200
453
  - `CarveKit` - the bundled Tiptap extension set.
201
- - Individual extensions: `CarveInsert`, `CarveDelete`, `CarveDiv`, `CarveSpan`, `CarveFootnote`, `CarveFootnoteDefinition`, `CarveMath`, `CarveEmbed`, `CarveAbbreviation`, `CarveDefinitionList`.
454
+ - Individual extensions: `CarveInsert`, `CarveDelete`, `CarveCriticComment`, `CarveDiv`, `CarveSpan`, `CarveFootnote`, `CarveFootnoteDefinition`, `CarveMath`, `CarveEmbed`, `CarveAbbreviation`, `CarveDefinitionList`, `CarveUnsupported`.
455
+
456
+ ## Schema map (for other engines)
457
+
458
+ `tiptap/schema-map.json` publishes the Carve-to-ProseMirror vocabulary as data, so
459
+ an engine building a bridge in another language reads it instead of restating it:
460
+
461
+ ```js
462
+ import map from '@markup-carve/carve-grammars/tiptap/schema-map.json'
463
+
464
+ map.types.strong // { kind: 'mark', pm: 'bold' }
465
+ map.types.list // { kind: 'node', pm: ['bulletList', 'orderedList', 'taskList'], ... }
466
+ map.unmapped.figure // 'figure / caption blocks are not modeled'
467
+ ```
468
+
469
+ Every Carve node type appears exactly once, either in `types` with its
470
+ ProseMirror name(s) or in `unmapped` with the reason it has none - the negative
471
+ space is part of the contract, because a bridge that silently drops table
472
+ alignment or figure captions is worse than one that says it cannot carry them.
473
+
474
+ `tests/schema-map-test.js` keeps it honest: every ProseMirror name must exist in
475
+ the `CarveKit` schema with the declared node/mark kind, and every type in the
476
+ pinned spec vocabulary must have a decision. Types the map covers ahead of the
477
+ `spec/` pin are declared explicitly and must be removed once the pin catches up.
478
+
479
+ Two sections are keyed by ProseMirror name rather than by Carve type, because
480
+ neither names a Carve construct: `preservationNodes` (`carveUnsupported` and
481
+ `carveUnsupportedInline`, the atoms holding a construct's exact source) and
482
+ `markCarrierNodes` (`carveEmptyMark`, the atom a mark with no content rides on).
483
+ Both are part of the wire - an unknown ProseMirror name is an error rather than a
484
+ skip - so a bridge has to read them alongside `types`.
485
+
486
+ Restating this mapping per engine is what the spec's own node-vocabulary test was
487
+ written to prevent: carve-php once emitted `citation-group` while every other
488
+ implementation spelled it with underscores.
202
489
 
203
490
  ## Attributes, math and footnotes
204
491
 
@@ -207,8 +494,25 @@ Named exports for other setups: `carveGrammar`, `carveLightExtras` /
207
494
  `[text]{#me .note}`, `![alt](src){.wide}`. Inline attrs trail their target;
208
495
  block attrs (headings) sit on the **preceding** line (strict djot), e.g.
209
496
  `{#slug}` then `# Title`.
210
- - **Math** - `CarveMath` (inline atom) serializes to `` $`x`$ `` and, with
211
- `display: true`, `` $$`x`$$ ``.
497
+ - **Attribute order** - a run is written back in the order it was AUTHORED, not
498
+ in a canonical one. ProseMirror attributes are an unordered map, so the order
499
+ travels as its own attribute, `carveAttrOrder`: the AST's `order` field
500
+ verbatim, an array whose entries are `#id`, `.class` and each key by name.
501
+
502
+ ```js
503
+ carveToProseMirror('[x]{key=c .a #b}')
504
+ // the carveSpan mark carries: { id: 'b', class: 'a',
505
+ // carveKeyValues: { key: 'c' }, carveAttrOrder: ['key', '.class', '#id'] }
506
+ ```
507
+
508
+ All of a node's classes stay contiguous at the position of the first one,
509
+ which is what the AST records. A document with no `carveAttrOrder` - anything
510
+ an editor builds from scratch - writes the canonical `#id .class key="val"`
511
+ order, and a slot the order names but the document no longer has is skipped.
512
+ - **Math** - `CarveMath` (inline atom) serializes to `` $`x` `` and, with
513
+ `display: true`, `` $$`x` ``. Math has no closing `$` sentinel (grammar.ebnf
514
+ PART 9 §18): the `$` / `$$` prefix opens a verbatim span and the backtick run
515
+ ends it, which is what keeps currency like `$5` literal.
212
516
  - **Footnotes** - `CarveFootnote` is the inline `[^label]` reference;
213
517
  `CarveFootnoteDefinition` is the matching body block, serialized as
214
518
  `[^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;