spec: a figure may wrap a table, and no Carve source spells it - #1211
Conversation
|
Blocked until the section 4a revert lands. This PR's section 16 closes with a paragraph contrasting the table wrapper against a quote, and its schema test asserts that |
013e19e to
003d7fa
Compare
cb8c14d to
120abbc
Compare
`figure.target` admits an image, a table, a code block or a paragraph. Feed it a
`block_quote` - the type PART 9 §4a removed when a caption on a quote became its
attribution - and this engine refused the payload, correctly, and then described
the wrong problem:
$.children[0].target is missing `src`, which the schema requires
`src` is the required property of the IMAGE branch, which is simply the first
alternative in the `oneOf`. A producer reading that message would add `src` to a
block quote. carve-js, from the same schema, says the useful thing:
a "block_quote" node sits where the schema admits only code_block, image,
paragraph, table
THE CAUSE IS ONE LINE. `checkComposition` returns `$first` - the failure of the
first branch that failed - whenever no branch of an `anyOf` / `oneOf` matched.
For a union of typed node definitions that is nearly always the wrong story,
because the branches differ by TYPE and the first one's missing field is an
artifact of branch order rather than a fact about the payload.
So a union of typed node definitions now reports the type mismatch:
$.children[0].target holds a "block_quote" node where the schema admits only
code_block, image, paragraph, table
BOTH CONDITIONS ARE REQUIRED before the message is built - the value identifies
itself as a node, and every branch pins a `type` constant - and anything else
keeps `$first` exactly as before. That is what stops the change from swallowing
a useful message: an `image` at `figure.target` with no `src` is a type the
position DOES admit, so the missing field is the real problem and is still what
gets reported. A target that is not even an object keeps its missing-`type`
report.
Which payloads are accepted and refused does not change; only what the refusal
says. The four tests pin the four paths: a refused type, an admitted type
missing a field, a value with no type at all, and a legitimate image target.
The reciprocal engine gap is markup-carve/carve-rs#982 - carve-rs accepted that
same payload rather than refusing it - and markup-carve/carve#1211 adds the
schema-side test for the admitted set.
`htmlToCarve` changed what a document renders and said nothing. HTML
distinguishes a figure wrapping a table from a table carrying a caption, and
the importer models that correctly:
<figure>
<table><tr><td>1</td></tr></table>
<figcaption>Cap</figcaption>
</figure>
comes back from `htmlToAst` as a `figure` whose target is the table, and
rendering that AST gives the same HTML again. Carve 0.1 source has no spelling
for the wrapper, so the canonical writer emits the table and a caption line:
| 1 |
^ Cap
which re-reads as the table's OWN caption. The text moves from a `<figcaption>`
beside the table to a `<caption>` inside it, and the figure is gone.
`carve migrate --from html --check-loss` exited 0 on that input and `--report`
printed an empty diagnostics array. Not because the loss was judged acceptable:
the diagnostics are collected by the IMPORT, and this loss happens in the
WRITER, so the one path whose job is to report structural loss could not see it.
PART 12 §16 (markup-carve/carve#1211) rules the shape and asks for exactly this:
a canonical writer emits the target and its caption, and a bridge or API which
exposes conversion diagnostics SHOULD report the structural loss. §14 and §15
already say the same for `shortCaption` and `rowGroups`.
WHERE THE REPORTING SITS IS THE DESIGN. The importer records the path where it
built a figure over a table; `htmlToAst` ignores that and reports nothing,
because a consumer that keeps the AST keeps the wrapper and loses nothing.
`htmlToCarve` turns each recorded path into a `structure-unspellable` warning
through the importer's own `add`, so the paths are the HTML paths every other
diagnostic uses and the `maxDiagnostics` limit stays a single budget rather
than two.
Reporting it from a post-hoc walk over the AST was written first and dropped: it
duplicated the diagnostics limit, and it could only produce a JSON-pointer path
into the tree, which would have made `path` mean two different things depending
on the code.
The tests pin both directions - the same input reports nothing through
`htmlToAst` - plus a nested case, a plain captioned table, and a figure over an
IMAGE, whose wrapper does have a Carve spelling and round-trips.
#1069) `htmlToCarve` changed what a document renders and said nothing. HTML distinguishes a figure wrapping a table from a table carrying a caption, and the importer models that correctly: <figure> <table><tr><td>1</td></tr></table> <figcaption>Cap</figcaption> </figure> comes back from `htmlToAst` as a `figure` whose target is the table, and rendering that AST gives the same HTML again. Carve 0.1 source has no spelling for the wrapper, so the canonical writer emits the table and a caption line: | 1 | ^ Cap which re-reads as the table's OWN caption. The text moves from a `<figcaption>` beside the table to a `<caption>` inside it, and the figure is gone. `carve migrate --from html --check-loss` exited 0 on that input and `--report` printed an empty diagnostics array. Not because the loss was judged acceptable: the diagnostics are collected by the IMPORT, and this loss happens in the WRITER, so the one path whose job is to report structural loss could not see it. PART 12 §16 (markup-carve/carve#1211) rules the shape and asks for exactly this: a canonical writer emits the target and its caption, and a bridge or API which exposes conversion diagnostics SHOULD report the structural loss. §14 and §15 already say the same for `shortCaption` and `rowGroups`. WHERE THE REPORTING SITS IS THE DESIGN. The importer records the path where it built a figure over a table; `htmlToAst` ignores that and reports nothing, because a consumer that keeps the AST keeps the wrapper and loses nothing. `htmlToCarve` turns each recorded path into a `structure-unspellable` warning through the importer's own `add`, so the paths are the HTML paths every other diagnostic uses and the `maxDiagnostics` limit stays a single budget rather than two. Reporting it from a post-hoc walk over the AST was written first and dropped: it duplicated the diagnostics limit, and it could only produce a JSON-pointer path into the tree, which would have made `path` mean two different things depending on the code. The tests pin both directions - the same input reports nothing through `htmlToAst` - plus a nested case, a plain captioned table, and a figure over an IMAGE, whose wrapper does have a Carve spelling and round-trips.
`figure.target` admits an image, a table, a code block or a paragraph. Feed it a
`block_quote` - the type PART 9 §4a removed when a caption on a quote became its
attribution - and this engine refused the payload, correctly, and then described
the wrong problem:
$.children[0].target is missing `src`, which the schema requires
`src` is the required property of the IMAGE branch, which is simply the first
alternative in the `oneOf`. A producer reading that message would add `src` to a
block quote. carve-js, from the same schema, says the useful thing:
a "block_quote" node sits where the schema admits only code_block, image,
paragraph, table
THE CAUSE IS ONE LINE. `checkComposition` returns `$first` - the failure of the
first branch that failed - whenever no branch of an `anyOf` / `oneOf` matched.
For a union of typed node definitions that is nearly always the wrong story,
because the branches differ by TYPE and the first one's missing field is an
artifact of branch order rather than a fact about the payload.
So a union of typed node definitions now reports the type mismatch:
$.children[0].target holds a "block_quote" node where the schema admits only
code_block, image, paragraph, table
BOTH CONDITIONS ARE REQUIRED before the message is built - the value identifies
itself as a node, and every branch pins a `type` constant - and anything else
keeps `$first` exactly as before. That is what stops the change from swallowing
a useful message: an `image` at `figure.target` with no `src` is a type the
position DOES admit, so the missing field is the real problem and is still what
gets reported. A target that is not even an object keeps its missing-`type`
report.
Which payloads are accepted and refused does not change; only what the refusal
says. The four tests pin the four paths: a refused type, an admitted type
missing a field, a value with no type at all, and a legitimate image target.
The reciprocal engine gap is markup-carve/carve-rs#982 - carve-rs accepted that
same payload rather than refusing it - and markup-carve/carve#1211 adds the
schema-side test for the admitted set.
* fix: an ingest refusal at a typed union names the admitted types
`figure.target` admits an image, a table, a code block or a paragraph. Feed it a
`block_quote` - the type PART 9 §4a removed when a caption on a quote became its
attribution - and this engine refused the payload, correctly, and then described
the wrong problem:
$.children[0].target is missing `src`, which the schema requires
`src` is the required property of the IMAGE branch, which is simply the first
alternative in the `oneOf`. A producer reading that message would add `src` to a
block quote. carve-js, from the same schema, says the useful thing:
a "block_quote" node sits where the schema admits only code_block, image,
paragraph, table
THE CAUSE IS ONE LINE. `checkComposition` returns `$first` - the failure of the
first branch that failed - whenever no branch of an `anyOf` / `oneOf` matched.
For a union of typed node definitions that is nearly always the wrong story,
because the branches differ by TYPE and the first one's missing field is an
artifact of branch order rather than a fact about the payload.
So a union of typed node definitions now reports the type mismatch:
$.children[0].target holds a "block_quote" node where the schema admits only
code_block, image, paragraph, table
BOTH CONDITIONS ARE REQUIRED before the message is built - the value identifies
itself as a node, and every branch pins a `type` constant - and anything else
keeps `$first` exactly as before. That is what stops the change from swallowing
a useful message: an `image` at `figure.target` with no `src` is a type the
position DOES admit, so the missing field is the real problem and is still what
gets reported. A target that is not even an object keeps its missing-`type`
report.
Which payloads are accepted and refused does not change; only what the refusal
says. The four tests pin the four paths: a refused type, an admitted type
missing a field, a value with no type at all, and a legitimate image target.
The reciprocal engine gap is markup-carve/carve-rs#982 - carve-rs accepted that
same payload rather than refusing it - and markup-carve/carve#1211 adds the
schema-side test for the admitted set.
* test: pick a figure target no version of the clause admits
The new case used a `block_quote` at `figure.target` as its example of a type the
schema refuses. That was true of the schema on the day it was written and is
about to stop being true: markup-carve/carve#1161 removed `block_quote` from the
admitted set and markup-carve/carve#1213 puts it back, so the assertion would
have flipped on a submodule bump and read as a regression in this diff.
A `heading` is not a captionable host under any version of the clause, so the
case pins the behavior under test - a typed union names the admitted set rather
than the first branch's required field - and nothing else. The assertion on the
set is a substring for the same reason.
The CHANGELOG entry drops its verbatim message quote, which carried the same
dependency and had a stray escape inside the code span.
* docs: say why three narrowing returns are not covered
codecov flags three `return null` lines in the new typed-union helper. They are
type narrowing rather than guards: both unions the published schema writes today
are typed node unions, so the branch shapes always resolve, and the checks exist
because decoded JSON reaches PHP as `mixed` and the function has to be total for
a union some later schema writes differently.
Saying so in the file is the honest resolution. Removing them to satisfy the
patch threshold would trade a coverage number for a crash on the first union that
is not a typed one, and PHPStan requires the narrowing regardless.
* test: expect the admitted set the pinned spec actually publishes
The expectation named "code_block, image, paragraph, table" while the spec
this branch pins already admits block_quote too: markup-carve/carve#1213
withdrew the attribution model and put a captioned quote back to a figure,
so figure.target regained the type carve#1161 had removed.
The refused type stays a heading, which is the point of choosing it - a
heading is not a captionable host under any version of the clause, so only
the admitted list moves with the pin. The docblock now says that rather
than describing the churn as still pending.
The changelog example moves off a block quote for the same reason: it is
admissible again, so it no longer illustrates a node the union rejects.
`figure.target` admits a `table`, and a `table` carries its own optional
`caption`. Those are two trees, and both render, but only one of them has a
source spelling: a `^ ` line after a table attaches to the table, so a parser
never produces the wrapper. Measured on all three engines, a captioned table in
source yields `table{caption}` every time.
THE WRAPPER IS NOT UNUSED, which is what makes it worth a clause rather than a
deletion. carve-js's HTML importer produces it from `<figure><table>…
<figcaption>`, a shape HTML itself distinguishes from a table carrying a
`<caption>`. All three engines then decode and render it identically:
<figure>
<table>...</table>
<figcaption>...</figcaption>
</figure>
WHAT NOTHING SAID is what happens on the way back out. The canonical writer has
no spelling for the wrapper, so it writes the table and its `^ ` caption line,
and that re-reads as `table{caption}` - a different tree whose HTML puts the
caption inside the table. So this is the one caption shape whose source round
trip changes the rendering, and until now no clause said so and no diagnostic
reported it. §14 already declares exactly this situation for `shortCaption` and
§15 for `rowGroups`: no source spelling, the writer omits it, a bridge SHOULD
report the structural loss. PART 12 §16 puts the figure-wrapped table on the
same footing.
IT ALSO PINS THE OTHER DIRECTION. §4a removed `block_quote` from
`figure.target` when a caption on a quote became its attribution, and nothing
in this repo asserted that the admitted set was closed. carve-rs went on
decoding `figure{target: block_quote}` and rendering the pre-4a
`<figure><blockquote>` from it, while carve-js and carve-php refuse it; the
engine fix is separate, but the schema test here is what would have caught the
divergence. The test was checked by adding the branch back to the schema and
watching it go red.
No engine behavior changes on the source path, and the schema is untouched: the
clause states the rule the schema already carries.
carve#1213 withdrew PART 9 section 4a - a caption on a quote is an attribution - and replaced it with 4b, which is explanatory. This clause cited 4a twice over in one sentence: for the section number, and for an escape hatch a quotation no longer needs, because a captioned quote is a figure again and has nothing to escape from. The closing paragraph also restated what 4b now says about which hosts a caption wraps. It defers to 4b and keeps only the part 4b does not cover: that a table is the one host with a caption slot of its own, which is why the wrapper around it means something the caption inside it does not.
Two things went stale while this sat.
PART 12 section 16 is taken. Composite figures claimed it for `figure_group`,
so the clause moves to section 17, and the references in docs/ast-json.md and
the schema test move with it. 17 is free: nothing else in PART 12 uses it, and
the `(section 17)` hits elsewhere in the grammar are the tight/loose rule in a
different PART.
The clause also predicted its own obsolescence, and the prediction was wrong.
It said the wrapper would gain a source spelling once `:::` became a
captionable host. PART 9 section 4c has since made it one, and the wrapper
still has no spelling: a `figure_group` holds its panels in `children` as the
nodes the inner caption rules built, so a table panel is a plain `table` child
of the group, never a `figure` targeting a table. A group wraps a sequence of
panels and captions the sequence; this wrapper wraps one table and captions
that table. That paragraph now states the distinction instead of predicting it,
because it is the first question a reader hits after composite figures.
The writer loss is re-measured rather than inherited: `figure{target: table}`
with a caption renders `<figure><table>...<figcaption>`, the canonical writer
emits the table followed by its `^ ` line, and that reparses as a `table`
carrying a `caption` whose HTML puts the caption inside the table.
120abbc to
f969118
Compare
|
The blocker is cleared. #1213 landed, so the two things you named are both handled:
Rebased onto current main and renumbered, because section 16 was taken by
|
f969118 to
5d57ec6
Compare
|
Rebased, renumbered and split. Split: the fourth commit, which ruled the two-caption collision, is now Your stated blocker is cleared. This was drafted "blocked until the section 4a revert lands" - #1213 landed, and both things it named are handled here. Renumbered to PART 12 §17. The PR originally claimed §16, which composite figures took while this sat. §17 was verified free by grep rather than assumed; the other The measurement was re-done, not inherited. One correction to the original body, which is now fixed in place: the clause predicted its own obsolescence and the prediction was wrong. It said the wrapper would gain a spelling once |
What
PART 12 gains section 17: a
figuremay wrap atable, and no Carve source spells that shape.Why
figure.targetadmits atable, and atablecarries its own optionalcaption. Those are two different trees for two different documents, and both render:PART 9 section 4 rules the source spelling, and it only ever produces the second. A
^line after a table attaches to the table, so a parser never produces the wrapper.The wrapper is not unused, which is why this is a clause rather than a deletion from the schema. carve-js's HTML importer produces it from a figure-wrapped table, a shape HTML itself distinguishes from a table carrying a caption element:
htmlToAston that returnsfigure{target: table}, and all three engines decode and render it identically.What nothing said is what happens on the way back out. The canonical writer has no spelling for the wrapper, so it writes the table and its caption line, which re-reads as
table{caption}, whose HTML puts the caption inside the table. This is the one caption shape whose source round trip changes the rendering, and until now no clause said so and no diagnostic reported it. Section 14 already declares exactly this situation forshortCaptionand section 15 forrowGroups: no source spelling, the writer omits it, a bridge SHOULD report the structural loss.Re-measured for this rebase rather than inherited from the original run. Feeding
figure{target: table, caption}through the pinned carve-js:The canonical writer emits:
which reparses as a
tablecarrying acaption, rendering:What the rebase changed
Section 16 was taken. Composite figures claimed it for
figure_groupwhile this sat, so the clause is section 17 and the references indocs/ast-json.mdand the schema test move with it. Verified free rather than assumed: nothing else in PART 12 uses 17, and the other(section 17)hits in the grammar are the tight/loose rule in a different PART.The clause predicted its own obsolescence, and the prediction was wrong. It said the wrapper would gain a source spelling once
:::became a captionable host. PART 9 section 4c has since made it one, and the wrapper still has no spelling. Afigure_groupholds its panels inchildrenas the nodes the inner caption rules built, so a table panel is a plaintablechild of the group, never afiguretargeting a table. A group wraps a sequence of panels and captions the sequence; this wrapper wraps one table and captions that table. That paragraph now states the distinction instead of predicting it, because it is the first question a reader hits after composite figures.docs/ast-json.mdgets the same distinction in one sentence.The other direction inverted. The original PR justified its schema test by section 4a having removed
block_quotefromfigure.target. #1213 withdrew that, so a quote is a figure target again, and the earlier follow-up commit already pointed the clause at section 4b. The test now pins the quote branch as valid rather than as removed. It is still load-bearing, and measured so: deleting thetablebranch fromfigure.targetinresources/ast-schema.jsonturns it red.One thing that needs your call
The clause said the writer emits "the target and its caption" without saying whose, which is only unambiguous when the table has none. A
tablecarries an optionalcaption, sofigure{ target: table{ caption }, caption }is a schema-valid tree with two captions and one^slot.Measured on the pinned carve-js:
With the slot free the figure's caption fills it. With the slot taken the writer emits a second
^line, and that line is not a caption: a^line after a caption line is an ordinary paragraph, so the output carries "Outer" as literal body prose in a<p>. That is worse than losing the wrapper, because the loss stops being observable as a loss.The last commit rules it the way the clause's own logic already points: the wrapper is lost, the figure's caption is part of the wrapper, so it goes with it, and a writer MUST NOT emit a second
^line. It reads to me as a consequence rather than a new decision, but it is a normative sentence that carve-js currently violates, so it is worth your nod before this merges rather than after. Happy to drop that commit and file it separately if you would rather rule on it on its own.Scope
The AST schema is untouched. The clause states the rule the schema already carries, and no engine behavior changes on the source path.