@markup-carve/carve-grammars 0.1.9 → 0.1.11

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
@@ -1,26 +1,20 @@
1
1
  # Carve Grammars
2
2
 
3
- Grammars for the [Carve](https://github.com/markup-carve/carve) markup language:
3
+ Editor integration and syntax-highlighting grammars for the
4
+ [Carve](https://github.com/markup-carve/carve) markup language.
4
5
 
5
- - a **Tiptap** integration (editor kit, Carve loader and serializer) that converts between Carve markup and Tiptap/ProseMirror JSON;
6
- - **Prism** and **highlight.js** syntax-highlighting grammars for rendering Carve source on the web;
7
- - a **TextMate** grammar (`textmate/carve.tmLanguage.json`) for TextMate-based highlighters such as Shiki (used by VitePress).
6
+ The package contains:
8
7
 
9
- Modeled on [djot-grammars](https://github.com/php-collective/djot-grammars), adapted to Carve's syntax. The Tiptap mark mapping mirrors `carve-php`'s `HtmlToCarve` converter; the highlighting grammars mirror the canonical token set in [`carve/resources/grammar.ebnf`](https://github.com/markup-carve/carve).
8
+ - a Tiptap kit, Carve loader, and serializer for Carve and ProseMirror JSON;
9
+ - Prism and highlight.js grammars for browser highlighting;
10
+ - a TextMate grammar for Shiki, VitePress, and other TextMate consumers.
10
11
 
11
- The TextMate grammar here is a **separate lineage** from the one in
12
- [vscode-carve](https://github.com/markup-carve/vscode-carve), not a copy of it. The two
13
- agree on the constructs they cover and differ in how they name scopes - this one carries
14
- 116 scope names against vscode-carve's 151, with `entity.name.tag.*` where that one uses
15
- `entity.name.type.*`, and so on. Neither is derived from the other, and a scope name
16
- present here is not a promise that the editor grammars use the same one.
17
-
18
- > **Status:** Tiptap integration, plus Prism, highlight.js and TextMate grammars.
19
- > Sibling editor grammars live in their own repos: editor-bundled **TextMate** copies in
20
- > [vscode-carve](https://github.com/markup-carve/vscode-carve) and
21
- > [intellij-carve](https://github.com/markup-carve/intellij-carve);
22
- > **Tree-sitter** in [tree-sitter-carve](https://github.com/markup-carve/tree-sitter-carve)
23
- > and [zed-carve](https://github.com/markup-carve/zed-carve).
12
+ Tree-sitter and editor-bundled grammars live in
13
+ [tree-sitter-carve](https://github.com/markup-carve/tree-sitter-carve),
14
+ [vscode-carve](https://github.com/markup-carve/vscode-carve), and the other
15
+ editor repositories. This package's TextMate grammar has a separate lineage
16
+ from the VS Code grammar. The [complete reference](docs/reference.md) explains
17
+ their scope and naming differences.
24
18
 
25
19
  ## Install
26
20
 
@@ -28,34 +22,26 @@ present here is not a promise that the editor grammars use the same one.
28
22
  npm install @markup-carve/carve-grammars
29
23
  ```
30
24
 
31
- All peer dependencies are optional - install only what you use: the Tiptap
32
- packages below (v2 or v3) for the editor, `prismjs` (v1) for Prism,
33
- `highlight.js` (v11) for highlight.js. CI runs the suite against both Tiptap
34
- majors.
35
-
36
- `@markup-carve/carve-grammars/tiptap` loads `CarveKit`, which imports several
37
- standalone Tiptap extensions, so the editor entry needs all of these installed.
38
- Disabling one through `CarveKit.configure()` does not remove its import:
39
-
40
- ```bash
41
- npm install @tiptap/core @tiptap/pm @tiptap/starter-kit \
42
- @tiptap/extension-{bullet-list,code,code-block,hard-break,heading,highlight,image,link,list-item,ordered-list,subscript,superscript,table,table-cell,table-header,table-row,task-item,task-list,underline}
43
- ```
44
-
45
- On Tiptap 3, `CarveKit` disables StarterKit's bundled Underline and Link, since
46
- it registers its own (underline carries Carve's `_text_` mapping). Pass
47
- `starterKit: { underline: true }` to opt back in, at the cost of a duplicate
48
- mark name.
25
+ Peer dependencies are optional. Install only those needed by the selected
26
+ entry point. For Tiptap, install `@tiptap/core`, `@tiptap/pm`,
27
+ `@tiptap/starter-kit`, and the extensions used by `CarveKit`. The package is
28
+ tested with Tiptap 2 and 3. The complete peer-dependency command is in the
29
+ [installation reference](docs/reference.md#install).
49
30
 
50
- ## Usage
31
+ ## Tiptap
51
32
 
52
33
  ```js
53
34
  import { Editor } from '@tiptap/core'
54
- import { CarveKit, serializeToCarve } from '@markup-carve/carve-grammars/tiptap'
35
+ import {
36
+ CarveKit,
37
+ carveToProseMirror,
38
+ serializeToCarve,
39
+ } from '@markup-carve/carve-grammars/tiptap'
55
40
 
56
41
  const editor = new Editor({
57
42
  element: document.getElementById('editor'),
58
43
  extensions: [CarveKit],
44
+ content: carveToProseMirror(source, { unsupported: 'preserve' }),
59
45
  onUpdate: ({ editor }) => {
60
46
  const carve = serializeToCarve(editor.getJSON())
61
47
  console.log(carve)
@@ -63,725 +49,82 @@ const editor = new Editor({
63
49
  })
64
50
  ```
65
51
 
66
- ### Individual extensions
67
-
68
- ```js
69
- import StarterKit from '@tiptap/starter-kit'
70
- import { CarveInsert, CarveDelete, CarveDiv, serializeToCarve } from '@markup-carve/carve-grammars/tiptap'
71
-
72
- const editor = new Editor({
73
- extensions: [StarterKit, CarveInsert, CarveDelete, CarveDiv],
74
- })
75
- ```
76
-
77
- ## Mark mapping
78
-
79
- | Tiptap mark | Carve token | Renders as |
80
- |-------------|-------------|------------|
81
- | bold | `*text*` / `{*text*}` | `<strong>` |
82
- | italic | `/text/` / `{/text/}` | `<em>` |
83
- | underline | `_text_` / `{_text_}` | `<u>` |
84
- | code | `` `text` `` | `<code>` |
85
- | highlight | `=text=` / `{=text=}` | `<mark>` |
86
- | strike | `~text~` / `{~text~}` | `<s>` |
87
- | subscript | `{,text,}` (braced only) | `<sub>` |
88
- | superscript | `{^text^}` (braced only) | `<sup>` |
89
- | insert | `{+text+}` | `<ins>` |
90
- | delete | `{-text-}` | `<del>` |
91
- | link | `[text](url)` / `[text](url "title")` | `<a>` |
92
- | image | `![alt](src)` / `![alt](src "title")` | `<img>` |
93
- | span | `[text]{.class}` | `<span class>` |
94
- | abbreviation | `[text]{abbr="..."}` | `<abbr title>` \*\*\* |
95
-
96
- \*\*\* `[text]{abbr="..."}` renders a real `<abbr title>` only when carve's
97
- `SemanticSpanExtension` is enabled (the same opt-in extension also maps `{kbd}`
98
- -> `<kbd>`, `{dfn}` -> `<dfn>`, `{samp}` -> `<samp>`, `{var}` -> `<var>`).
99
- Without it, the attribute stays literal: `<span abbr="...">`. The mark's
100
- `parseHTML` reads back the `<abbr title>` form.
101
-
102
- The tokens target carve-php's **parser** (the contract: serialized Carve must parse
103
- back to the same elements). Carve's inline syntax differs from Djot's:
104
- emphasis is `/text/` (Djot uses `_`), `_text_` is underline, `~text~` is
105
- strikethrough, highlight is `=text=`, and subscript/superscript are the
106
- braced `{,text,}` / `{^text^}` only (a bare `,` or `^` is literal text since
107
- carve #259).
108
-
109
- Each single-char delimiter has two equivalent forms: a **bare** form
110
- (`=text=`) and a **forced brace** form (`{=text=}`) that also works intraword;
111
- both parse to the same element. The two columns above list bare / forced.
112
- `serializeToCarve` emits the bare form for `* / _ ~`, or the forced form where a
113
- letter or digit touches the delimiter, and the forced `{…}` form
114
- for `= , ^` (round-trip-safe: those delimiters are likelier to be inert bare);
115
- `{+…+}` / `{-…-}` (insert / delete) have only the brace form, since `+` / `-`
116
- are not emphasis delimiters.
52
+ `unsupported: 'throw'` is the default. Preservation mode retains authored
53
+ source details that ProseMirror cannot model directly and merges edits back
54
+ into the original Carve source.
117
55
 
118
- ### Escaping
119
-
120
- To honor that round-trip contract, `serializeToCarve` escapes literal Carve
121
- syntax in plain text so it parses back as text rather than markup - inline code,
122
- links, footnotes, CriticMarkup, mentions/tags/emoji, and an emphasis delimiter
123
- appearing inside its own span. Escaping is **contextual**: Carve's flanking rules
124
- already make most lone delimiters inert (`price * 2`, intraword `x_1`,
125
- `comma,, two`, `C:\path`, `a@b.com`), so those stay clean. The same logic is
126
- exposed as `escapeCarve(text)`.
127
-
128
- ## Block elements
129
-
130
- Headings (`#`), bullet / ordered / task lists, blockquotes (`>`), fenced code
131
- blocks (`` ``` lang ``), horizontal rules (`---`), tables (with `|=` header
132
- cells and `^` / `<` row / column spans), container divs (`::: class`), and
133
- definition lists.
134
-
135
- ## Loading Carve Into Tiptap
136
-
137
- Use the AST loader when opening Carve source in an editor. It parses Carve with
138
- `@markup-carve/carve` and builds the ProseMirror JSON shape consumed by
139
- `CarveKit`, avoiding the lossy HTML pivot where attributes disappear unless a
140
- Tiptap extension happens to claim them during `parseHTML`.
56
+ Individual extensions are also exported for hosts that do not want
57
+ `CarveKit`:
141
58
 
142
59
  ```js
60
+ import StarterKit from '@tiptap/starter-kit'
143
61
  import {
144
- CarveKit,
145
- carveToProseMirror,
62
+ CarveDelete,
63
+ CarveDiv,
64
+ CarveInsert,
146
65
  serializeToCarve,
147
66
  } from '@markup-carve/carve-grammars/tiptap'
148
67
 
149
- const content = carveToProseMirror(source, { unsupported: 'preserve' })
150
-
151
68
  const editor = new Editor({
152
- extensions: [CarveKit],
153
- content,
154
- })
155
-
156
- const saved = serializeToCarve(editor.getJSON())
157
- ```
158
-
159
- Entry points:
160
-
161
- - `carveToProseMirror(source, options?)` parses Carve source and returns a
162
- ProseMirror `doc`.
163
- - `astToProseMirror(ast, options?)` converts an already parsed Carve
164
- `document` AST.
165
-
166
- Unsupported handling:
167
-
168
- - `unsupported: 'throw'` is the default. The loader throws `UnsupportedNodeError`
169
- instead of silently dropping content.
170
- - `unsupported: 'preserve'` builds the richest available document and verifies
171
- its canonical serialization against the parsed AST. When authored columns,
172
- delimiter choices, blank ownership, or other source layout cannot be held in
173
- ProseMirror attributes, the document carries both the authored source and its
174
- canonical projection. `serializeToCarve` performs a three-way merge after an
175
- edit, preserving untouched authored layout while giving changed content the
176
- canonical Carve spelling.
177
-
178
- All 1,538 documents and 440 categories in the pinned corpus are load/save
179
- lossless in preservation mode, with no whole-document fallback. Abbreviation
180
- definitions and uses, figures and captions, advanced tables, comments, raw
181
- passthrough, references, and footnotes all have structured editor mappings.
182
- `carveToProseMirrorWithReport()` identifies any future construct that still has
183
- to use a local opaque atom; `tiptap/schema-map.json` is the public mapping
184
- authority.
185
-
186
- ## Tab sets and code groups in the editor
187
-
188
- A tab set and a code group are the same thing to a reader - a strip of labels,
189
- one panel visible. In the editor they were not. A `:::: tabs` container had a
190
- bar that could only switch panels; a `:::: code-group` had no bar at all, so it
191
- rendered as a plain vertical stack of code blocks with its `[one.js]` labels
192
- invisible and no way to tell it apart from two adjacent code blocks. Neither
193
- could be edited as a widget: adding, removing, renaming or reordering a panel
194
- meant leaving the visual editor and editing source.
195
-
196
- Both now render an interactive bar:
197
-
198
- | Action | How |
199
- | --- | --- |
200
- | switch panel | click a label |
201
- | rename | double-click a label, then Enter (Escape abandons) |
202
- | add | `+` |
203
- | remove | `×` - refuses on the last panel |
204
- | reorder | `‹` / `›` |
205
-
206
- **Switching dispatches nothing.** It sets `data-active` on the wrapper and the
207
- stylesheet does the rest, so moving between tabs never marks the document dirty
208
- or reaches the serializer. The other four change the document and are undoable
209
- like any other edit.
210
-
211
- **The stylesheet is required, not decoration.** Because switching is only an
212
- attribute, without these rules every panel is visible at once and clicking a
213
- label appears to do nothing:
214
-
215
- ```js
216
- import '@markup-carve/carve-grammars/tiptap/editor.css'
217
- ```
218
-
219
- It reads carve-css custom properties when they are present and falls back to
220
- literals otherwise, so it composes with that package without depending on it.
221
-
222
- Two things worth knowing:
223
-
224
- - **A code group is still a plain `carveDiv`.** It arrives as a div with
225
- `class: "code-group"` whose children carry their own `carveLabel`, and giving
226
- it dedicated node types to mirror the tab-set shape would change what the
227
- serializer sees for a change that is entirely about presentation. So the bar
228
- attaches to the existing node: the document shape, the serializer and the
229
- round trip are untouched. The cost is that the nodeView is called for every
230
- div, so every other kind - admonitions, figures, plain containers - is handed
231
- straight back to the schema's own `toDOM`.
232
- - **A code group mounted from HTML shows languages, not labels.** carve-js's
233
- HTML for a code group emits bare `<pre>` children and drops the per-block
234
- `[label]`, so `one.js` is not recoverable from that seed and the bar falls
235
- back to `js`. Mounted from the AST (`carveToProseMirror`) the labels survive
236
- and are shown. Renaming writes `carveLabel` and never touches the language, so
237
- changing a tab's caption cannot silently restyle the code.
238
-
239
- Disable the code-group bar with `CarveKit.configure({ carveCodeGroup: false })`,
240
- or keep it read-only with
241
- `CarveKit.configure({ carveCodeGroup: { editable: false } })`.
242
-
243
- ## Framework-independent editor element
244
-
245
- Applications that do not otherwise use Tiptap can mount the same lossless
246
- bridge through a Web Component:
247
-
248
- ```js
249
- import { defineCarveEditor } from '@markup-carve/carve-grammars/editor'
250
-
251
- defineCarveEditor()
252
- const editor = document.querySelector('carve-editor')
253
- editor.value = '# Hello'
254
- editor.addEventListener('input', event => save(event.detail.value))
255
- ```
256
-
257
- ```html
258
- <carve-editor></carve-editor>
259
- ```
260
-
261
- The element exposes a string `value`, emits bubbling and composed `input`
262
- events, and uses `unsupported: 'preserve'` internally. Its editable surface is
263
- available as the `editor` CSS part (`carve-editor::part(editor)`). Tiptap stays
264
- an implementation detail of the element, although its peer packages must be
265
- installed with `carve-grammars`.
266
-
267
- Document front matter appears as a collapsed **Document metadata** card. Expand
268
- it to edit the common `title`, `lang`, `author`, and `description` fields, or use
269
- the raw YAML/TOML field for custom metadata. Both paths update the document
270
- through the editor, so they participate in undo/redo and preserve unknown keys.
271
-
272
- ### Quick fields
273
-
274
- The four quick fields are a default, not the contract. A product whose front
275
- matter means something else describes its own:
276
-
277
- ```js
278
- CarveKit.configure({
279
- carveFrontmatter: {
280
- fields: [
281
- { key: 'title', label: 'Note title', placeholder: 'Optional title', inputAttributes: { required: true, maxlength: 120 } },
282
- { key: 'summary', label: 'Summary', multiline: true },
283
- ],
284
- },
69
+ extensions: [StarterKit, CarveInsert, CarveDelete, CarveDiv],
285
70
  })
286
71
  ```
287
72
 
288
- A descriptor needs a `key` - the front matter key it reads and writes. `label`
289
- defaults to the capitalized key, `placeholder` is optional, and `multiline`
290
- picks a `textarea` instead of an `input`. The list replaces the default one
291
- entirely, so an empty array renders the raw front matter editor with no quick
292
- fields at all:
293
-
294
- ```js
295
- CarveKit.configure({ carveFrontmatter: { fields: [] } })
296
- ```
297
-
298
- A key may appear only once; a second descriptor for it is refused, because the
299
- controls are held by key and the earlier one would render blank and write
300
- nowhere.
301
-
302
- `inputAttributes` carries native validation and input hints: `required`,
303
- `autocomplete`, `inputmode`, `minlength`, `maxlength`, `pattern` and
304
- `aria-describedby`. These are enforced: a change that fails the control's own
305
- `checkValidity()` is reported to the author and never reaches the document.
306
- Anything else is **refused with a `TypeError`** rather than ignored - the node view owns `name`, `type`, `value`, `disabled` and `readonly`,
307
- and an event handler set here would bypass the format-aware update path. A
308
- constraint that is silently dropped reads like a working one that never fires,
309
- which is why the rejection is loud.
310
-
311
- The collapsed summary still reads `title` and `lang` out of the document
312
- whatever the field list says, and writes still go through an editor transaction,
313
- so undo/redo and unknown-key preservation are unchanged.
314
- Unsupported-source atoms use the same compact/editable pattern for their exact
315
- Carve payload. Inline footnotes, cross-references, and citations open target
316
- pickers, while abbreviation and link-reference definitions use collapsible
317
- forms. Import `@markup-carve/carve-grammars/tiptap/editor.css` when mounting
318
- `CarveKit` directly to receive the associated editor chrome and table styling.
319
-
320
73
  ## Syntax highlighting
321
74
 
322
- Render Carve source as highlighted HTML on the web. Both grammars cover the full
323
- Carve token set: headings, lists, tables, blockquotes, fenced/raw blocks,
324
- container divs, front matter and comments, plus inline emphasis
325
- (`*bold*` `/italic/` `_underline_` `~strike~` `=highlight=`, braced
326
- `{^sup^}` `{,sub,}`),
327
- code, links, images, spans, attributes, footnotes, math (`` $`x` ``),
328
- CriticMarkup (`{+ins+}` `{-del-}`), mentions, tags and emoji.
329
-
330
- ### Where the three grammars deliberately differ
331
-
332
- The TextMate grammar is stricter than the Prism and highlight.js grammars about
333
- **indented block openers at document level**, and that difference is a decision
334
- rather than drift.
335
-
336
- Carve opens a block at column 0, or at an enclosing container's content column -
337
- nowhere in between. So at document level these are all ordinary paragraphs:
338
-
339
- ````
340
- # H
341
- > q
342
- *[HTML]: HyperText
343
- ```js
344
- x
345
- ```
346
- ````
347
-
348
- while the same four openers at a list item's content column are real blocks:
349
-
350
- ````
351
- - item
352
-
353
- # H
354
-
355
- > quoted
356
-
357
- ```js
358
- x
359
- ```
360
- ````
361
-
362
- Telling those two apart needs block context. Only the TextMate grammar has it:
363
- its list-item rules track the item's actual content column, so a document-level
364
- rule can be anchored at column 0 while an `_in_container` twin stays permissive
365
- and is reachable only from inside a container. Its `heading`, `fenced_code`,
366
- `blockquote` and `abbreviation` rules are therefore anchored at column 0, and
367
- `heading_in_container`, `fenced_code_in_container`, `blockquote_in_container`
368
- and `abbreviation_in_container` carry the indented forms.
369
-
370
- Prism and highlight.js are line-based and have no container model, so they
371
- cannot make that distinction. Anchoring their block rules at column 0 would not
372
- buy accuracy - it would stop highlighting **every** legitimately indented
373
- construct inside a list item or a block quote, which is a common valid shape,
374
- in exchange for correcting a rare invalid one. So both keep their `^[ \t]*`
375
- anchors and knowingly over-colour the indented-at-document-level case.
376
-
377
- The practical consequence: a document that indents a heading, fence, blockquote
378
- or abbreviation definition by one or two columns at top level is highlighted by
379
- Prism and highlight.js and left as plain text by the TextMate grammar (Shiki,
380
- VS Code). The TextMate answer is the one that agrees with the engines.
381
-
382
- `tests/lib/constructs.js` is the shared construct inventory all three sweeps
383
- read, and the same asymmetry is written down there as `skip` entries on the
384
- column-sensitive cases; the TextMate-only column cases live in the `NEGATIVE`
385
- list in `tests/textmate-sweep-test.js`.
386
-
387
- ### One rule, three spellings: a leading byte order mark
388
-
389
- A byte order mark at the **start of a document** is not content. The spec says
390
- so ("Line endings and a byte order mark"), and carve-js, carve-rs and carve-php
391
- all strip it before the block scanner runs. It is neither a space nor a tab, so
392
- without an explicit allowance it sits between the line start and the marker and
393
- defeats every line-anchored opener - a mark in front of a heading left the title
394
- unscoped, and a mark in front of a fence handed the line to the inline code rule
395
- instead.
396
-
397
- All three grammars now allow it, and the restriction to the document's start is
398
- load-bearing rather than pedantry. A mark anywhere else is an ordinary
399
- zero-width character that opens nothing:
400
-
401
- ```
402
- # T
403
-
404
- <a byte order mark here>- item
405
- ```
406
-
407
- renders as a paragraph holding literal text in carve-rs and in carve-php, and as
408
- a list only in carve-js, whose own `\s` class is Unicode White_Space plus U+FEFF
409
- (markup-carve/carve#806). Every rule here anchors with `^` under a multiline
410
- flag, which matches at *every* line start, so the allowance has to carry its own
411
- document-start assertion - and the three grammars do not share one:
412
-
413
- | grammar | spelling | mechanism |
414
- | --- | --- | --- |
415
- | prism | `(?:(?<![\s\S])\uFEFF)?` | JavaScript lookbehind: nothing precedes offset 0 |
416
- | highlightjs | `(?:(?<![\s\S])\uFEFF)?` | the same, and it survives highlight.js compilation |
417
- | textmate | `(?:\A\x{FEFF})?` | Oniguruma `\A`, which vscode-textmate resolves against the first line only |
418
-
419
- The codepoint is always written as an escape. No file in this repo holds a
420
- literal byte order mark: it is invisible, and an editor or a normalizing filter
421
- can drop the one character a rule is about. The spec corpus is the exception and
422
- can afford to be - it marks `tests/corpus/**` as `-text`, so
423
- `250-line-endings-and-a-byte-order-mark-3.crv` really does begin `ef bb bf`.
424
-
425
- ### Link destinations inside a bare run
426
-
427
- A delimiter inside a link destination, its title or an autolink does not close
428
- the bare run around it, so `/see [x](http://a.b/c) now/` stays one italic run.
429
- The three grammars follow the spec's productions for what counts: a `](` needs a
430
- complete label before it (`link_text`), a destination and a title take only
431
- their own escapes, a title follows exactly one space (`link_title`), a URL
432
- autolink holds only `url_char`, and an email autolink needs a dotted domain
433
- ending in letters (`email_autolink`). Anything else closes the run where the
434
- spec closes it.
435
-
436
- Known limits, shared by all three unless noted:
437
-
438
- - A label nested more than four brackets deep, or a destination holding three
439
- levels of parentheses, is not recognized. A regex cannot count.
440
- - Prism and highlight.js compile without the `u` flag, so an email autolink
441
- accepts any non-ASCII character other than whitespace where the spec asks for
442
- a letter. The TextMate grammar uses `\p{L}`.
443
-
444
- ### Substitutions
445
-
446
- A substitution splits at its first arrow outside code, math, a literal, a
447
- comment or an escape, and each half holds inline content. With no such arrow,
448
- `{~ ~}` is a forced strikethrough.
449
-
450
- Known limit: in highlight.js and TextMate, a link or autolink that spans the
451
- arrow stays whole in the deleted half. `{~[x](u~>v)~>c~}` splits after `u`.
452
-
453
- ### Fence words
454
-
455
- All three surfaces answer `carve` and `crv`. `.crv` is the canonical file
456
- extension, so a ` ```crv ` fence highlights wherever a ` ```carve ` one does,
457
- whichever highlighter a site runs.
458
-
459
- | Surface | Answers | Extra |
460
- |---|---|---|
461
- | Prism | `carve`, `crv` | `carvemd`, the embedded form |
462
- | highlight.js | `carve`, `crv` | any casing: `getLanguage` lowercases its argument |
463
- | Shiki | `carve`, `crv` | `Carve`, because Shiki matches a name by exact string |
464
-
465
- The extras differ because the lookups do. Shiki is the only surface where a
466
- capitalized spelling is a distinct alias worth listing; Prism keys must be
467
- lowercase, since `Prism.util.getLanguage` lowercases the `language-xxx` class
468
- before resolving it. `tests/lib/aliases.js` holds the required set and
469
- `tests/alias-parity-test.js` asserts it on each surface through that surface's
470
- own registration API.
471
-
472
- ### Attribute blocks
473
-
474
- Known limit, shared by all three: a block glued to a bare delimiter is read as
475
- attached even where the delimiter closes no run. In `` x*{title="`"} y `` the
476
- braces are text, so the backtick opens a code span, but all three scope an
477
- attribute block.
478
-
479
- ### TextMate limits
480
-
481
- A TextMate rule sees one line at a time, so a few shapes color differently from
482
- how Carve reads them. vscode-carve declares the same three.
483
-
484
- - A bare bold run can cross a soft line break, so its opener can't check for a
485
- closer first. When the only closer-shaped `*` sits inside a code span, or
486
- there is none, the run colors to the end of its paragraph:
487
- `` x *a `b* c` d `` renders as text but shows as bold.
488
- - A braced span whose closer is on a later line is not scoped as a span.
489
- - A bare italic, underline, strikethrough or highlight run is scoped only when
490
- its closer is on the same line.
491
-
492
- ### Prism and highlight.js limits
493
-
494
- - Prism scopes a bare run only when its closer is on the same line.
495
- - highlight.js compiles without the `u` flag, so it reads any non-ASCII
496
- character after a bare closer as a letter. `/a/« b` renders `a` in italics
497
- but shows as text.
498
-
499
- ### Prism
500
-
501
- The grammar registers itself against the global `Prism`, so `Prism` must be
502
- global before the grammar module runs. Because static `import` statements are
503
- hoisted (they all evaluate before any top-level assignment), load the grammar
504
- with a dynamic `import` after assigning `globalThis.Prism`:
505
-
506
75
  ```js
507
76
  import Prism from 'prismjs'
508
77
 
509
- globalThis.Prism = Prism // grammar reads the global Prism
510
- await import('@markup-carve/carve-grammars/prism/carve.js') // registers Prism.languages.carve
511
-
512
- const html = Prism.highlight(source, Prism.languages.carve, 'carve')
78
+ globalThis.Prism = Prism
79
+ await import('@markup-carve/carve-grammars/prism/carve.js')
513
80
  ```
514
81
 
515
- In the browser, load `prismjs` first (it sets the global `Prism`), then load
516
- `@markup-carve/carve-grammars/prism/carve.js`.
517
-
518
- ### highlight.js
519
-
520
82
  ```js
521
- import hljs from 'highlight.js'
83
+ import hljs from 'highlight.js/lib/core'
522
84
  import carve from '@markup-carve/carve-grammars/highlightjs/carve.js'
523
85
 
524
86
  hljs.registerLanguage('carve', carve)
525
- const { value } = hljs.highlight(source, { language: 'carve' })
526
- ```
527
-
528
- Loaded as a classic `<script>` after highlight.js, it self-registers against
529
- the global `hljs`:
530
-
531
- ```html
532
- <script src="highlight.min.js"></script>
533
- <script src="node_modules/@markup-carve/carve-grammars/highlightjs/carve.js"></script>
534
- <script>hljs.highlightAll();</script>
535
- ```
536
-
537
- ### Shiki / VitePress
538
-
539
- `@markup-carve/carve-grammars/shiki` is the shared kit every Carve docs site uses, so
540
- highlighting stays identical across them: the TextMate grammar, GitHub
541
- light/dark themes extended with Carve scope colors, and a transformer + CSS
542
- pair that bridges what Shiki's HTML emitter cannot express (strikethrough,
543
- sub/superscript positioning, highlight background).
544
-
545
- ```ts
546
- // .vitepress/config.ts
547
- import { defineConfig } from 'vitepress'
548
- import { carveMarkdown } from '@markup-carve/carve-grammars/shiki'
549
-
550
- export default defineConfig({
551
- markdown: {
552
- ...carveMarkdown(),
553
- // carveMarkdown({ light, dark, languages }) to override base themes
554
- // or register extra grammars
555
- },
556
- })
557
- ```
558
-
559
- ```ts
560
- // .vitepress/theme/index.ts
561
- import '@markup-carve/carve-grammars/shiki/carve.css'
562
- ```
563
-
564
- Named exports for other setups: `carveGrammar`, `carveLightExtras` /
565
- `carveDarkExtras`, `carveLightTheme` / `carveDarkTheme`, `extendTheme`,
566
- `carveStylingTransformer`.
567
-
568
- #### Diff presentation with an underlying language
569
-
570
- Carve keeps a code block's presentation hooks separate from its language. The
571
- portable source convention for an instructional diff is a `.diff` block
572
- attribute above a language-tagged fence:
573
-
574
- ````carve
575
- {.diff}
576
- ```js
577
- - fileIcon.classList.add("icon-file-text");
578
- + fileIcon.classList.remove("icon-file-text");
579
- ```
580
- ````
581
-
582
- Core HTML preserves both channels as
583
- `<pre class="diff"><code class="language-js">`. A host can detect that shape
584
- and invoke the opt-in `diffCodeTransformer()` with the fence language:
585
-
586
- ```js
587
- import { diffCodeTransformer } from '@markup-carve/carve-grammars/shiki/diff'
588
- import '@markup-carve/carve-grammars/shiki/carve.css'
589
-
590
- const transformers = pre.classList.contains('diff')
591
- ? [diffCodeTransformer()]
592
- : []
593
-
594
- const highlighted = highlighter.codeToHtml(code.textContent, {
595
- lang: 'javascript',
596
- theme: 'github-light',
597
- transformers,
598
- })
599
- ```
600
-
601
- Create a fresh transformer for every code block. It treats `+`, `-`, and a
602
- space as structural first characters, removes that character while Shiki
603
- tokenizes the underlying language, then restores it and marks added/removed
604
- lines. This first version targets compact instructional changes, not complete
605
- patch files with `@@` hunks or `---` / `+++` file headers. It is deliberately
606
- not enabled by `carveMarkdown()`, because ordinary code blocks must not lose
607
- their first character.
608
-
609
- A host that highlights with **highlight.js, Prism, or nothing** - where Shiki's
610
- per-line token model is not available - uses the highlighter-agnostic helper
611
- instead. It takes a per-line highlight callback (or defaults to HTML-escaping)
612
- and produces the same `line` / `diff add` / `diff remove` / `diff-marker`
613
- classes:
614
-
615
- ```js
616
- import { applyLanguageDiff } from '@markup-carve/carve-grammars/diff'
617
- import '@markup-carve/carve-grammars/diff/carve-diff.css'
618
-
619
- document.querySelectorAll('pre.diff > code').forEach((code) => {
620
- const language = [...code.classList]
621
- .find((name) => name.startsWith('language-'))
622
- ?.slice('language-'.length)
623
- applyLanguageDiff(code, (body) => hljs.highlight(body, { language }).value)
624
- })
625
- ```
626
-
627
- `renderLanguageDiff(code, highlightLine)` returns the HTML string if the host
628
- manages the DOM itself (a webview building a document string, a server writing
629
- markup). The marker is stripped before `highlightLine` runs, so the underlying
630
- language tokenizes the line body without its `+`/`-`/space.
631
-
632
- ## Diagram rendering
633
-
634
- Carve's `FencedRenderExtension` presets emit a `<pre class="LANG">source</pre>`
635
- hydration element; something on the client turns it into a diagram. Mermaid,
636
- WaveDrom, Vega-Lite and Chart each render once **you** load their browser
637
- library. For the rest, `@markup-carve/carve-grammars/diagrams` ships renderers:
638
-
639
- | Type | Renderer | Engine | Network |
640
- |------|----------|--------|---------|
641
- | `graphviz` (`dot`) | `renderGraphvizDiagrams` | `@viz-js/viz` (WASM) | **offline** |
642
- | `d2` | `renderD2Diagrams` | `@terrastruct/d2` (WASM) | **offline** |
643
- | `plantuml` (`puml`) | `renderKrokiDiagrams` | a Kroki server | **network** |
644
-
645
- Graphviz and D2 render **entirely in the browser** - no server, no external
646
- call, works offline (in an IDE, behind a firewall, ...). The rendered SVG is
647
- placed in an inert `<img>` data URI (like the Kroki path), so even untrusted
648
- diagram source cannot run script or expose a `javascript:` link. The WASM
649
- libraries are optional peer dependencies, imported lazily only when a matching
650
- block is on the page:
651
-
652
- ```js
653
- import { renderGraphvizDiagrams } from '@markup-carve/carve-grammars/diagrams/graphviz'
654
- import { renderD2Diagrams } from '@markup-carve/carve-grammars/diagrams/d2'
655
-
656
- await renderGraphvizDiagrams(container)
657
- await renderD2Diagrams(container)
658
- ```
659
-
660
- `renderDiagrams` runs both (and PlantUML, when you opt in) in one call; each
661
- no-ops when its blocks are absent, so you pay nothing for the types not present:
662
-
663
- ```js
664
- import { renderDiagrams } from '@markup-carve/carve-grammars/diagrams'
665
-
666
- await renderDiagrams(container) // graphviz + d2, offline
667
- await renderDiagrams(container, { kroki: {} }) // + PlantUML via kroki.io
668
- await renderDiagrams(container, { kroki: { server: 'https://kroki.internal' } })
669
87
  ```
670
88
 
671
- ### PlantUML (Kroki)
672
-
673
- PlantUML is the one preset with no practical in-browser renderer - its only
674
- pure-JS build is a multi-megabyte JVM-in-WASM. `renderKrokiDiagrams` renders it
675
- by POSTing the source to a [Kroki](https://kroki.io) server; the returned SVG
676
- rides in an `<img>` data URI (which cannot execute script). Idempotent, and
677
- dependency-free (plain-text POST, no deflate/base64).
678
-
679
- > ⚠️ **Privacy / GDPR.** The default server is the **public `https://kroki.io`**,
680
- > so the diagram source is sent to a **third party outside your domain**. For
681
- > anything sensitive, or to stay offline, point `server` at a **self-hosted or
682
- > localhost Kroki** so no data leaves your control - and disclose the external
683
- > call to end users where required. Because of this, `renderDiagrams` leaves the
684
- > Kroki step **off unless you pass `kroki`**.
89
+ For Prism or highlight.js, load the optional table token colors after the
90
+ highlighter's theme stylesheet:
685
91
 
686
92
  ```js
687
- import { renderKrokiDiagrams } from '@markup-carve/carve-grammars/diagrams/kroki'
688
-
689
- await renderKrokiDiagrams(container, { server: 'https://kroki.internal' })
93
+ import '@markup-carve/carve-grammars/shiki/table-tokens.css'
690
94
  ```
691
95
 
692
- Options: `server` (default `https://kroki.io`), `types` (class → Kroki-type map,
693
- default `KROKI_DIAGRAM_TYPES` = `plantuml`/`puml` only; extend it to Kroki-render
694
- graphviz/d2 against a self-hosted server), `onError`, `fetch`.
695
-
696
- > When the diagram is rendered at build time (SSG) rather than in the browser,
697
- > prefer the engine's static-render hook (carve-js `renderers.plantuml`,
698
- > carve-php's own render pipeline) so the page ships finished SVG and needs no
699
- > client JS at all.
700
-
701
- ## API
702
-
703
- - `renderDiagrams(container, options?)` - render Graphviz + D2 (offline), and
704
- PlantUML via Kroki when `options.kroki` is set. See
705
- [Diagram rendering](#diagram-rendering).
706
- - `renderGraphvizDiagrams(container, options?)` / `renderD2Diagrams(container, options?)` -
707
- render `graphviz`/`d2` blocks with the offline WASM engines.
708
- - `renderKrokiDiagrams(container, options?)` - render PlantUML (and any opted-in
709
- type) via a Kroki server; `KROKI_DIAGRAM_TYPES` is the default class→type map.
710
- - `carveToProseMirror(source, options?)` - parse Carve source and convert it to
711
- ProseMirror JSON. `options.unsupported` is `'throw'` by default or
712
- `'preserve'` for opaque source-preserving blocks.
713
- - `astToProseMirror(ast, options?)` - convert an existing `@markup-carve/carve`
714
- AST to ProseMirror JSON.
715
- - `serializeToCarve(doc)` - serialize an `editor.getJSON()` document to Carve markup.
716
- - `serializeToCarveWithReport(doc)` - the same, returning `{ source, dropped, degraded }`. `dropped` names what the source could not carry (a mention's display `label` that differs from its `id`), `degraded` a node written as literal text (a mention name with no Carve spelling, such as `Lea Thompson`, becomes `\@Lea Thompson`). A stock Tiptap `mention` node is written like `carveMention`: `id` is the name, a `null` label counts as absent, and `mentionSuggestionChar` is never written.
717
- - `escapeCarve(text)` - contextually escape literal Carve syntax in a plain-text run so it round-trips as text (used internally by `serializeToCarve`).
718
- - `CarveKit` - the bundled Tiptap extension set.
719
- - Individual extensions: `CarveInsert`, `CarveDelete`, `CarveCriticComment`, `CarveDiv`, `CarveSpan`, `CarveFootnote`, `CarveFootnoteDefinition`, `CarveMath`, `CarveEmbed`, `CarveAbbreviation`, `CarveDefinitionList`, `CarveUnsupported`.
720
-
721
- ## Schema map (for other engines)
722
-
723
- `tiptap/schema-map.json` publishes the Carve-to-ProseMirror vocabulary as data, so
724
- an engine building a bridge in another language reads it instead of restating it:
725
-
726
- ```js
727
- import map from '@markup-carve/carve-grammars/tiptap/schema-map.json'
728
-
729
- map.types.strong // { kind: 'mark', pm: 'bold' }
730
- map.types.list // { kind: 'node', pm: ['bulletList', 'orderedList', 'taskList'], ... }
731
- map.unmapped.figure // 'figure / caption blocks are not modeled'
732
- ```
733
-
734
- Every Carve node type appears exactly once, either in `types` with its
735
- ProseMirror name(s) or in `unmapped` with the reason it has none - the negative
736
- space is part of the contract, because a bridge that silently drops table
737
- alignment or figure captions is worse than one that says it cannot carry them.
738
-
739
- `tests/schema-map-test.js` keeps it honest: every ProseMirror name must exist in
740
- the `CarveKit` schema with the declared node/mark kind, and every type in the
741
- pinned spec vocabulary must have a decision. Types the map covers ahead of the
742
- `spec/` pin are declared explicitly and must be removed once the pin catches up.
96
+ Plain table pipes use a muted border color; header and span markers use a
97
+ stronger operator color. Shiki's included light and dark themes carry the same
98
+ palette without this stylesheet.
99
+ For a dark Prism or highlight.js theme, set `.dark` or
100
+ `data-theme="dark"` on an ancestor.
743
101
 
744
- Two sections are keyed by ProseMirror name rather than by Carve type, because
745
- neither names a Carve construct: `preservationNodes` (`carveUnsupported` and
746
- `carveUnsupportedInline`, the atoms holding a construct's exact source) and
747
- `markCarrierNodes` (`carveEmptyMark`, the atom a mark with no content rides on).
748
- Both are part of the wire - an unknown ProseMirror name is an error rather than a
749
- skip - so a bridge has to read them alongside `types`.
102
+ TextMate consumers can load `textmate/carve.tmLanguage.json`. Shiki and
103
+ VitePress users can call `carveMarkdown()` from
104
+ `@markup-carve/carve-grammars/shiki`; it registers both `carve` and `crv`.
750
105
 
751
- Restating this mapping per engine is what the spec's own node-vocabulary test was
752
- written to prevent: carve-php once emitted `citation-group` while every other
753
- implementation spelled it with underscores.
106
+ The implementations deliberately differ where their host engines have
107
+ different capabilities. The [syntax-highlighting reference](docs/reference.md#syntax-highlighting)
108
+ covers substitutions, attributes, fence words, byte order marks, and engine
109
+ limits.
754
110
 
755
- ## Attributes, math and footnotes
111
+ ## Diagrams
756
112
 
757
- - **Attributes** - spans, headings and images serialize an `id` and `class`
758
- (and any extra non-structural attrs) as a `{#id .class key="val"}` block, e.g.
759
- `[text]{#me .note}`, `![alt](src){.wide}`. Inline attrs trail their target;
760
- block attrs (headings) sit on the **preceding** line (strict djot), e.g.
761
- `{#slug}` then `# Title`.
762
- - **Attribute order** - a run is written back in the order it was AUTHORED, not
763
- in a canonical one. ProseMirror attributes are an unordered map, so the order
764
- travels as its own attribute, `carveAttrOrder`: the AST's `order` field
765
- verbatim, an array whose entries are `#id`, `.class` and each key by name.
113
+ The `/diagrams` entry point renders Graphviz and D2 offline. PlantUML rendering
114
+ is available through an opt-in Kroki renderer, which sends diagram source to
115
+ the configured Kroki service. Mermaid, WaveDrom, Vega-Lite, and Chart blocks
116
+ use libraries loaded by the host page. Configuration and security details are
117
+ in the [diagram reference](docs/reference.md#diagram-rendering).
766
118
 
767
- ```js
768
- carveToProseMirror('[x]{key=c .a #b}')
769
- // the carveSpan mark carries: { id: 'b', class: 'a',
770
- // carveKeyValues: { key: 'c' }, carveAttrOrder: ['key', '.class', '#id'] }
771
- ```
119
+ ## Reference
772
120
 
773
- All of a node's classes stay contiguous at the position of the first one,
774
- which is what the AST records. A document with no `carveAttrOrder` - anything
775
- an editor builds from scratch - writes the canonical `#id .class key="val"`
776
- order, and a slot the order names but the document no longer has is skipped.
777
- - **Math** - `CarveMath` (inline atom) serializes to `` $`x` `` and, with
778
- `display: true`, `` $$`x` ``. Math has no closing `$` sentinel (grammar.ebnf
779
- PART 9 §18): the `$` / `$$` prefix opens a verbatim span and the backtick run
780
- ends it, which is what keeps currency like `$5` literal.
781
- - **Footnotes** - `CarveFootnote` is the inline `[^label]` reference;
782
- `CarveFootnoteDefinition` is the matching body block, serialized as
783
- `[^label]: body`.
121
+ The [complete reference](docs/reference.md) covers the Carve-to-Tiptap mapping,
122
+ loading and preservation behavior, tab sets, code groups, and the
123
+ framework-independent `<carve-editor>` element. It also documents highlighting
124
+ compatibility, diagram configuration, API exports, and the schema map.
784
125
 
785
126
  ## Development
786
127
 
787
- Contributor setup, testing, and maintenance notes are in the [development guide](docs/development.md).
128
+ Contributor setup, tests, and maintenance commands are in the
129
+ [development guide](docs/development.md). The [September 30 audit](https://github.com/markup-carve/carve-grammars/blob/main/docs/spec-engine-audit-20260930.md)
130
+ records current editor behavior and remaining highlighting gaps.