@markup-carve/carve-grammars 0.1.3 → 0.1.5

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 (51) hide show
  1. package/LICENSE +1 -0
  2. package/README.md +309 -1
  3. package/highlightjs/carve.js +869 -57
  4. package/highlightjs/carve.mjs +2 -2
  5. package/package.json +28 -11
  6. package/prism/carve.js +740 -59
  7. package/shiki/index.js +13 -4
  8. package/textmate/carve.tmLanguage.json +369 -28
  9. package/tiptap/carve-editor.d.ts +33 -0
  10. package/tiptap/carve-editor.js +109 -0
  11. package/tiptap/carve-kit.js +122 -28
  12. package/tiptap/carve-to-pm.js +387 -57
  13. package/tiptap/editor.css +193 -0
  14. package/tiptap/extensions/carve-attribute-slots.js +80 -0
  15. package/tiptap/extensions/carve-citation-definition.js +32 -0
  16. package/tiptap/extensions/carve-citation.js +19 -0
  17. package/tiptap/extensions/carve-code-group.js +141 -0
  18. package/tiptap/extensions/carve-comment.js +2 -2
  19. package/tiptap/extensions/carve-critic-comment.js +1 -1
  20. package/tiptap/extensions/carve-crossref.js +17 -0
  21. package/tiptap/extensions/carve-definition-list.js +8 -0
  22. package/tiptap/extensions/carve-delete.js +1 -1
  23. package/tiptap/extensions/carve-div.js +12 -2
  24. package/tiptap/extensions/carve-embed.js +1 -1
  25. package/tiptap/extensions/carve-empty-mark.js +66 -0
  26. package/tiptap/extensions/carve-figure.js +43 -1
  27. package/tiptap/extensions/carve-footnote-definition.js +1 -1
  28. package/tiptap/extensions/carve-footnote.js +1 -1
  29. package/tiptap/extensions/carve-frontmatter.js +17 -0
  30. package/tiptap/extensions/carve-heading.js +4 -2
  31. package/tiptap/extensions/carve-inline-extension.js +38 -0
  32. package/tiptap/extensions/carve-inline-note.js +17 -0
  33. package/tiptap/extensions/carve-insert.js +1 -1
  34. package/tiptap/extensions/carve-link-ref-def.js +27 -0
  35. package/tiptap/extensions/carve-literal.js +17 -0
  36. package/tiptap/extensions/carve-math.js +5 -3
  37. package/tiptap/extensions/carve-raw-inline.js +19 -0
  38. package/tiptap/extensions/carve-section.js +15 -0
  39. package/tiptap/extensions/carve-span.js +12 -4
  40. package/tiptap/extensions/carve-substitution.js +20 -0
  41. package/tiptap/extensions/carve-symbol.js +17 -0
  42. package/tiptap/extensions/carve-tabs.js +45 -61
  43. package/tiptap/extensions/carve-unsupported-inline.js +7 -0
  44. package/tiptap/extensions/carve-unsupported.js +7 -0
  45. package/tiptap/extensions/index.js +15 -1
  46. package/tiptap/extensions/panel-bar.js +296 -0
  47. package/tiptap/index.d.ts +70 -0
  48. package/tiptap/index.js +7 -5
  49. package/tiptap/schema-map.json +347 -39
  50. package/tiptap/serializer.js +380 -46
  51. package/tiptap/wire-fixtures.json +1447 -0
package/LICENSE CHANGED
@@ -1,6 +1,7 @@
1
1
  MIT License
2
2
 
3
3
  Copyright (c) 2024 php-collective
4
+ Copyright (c) 2026 Mark Scherer
4
5
 
5
6
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
7
  of this software and associated documentation files (the "Software"), to deal
package/README.md CHANGED
@@ -6,7 +6,14 @@ Grammars for the [Carve](https://github.com/markup-carve/carve) markup language:
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
 
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) and the TextMate grammar in [vscode-carve](https://github.com/markup-carve/vscode-carve).
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).
10
+
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.
10
17
 
11
18
  > **Status:** Tiptap integration, plus Prism, highlight.js and TextMate grammars.
12
19
  > Sibling editor grammars live in their own repos: editor-bundled **TextMate** copies in
@@ -167,6 +174,87 @@ of figures, advanced tables, comments, raw passthrough, and source-layout edge
167
174
  cases. `tiptap/schema-map.json` is the public rich-mapping authority;
168
175
  `tests/lib/coverage.js` records why structured conversion falls back.
169
176
 
177
+ ## Tab sets and code groups in the editor
178
+
179
+ A tab set and a code group are the same thing to a reader - a strip of labels,
180
+ one panel visible. In the editor they were not. A `:::: tabs` container had a
181
+ bar that could only switch panels; a `:::: code-group` had no bar at all, so it
182
+ rendered as a plain vertical stack of code blocks with its `[one.js]` labels
183
+ invisible and no way to tell it apart from two adjacent code blocks. Neither
184
+ could be edited as a widget: adding, removing, renaming or reordering a panel
185
+ meant leaving the visual editor and editing source.
186
+
187
+ Both now render an interactive bar:
188
+
189
+ | Action | How |
190
+ | --- | --- |
191
+ | switch panel | click a label |
192
+ | rename | double-click a label, then Enter (Escape abandons) |
193
+ | add | `+` |
194
+ | remove | `×` - refuses on the last panel |
195
+ | reorder | `‹` / `›` |
196
+
197
+ **Switching dispatches nothing.** It sets `data-active` on the wrapper and the
198
+ stylesheet does the rest, so moving between tabs never marks the document dirty
199
+ or reaches the serializer. The other four change the document and are undoable
200
+ like any other edit.
201
+
202
+ **The stylesheet is required, not decoration.** Because switching is only an
203
+ attribute, without these rules every panel is visible at once and clicking a
204
+ label appears to do nothing:
205
+
206
+ ```js
207
+ import '@markup-carve/carve-grammars/tiptap/editor.css'
208
+ ```
209
+
210
+ It reads carve-css custom properties when they are present and falls back to
211
+ literals otherwise, so it composes with that package without depending on it.
212
+
213
+ Two things worth knowing:
214
+
215
+ - **A code group is still a plain `carveDiv`.** It arrives as a div with
216
+ `class: "code-group"` whose children carry their own `carveLabel`, and giving
217
+ it dedicated node types to mirror the tab-set shape would change what the
218
+ serializer sees for a change that is entirely about presentation. So the bar
219
+ attaches to the existing node: the document shape, the serializer and the
220
+ round trip are untouched. The cost is that the nodeView is called for every
221
+ div, so every other kind - admonitions, figures, plain containers - is handed
222
+ straight back to the schema's own `toDOM`.
223
+ - **A code group mounted from HTML shows languages, not labels.** carve-js's
224
+ HTML for a code group emits bare `<pre>` children and drops the per-block
225
+ `[label]`, so `one.js` is not recoverable from that seed and the bar falls
226
+ back to `js`. Mounted from the AST (`carveToProseMirror`) the labels survive
227
+ and are shown. Renaming writes `carveLabel` and never touches the language, so
228
+ changing a tab's caption cannot silently restyle the code.
229
+
230
+ Disable the code-group bar with `CarveKit.configure({ carveCodeGroup: false })`,
231
+ or keep it read-only with
232
+ `CarveKit.configure({ carveCodeGroup: { editable: false } })`.
233
+
234
+ ## Framework-independent editor element
235
+
236
+ Applications that do not otherwise use Tiptap can mount the same lossless
237
+ bridge through a Web Component:
238
+
239
+ ```js
240
+ import { defineCarveEditor } from '@markup-carve/carve-grammars/editor'
241
+
242
+ defineCarveEditor()
243
+ const editor = document.querySelector('carve-editor')
244
+ editor.value = '# Hello'
245
+ editor.addEventListener('input', event => save(event.detail.value))
246
+ ```
247
+
248
+ ```html
249
+ <carve-editor></carve-editor>
250
+ ```
251
+
252
+ The element exposes a string `value`, emits bubbling and composed `input`
253
+ events, and uses `unsupported: 'preserve'` internally. Its editable surface is
254
+ available as the `editor` CSS part (`carve-editor::part(editor)`). Tiptap stays
255
+ an implementation detail of the element, although its peer packages must be
256
+ installed with `carve-grammars`.
257
+
170
258
  ## Syntax highlighting
171
259
 
172
260
  Render Carve source as highlighted HTML on the web. Both grammars cover the full
@@ -272,6 +360,25 @@ can drop the one character a rule is about. The spec corpus is the exception and
272
360
  can afford to be - it marks `tests/corpus/**` as `-text`, so
273
361
  `250-line-endings-and-a-byte-order-mark-3.crv` really does begin `ef bb bf`.
274
362
 
363
+ ### Fence words
364
+
365
+ All three surfaces answer `carve` and `crv`. `.crv` is the canonical file
366
+ extension, so a ` ```crv ` fence highlights wherever a ` ```carve ` one does,
367
+ whichever highlighter a site runs.
368
+
369
+ | Surface | Answers | Extra |
370
+ |---|---|---|
371
+ | Prism | `carve`, `crv` | `carvemd`, the embedded form |
372
+ | highlight.js | `carve`, `crv` | any casing: `getLanguage` lowercases its argument |
373
+ | Shiki | `carve`, `crv` | `Carve`, because Shiki matches a name by exact string |
374
+
375
+ The extras differ because the lookups do. Shiki is the only surface where a
376
+ capitalized spelling is a distinct alias worth listing; Prism keys must be
377
+ lowercase, since `Prism.util.getLanguage` lowercases the `language-xxx` class
378
+ before resolving it. `tests/lib/aliases.js` holds the required set and
379
+ `tests/alias-parity-test.js` asserts it on each surface through that surface's
380
+ own registration API.
381
+
275
382
  ### Prism
276
383
 
277
384
  The grammar registers itself against the global `Prism`, so `Prism` must be
@@ -452,6 +559,13 @@ the `CarveKit` schema with the declared node/mark kind, and every type in the
452
559
  pinned spec vocabulary must have a decision. Types the map covers ahead of the
453
560
  `spec/` pin are declared explicitly and must be removed once the pin catches up.
454
561
 
562
+ Two sections are keyed by ProseMirror name rather than by Carve type, because
563
+ neither names a Carve construct: `preservationNodes` (`carveUnsupported` and
564
+ `carveUnsupportedInline`, the atoms holding a construct's exact source) and
565
+ `markCarrierNodes` (`carveEmptyMark`, the atom a mark with no content rides on).
566
+ Both are part of the wire - an unknown ProseMirror name is an error rather than a
567
+ skip - so a bridge has to read them alongside `types`.
568
+
455
569
  Restating this mapping per engine is what the spec's own node-vocabulary test was
456
570
  written to prevent: carve-php once emitted `citation-group` while every other
457
571
  implementation spelled it with underscores.
@@ -463,6 +577,21 @@ implementation spelled it with underscores.
463
577
  `[text]{#me .note}`, `![alt](src){.wide}`. Inline attrs trail their target;
464
578
  block attrs (headings) sit on the **preceding** line (strict djot), e.g.
465
579
  `{#slug}` then `# Title`.
580
+ - **Attribute order** - a run is written back in the order it was AUTHORED, not
581
+ in a canonical one. ProseMirror attributes are an unordered map, so the order
582
+ travels as its own attribute, `carveAttrOrder`: the AST's `order` field
583
+ verbatim, an array whose entries are `#id`, `.class` and each key by name.
584
+
585
+ ```js
586
+ carveToProseMirror('[x]{key=c .a #b}')
587
+ // the carveSpan mark carries: { id: 'b', class: 'a',
588
+ // carveKeyValues: { key: 'c' }, carveAttrOrder: ['key', '.class', '#id'] }
589
+ ```
590
+
591
+ All of a node's classes stay contiguous at the position of the first one,
592
+ which is what the AST records. A document with no `carveAttrOrder` - anything
593
+ an editor builds from scratch - writes the canonical `#id .class key="val"`
594
+ order, and a slot the order names but the document no longer has is skipped.
466
595
  - **Math** - `CarveMath` (inline atom) serializes to `` $`x` `` and, with
467
596
  `display: true`, `` $$`x` ``. Math has no closing `$` sentinel (grammar.ebnf
468
597
  PART 9 §18): the `$` / `$$` prefix opens a verbatim span and the backtick run
@@ -496,3 +625,182 @@ vendored as the `spec/` git submodule (`git submodule update --init`).
496
625
 
497
626
  `npm test` runs all of the above plus the structural grammar and serializer
498
627
  unit tests. CI runs the same on Node 18, 20 and 22.
628
+
629
+ ### The construct ledger: ten surfaces, one derived list
630
+
631
+ Carve's syntax lives on ten grammar surfaces - `tree-sitter-carve`, the four
632
+ here (TextMate, Prism, highlight.js, Tiptap), `vscode-carve`, `intellij-carve`,
633
+ `sublime-carve`, `vim-carve` and `emacs-carve` - and nothing used to measure
634
+ them against the same construct list. `{%` comments landed on five of them and
635
+ had no rule at all on the other five, with nothing going red
636
+ (markup-carve/carve#1239, #284).
637
+
638
+ `npm test` now runs `tests/construct-ledger-test.js`, which holds
639
+ `tests/lib/construct-ledger.json` to three rules:
640
+
641
+ - **The construct list is derived, never written.** `scripts/spec-constructs.mjs`
642
+ reads the alternatives of the `block` and `inline_element` productions out of
643
+ the spec's normative `spec/resources/grammar.ebnf`. A clause that adds a
644
+ construct makes every surface unclassified until someone says what it did
645
+ about it. A hand-maintained checklist would go stale in exactly that case,
646
+ because whoever forgot the grammar rule also forgot the checklist row.
647
+ - **Three columns, not two.** Each construct on each surface is `IMPLEMENTED`
648
+ (with the name that surface gives it), `UNSUPPORTED` (with a reason - an empty
649
+ reason fails), or `GAP` (with a ticket). A construct in none of them fails the
650
+ test. Without the third column a legitimate gap - a Prism tokenizer cannot do
651
+ what a tree-sitter grammar does - reads as a defect, and a check with a high
652
+ noise floor gets muted.
653
+ - **Two axes per construct.** Recognizing a construct and keeping its payload
654
+ inert are different questions, and a surface can be right about the first and
655
+ wrong about the second: on four editor surfaces `{% *not bold* %}` colored a
656
+ bold run inside a comment. Every entry declares which, and the answer is
657
+ measured on every run - by tokenizing a sample with a live marker inside the
658
+ payload - for Prism, highlight.js and every TextMate grammar whose checkout is
659
+ in front of the seeder, and by converting that sample through the bridge for
660
+ Tiptap. Those rows cannot rot. The rest are recorded, which is what
661
+ `payload: "unmeasured"` says out loud - a state no cell is in any more,
662
+ since intellij-carve's thirteen were the last of them (#329).
663
+
664
+ One sample per construct is a re-measurement, not a measurement.
665
+ `tests/opaque-payload-test.js` generates every payload up to three characters
666
+ over each construct's own delimiter alphabet - about ten thousand documents -
667
+ and it is what finds this class: of 1325 corpus documents exactly ONE exposed
668
+ the fenced-code leak in #309, and a comment leaking only when it spans a line
669
+ break is invisible to a single-line sample (#320).
670
+
671
+ #### A red row is a question, not a defect
672
+
673
+ Three surfaces have now been worked row by row, and the ratio is stable enough
674
+ to plan against rather than be surprised by:
675
+
676
+ | pass | rows | the instrument | genuinely absent |
677
+ | --- | --- | --- | --- |
678
+ | `tree-sitter-carve` (#245) | 14 | 9 | 3 |
679
+ | `vim-carve` + `sublime-carve` (#318) | 20 | 11 | 6 |
680
+ | `textmate`, `prism`, `tiptap`, `vscode-carve` (#307, #308, #310) | 47 | 31 | 9 |
681
+
682
+ **Two rows in three are a name the probe cannot reach**, and the third pass also
683
+ turned up six rows that read `IMPLEMENTED` and were not. So the first job on a
684
+ per-surface ticket is deciding which rows are real, and the answer is expected
685
+ to be "most of them are not". A GAP row means *the probe did not find a rule it
686
+ recognizes*, which is a different claim from *this surface does not implement
687
+ the construct*.
688
+
689
+ A fold belongs in `SIGNATURE_OVERRIDES`, which the file calls a per-surface
690
+ NAMING table - never in a rule renamed on a surface to satisfy the probe. Where
691
+ a surface genuinely cannot express a construct, `UNSUPPORTED` **with a reason**
692
+ is the entry; a wrong rule is worse than a missing one.
693
+
694
+ #### The instrument can be wrong in the direction that looks green
695
+
696
+ Five ways the probe has mis-read a surface, each found by opening the file:
697
+
698
+ 1. **It read too little.** `Object.keys(grammar.json.rules)` is 196 of
699
+ tree-sitter's 346 names; a `.sublime-syntax` scopes a capture as well as a
700
+ match (#315).
701
+ 2. **The signature list had holes** - `code_span` is `verbatim` on one surface
702
+ and `code_inline` on another (#315, #307).
703
+ 3. **One rule implements several constructs**, and one name cannot carry four
704
+ through a shared table (#318).
705
+ 4. **The evidence named a rule about a different construct.** `carveCommentInline`
706
+ is the braced comment and was cited for the trailing one (#318). The same
707
+ pair beat the word-boundary rank on the TextMate family, where both
708
+ candidates are whole-name hits and *length* picked the wrong one (#307).
709
+ 5. **The re-check read the file's prose as evidence.** `every IMPLEMENTED row
710
+ cites a name the shipped grammar really carries` asked whether the evidence
711
+ was a SUBSTRING of the source. `prism/carve.js` carries the comment "Prism
712
+ has no cross-reference token at all" beside the lookbehind that worked
713
+ around the absence - so a row citing `cross-ref` re-checked **green** for as
714
+ long as the rule was missing (#307). That is the trap the docblock at the
715
+ top of `scripts/surface-probe.mjs` describes, and that its extractors were
716
+ built to avoid, surviving in the check that verifies their output. It reads
717
+ `vocabulary()` now, so the seed and the re-check agree on what a name is.
718
+
719
+ The lesson generalizes past this file: **a check that treats a file's prose as
720
+ evidence that the file implements what the prose says it does NOT implement
721
+ cannot fail.** Match against what a grammar DECLARES, never against its text.
722
+
723
+ Re-measure after changing a grammar:
724
+
725
+ ```bash
726
+ node scripts/seed-construct-ledger.mjs
727
+ ```
728
+
729
+ That reads the four surfaces here directly. The six in other repositories are
730
+ read from checkouts named by environment variables, and any that is not given
731
+ keeps the row already recorded, with the commit it was read at:
732
+
733
+ ```bash
734
+ CARVE_SURFACE_VIM_CARVE=../vim-carve \
735
+ CARVE_SURFACE_EMACS_CARVE=../emacs-carve \
736
+ node scripts/seed-construct-ledger.mjs
737
+ ```
738
+
739
+ Statuses are measured; reasons, per-construct notes, tickets and a surface's
740
+ `note` are written by hand and carried across a re-measurement, so a re-run
741
+ never drops a stated reason. A surface-level `note` is for what the rows cannot
742
+ say - why a surface needed no work to reach zero, or what its measured payload
743
+ column does NOT cover.
744
+
745
+ One thing a re-run does NOT carry: the payload axis of a surface the seeder
746
+ cannot tokenize. It is only carried over when the recorded value is already
747
+ `inert` or `leaks`, so a construct that moves from `GAP` to `IMPLEMENTED` in the
748
+ same run arrives with `payload: "unmeasured"` and a ticket, even when the person
749
+ doing the run has just measured it. That is deliberate: a seeder cannot tokenize
750
+ a Vim syntax file or an emacs font-lock table, so the alternative is inventing an
751
+ answer. Measure the payload as part of the same pass and write `inert` (or
752
+ `leaks`, with a note) by hand on those rows.
753
+
754
+ It applies to fewer surfaces than it used to. `vscode-carve` and
755
+ `intellij-carve` are TextMate grammars, and the seeder loads any of those through
756
+ Shiki, so naming their checkout measures the recognition axis and the payload
757
+ sample in the same run:
758
+
759
+ ```bash
760
+ CARVE_SURFACE_VSCODE_CARVE=../vscode-carve node scripts/seed-construct-ledger.mjs
761
+ CARVE_SURFACE_INTELLIJ_CARVE=../intellij-carve node scripts/seed-construct-ledger.mjs
762
+ ```
763
+
764
+ **A `leaks` cell is still hand-written on those surfaces, and the seeder carries
765
+ it rather than overwriting it.** The seeder tokenizes ONE sample per construct
766
+ and the sweep below generates hundreds, and on all three surfaces measured for
767
+ the first time since #320 the sample said every row was inert while the sweep
768
+ disagreed - five rows on intellij-carve alone (#329). So a run whose sample says
769
+ `inert` leaves a recorded `leaks` alone. That cannot hide a fix: the sweep
770
+ asserts every recorded leak STILL leaks, so a fix fails that file, its entry
771
+ comes out, and the ledger's own orphan check then fails until the cell is
772
+ corrected too.
773
+
774
+ ### The payload sweep reaches nine of the ten surfaces
775
+
776
+ `tests/opaque-payload-test.js` is the second axis measured over a GENERATED space
777
+ rather than one sample per construct, and one sample is not enough. Every surface
778
+ measured for the first time so far has had leaks the sample could not see:
779
+
780
+ | surface | leaking rows | the sample found |
781
+ | --- | --- | --- |
782
+ | tree-sitter-carve (#328) | 3 | none of them |
783
+ | emacs-carve (#328) | 2 | the `%%%` fence, not the verbatim run |
784
+ | intellij-carve (#329) | 4 | none of them |
785
+
786
+ Name a checkout and the sweep drives it:
787
+
788
+ ```bash
789
+ CARVE_SURFACE_TREE_SITTER_CARVE=../tree-sitter-carve \
790
+ CARVE_SURFACE_EMACS_CARVE=../emacs-carve \
791
+ CARVE_SURFACE_VSCODE_CARVE=../vscode-carve \
792
+ CARVE_SURFACE_INTELLIJ_CARVE=../intellij-carve \
793
+ node tests/opaque-payload-test.js
794
+ ```
795
+
796
+ tree-sitter needs its native addon built (`npm install` in that checkout), and
797
+ emacs-carve needs an `emacs` on PATH - the mode is fontified in one batch Emacs
798
+ per sweep row, which is what `prime` on that tokenizer is for. A surface whose
799
+ checkout is not named, or not built, is simply not swept.
800
+
801
+ A leak on a grammar in ANOTHER repository is a defect this suite can measure and
802
+ not fix, so it is recorded in `KNOWN_LEAKS` with the ticket it lives on and
803
+ asserted to STILL leak. A fix therefore fails this file and the entry comes out
804
+ with the fix, the same arrangement the residual table at the end of it uses for
805
+ one document at a time. A surface in THIS repository is deliberately absent from
806
+ that table: a leak here is fixable here, so it stays red.