@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 +265 -7
- package/diagrams/d2.js +55 -0
- package/diagrams/graphviz.js +59 -0
- package/diagrams/index.js +55 -0
- package/diagrams/kroki.js +147 -0
- package/diagrams/render-blocks.js +79 -0
- package/highlightjs/carve.js +460 -161
- package/package.json +52 -28
- package/prism/carve.js +434 -60
- package/textmate/carve.tmLanguage.json +1453 -928
- package/tiptap/carve-kit.js +179 -5
- package/tiptap/carve-to-pm.js +879 -0
- package/tiptap/extensions/carve-comment.js +33 -0
- package/tiptap/extensions/carve-critic-comment.js +51 -0
- package/tiptap/extensions/carve-div.js +3 -0
- package/tiptap/extensions/carve-figure.js +29 -0
- package/tiptap/extensions/carve-footnote-definition.js +4 -2
- package/tiptap/extensions/carve-footnote.js +6 -2
- package/tiptap/extensions/carve-heading.js +46 -0
- package/tiptap/extensions/carve-line-block.js +13 -0
- package/tiptap/extensions/carve-math.js +49 -2
- package/tiptap/extensions/carve-raw-block.js +17 -0
- package/tiptap/extensions/carve-source-preservation.js +24 -0
- package/tiptap/extensions/carve-span.js +19 -5
- package/tiptap/extensions/carve-unsupported-inline.js +33 -0
- package/tiptap/extensions/carve-unsupported.js +40 -0
- package/tiptap/extensions/index.js +9 -0
- package/tiptap/index.js +12 -0
- package/tiptap/schema-map.json +236 -0
- package/tiptap/serializer.js +717 -88
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
|
|
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)
|
|
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}`, `{.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
|
|
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;
|