@markup-carve/carve-grammars 0.1.3 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/README.md +46 -0
  2. package/highlightjs/carve.js +375 -25
  3. package/package.json +25 -10
  4. package/prism/carve.js +319 -26
  5. package/textmate/carve.tmLanguage.json +294 -7
  6. package/tiptap/carve-editor.d.ts +33 -0
  7. package/tiptap/carve-editor.js +109 -0
  8. package/tiptap/carve-kit.js +111 -26
  9. package/tiptap/carve-to-pm.js +382 -56
  10. package/tiptap/extensions/carve-attribute-slots.js +80 -0
  11. package/tiptap/extensions/carve-citation-definition.js +32 -0
  12. package/tiptap/extensions/carve-citation.js +19 -0
  13. package/tiptap/extensions/carve-comment.js +2 -2
  14. package/tiptap/extensions/carve-crossref.js +17 -0
  15. package/tiptap/extensions/carve-div.js +11 -1
  16. package/tiptap/extensions/carve-empty-mark.js +66 -0
  17. package/tiptap/extensions/carve-figure.js +43 -1
  18. package/tiptap/extensions/carve-frontmatter.js +17 -0
  19. package/tiptap/extensions/carve-heading.js +4 -2
  20. package/tiptap/extensions/carve-inline-extension.js +38 -0
  21. package/tiptap/extensions/carve-inline-note.js +17 -0
  22. package/tiptap/extensions/carve-link-ref-def.js +27 -0
  23. package/tiptap/extensions/carve-literal.js +17 -0
  24. package/tiptap/extensions/carve-math.js +4 -2
  25. package/tiptap/extensions/carve-raw-inline.js +19 -0
  26. package/tiptap/extensions/carve-section.js +15 -0
  27. package/tiptap/extensions/carve-span.js +11 -3
  28. package/tiptap/extensions/carve-substitution.js +20 -0
  29. package/tiptap/extensions/carve-symbol.js +17 -0
  30. package/tiptap/extensions/carve-unsupported-inline.js +7 -0
  31. package/tiptap/extensions/carve-unsupported.js +7 -0
  32. package/tiptap/extensions/index.js +14 -1
  33. package/tiptap/index.d.ts +70 -0
  34. package/tiptap/index.js +4 -2
  35. package/tiptap/schema-map.json +340 -38
  36. package/tiptap/serializer.js +360 -42
  37. package/tiptap/wire-fixtures.json +1467 -0
package/README.md CHANGED
@@ -167,6 +167,30 @@ of figures, advanced tables, comments, raw passthrough, and source-layout edge
167
167
  cases. `tiptap/schema-map.json` is the public rich-mapping authority;
168
168
  `tests/lib/coverage.js` records why structured conversion falls back.
169
169
 
170
+ ## Framework-independent editor element
171
+
172
+ Applications that do not otherwise use Tiptap can mount the same lossless
173
+ bridge through a Web Component:
174
+
175
+ ```js
176
+ import { defineCarveEditor } from '@markup-carve/carve-grammars/editor'
177
+
178
+ defineCarveEditor()
179
+ const editor = document.querySelector('carve-editor')
180
+ editor.value = '# Hello'
181
+ editor.addEventListener('input', event => save(event.detail.value))
182
+ ```
183
+
184
+ ```html
185
+ <carve-editor></carve-editor>
186
+ ```
187
+
188
+ The element exposes a string `value`, emits bubbling and composed `input`
189
+ events, and uses `unsupported: 'preserve'` internally. Its editable surface is
190
+ available as the `editor` CSS part (`carve-editor::part(editor)`). Tiptap stays
191
+ an implementation detail of the element, although its peer packages must be
192
+ installed with `carve-grammars`.
193
+
170
194
  ## Syntax highlighting
171
195
 
172
196
  Render Carve source as highlighted HTML on the web. Both grammars cover the full
@@ -452,6 +476,13 @@ the `CarveKit` schema with the declared node/mark kind, and every type in the
452
476
  pinned spec vocabulary must have a decision. Types the map covers ahead of the
453
477
  `spec/` pin are declared explicitly and must be removed once the pin catches up.
454
478
 
479
+ Two sections are keyed by ProseMirror name rather than by Carve type, because
480
+ neither names a Carve construct: `preservationNodes` (`carveUnsupported` and
481
+ `carveUnsupportedInline`, the atoms holding a construct's exact source) and
482
+ `markCarrierNodes` (`carveEmptyMark`, the atom a mark with no content rides on).
483
+ Both are part of the wire - an unknown ProseMirror name is an error rather than a
484
+ skip - so a bridge has to read them alongside `types`.
485
+
455
486
  Restating this mapping per engine is what the spec's own node-vocabulary test was
456
487
  written to prevent: carve-php once emitted `citation-group` while every other
457
488
  implementation spelled it with underscores.
@@ -463,6 +494,21 @@ implementation spelled it with underscores.
463
494
  `[text]{#me .note}`, `![alt](src){.wide}`. Inline attrs trail their target;
464
495
  block attrs (headings) sit on the **preceding** line (strict djot), e.g.
465
496
  `{#slug}` then `# Title`.
497
+ - **Attribute order** - a run is written back in the order it was AUTHORED, not
498
+ in a canonical one. ProseMirror attributes are an unordered map, so the order
499
+ travels as its own attribute, `carveAttrOrder`: the AST's `order` field
500
+ verbatim, an array whose entries are `#id`, `.class` and each key by name.
501
+
502
+ ```js
503
+ carveToProseMirror('[x]{key=c .a #b}')
504
+ // the carveSpan mark carries: { id: 'b', class: 'a',
505
+ // carveKeyValues: { key: 'c' }, carveAttrOrder: ['key', '.class', '#id'] }
506
+ ```
507
+
508
+ All of a node's classes stay contiguous at the position of the first one,
509
+ which is what the AST records. A document with no `carveAttrOrder` - anything
510
+ an editor builds from scratch - writes the canonical `#id .class key="val"`
511
+ order, and a slot the order names but the document no longer has is skipped.
466
512
  - **Math** - `CarveMath` (inline atom) serializes to `` $`x` `` and, with
467
513
  `display: true`, `` $$`x` ``. Math has no closing `$` sentinel (grammar.ebnf
468
514
  PART 9 §18): the `$` / `$$` prefix opens a verbatim span and the backtick run
@@ -101,7 +101,39 @@
101
101
  // attribute block, and one invalid name is enough to leave the whole run
102
102
  // literal. A colon belongs to the VALUE grammar, not the key: an unquoted
103
103
  // value may contain dots and colons, so `{k=a:b}` is a real attribute block.
104
- const ATTR_ITEM = /(?:[.#][A-Za-z_][\w-]*|[A-Za-z_][\w-]*(?:=(?:"(?:[^"\\\n]|\\.)*"|'(?:[^'\\\n]|\\.)*'|[^\s"'{}]+))?)/.source;
104
+ const ATTR_ITEM = /(?::(?:[A-Za-z0-9]{1,8}(?:-[A-Za-z0-9]{1,8})*)?|[.#][A-Za-z_][\w-]*|[A-Za-z_][\w-]*(?:=(?:"(?:[^"\\\n]|\\.)*"|'(?:[^'\\\n]|\\.)*'|[^\s"'{}]+))?)/.source;
105
+ // A LIST MARKER MAY BE GLUED TO AN ATTRIBUTE BLOCK (`-{#x} item`), so the
106
+ // marker rules have to look past a whole block to decide there is a marker
107
+ // at all. That lookahead spelled the item alternation out again, and the
108
+ // copies went stale: when the language attribute (`{:fr}`) joined
109
+ // `ATTR_ITEM`, `-{:fr} item` stopped colouring its `-` as a bullet while
110
+ // `-{.c} item` still did. Built from `ATTR_ITEM` now, so one spelling of
111
+ // what an attribute item is serves the attribute rule and both marker
112
+ // rules, and they cannot drift apart again.
113
+ const GLUED_ATTR_BLOCK =
114
+ '(?=\\{\\s*(?:' + ATTR_ITEM + '(?:\\s+' + ATTR_ITEM + ')*\\s*)?\\}[ \\t]+[^ \\t\\n])';
115
+ // BALANCED BRACKET TEXT - the body of a link label, an image alt text and a
116
+ // bracketed span. See the long note on Prism's `bracketText`, which this
117
+ // mirrors; the short version is that the spec closes a label at the
118
+ // MATCHING `]` and the scan is escape-aware, while a `[^\]]*` body closes
119
+ // at the FIRST `]`. That left `![t[z]](/i.png)` - an image - entirely
120
+ // unscoped, and scoped the unbalanced `[t[z](/u)` from the outer `[` where
121
+ // the engine reads `[t` as prose (carve-grammars#226).
122
+ //
123
+ // The nesting is unrolled to FOUR levels because a regex cannot count;
124
+ // deeper stays unscoped, which is what every depth did before.
125
+ //
126
+ // Every quantifier here is BOUNDED, for the reason Prism's copy gives: an
127
+ // unbounded body is a scan to the end of the document at every `[` the
128
+ // tokenizer tries, and it tries each position.
129
+ const BRACKET_CHAR = /(?:[^\[\]\\]|\\[\s\S])/.source;
130
+ const BRACKET_SCAN = '{0,512}';
131
+ let BRACKET_TEXT = BRACKET_CHAR + BRACKET_SCAN;
132
+ for (let depth = 0; depth < 3; depth++) {
133
+ BRACKET_TEXT = '(?:' + BRACKET_CHAR + '|\\[' + BRACKET_TEXT + '\\])' + BRACKET_SCAN;
134
+ }
135
+ // The same body, required to be non-empty, for the rules that reject `[]`.
136
+ const BRACKET_TEXT_NONEMPTY = '(?!\\])' + BRACKET_TEXT;
105
137
  /**
106
138
  * A begin/end mode opens its span the moment `begin` matches, whether or
107
139
  * not the closer ever arrives - so an unpartnered delimiter colors every
@@ -153,6 +185,20 @@
153
185
  relevance: 5,
154
186
  };
155
187
 
188
+ // Front matter is valid only at byte offset zero. Unlike the historical
189
+ // `^---$` rule, the negative lookbehind below cannot match a thematic break
190
+ // on a later line even though highlight.js compiles modes with `m`.
191
+ const FRONT_MATTER = {
192
+ className: 'meta',
193
+ begin: /^(?<![\s\S])\uFEFF?---(?:[A-Za-z0-9_-]+| [A-Za-z0-9_-]+)?[ \t]*$/,
194
+ end: /^---[ \t]*$/,
195
+ relevance: 10,
196
+ contains: [
197
+ { className: 'symbol', begin: /^[A-Za-z_][\w-]*(?=[ \t]*:)/ },
198
+ { className: 'punctuation', begin: /---/ },
199
+ ],
200
+ };
201
+
156
202
  // Headings: # to ######
157
203
  const HEADING = {
158
204
  className: 'section',
@@ -291,14 +337,14 @@
291
337
  // Inline links: [text](url) with optional trailing attributes
292
338
  const LINK = {
293
339
  className: 'link',
294
- begin: /\[[^\]]*\]\([^)]*\)(\{[^}]+\})?/,
340
+ begin: new RegExp('\\[' + BRACKET_TEXT + '\\]\\([^)]*\\)(\\{[^}]+\\})?'),
295
341
  relevance: 5,
296
342
  };
297
343
 
298
344
  // Autolinks: <https://...> or <mailto:...>
299
345
  const AUTOLINK = {
300
346
  className: 'link',
301
- begin: /<(?:https?:\/\/|mailto:)[^>]+>/,
347
+ begin: /<(?:https?:\/\/|mailto:)[^>]{1,2048}>/,
302
348
  relevance: 5,
303
349
  };
304
350
 
@@ -312,14 +358,14 @@
312
358
  // Images: ![alt](url) with optional trailing attributes
313
359
  const IMAGE = {
314
360
  className: 'link',
315
- begin: /!\[[^\]]*\]\([^)]*\)(\{[^}]+\})?/,
361
+ begin: new RegExp('!\\[' + BRACKET_TEXT + '\\]\\([^)]*\\)(\\{[^}]+\\})?'),
316
362
  relevance: 5,
317
363
  };
318
364
 
319
365
  // Reference links: [text][ref] with optional trailing attributes
320
366
  const REFERENCE_LINK = {
321
367
  className: 'link',
322
- begin: /\[[^\]]+\]\[[^\]]*\](\{[^}]+\})?/,
368
+ begin: new RegExp('\\[' + BRACKET_TEXT_NONEMPTY + '\\]\\[[^\\]]*\\](\\{[^}]+\\})?'),
323
369
  relevance: 5,
324
370
  };
325
371
 
@@ -328,7 +374,7 @@
328
374
  className: 'string',
329
375
  // Only the bracket run; the trailing `{...}` is left to ATTRIBUTE so it
330
376
  // scopes as an attribute block rather than vanishing into the span.
331
- begin: /\[[^\]]+\](?=\{)/,
377
+ begin: new RegExp('\\[' + BRACKET_TEXT_NONEMPTY + '\\](?=\\{)'),
332
378
  relevance: 5,
333
379
  };
334
380
 
@@ -343,7 +389,7 @@
343
389
  // Footnote references: [^note]
344
390
  const FOOTNOTE_REF = {
345
391
  className: 'symbol',
346
- begin: /\[\^[^\]]+\]/,
392
+ begin: /\[\^[^\]]{1,512}\]/,
347
393
  relevance: 5,
348
394
  };
349
395
 
@@ -351,7 +397,7 @@
351
397
  // leads, which is what separates it from the reference above.
352
398
  const INLINE_FOOTNOTE = {
353
399
  className: 'symbol',
354
- begin: /\^\[[^\]\n]*\]/,
400
+ begin: /\^\[[^\]\n]{0,512}\]/,
355
401
  relevance: 5,
356
402
  };
357
403
 
@@ -404,7 +450,7 @@
404
450
  // first to claim those suffixed forms).
405
451
  const CITATION = {
406
452
  className: 'symbol',
407
- begin: /\[\+?(?:[^\]@]*@[A-Za-z0-9_][A-Za-z0-9_.:#$%&+?<>~\/-]*[^\]]*)\](?!\(|\[|\{)/,
453
+ begin: /\[\+?(?:[^\]@]{0,512}@[A-Za-z0-9_][A-Za-z0-9_.:#$%&+?<>~\/-]*[^\]]{0,512})\](?!\(|\[|\{)/,
408
454
  relevance: 8,
409
455
  };
410
456
 
@@ -439,6 +485,93 @@
439
485
  relevance: 10,
440
486
  };
441
487
 
488
+ // A list item's MARKER, as it may appear before a block opener on the same
489
+ // line. Matched in a LOOKBEHIND everywhere it is used rather than consumed,
490
+ // so `TASK_LIST`/`LIST_BULLET`/`LIST_NUMBER` still scope it - which is
491
+ // tree-sitter-carve's split too, with the block INSIDE `list_item_content`
492
+ // beside the `list_marker_*` rather than over it.
493
+ //
494
+ // Used by the marker-line comment fence AND by the quote rules below: a
495
+ // quote may open on an item's own marker line (`- > x`), where carve-js
496
+ // nests the quote and everything after the marker inside the item.
497
+ // EVERY SEPARATOR IS A LITERAL SPACE, never a tab: `-<TAB>a`, `1.<TAB>a` and
498
+ // `- [x]<TAB>a` are paragraphs in the engine, which the shared block battery
499
+ // already pins per marker. Written `[ \t]+` this admitted `-<TAB>%%%` as a
500
+ // marker-line fence, and with the quote rules sharing the prefix it coloured
501
+ // `-<TAB>> q` as a quote on a line the language renders as prose
502
+ // (carve-grammars#259).
503
+ const LIST_MARKER_BEFORE_BLOCK =
504
+ '^(?:(?<![\\s\\S])\\uFEFF)?[ \\t]*'
505
+ + '(?:(?:[-*] +)*[-*] +(?:\\[[ xX\\-_>?]\\] +)?'
506
+ + '|(?:[0-9]+|[A-Za-z]|[ivxlcdm]+|[IVXLCDM]+)[.)] +|\\. +)';
507
+
508
+ // A comment fence may open on a BLOCK-QUOTE marker line (`> %%%`), and then
509
+ // its body is hidden exactly as it is anywhere else - \u00A724 S2 and \u00A728 make a
510
+ // comment's body verbatim and invisible WHEREVER the fence sits. Corpus 70
511
+ // pins this spelling: `> q` / `> %%%` / `> x` / `> %%%` / `> body` renders
512
+ // the quote with `q` and `body` only.
513
+ //
514
+ // `BLOCK_COMMENT` cannot reach it - its begin allows only whitespace before
515
+ // the run - so all three lines scoped as plain quote and nothing was marked
516
+ // hidden. It lives in BLOCKQUOTE's `contains` rather than in the top-level
517
+ // list because BLOCKQUOTE ends at `$`: a mode that begins at column 0 would
518
+ // out-rank it and take the marker, and a mode outside it never gets to run
519
+ // on a quote line at all. Reached from here the marker stays a quote, which
520
+ // is tree-sitter-carve's split too - the `fenced_comment_block` sits inside
521
+ // the quote's `content`, beside the `block_quote_marker`.
522
+ // The marker run may itself follow a LIST ITEM'S marker (`- > %%%`), so the
523
+ // list prefix is an alternative to the plain line start rather than a rule of
524
+ // its own. carve-grammars#246 left that shape out because two of the three
525
+ // grammars here could not reach it; a lookbehind reaches it here.
526
+ const QUOTE_MARKER_BEFORE_FENCE =
527
+ '(?:' + LIST_MARKER_BEFORE_BLOCK + '|^(?:(?<![\\s\\S])\\uFEFF)?[ \\t]*)(?:> )+';
528
+ // A line carrying a quote marker of its own. Every line from opener to
529
+ // closer must be one: an UNMARKED line is where the quote can end, and the
530
+ // engine degrades an unclosed opener to a line comment rather than hiding
531
+ // anything (`> %%%` / `> c` / blank leaves `c` VISIBLE). The engine does
532
+ // absorb an unmarked LAZY continuation into a fence that closes later;
533
+ // refusing it costs a mis-scope there and buys never hiding a visible block.
534
+ const QUOTE_MARKED_LINE = '(?:\\n[ \\t]*>[^\\n]*)';
535
+ const BLOCK_COMMENT_ON_QUOTE_MARKER_LINE = {
536
+ className: 'comment',
537
+ // The closer is REQUIRED up front, as in BLOCK_COMMENT_ON_MARKER_LINE
538
+ // below: highlight.js has no begin->end backreference, so without the
539
+ // guard an opener with no closer runs to end of file, and on this shape
540
+ // an unclosed opener is the common case.
541
+ begin: RegExp(
542
+ '(?<=' + QUOTE_MARKER_BEFORE_FENCE + ')(%{3,})(?!%)[^\\n]*$'
543
+ + '(?=' + QUOTE_MARKED_LINE + '*?\\n[ \\t]*(?:> )+\\1(?!%)[^\\n]*$)',
544
+ ),
545
+ 'on:begin': (m, resp) => {
546
+ resp.data._quoteFenceWidth = m[1].length;
547
+ },
548
+ end: /^[ \t]*(?:> )+(%{3,})[^\n]*$/,
549
+ 'on:end': (m, resp) => {
550
+ if (m[1].length !== resp.data._quoteFenceWidth) resp.ignoreMatch();
551
+ },
552
+ relevance: 10,
553
+ };
554
+
555
+ // The other half of the rule above: an UNTERMINATED `%{3,}` run opens
556
+ // nothing and degrades to a LINE comment (PART 9 S28), so it must still
557
+ // scope as one. Placed after `BLOCK_COMMENT` in `contains`, which consumes
558
+ // every fence that does have a closer - the same pair Prism carries, where
559
+ // the unterminated pattern sits after the block form for the same reason.
560
+ //
561
+ // The marker prefixes are here as well, so `- %%%` and `> %%%` with no
562
+ // closer grey their opener out too rather than losing the run entirely.
563
+ // Reached inside a quote through `BLOCKQUOTE.contains`, since a mode that
564
+ // begins at column 0 never runs on a quote line.
565
+ const UNTERMINATED_BLOCK_COMMENT = {
566
+ className: 'comment',
567
+ begin: RegExp(
568
+ '(?:^(?:(?<![\\s\\S])\\uFEFF)?[ \\t]*'
569
+ + '|(?<=' + LIST_MARKER_BEFORE_BLOCK + ')'
570
+ + '|(?<=' + QUOTE_MARKER_BEFORE_FENCE + '))%{3,}',
571
+ ),
572
+ end: /$/,
573
+ relevance: 5,
574
+ };
442
575
  // Blockquotes: a `>` marker followed by a SPACE, or alone on its line.
443
576
  //
444
577
  // Verified against carve-rs: `>no space`, `>>x`, `>> x` and `>\tx` are all
@@ -449,8 +582,24 @@
449
582
  className: 'quote',
450
583
  // Anchored `^[ \t]*` on purpose - no container model here, see the
451
584
  // indented-block-openers note in the module docblock (carve-grammars#138).
452
- begin: /^(?:(?<![\s\S])\uFEFF)?[ \t]*>(?= |$)/,
585
+ // A LIST ITEM'S MARKER may stand before the `>` on the same line
586
+ // (`- > x`, `1. > x`, `- - > x`, `- [ ] > x`). Reached through a
587
+ // lookbehind so the marker is not consumed and the list rules still
588
+ // scope it. Without this branch the marker scoped as a bullet and the
589
+ // rest of the line carried no scope at all, where carve-js nests it
590
+ // (carve-grammars#259).
591
+ begin: RegExp(
592
+ '(?:^(?:(?<![\\s\\S])\\uFEFF)?[ \\t]*|(?<=' + LIST_MARKER_BEFORE_BLOCK + '))'
593
+ + '>(?= |$)',
594
+ ),
453
595
  end: /$/,
596
+ // The ONE construct a quote line contains: a comment fence opened on
597
+ // the marker line, which outlives the `$` that ends every other quote.
598
+ // The two constructs a quote line contains: a comment fence opened on
599
+ // the marker line, which outlives the `$` that ends every other quote,
600
+ // and the unterminated form of the same run, which does not (it is a
601
+ // line comment, so it ends where the quote line does).
602
+ contains: [BLOCK_COMMENT_ON_QUOTE_MARKER_LINE, UNTERMINATED_BLOCK_COMMENT],
454
603
  relevance: 0,
455
604
  };
456
605
 
@@ -469,7 +618,9 @@
469
618
  const LIST_BULLET = {
470
619
  className: 'bullet',
471
620
  // A marker line may carry several markers (`- - A`, corpus 103).
472
- begin: /^(?:(?<![\s\S])\uFEFF)?[ \t]*(?:[-*] +)*[-*](?:(?= )|(?=\{\s*(?:(?:[.#][A-Za-z_][\w-]*|[A-Za-z_][\w-]*(?:=(?:"(?:[^"\\\n]|\\.)*"|'(?:[^'\\\n]|\\.)*'|[^\s"'{}]+))?)(?:\s+(?:[.#][A-Za-z_][\w-]*|[A-Za-z_][\w-]*(?:=(?:"(?:[^"\\\n]|\\.)*"|'(?:[^'\\\n]|\\.)*'|[^\s"'{}]+))?))*\s*)?\}[ \t]+[^ \t\n]))(?![ \t]*$)/,
621
+ begin: RegExp(
622
+ '^(?:(?<![\\s\\S])\\uFEFF)?[ \\t]*(?:[-*] +)*[-*](?:(?= )|' + GLUED_ATTR_BLOCK + ')(?![ \\t]*$)',
623
+ ),
473
624
  relevance: 0,
474
625
  };
475
626
 
@@ -485,7 +636,9 @@
485
636
  // stops in the wrong place and `{title="a}b"} x` is a valid item (#85).
486
637
  const LIST_NUMBER = {
487
638
  className: 'bullet',
488
- begin: /^(?:(?<![\s\S])\uFEFF)?[ \t]*(\d+[.)]|[a-zA-Z][.)]|[ivxlcdm]+[.)]|[IVXLCDM]+[.)]|\.)(?:(?= )|(?=\{\s*(?:(?:[.#][A-Za-z_][\w-]*|[A-Za-z_][\w-]*(?:=(?:"(?:[^"\\\n]|\\.)*"|'(?:[^'\\\n]|\\.)*'|[^\s"'{}]+))?)(?:\s+(?:[.#][A-Za-z_][\w-]*|[A-Za-z_][\w-]*(?:=(?:"(?:[^"\\\n]|\\.)*"|'(?:[^'\\\n]|\\.)*'|[^\s"'{}]+))?))*\s*)?\}[ \t]+[^ \t\n]))(?![ \t]*$)/,
639
+ begin: RegExp(
640
+ '^(?:(?<![\\s\\S])\\uFEFF)?[ \\t]*(\\d+[.)]|[a-zA-Z][.)]|[ivxlcdm]+[.)]|[IVXLCDM]+[.)]|\\.)(?:(?= )|' + GLUED_ATTR_BLOCK + ')(?![ \\t]*$)',
641
+ ),
489
642
  relevance: 0,
490
643
  };
491
644
 
@@ -584,9 +737,101 @@
584
737
  relevance: 10,
585
738
  };
586
739
 
740
+ // Composite figure block: a BARE `::: figure` opener through its matching
741
+ // closer (PART 9 §4c, markup-carve/carve#1215).
742
+ //
743
+ // The kind word `figure` is RESERVED among the `:::` types: a bare opener -
744
+ // the fence, its separator, the word `figure`, and NOTHING else - is ONE
745
+ // figure of ordered panels, not an admonition. It carries `section` rather
746
+ // than DIV_BLOCK's `keyword` so a consumer can tell the two readings apart,
747
+ // and it is listed BEFORE DIV_BLOCK, whose begin also matches this line -
748
+ // highlight.js takes the earliest match and breaks a tie by mode order.
749
+ //
750
+ // The `[ \t]*$` tail is the whole distinction. An opener carrying a quoted
751
+ // title or a [label] (`::: figure "T"`, `::: figure [g]`) does not match
752
+ // here at all and falls to DIV_BLOCK, which is the generic Tier-2 container
753
+ // the clause says it stays.
754
+ //
755
+ // The separator is a SPACE run, never a tab (grammar.ebnf PART 7, MARKER
756
+ // SEPARATORS; corpus 254 renders `:::<TAB>note` as a paragraph). A
757
+ // tab-separated opener is not claimed here and falls to DIV_BLOCK, which
758
+ // over-colours it exactly as it does today - a pre-existing trade this mode
759
+ // neither widens nor fixes. Trailing whitespace after the kind word is
760
+ // insignificant and may be a tab.
761
+ //
762
+ // Closer width is carried in `resp.data` and compared on `on:end`, the same
763
+ // idiom DIV_BLOCK and BLOCK_COMMENT already use, so the closer matches the
764
+ // opener's colon run EXACTLY. `contains` is assigned below, once the full
765
+ // mode list is known.
766
+ // A STACK, where the modes above keep a single width. highlight.js hands
767
+ // every instance of a mode the SAME `resp.data` object, so one slot holds
768
+ // only the innermost fence: with `::: figure` > `:::: note` > `::::: x`,
769
+ // the width-5 opener overwrote the width-4 one, both outer closers then
770
+ // failed their check, and the group ran to end of input unscoped. These two
771
+ // modes nest inside each other by construction, so they push and pop
772
+ // instead. `container` names which stack, because the two modes are
773
+ // separate objects and each needs its own.
774
+ const pushFence = (container) => (m, resp) => {
775
+ (resp.data[container] ??= []).push(m[1].length);
776
+ };
777
+ const popFence = (container) => (m, resp) => {
778
+ const open = resp.data[container];
779
+ if (!open?.length || m[1].length !== open[open.length - 1]) {
780
+ resp.ignoreMatch();
781
+ return;
782
+ }
783
+ open.pop();
784
+ };
785
+
786
+ const FIGURE_GROUP_BLOCK = {
787
+ beginScope: 'section',
788
+ begin: /^(?:(?<![\s\S])\uFEFF)?[ \t]*(:{3,}) +figure[ \t]*$/,
789
+ 'on:begin': pushFence('_groupFences'),
790
+ endScope: 'section',
791
+ end: /^[ \t]*(:{3,})[ \t]*$/,
792
+ 'on:end': popFence('_groupFences'),
793
+ relevance: 10,
794
+ };
795
+
796
+ // The generic container reached only from INSIDE a composite figure group.
797
+ // Its `contains` differs from DIV_BLOCK's (assigned below), so a bare
798
+ // `::: figure` at any depth inside an open group reads as the generic
799
+ // container PART 9 §4c degrades it to (GROUPS DO NOT NEST; corpus
800
+ // 318-composite-figures-9). The scope stays `keyword` - it IS a div, and a
801
+ // consumer selecting that must keep seeing it.
802
+ //
803
+ // Its `begin` also differs, in the one way that lets a group close at all.
804
+ // highlight.js tries a mode's CONTAINS before its own `end`, so DIV_BLOCK's
805
+ // optional tail - which makes a bare `:::` line a typeless div opener -
806
+ // matched the group's own closing fence and opened a phantom container
807
+ // instead of closing the group. Measured before this line existed: a second
808
+ // `::: figure` later in the same document scoped `keyword`, because the
809
+ // whole rest of the file was still inside that phantom.
810
+ //
811
+ // Requiring the tail here is not a narrowing of what Carve accepts, it is
812
+ // the colon-fence depth rule (PART 9 §12) written where highlight.js can
813
+ // act on it: a `:::` line inside a `:::` container CLOSES it, and a
814
+ // container nested inside one has to open with a LONGER run. So a bare
815
+ // fence line inside a group is a closer, and reaching `end` is the correct
816
+ // reading of it. A longer bare run (`::::` inside a `:::` group) fails the
817
+ // width check in `on:end` and is left unscoped, which is the one case this
818
+ // trades away.
819
+ const DIV_BLOCK_IN_GROUP = {
820
+ ...DIV_BLOCK,
821
+ begin: /^(?:(?<![\s\S])\uFEFF)?[ \t]*(:{3,})(?:[ \t]*(?:\||\\)|[ \t]*[a-zA-Z_][\w-]*(?:[ \t]+"[^"\n]*")?(?:[ \t]+\[[^\]\n]*\])?|[ \t]*\[[^\]\n]*\])[ \t]*$/,
822
+ 'on:begin': pushFence('_groupDivFences'),
823
+ 'on:end': popFence('_groupDivFences'),
824
+ };
825
+
587
826
  // Carve comments: `%%` to end of line, a `%%%` fenced block, and the
588
- // CriticMarkup comment `{# ... #}`. (An earlier rule here matched
589
- // `{% ... %}`, which is Jinja/Liquid syntax and does not exist in Carve.)
827
+ // CriticMarkup comment `{# ... #}`.
828
+ //
829
+ const DELIMITED_COMMENT = {
830
+ className: 'comment',
831
+ begin: /\{%/,
832
+ end: /%\}/,
833
+ relevance: 5,
834
+ };
590
835
  const LINE_COMMENT = {
591
836
  className: 'comment',
592
837
  begin: /(?:^|(?<=\s))%%(?!%)/,
@@ -606,7 +851,36 @@
606
851
  // `ignoreMatch()` - the same idiom the bundled markdown grammar uses.
607
852
  const BLOCK_COMMENT = {
608
853
  className: 'comment',
609
- begin: /^(?:(?<![\s\S])\uFEFF)?[ \t]*(%{3,})[^\n]*$/,
854
+ // THE CLOSER IS REQUIRED UP FRONT, as in both marker-line fences below,
855
+ // and for the reason they already give: highlight.js has no begin->end
856
+ // backreference, so an opener with no closer runs to END OF FILE. That
857
+ // is the worst failure a highlighter has - everything below one stray
858
+ // `%%%` is greyed out - and PART 9 S28 says the opposite happens: an
859
+ // UNTERMINATED run opens nothing, degrades to a line comment, and leaves
860
+ // the block below it visible (`%%%` / blank / `after` renders
861
+ // `<p>after</p>`). Measured at every indent, column 0 included, and the
862
+ // swallow reproduced at all of them (carve-grammars#260).
863
+ //
864
+ // The lookahead carries the width rule too: `\1(?!%)` matches the
865
+ // opener's run EXACTLY, so `%%%` offered a `%%%%` closer is still
866
+ // unterminated - which the engine renders with the body VISIBLE between
867
+ // two vanished runs, not hidden.
868
+ // THE FORWARD SCAN IS BOUNDED, and the bound is the whole reason this
869
+ // lookahead is affordable. Proving there is NO closer costs a scan to
870
+ // end of input, and a document can hold one unmatched run per fence
871
+ // WIDTH, so an adversarial file pays that scan many times over: 2000
872
+ // runs of increasing width (2 MB) took 2045 ms unbounded and 40 ms at
873
+ // 8000 characters, and the bounded form grows linearly where the
874
+ // unbounded one grows with the square. Prism carries the same unbounded
875
+ // scan and measured 14.2 s on the 8 MB case, so this is the rule's cost
876
+ // rather than this engine's (carve-grammars#260).
877
+ //
878
+ // A closer further than 8000 characters below its opener is therefore
879
+ // not found, and the fence degrades to a line comment. That is the SAFE
880
+ // direction and the one this whole rule is about: a run that opens
881
+ // nothing leaves the text below it VISIBLE, where the failure being
882
+ // fixed here hid it.
883
+ begin: /^(?:(?<![\s\S])\uFEFF)?[ \t]*(%{3,})(?!%)[^\n]*$(?=[\s\S]{0,8000}?\n[ \t]*\1(?!%)[^\n]*$)/,
610
884
  'on:begin': (m, resp) => {
611
885
  resp.data._fenceWidth = m[1].length;
612
886
  },
@@ -621,6 +895,48 @@
621
895
  },
622
896
  relevance: 10,
623
897
  };
898
+ // A fence may also open on a list item's MARKER LINE (`- %%%`), and then
899
+ // the body is hidden exactly as it is anywhere else - §24 S2 and §28 make a
900
+ // comment's body verbatim and invisible WHEREVER the fence sits (corpus
901
+ // 337). `BLOCK_COMMENT` above cannot reach that shape: its begin is
902
+ // line-anchored, so on `- %%%` it never fires, the scanner then takes the
903
+ // REAL closer on the next line for an opener, and the comment runs to end
904
+ // of file - swallowing every block after the item.
905
+ //
906
+ // The marker is matched in a LOOKBEHIND rather than consumed, so
907
+ // `TASK_LIST`/`LIST_BULLET`/`LIST_NUMBER` still scope it as a bullet (which
908
+ // is what tree-sitter-carve does too: a `fenced_comment_block` sits INSIDE
909
+ // `list_item_content`, beside a `list_marker_*`, not over it).
910
+ // `LIST_MARKER_BEFORE_BLOCK` is defined above BLOCKQUOTE, which needs the
911
+ // same prefix for `- > x`.
912
+ // A line that is blank, or indented by at least one column. A COLUMN-0 line
913
+ // is neither, and that is the point: it ends the container and with it the
914
+ // fence, so it must not be skipped over while looking for the closer
915
+ // (corpus 326-6 - `- %%%` / `c` / `%%%` leaves `c` and the trailing
916
+ // paragraph VISIBLE, with the unclosed opener degrading to a line comment).
917
+ const BLANK_OR_INDENTED_LINE = '(?:\\n(?![ \\t]*[^ \\t\\n])[^\\n]*|\\n[ \\t]+[^\\n]*)';
918
+ const BLOCK_COMMENT_ON_MARKER_LINE = {
919
+ className: 'comment',
920
+ // The closer is REQUIRED up front, unlike `BLOCK_COMMENT`. highlight.js
921
+ // has no begin->end backreference, so without this guard an opener with
922
+ // no closer would run to end of file - and on this shape an unclosed
923
+ // opener is the common case (a column-0 line ends the item), where the
924
+ // right answer is a one-line comment, not a swallowed document.
925
+ begin: RegExp(
926
+ '(?<=' + LIST_MARKER_BEFORE_BLOCK + ')(%{3,})(?!%)[^\\n]*$'
927
+ + '(?=' + BLANK_OR_INDENTED_LINE + '*?\\n[ \\t]+\\1(?!%)[^\\n]*$)',
928
+ ),
929
+ 'on:begin': (m, resp) => {
930
+ resp.data._fenceWidth = m[1].length;
931
+ },
932
+ // `[ \t]+`, not `[ \t]*`: the closer sits at the item's content column,
933
+ // and a column-0 run is a different block entirely (see above).
934
+ end: /^[ \t]+(%{3,})[^\n]*$/,
935
+ 'on:end': (m, resp) => {
936
+ if (m[1].length !== resp.data._fenceWidth) resp.ignoreMatch();
937
+ },
938
+ relevance: 10,
939
+ };
624
940
  const CRITIC_SUB = {
625
941
  className: 'meta',
626
942
  // The `~>` arrow is what distinguishes a substitution from a forced
@@ -633,7 +949,7 @@
633
949
  className: 'comment',
634
950
  // The closing `#}` is required, or this would swallow an attribute
635
951
  // block whose id comes first (`{#id .class}`).
636
- begin: /\{#(?=[^}\n]*#\})/,
952
+ begin: /\{#(?=[^}\n]{0,4096}#\})/,
637
953
  end: /#\}/,
638
954
  relevance: 5,
639
955
  };
@@ -747,18 +1063,12 @@
747
1063
  HEADING.contains = [HEADING_TAG];
748
1064
 
749
1065
  const CONTAINS = [
750
- // NOTE: front matter is intentionally NOT highlighted. It is valid
751
- // only at the very top of the document, but highlight.js has no
752
- // document-start anchor, so a `^---$` begin would also match a bare
753
- // `---` horizontal rule mid-document and swallow everything up to
754
- // the next `---`. The horizontal-rule rule below handles `---`
755
- // lines instead. (Prism anchors front matter via `^` with no `m`
756
- // flag; see prism/carve.js.)
757
-
758
1066
  // Block-level elements (order matters - more specific first)
1067
+ FRONT_MATTER,
759
1068
  HEADING,
760
1069
  CODE_FENCE_START,
761
1070
  CODE_FENCE_END,
1071
+ FIGURE_GROUP_BLOCK, // Must be before DIV_BLOCK (both match `::: figure`)
762
1072
  DIV_BLOCK,
763
1073
  HORIZONTAL_RULE,
764
1074
  TABLE_SEPARATOR,
@@ -792,6 +1102,9 @@
792
1102
  INSERT, // {+text+}
793
1103
  DELETE, // {-text-}
794
1104
  BLOCK_COMMENT, // %%% fence - before LINE_COMMENT
1105
+ BLOCK_COMMENT_ON_MARKER_LINE, // `- %%%` - the marker is left to the list rules
1106
+ UNTERMINATED_BLOCK_COMMENT, // a `%%%` run with no closer - AFTER both fences
1107
+ DELIMITED_COMMENT, // {% ... %}
795
1108
  LINE_COMMENT, // %% to end of line
796
1109
  CRITIC_SUB, // {~old~>new~} - before FORCED_STRIKE
797
1110
  CRITIC_COMMENT, // {# ... #} - must be before ATTRIBUTE
@@ -824,7 +1137,44 @@
824
1137
  // so a nested div still scopes. highlight.js resolves the 'self' string
825
1138
  // to this same mode natively, so this does not need the width-tracking
826
1139
  // idiom above to also handle recursion.
827
- DIV_BLOCK.contains = ['self', ...CONTAINS.filter((mode) => mode !== ABBREVIATION_DEF)];
1140
+ // `'self'` sits LAST, not first. highlight.js takes the first mode in
1141
+ // `contains` that matches at the earliest position, and DIV_BLOCK's own
1142
+ // begin matches `::: figure` (its tail is optional), so a leading `'self'`
1143
+ // out-ranked FIGURE_GROUP_BLOCK for every group nested inside a div - and
1144
+ // because a div's closing fence opens a phantom `'self'` too, "inside a
1145
+ // div" meant "anywhere after the first container in the document".
1146
+ // Measured: `::: note` / `:::` / blank / `::: figure` scoped the group
1147
+ // `keyword`. It is also redundant where it stands, since DIV_BLOCK is
1148
+ // itself an entry in CONTAINS; it is kept, at the end, as the explicit
1149
+ // statement that a div nests in a div.
1150
+ DIV_BLOCK.contains = [...CONTAINS.filter((mode) => mode !== ABBREVIATION_DEF), 'self'];
1151
+
1152
+ // A composite figure's body is the div body MINUS FIGURE_GROUP_BLOCK (PART 9
1153
+ // §4c: GROUPS DO NOT NEST - a bare `::: figure` inside an open group is a
1154
+ // generic container at any depth, corpus 318-composite-figures-9). Dropping
1155
+ // the mode is what makes the inner opener fall to the div mode, which is
1156
+ // exactly the generic reading the clause asks for.
1157
+ //
1158
+ // The div reached from here is DIV_BLOCK_IN_GROUP rather than DIV_BLOCK, so
1159
+ // the exclusion survives a level of nesting: DIV_BLOCK still offers
1160
+ // FIGURE_GROUP_BLOCK (a bare opener inside a `::: note` IS a group - only a
1161
+ // group suppresses one), and this variant does not, so
1162
+ // `::: figure` > `::: note` > `::: figure` reads generic too. `'self'` would
1163
+ // not do here: inside DIV_BLOCK_IN_GROUP it resolves to that same div, which
1164
+ // is what is wanted, but the mode has to be a distinct object for its
1165
+ // `contains` to differ from DIV_BLOCK's at all.
1166
+ //
1167
+ // RESIDUAL, written down rather than left to be rediscovered: a bare opener
1168
+ // reached through a LIST ITEM or a BLOCKQUOTE inside a group is matched by
1169
+ // the top-level modes again, so it over-colours as a group there. That is
1170
+ // the same block-context limit every mode in this file has (see the
1171
+ // indented-block-openers note in the module docblock); tree-sitter-carve is
1172
+ // where a real container model lives.
1173
+ const IN_GROUP = CONTAINS
1174
+ .filter((mode) => mode !== ABBREVIATION_DEF && mode !== FIGURE_GROUP_BLOCK)
1175
+ .map((mode) => (mode === DIV_BLOCK ? DIV_BLOCK_IN_GROUP : mode));
1176
+ FIGURE_GROUP_BLOCK.contains = IN_GROUP;
1177
+ DIV_BLOCK_IN_GROUP.contains = [...IN_GROUP.filter((mode) => mode !== DIV_BLOCK_IN_GROUP), 'self'];
828
1178
 
829
1179
  return {
830
1180
  name: 'Carve',