@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.
- package/README.md +46 -0
- package/highlightjs/carve.js +375 -25
- package/package.json +25 -10
- package/prism/carve.js +319 -26
- package/textmate/carve.tmLanguage.json +294 -7
- package/tiptap/carve-editor.d.ts +33 -0
- package/tiptap/carve-editor.js +109 -0
- package/tiptap/carve-kit.js +111 -26
- package/tiptap/carve-to-pm.js +382 -56
- package/tiptap/extensions/carve-attribute-slots.js +80 -0
- package/tiptap/extensions/carve-citation-definition.js +32 -0
- package/tiptap/extensions/carve-citation.js +19 -0
- package/tiptap/extensions/carve-comment.js +2 -2
- package/tiptap/extensions/carve-crossref.js +17 -0
- package/tiptap/extensions/carve-div.js +11 -1
- package/tiptap/extensions/carve-empty-mark.js +66 -0
- package/tiptap/extensions/carve-figure.js +43 -1
- package/tiptap/extensions/carve-frontmatter.js +17 -0
- package/tiptap/extensions/carve-heading.js +4 -2
- package/tiptap/extensions/carve-inline-extension.js +38 -0
- package/tiptap/extensions/carve-inline-note.js +17 -0
- package/tiptap/extensions/carve-link-ref-def.js +27 -0
- package/tiptap/extensions/carve-literal.js +17 -0
- package/tiptap/extensions/carve-math.js +4 -2
- package/tiptap/extensions/carve-raw-inline.js +19 -0
- package/tiptap/extensions/carve-section.js +15 -0
- package/tiptap/extensions/carve-span.js +11 -3
- package/tiptap/extensions/carve-substitution.js +20 -0
- package/tiptap/extensions/carve-symbol.js +17 -0
- package/tiptap/extensions/carve-unsupported-inline.js +7 -0
- package/tiptap/extensions/carve-unsupported.js +7 -0
- package/tiptap/extensions/index.js +14 -1
- package/tiptap/index.d.ts +70 -0
- package/tiptap/index.js +4 -2
- package/tiptap/schema-map.json +340 -38
- package/tiptap/serializer.js +360 -42
- 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}`, `{.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
|
package/highlightjs/carve.js
CHANGED
|
@@ -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:  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: /\[\+?(?:[^\]@]
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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 `{# ... #}`.
|
|
589
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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',
|