spec: composite figures - ::: figure as a captionable host - #1215
Merged
Conversation
This was referenced Aug 15, 2026
Merged
dereuromark
added a commit
to markup-carve/carve-js
that referenced
this pull request
Aug 15, 2026
PART 9 section 4c (markup-carve/carve#1122). A bare `::: figure` container - the fence, its separator, and the kind word, nothing else - parses as the new `figure_group` node: ONE figure whose direct captionable children are its PANELS, in source order. The body parses under the unchanged inner rules, so a panel is simply the `figure` or `table` node those rules already produce - a captioned image paragraph, a captioned quote, a captioned code listing, captioned display math, or a table captioned or not - and everything else is plain group content, preserved in place. The `^ ` line after the CLOSING fence is section 4's sixth caption host, this kind only, and it reaches the group through the same tryParseCaption slot every other host uses, so the one-blank-line allowance, the two-blank detachment, the invisible-interrupter rule and the second-caption rule all hold without a second spelling. An opener carrying a quoted title or a `[label]` does not match the production and stays a generic Tier-2 container, title and label preserved; a bare figure opener anywhere inside an open group's body stays one too, because groups do not nest. NUMBERING (PART 9R R5 through section 4c): the group is one sequence unit. Its caption's `#` draws one number from the label's own sequence, the group id registers as "Label N", and each panel id registers as "Label N" plus a letter by panel order - a..z, then aa - but only when the group itself drew a number. Panels draw nothing: the resolver skips them, and a `#` in a panel caption stays LITERAL. That last word is why renderCaptionNumber() now prints `#` for an unresolved placeholder instead of the empty string - the Markdown, plain-text and ANSI targets already printed the literal, and the empty string was unreachable before this change because every placeholder the walk found got a number. The wire shape is PART 12 section 16: `type`, `children`, an optional `caption` holding inline content - the same flattening a table's caption already gets - `attrs` and `pos`, and deliberately no target, title or label. The vendored resources/ast-schema.json moves to the spec pin that names it, and the ProseMirror bridge lists the type as unmapped beside `figure`. The canonical writer emits the authored form and does not escape the group caption's caret (it is the caption the closer hosts, not text in that position) while a paragraph that merely sits where the allowance would reach the closer keeps its `\^ ` escape; Markdown, plain text and ANSI degrade per PART 11 section 10g; the HTML importer reads the rendered carve-figure-group shape back to `::: figure` source, own output only. The tests/spec submodule pin moves to 078c32d on the spec draft branch spec/1122-composite-figures (markup-carve/carve#1215), which carries the corpus category 318-composite-figures on top of the quote-attribution withdrawal this engine already made. The pin is temporary and moves to the merged spec main commit before this PR is undrafted. A cross-engine parity pass against carve-php and carve-rs settled three divergences before this landed. Markdown separates a panel host from its emphasized caption with a blank line - the caption is its own paragraph. The canonical writer escapes ONLY the structural caret of a detached caption paragraph: the caret sat outside the minimal escape class, so the minimal form always failed the redundancy check and the whole document escalated to conservative escaping, writing escaped parens and hashes where the other engines write the characters bare. And a panel caption's placeholder stays a TYPED caption_number node on the wire, un-numbered, rather than flattening to text - the unresolved-reference precedent: keep the node, render its authored spelling, which the HTML arm now does for an unresolved placeholder instead of rendering nothing.
dereuromark
added a commit
to markup-carve/carve-php
that referenced
this pull request
Aug 15, 2026
PART 9 section 4c (markup-carve/carve#1122). A bare `::: figure` container - the fence, its separator, and the kind word, nothing else - parses as the new `figure_group` node: ONE figure whose direct captionable children are its PANELS, in source order. The body parses under the unchanged inner rules, so a panel is simply the `figure` or `table` node those rules already produce - a captioned image paragraph, a captioned quote, a captioned code listing, captioned display math, or a table captioned or not - and everything else is plain group content, preserved in place. The `^ ` line after the CLOSING fence is section 4's sixth caption host, this kind only, and it reaches the group through the same tryParseCaption slot every other host uses, so the one-blank-line allowance, the two-blank detachment, the invisible-interrupter rule and the second-caption rule all hold without a second spelling. An opener carrying a quoted title or a `[label]` does not match the production and stays a generic Tier-2 container, title and label preserved; a bare figure opener anywhere inside an open group's body stays one too, because groups do not nest. NUMBERING (PART 9R R5 through section 4c): the group is one sequence unit. Its caption's `#` draws one number from the label's own sequence, the group id registers as "Label N", and each panel id registers as "Label N" plus a letter by panel order - a..z, then aa - but only when the group itself drew a number. Panels draw nothing: the resolver skips them, and a `#` in a panel caption stays LITERAL. That last word is why renderCaptionNumber() now prints `#` for an unresolved placeholder instead of the empty string - the Markdown, plain-text and ANSI targets already printed the literal, and the empty string was unreachable before this change because every placeholder the walk found got a number. The wire shape is PART 12 section 16: `type`, `children`, an optional `caption` holding inline content - the same flattening a table's caption already gets - `attrs` and `pos`, and deliberately no target, title or label. The vendored resources/ast-schema.json moves to the spec pin that names it, and the ProseMirror bridge lists the type as unmapped beside `figure`. The canonical writer emits the authored form and does not escape the group caption's caret (it is the caption the closer hosts, not text in that position) while a paragraph that merely sits where the allowance would reach the closer keeps its `\^ ` escape; Markdown, plain text and ANSI degrade per PART 11 section 10g; the HTML importer reads the rendered carve-figure-group shape back to `::: figure` source, own output only. The tests/spec submodule pin moves to 078c32d on the spec draft branch spec/1122-composite-figures (markup-carve/carve#1215), which carries the corpus category 318-composite-figures on top of the quote-attribution withdrawal this engine already made. The pin is temporary and moves to the merged spec main commit before this PR is undrafted.
This was referenced Aug 15, 2026
Closed
dereuromark
force-pushed
the
spec/1122-composite-figures
branch
from
August 15, 2026 09:12
078c32d to
153f665
Compare
dereuromark
added a commit
to markup-carve/carve-js
that referenced
this pull request
Aug 15, 2026
PART 9 section 4c (markup-carve/carve#1122). A bare `::: figure` container - the fence, its separator, and the kind word, nothing else - parses as the new `figure_group` node: ONE figure whose direct captionable children are its PANELS, in source order. The body parses under the unchanged inner rules, so a panel is simply the `figure` or `table` node those rules already produce - a captioned image paragraph, a captioned quote, a captioned code listing, captioned display math, or a table captioned or not - and everything else is plain group content, preserved in place. The `^ ` line after the CLOSING fence is section 4's sixth caption host, this kind only, and it reaches the group through the same tryParseCaption slot every other host uses, so the one-blank-line allowance, the two-blank detachment, the invisible-interrupter rule and the second-caption rule all hold without a second spelling. An opener carrying a quoted title or a `[label]` does not match the production and stays a generic Tier-2 container, title and label preserved; a bare figure opener anywhere inside an open group's body stays one too, because groups do not nest. NUMBERING (PART 9R R5 through section 4c): the group is one sequence unit. Its caption's `#` draws one number from the label's own sequence, the group id registers as "Label N", and each panel id registers as "Label N" plus a letter by panel order - a..z, then aa - but only when the group itself drew a number. Panels draw nothing: the resolver skips them, and a `#` in a panel caption stays LITERAL. That last word is why renderCaptionNumber() now prints `#` for an unresolved placeholder instead of the empty string - the Markdown, plain-text and ANSI targets already printed the literal, and the empty string was unreachable before this change because every placeholder the walk found got a number. The wire shape is PART 12 section 16: `type`, `children`, an optional `caption` holding inline content - the same flattening a table's caption already gets - `attrs` and `pos`, and deliberately no target, title or label. The vendored resources/ast-schema.json moves to the spec pin that names it, and the ProseMirror bridge lists the type as unmapped beside `figure`. The canonical writer emits the authored form and does not escape the group caption's caret (it is the caption the closer hosts, not text in that position) while a paragraph that merely sits where the allowance would reach the closer keeps its `\^ ` escape; Markdown, plain text and ANSI degrade per PART 11 section 10g; the HTML importer reads the rendered carve-figure-group shape back to `::: figure` source, own output only. The tests/spec submodule pin moves to 078c32d on the spec draft branch spec/1122-composite-figures (markup-carve/carve#1215), which carries the corpus category 318-composite-figures on top of the quote-attribution withdrawal this engine already made. The pin is temporary and moves to the merged spec main commit before this PR is undrafted. A cross-engine parity pass against carve-php and carve-rs settled three divergences before this landed. Markdown separates a panel host from its emphasized caption with a blank line - the caption is its own paragraph. The canonical writer escapes ONLY the structural caret of a detached caption paragraph: the caret sat outside the minimal escape class, so the minimal form always failed the redundancy check and the whole document escalated to conservative escaping, writing escaped parens and hashes where the other engines write the characters bare. And a panel caption's placeholder stays a TYPED caption_number node on the wire, un-numbered, rather than flattening to text - the unresolved-reference precedent: keep the node, render its authored spelling, which the HTML arm now does for an unresolved placeholder instead of rendering nothing. A branch review then caught the class of miss that produces most cross-engine drift here: an existing traversal that switches on block types and lacks the new arm. The Markdown prepass never entered a group, so a heading inside one lost its anchor stamp and every crossref to it degraded to plain text; the lint heading index had the same hole, reporting valid references as broken and missing duplicate ids inside groups. Both descend now, and a sweep over every block-type switch in src/ (via the block_quote arms they all share) confirms the remaining walkers either carry the figure_group arm or traverse generically by field name, which covers it by construction.
dereuromark
force-pushed
the
spec/1122-composite-figures
branch
from
August 15, 2026 09:15
153f665 to
357e752
Compare
dereuromark
added a commit
to markup-carve/carve-js
that referenced
this pull request
Aug 15, 2026
PART 9 section 4c (markup-carve/carve#1122). A bare `::: figure` container - the fence, its separator, and the kind word, nothing else - parses as the new `figure_group` node: ONE figure whose direct captionable children are its PANELS, in source order. The body parses under the unchanged inner rules, so a panel is simply the `figure` or `table` node those rules already produce - a captioned image paragraph, a captioned quote, a captioned code listing, captioned display math, or a table captioned or not - and everything else is plain group content, preserved in place. The `^ ` line after the CLOSING fence is section 4's sixth caption host, this kind only, and it reaches the group through the same tryParseCaption slot every other host uses, so the one-blank-line allowance, the two-blank detachment, the invisible-interrupter rule and the second-caption rule all hold without a second spelling. An opener carrying a quoted title or a `[label]` does not match the production and stays a generic Tier-2 container, title and label preserved; a bare figure opener anywhere inside an open group's body stays one too, because groups do not nest. NUMBERING (PART 9R R5 through section 4c): the group is one sequence unit. Its caption's `#` draws one number from the label's own sequence, the group id registers as "Label N", and each panel id registers as "Label N" plus a letter by panel order - a..z, then aa - but only when the group itself drew a number. Panels draw nothing: the resolver skips them, and a `#` in a panel caption stays LITERAL. That last word is why renderCaptionNumber() now prints `#` for an unresolved placeholder instead of the empty string - the Markdown, plain-text and ANSI targets already printed the literal, and the empty string was unreachable before this change because every placeholder the walk found got a number. The wire shape is PART 12 section 16: `type`, `children`, an optional `caption` holding inline content - the same flattening a table's caption already gets - `attrs` and `pos`, and deliberately no target, title or label. The vendored resources/ast-schema.json moves to the spec pin that names it, and the ProseMirror bridge lists the type as unmapped beside `figure`. The canonical writer emits the authored form and does not escape the group caption's caret (it is the caption the closer hosts, not text in that position) while a paragraph that merely sits where the allowance would reach the closer keeps its `\^ ` escape; Markdown, plain text and ANSI degrade per PART 11 section 10g; the HTML importer reads the rendered carve-figure-group shape back to `::: figure` source, own output only. The tests/spec submodule pin moves to 078c32d on the spec draft branch spec/1122-composite-figures (markup-carve/carve#1215), which carries the corpus category 318-composite-figures on top of the quote-attribution withdrawal this engine already made. The pin is temporary and moves to the merged spec main commit before this PR is undrafted. A cross-engine parity pass against carve-php and carve-rs settled three divergences before this landed. Markdown separates a panel host from its emphasized caption with a blank line - the caption is its own paragraph. The canonical writer escapes ONLY the structural caret of a detached caption paragraph: the caret sat outside the minimal escape class, so the minimal form always failed the redundancy check and the whole document escalated to conservative escaping, writing escaped parens and hashes where the other engines write the characters bare. And a panel caption's placeholder stays a TYPED caption_number node on the wire, un-numbered, rather than flattening to text - the unresolved-reference precedent: keep the node, render its authored spelling, which the HTML arm now does for an unresolved placeholder instead of rendering nothing. A branch review then caught the class of miss that produces most cross-engine drift here: an existing traversal that switches on block types and lacks the new arm. The Markdown prepass never entered a group, so a heading inside one lost its anchor stamp and every crossref to it degraded to plain text; the lint heading index had the same hole, reporting valid references as broken and missing duplicate ids inside groups. Both descend now, and a sweep over every block-type switch in src/ (via the block_quote arms they all share) confirms the remaining walkers either carry the figure_group arm or traverse generically by field name, which covers it by construction.
dereuromark
added a commit
to markup-carve/carve-php
that referenced
this pull request
Aug 15, 2026
PART 9 section 4c (markup-carve/carve#1122). A bare `::: figure` container - the fence, its separator, and the kind word, nothing else - parses as the new `figure_group` node: ONE figure whose direct captionable children are its PANELS, in source order. The body parses under the unchanged inner rules, so a panel is simply the `figure` or `table` node those rules already produce - a captioned image paragraph, a captioned quote, a captioned code listing, captioned display math, or a table captioned or not - and everything else is plain group content, preserved in place. The `^ ` line after the CLOSING fence is section 4's sixth caption host, this kind only, and it reaches the group through the same tryParseCaption slot every other host uses, so the one-blank-line allowance, the two-blank detachment, the invisible-interrupter rule and the second-caption rule all hold without a second spelling. An opener carrying a quoted title or a `[label]` does not match the production and stays a generic Tier-2 container, title and label preserved; a bare figure opener anywhere inside an open group's body stays one too, because groups do not nest. NUMBERING (PART 9R R5 through section 4c): the group is one sequence unit. Its caption's `#` draws one number from the label's own sequence, the group id registers as "Label N", and each panel id registers as "Label N" plus a letter by panel order - a..z, then aa - but only when the group itself drew a number. Panels draw nothing: the resolver skips them, and a `#` in a panel caption stays LITERAL. That last word is why renderCaptionNumber() now prints `#` for an unresolved placeholder instead of the empty string - the Markdown, plain-text and ANSI targets already printed the literal, and the empty string was unreachable before this change because every placeholder the walk found got a number. The wire shape is PART 12 section 16: `type`, `children`, an optional `caption` holding inline content - the same flattening a table's caption already gets - `attrs` and `pos`, and deliberately no target, title or label. The vendored resources/ast-schema.json moves to the spec pin that names it, and the ProseMirror bridge lists the type as unmapped beside `figure`. The canonical writer emits the authored form and does not escape the group caption's caret (it is the caption the closer hosts, not text in that position) while a paragraph that merely sits where the allowance would reach the closer keeps its `\^ ` escape; Markdown, plain text and ANSI degrade per PART 11 section 10g; the HTML importer reads the rendered carve-figure-group shape back to `::: figure` source, own output only. The tests/spec submodule pin moves to 357e752 on the spec draft branch spec/1122-composite-figures (markup-carve/carve#1215), which carries the corpus category 318-composite-figures on top of the quote-attribution withdrawal this engine already made. The pin is temporary and moves to the merged spec main commit before this PR is undrafted. A cross-engine parity pass against carve-php and carve-rs settled three divergences before this landed. Markdown separates a panel host from its emphasized caption with a blank line - the caption is its own paragraph. The canonical writer escapes ONLY the structural caret of a detached caption paragraph: the caret sat outside the minimal escape class, so the minimal form always failed the redundancy check and the whole document escalated to conservative escaping, writing escaped parens and hashes where the other engines write the characters bare. And a panel caption's placeholder stays a TYPED caption_number node on the wire, un-numbered, rather than flattening to text - the unresolved-reference precedent: keep the node, render its authored spelling, which the HTML arm now does for an unresolved placeholder instead of rendering nothing. A branch review then caught the class of miss that produces most cross-engine drift here: an existing traversal that switches on block types and lacks the new arm. The Markdown prepass never entered a group, so a heading inside one lost its anchor stamp and every crossref to it degraded to plain text; the lint heading index had the same hole, reporting valid references as broken and missing duplicate ids inside groups. Both descend now, and a sweep over every block-type switch in src/ (via the block_quote arms they all share) confirms the remaining walkers either carry the figure_group arm or traverse generically by field name, which covers it by construction.
dereuromark
force-pushed
the
spec/1122-composite-figures
branch
from
August 15, 2026 10:37
357e752 to
a713514
Compare
PART 9 gains the composite-figure host carve#1122 asked for: a bare ::: figure container is a figure_group - one figure-numbering unit whose direct captionable children are its panels, with the caption after the CLOSING fence (caption placement's sixth host, this kind only) captioning and numbering the whole group. An opener carrying a quoted title or a label stays a generic container, as does a nested bare opener; both lint. Panels draw nothing from the document sequence; a panel id resolves a crossref as the group number plus a letter by panel order. The executable oracle parses and renders the construct, ten corpus cases pin the bytes, the AST schema gains the additive figure_group node, and the new slugs the pinned engine cannot reproduce are declared in the engine-pin-drift file; the pin itself does not move here.
dereuromark
force-pushed
the
spec/1122-composite-figures
branch
from
August 15, 2026 10:40
a713514 to
012f23a
Compare
dereuromark
marked this pull request as ready for review
August 15, 2026 10:41
dereuromark
added a commit
to markup-carve/carve-php
that referenced
this pull request
Aug 15, 2026
PART 9 section 4c (markup-carve/carve#1122). A bare `::: figure` container - the fence, its separator, and the kind word, nothing else - parses as the new `figure_group` node: ONE figure whose direct captionable children are its PANELS, in source order. The body parses under the unchanged inner rules, so a panel is simply the `figure` or `table` node those rules already produce - a captioned image paragraph, a captioned quote, a captioned code listing, captioned display math, or a table captioned or not - and everything else is plain group content, preserved in place. The `^ ` line after the CLOSING fence is section 4's sixth caption host, this kind only, and it reaches the group through the same tryParseCaption slot every other host uses, so the one-blank-line allowance, the two-blank detachment, the invisible-interrupter rule and the second-caption rule all hold without a second spelling. An opener carrying a quoted title or a `[label]` does not match the production and stays a generic Tier-2 container, title and label preserved; a bare figure opener anywhere inside an open group's body stays one too, because groups do not nest. NUMBERING (PART 9R R5 through section 4c): the group is one sequence unit. Its caption's `#` draws one number from the label's own sequence, the group id registers as "Label N", and each panel id registers as "Label N" plus a letter by panel order - a..z, then aa - but only when the group itself drew a number. Panels draw nothing: the resolver skips them, and a `#` in a panel caption stays LITERAL. That last word is why renderCaptionNumber() now prints `#` for an unresolved placeholder instead of the empty string - the Markdown, plain-text and ANSI targets already printed the literal, and the empty string was unreachable before this change because every placeholder the walk found got a number. The wire shape is PART 12 section 16: `type`, `children`, an optional `caption` holding inline content - the same flattening a table's caption already gets - `attrs` and `pos`, and deliberately no target, title or label. The vendored resources/ast-schema.json moves to the spec pin that names it, and the ProseMirror bridge lists the type as unmapped beside `figure`. The canonical writer emits the authored form and does not escape the group caption's caret (it is the caption the closer hosts, not text in that position) while a paragraph that merely sits where the allowance would reach the closer keeps its `\^ ` escape; Markdown, plain text and ANSI degrade per PART 11 section 10g; the HTML importer reads the rendered carve-figure-group shape back to `::: figure` source, own output only. The tests/spec submodule pin moves to 9cd27d8 on spec main, the merged markup-carve/carve#1215, which carries the corpus category 318-composite-figures on top of the quote-attribution withdrawal this engine already made. A cross-engine parity pass against carve-php and carve-rs settled three divergences before this landed. Markdown separates a panel host from its emphasized caption with a blank line - the caption is its own paragraph. The canonical writer escapes ONLY the structural caret of a detached caption paragraph: the caret sat outside the minimal escape class, so the minimal form always failed the redundancy check and the whole document escalated to conservative escaping, writing escaped parens and hashes where the other engines write the characters bare. And a panel caption's placeholder stays a TYPED caption_number node on the wire, un-numbered, rather than flattening to text - the unresolved-reference precedent: keep the node, render its authored spelling, which the HTML arm now does for an unresolved placeholder instead of rendering nothing. A branch review then caught the class of miss that produces most cross-engine drift here: an existing traversal that switches on block types and lacks the new arm. The Markdown prepass never entered a group, so a heading inside one lost its anchor stamp and every crossref to it degraded to plain text; the lint heading index had the same hole, reporting valid references as broken and missing duplicate ids inside groups. Both descend now, and a sweep over every block-type switch in src/ (via the block_quote arms they all share) confirms the remaining walkers either carry the figure_group arm or traverse generically by field name, which covers it by construction.
dereuromark
added a commit
to markup-carve/carve-php
that referenced
this pull request
Aug 15, 2026
PART 9 section 4c (markup-carve/carve#1122). A bare `::: figure` container - the fence, its separator, and the kind word, nothing else - parses as the new `figure_group` node: ONE figure whose direct captionable children are its PANELS, in source order. The body parses under the unchanged inner rules, so a panel is simply the `figure` or `table` node those rules already produce - a captioned image paragraph, a captioned quote, a captioned code listing, captioned display math, or a table captioned or not - and everything else is plain group content, preserved in place. The `^ ` line after the CLOSING fence is section 4's sixth caption host, this kind only, and it reaches the group through the same tryParseCaption slot every other host uses, so the one-blank-line allowance, the two-blank detachment, the invisible-interrupter rule and the second-caption rule all hold without a second spelling. An opener carrying a quoted title or a `[label]` does not match the production and stays a generic Tier-2 container, title and label preserved; a bare figure opener anywhere inside an open group's body stays one too, because groups do not nest. NUMBERING (PART 9R R5 through section 4c): the group is one sequence unit. Its caption's `#` draws one number from the label's own sequence, the group id registers as "Label N", and each panel id registers as "Label N" plus a letter by panel order - a..z, then aa - but only when the group itself drew a number. Panels draw nothing: the resolver skips them, and a `#` in a panel caption stays LITERAL. That last word is why renderCaptionNumber() now prints `#` for an unresolved placeholder instead of the empty string - the Markdown, plain-text and ANSI targets already printed the literal, and the empty string was unreachable before this change because every placeholder the walk found got a number. The wire shape is PART 12 section 16: `type`, `children`, an optional `caption` holding inline content - the same flattening a table's caption already gets - `attrs` and `pos`, and deliberately no target, title or label. The vendored resources/ast-schema.json moves to the spec pin that names it, and the ProseMirror bridge lists the type as unmapped beside `figure`. The canonical writer emits the authored form and does not escape the group caption's caret (it is the caption the closer hosts, not text in that position) while a paragraph that merely sits where the allowance would reach the closer keeps its `\^ ` escape; Markdown, plain text and ANSI degrade per PART 11 section 10g; the HTML importer reads the rendered carve-figure-group shape back to `::: figure` source, own output only. The tests/spec submodule pin moves to 9cd27d8 on spec main, the merged markup-carve/carve#1215, which carries the corpus category 318-composite-figures on top of the quote-attribution withdrawal this engine already made. A cross-engine parity pass against carve-php and carve-rs settled three divergences before this landed. Markdown separates a panel host from its emphasized caption with a blank line - the caption is its own paragraph. The canonical writer escapes ONLY the structural caret of a detached caption paragraph: the caret sat outside the minimal escape class, so the minimal form always failed the redundancy check and the whole document escalated to conservative escaping, writing escaped parens and hashes where the other engines write the characters bare. And a panel caption's placeholder stays a TYPED caption_number node on the wire, un-numbered, rather than flattening to text - the unresolved-reference precedent: keep the node, render its authored spelling, which the HTML arm now does for an unresolved placeholder instead of rendering nothing. A branch review then caught the class of miss that produces most cross-engine drift here: an existing traversal that switches on block types and lacks the new arm. The Markdown prepass never entered a group, so a heading inside one lost its anchor stamp and every crossref to it degraded to plain text; the lint heading index had the same hole, reporting valid references as broken and missing duplicate ids inside groups. Both descend now, and a sweep over every block-type switch in src/ (via the block_quote arms they all share) confirms the remaining walkers either carry the figure_group arm or traverse generically by field name, which covers it by construction.
This was referenced Aug 15, 2026
Open
This was referenced Aug 15, 2026
Merged
dereuromark
added a commit
to markup-carve/zed-carve
that referenced
this pull request
Aug 15, 2026
PART 9 section 4c reserves the kind word `figure` among the `:::` types (markup-carve/carve#1215): a BARE opener - the fence, its separator, the word, and nothing else - is one figure of ordered panels rather than an admonition. An opener carrying a quoted title or a `[label]` is not that production and stays a generic container, and groups do not nest, so a bare opener inside an open group is generic again at any depth. tree-sitter-carve landed the queries for this in its own #197. This repo carries its own port of those queries, not a copy, so the patterns are ported rather than pasted: the upstream file paints `@markup.heading`, `@markup.raw.block` and `@markup.quote`, this one paints `@title`, `@text.literal` and the Zed names beside them, and it carries no `(#set! priority N)` anywhere. Upstream tags the new patterns 105 and 110 against a default of 100. Those priorities are redundant with the order the patterns are already written in, and order is what Zed resolves by, which is why `(code) @none` already sits BELOW the `@text.literal` block in this file rather than outranking it. So the port keeps the order and drops the directives, and the comment at the block says to keep it that way. THE PIN MOVE IS BOOKKEEPING HERE, and saying so is the point rather than a caveat. `git diff b6877de..17362de --name-only` upstream touches queries/highlights.scm, tests, CI, CHANGELOG and package.json - grammar.js and src/ are untouched, `div` already carried the `title` and `label` fields at the old revision, and the new patterns compile and resolve identically against it. The bump is still right, because the pin is what says which revision these queries were ported from and the drift job reports the distance, but nothing here would have failed without it. The previous bump in this repo, for the language attribute, was the opposite case: the query did not compile against the old pin at all. And a check this repo did not have. CI compiled every query and parsed every fixture, neither of which can see which of two overlapping patterns an editor ends up showing - a pattern that never wins is indistinguishable from a pattern that is absent, and both stayed green. scripts/highlight-captures.mjs resolves the winner at a position the way Zed does, last capture emitted wins, and asserts fifteen shapes including both directions of the distinction. It shells out to the tree-sitter CLI, so it adds no dependency. One thing the resolver had to get right, found by watching it fail on the blockquote case: `tree-sitter query --captures` prints a capture's text inline, so a capture spanning a newline breaks its own output line and leaves the closing backtick alone in column 0. Treating an unindented line as the next file's header dropped every later capture into a bucket nothing read, and reported a pattern that does match as not matching. File headers are matched against the paths actually passed instead.
dereuromark
added a commit
to markup-carve/intellij-carve
that referenced
this pull request
Aug 15, 2026
… container (#68) PART 9 section 4c (markup-carve/carve#1215) reserves the kind word `figure` among the `:::` types. A BARE opener - the fence, its separator, the word `figure`, and NOTHING else - is ONE figure of ordered panels. An opener carrying a quoted title or a `[label]` is not that production and stays the generic Tier-2 container it has always been. Before this the two read identically. Measured through the IDE's own TextMate engine, every spelling produced the same two scopes: ::: figure markup.other.div.carve entity.name.type.div.carve ::: figure "A titled figure div" markup.other.div.carve entity.name.type.div.carve The distinction lives entirely in the tail of one line, so the new rule's `[ \t]*$` tail is the whole of it: a titled or labelled opener does not match and falls through to `#divs` unchanged. The separator is spelled as a space run, never `[ \t]+`, because a tab does not separate a marker (PART 7, MARKER SEPARATORS; corpus 254 renders `:::<TAB>note` as a paragraph) - a tab-separated opener therefore reads exactly as it did before. WHY BEGIN/END RATHER THAN A LINE RULE. `#divs` is a flat per-line `match`, so this repo's `:::` handling is not stateful today, whatever markup-carve/carve-grammars#222 assumed. Making the new rule begin/end buys the one part of the clause a line rule cannot state: GROUPS DO NOT NEST, at any depth. It falls out of the rule leaving itself out of its own `patterns` - a nested bare opener reaches `#divs`, which recurses into no pattern list and so cannot reach back here from any depth. That is also why there is no `divs-in-group` twin, which carve-grammars needs only because its generic container is itself begin/end. The closer backreferences the opener's colon run so it closes on an exact length match, which is PART 9 section 12's colon-fence depth rule and the reason a bare `:::` inside a `:::: figure` group does not close it. WHAT THE STATE DOES NOT BUY. carve-grammars#222 expects a stateful grammar to scope the post-closer caption exactly where a stateless one over-approximates. It cannot. The group caption is an ordinary `^ ` line one line BELOW the closing fence, which puts it outside the span any begin/end rule can hold; the document-level caption rule claims it, and claims the same line after a `::: note` closer too. The caption position already worked and still does, the over-approximation is unchanged, and the test asserts both halves so the limit is recorded rather than rediscovered. Also recorded as a residual: an opener carrying a bullet prefix (`- ::: figure`, corpus 114) is not matched here and falls to `#divs`. A line-based grammar has no list-item content column, and under-colouring the rare case is the side every block rule in this file already picks. Two tests, because they answer different questions. The `composite-figure` fixture pins the whole token stream over seven shapes, which is what catches an accidental change. `CarveCompositeFigureTest` asserts what the rule is FOR - a golden agrees with whatever the grammar currently does, so on its own it cannot tell a rule that distinguishes the two readings from one that never reaches the second.
dereuromark
added a commit
to markup-carve/sublime-carve
that referenced
this pull request
Aug 15, 2026
PART 9 S4c (markup-carve/carve#1215) reserves the kind word `figure` among the `:::` types: a BARE opener - the fence, its separator, the word, and NOTHING else - is ONE figure of ordered panels. An opener carrying a quoted title or a `[label]` is not that production at all and stays the generic Tier-2 container, with both preserved. Before this the two scoped identically, so a reader of the editor could not tell which of the two readings a line had - and the whole distinction lives in the tail of one line. A `figure-groups` context sits ahead of `divs` in `body`, because the `divs` pattern matches every `:::` line. It claims the bare opener only, and names it with this syntax's own vocabulary rather than carve-grammars': `markup.other.figure-group.carve` on the line, `punctuation.definition.figure-group.carve` on the fence and `entity.name.type.figure-group.carve` on the kind word, beside the `entity.name.type.div.carve` the generic context already uses. THE SEPARATOR IS A SPACE RUN, NEVER A TAB (grammar.ebnf PART 7, MARKER SEPARATORS; corpus 254): `:::<TAB>note` renders as a paragraph. So the new pattern is spelled ` +` rather than the `\s*` `divs` uses beside it. Claiming the tab form would be a stronger claim than the generic context makes, since that one merely over-colours a line this syntax has always over-coloured; a tab-separated opener falls to it and reads exactly as it did. THE GROUP CAPTION NEEDED NO RULE, verified rather than assumed: `divs` pops at end of line, so no `:::` container is ever open across lines here, and the `^ ` line below the closing fence is reached by `captions` at document level like any other. The assertions pin that position so a later narrowing of `captions` cannot take it away silently. RESIDUAL, written into the context's comment rather than left to be rediscovered: groups do not nest, so a bare `::: figure` at any depth inside an open group is a generic container - but with no multi-line `:::` region this syntax has no notion of being inside one, and over-colours the inner opener as a group. carve-grammars carries the rule through a begin/end pair plus a second container variant; giving `divs` a region is what it would take here too, and is out of scope for this change. Tests: `syntax_test_carve.crv` gains 22 assertions over seven opener spellings and the two caption positions - the bare opener, the titled one, the labelled one, both together, the tab-separated one, a longer fence, and `::: figures`, which is a different word. Each generic-container case carries a negative against the new scope as well as a positive for the old one, so the pair fails in both directions. Verified: with the `figure-groups` include removed the bare opener and the longer fence fail against the new scope, and with the pattern widened to accept a trailing title and label the titled, labelled and both-together lines fail their negatives - so those controls are reached by the rule and pass because it rejects them, not because it never sees them. No upgrade message: `messages/` is written at release time in this repo, and the last released version is 0.1.2. README's feature list gains the opener. Ports markup-carve/carve-grammars#223. Tracking: markup-carve/carve-grammars#222.
dereuromark
added a commit
to markup-carve/helix-carve
that referenced
this pull request
Aug 15, 2026
PART 9 section 4c reserves the kind word `figure` among the `:::` types (markup-carve/carve#1215): a BARE opener - the fence, its separator, the word, and nothing else - is one figure of ordered panels rather than an admonition. An opener carrying a quoted title or a `[label]` is not that production and stays a generic container, and groups do not nest, so a bare opener inside an open group is generic again at any depth. tree-sitter-carve landed the queries for this in its own #197. This repo carries a Helix-flavored ADAPTATION of that file, not a copy, so the patterns are ported rather than pasted, and the part that had to be decided is precedence. Upstream tags the new patterns 105 and 110 against a default of 100. This file carries no `#set!` directive at all, and Helix 25.07 ships none in any of its own bundled queries either; it layers overlapping captures in the order they are written, the later one patching over the earlier, which is already what makes `@markup.heading.1` win over the `(heading) @markup.heading` line above it here. The upstream priorities are redundant with that order - 100 < 105 < 110 is the order the patterns are written in - so the port keeps the order, drops the directives, and says at the block and in the README that position is now the precedence mechanism. THE PIN MOVE IS BOOKKEEPING HERE, and saying so is the point rather than a caveat. Upstream between the old revision and the new one touches queries/highlights.scm, tests, CI, CHANGELOG and package.json; grammar.js and src/ are untouched, `div` already carried the `title` and `label` fields at the old revision, and the new patterns compile and resolve identically against it. The bump is still right, because the pin is what records which revision these queries were adapted from and the drift job measures the distance, but nothing here would have failed without it. The previous bump in this repo, for the language attribute, was the opposite case: the query did not compile against the old pin at all. The README's copy of the `[[grammar]]` block moves with languages.toml, as it must after the divergence that PR fixed. And a check this repo did not have. CI compiled every query and parsed every sample, neither of which can see which of two overlapping patterns an editor ends up showing - a pattern that never wins is indistinguishable from a pattern that is absent, and both stayed green. scripts/highlight-captures.mjs resolves the winner at a position the way Helix does and asserts fifteen shapes, including a plain-prose control that must come back with no color and a tab-separated fence, which grammar PART 7 makes a paragraph, so there is no class name to color at all. It shells out to the tree-sitter CLI and adds no dependency. One thing the resolver had to get right, found by watching it fail on the blockquote case: `tree-sitter query --captures` prints a capture's text inline, so a capture spanning a newline breaks its own output line and leaves the closing backtick alone in column 0. Treating an unindented line as the next file's header dropped every later capture into a bucket nothing read, and reported a pattern that does match as not matching. File headers are matched against the paths actually passed instead.
dereuromark
added a commit
to markup-carve/emacs-carve
that referenced
this pull request
Aug 15, 2026
#17) PART 9 section 4c (markup-carve/carve#1215) reserves the kind word `figure` among the `:::` types. A BARE opener - the fence, its separator, the word `figure`, and NOTHING else - is ONE figure of ordered panels. An opener carrying a quoted title or a `[label]` is not that production and stays the generic Tier-2 container it has always been. Before this the two read identically. Measured by driving Emacs over the mode and reading the face at the opener, every spelling came back `carve-admonition-face`: ::: figure carve-admonition-face ::: figure "A titled figure div" carve-admonition-face ::: figure [g] carve-admonition-face :::<TAB>figure carve-admonition-face The distinction lives entirely in the tail of one line, so the new rule ends at `line-end` and that is the whole of it: a titled or labelled opener does not match and falls through to the generic rule below, unchanged. The rule sits ahead of that one because `font-lock-keywords` are applied in order and a later keyword does not override a face an earlier one already applied. THE SEPARATOR IS A SPACE RUN. It is spelled `(one-or-more " ")` and deliberately NOT the `(zero-or-more space)` the generic rule beside it uses: `space` in `rx` is `[[:space:]]`, which admits a tab, and a tab does not separate a marker (grammar PART 7, MARKER SEPARATORS; corpus 254 renders `:::<TAB>note` as a paragraph). A tab-separated opener therefore falls to the generic rule and reads exactly as it did before. Trailing whitespace after the kind word is insignificant and may be a tab, which the rule allows. Only the OPENER carries the new face. The closing fence is a bare `:::` line and which container it closes is not something a per-line rule can know, so it keeps `carve-admonition-face`. TWO RESIDUALS, recorded at the rule and in the README rather than left to be rediscovered. Every rule in this file is a flat per-line regexp with no container state, so: a bare `::: figure` nested inside an open group is a generic container in the language but over-fontifies as a group here; and the `^ ` caption line below a closing fence is a caption only for this container kind, while the caption rule claims it after any `:::` closer. The second is why the caption position already worked here and still does. Making either exact needs a real container model, which is tree-sitter-carve's job rather than font-lock's. Seven tests, and the four controls are the point of them rather than padding: a rule that never reached a titled, labelled or tab-separated opener would pass the positive test on its own.
This was referenced Aug 15, 2026
Merged
Merged
Document why a composite figure is not a grouped float in this adapter yet
markup-carve/carve-sile#3
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Part of #1122; spec: #1215.
A bare
::: figurecontainer parses as the newfigure_groupnode: one figure of ordered panels, per PART 9 §4c of the spec draft. All ten318-composite-figurescorpus documents pass byte for byte.FigureGroup; the^line after the closing fence is the group caption, reached through the sharedtryParseCaptionslot (adjacent or one blank line attaches, two detach, second caption stays a paragraph). An opener with a quoted title or[label]stays a generic container; a bare figure opener anywhere inside an open group's body stays generic too (groups do not nest).figurenodes (captioned image, quote, code listing, display math, promoted reference image) andtablenodes, captioned or not - in source order; stray content is preserved in place.</#id>as "Figure Na" (letter by panel order, a..z then aa) when the group drew a number; a#in a panel caption stays literal, andrenderCaptionNumber()now prints the literal#for an unresolved placeholder, matching the other three targets (previously unreachable on the HTML path).carve-figure-group/carve-figure-panels/carve-figure-panelshape - class-first with deduplicated merge, panels div unconditional, no trailingfigcaptionwithout a group caption.figure_group=type,children, optional inlinecaption,attrs,pos(PART 12 §16); vendoredresources/ast-schema.jsonsynced from the spec pin; ProseMirror bridge lists the type as unmapped besidefigure.\^paragraph keeps its escape. Fmt corpus (semantic + idempotence) green with no exclusions.<figure class="carve-figure-group">back to::: figuresource (own-output round trip).Out of scope, per the rollout plan: the D7b lint codes (
figure-group-nested,figure-group-opener-metadata,figure-group-panel-number,figure-group-empty,figure-group-single-panel) have no home in this engine's current lint surface (a line-based Markdown-habit linter plus one attribute pass, no container diagnostics) and follow separately.Temporary spec pin
tests/specis pinned to078c32don the unmerged spec draft branchspec/1122-composite-figures(#1215). The branch sits on the same quote-attribution withdrawal this engine already carries (#1264) and adds the composite-figures corpus category on top. Per the established rollout order the engine PRs merge first and the spec corpus pin moves after; this submodule pin moves to the merged spec main commit before this PR is undrafted.Rollout order
Cross-engine parity pass
A parity run against carve-php and carve-rs (which agreed with each other and the spec) settled three divergences, fixed here with regression tests:
318-composite-figures-6) now writes exactly one structural escape (\^on the detached line). The caption-opening caret sat outside the minimal escape class, so the minimal pass always failed the writer's redundancy check and the whole document escalated to conservative escaping (\(a\),\#where the other engines write the characters bare).#placeholder in a PANEL caption stays a typedcaption_numbernode withoutn, matching the carve-php/carve-rs serialization (the unresolved-reference precedent: keep the node, render its authored spelling). The HTML arm now renders an unresolved placeholder as the literal#it spells, like the Markdown/plain/ANSI arms already did.