Skip to content

spec: composite figures - ::: figure as a captionable host - #1215

Merged
dereuromark merged 1 commit into
mainfrom
spec/1122-composite-figures
Aug 15, 2026
Merged

spec: composite figures - ::: figure as a captionable host#1215
dereuromark merged 1 commit into
mainfrom
spec/1122-composite-figures

Conversation

@dereuromark

@dereuromark dereuromark commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

Part of #1122; spec: #1215.

A bare ::: figure container parses as the new figure_group node: one figure of ordered panels, per PART 9 §4c of the spec draft. All ten 318-composite-figures corpus documents pass byte for byte.

  • Parser: bare opener (kind word only) -> FigureGroup; the ^ line after the closing fence is the group caption, reached through the shared tryParseCaption slot (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).
  • Panels: the direct children the inner rules already made captionable things of - figure nodes (captioned image, quote, code listing, display math, promoted reference image) and table nodes, captioned or not - in source order; stray content is preserved in place.
  • Numbering: the group is one sequence unit; panel ids resolve </#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, and renderCaptionNumber() now prints the literal # for an unresolved placeholder, matching the other three targets (previously unreachable on the HTML path).
  • HTML: the corpus-pinned carve-figure-group / carve-figure-panels / carve-figure-panel shape - class-first with deduplicated merge, panels div unconditional, no trailing figcaption without a group caption.
  • AST: figure_group = type, children, optional inline caption, attrs, pos (PART 12 §16); vendored resources/ast-schema.json synced from the spec pin; ProseMirror bridge lists the type as unmapped beside figure.
  • fmt: authored form back - bare opener, one blank line between children, closer at opener width, group caption caret unescaped; a detached \^ paragraph keeps its escape. Fmt corpus (semantic + idempotence) green with no exclusions.
  • Degradation (PART 11 §10g): Markdown emits panels with emphasized panel captions and the bold group caption last; plain text and ANSI put the group caption first.
  • HTML import: <figure class="carve-figure-group"> back to ::: figure source (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/spec is pinned to 078c32d on the unmerged spec draft branch spec/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

  1. Engine draft PRs (carve-js, carve-php, carve-rs) land the behavior behind the temporary branch pin.
  2. Spec PR spec: composite figures - ::: figure as a captionable host #1215 merges; each engine moves its pin to the merged main commit and undrafts.
  3. Satellites (pandoc-carve, carve-lsp, carve-hexapdf, carve-sile, wysiwyg) are follow-up tickets, filed not implemented.

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:

  • Markdown target: a panel host and its emphasized caption are separated by a blank line - the caption is its own paragraph, per the ticket's degradation example.
  • fmt escaping: the detached-caption shape (corpus 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).
  • Wire shape: a # placeholder in a PANEL caption stays a typed caption_number node without n, 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.

@dereuromark dereuromark added the enhancement New feature or request label Aug 15, 2026
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.
@dereuromark
dereuromark force-pushed the spec/1122-composite-figures branch from 078c32d to 153f665 Compare August 15, 2026 09:12
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
dereuromark force-pushed the spec/1122-composite-figures branch from 153f665 to 357e752 Compare August 15, 2026 09:15
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
dereuromark force-pushed the spec/1122-composite-figures branch from 357e752 to a713514 Compare August 15, 2026 10:37
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
dereuromark force-pushed the spec/1122-composite-figures branch from a713514 to 012f23a Compare August 15, 2026 10:40
@dereuromark
dereuromark marked this pull request as ready for review August 15, 2026 10:41
@dereuromark
dereuromark merged commit 9cd27d8 into main Aug 15, 2026
4 checks passed
@dereuromark
dereuromark deleted the spec/1122-composite-figures branch 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.
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant