Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 2 additions & 46 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,8 @@ This project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
whose value attribute is absent gives the bare boolean, leftover `id`, `class`
and `data-*` ride the same span, and the import report records no loss for any
of the seven, in all three modes. `<mark>` keeps its `=m=` spelling, inline
`<code>` keeps its code span, `<code>` inside `<pre>` keeps going to a code
block, and a `<cite>` read as a block quote's attribution stays an
attribution. `abbr`, `time` and `kbd` render back as the original element;
`<code>` keeps its code span, and `<code>` inside `<pre>` keeps going to a
code block. `abbr`, `time` and `kbd` render back as the original element;
`samp`, `var`, `cite` and `dfn` need `SemanticSpanExtension` registered to do
so, and render as `<span samp="">` without it.

Expand Down Expand Up @@ -107,28 +106,6 @@ This project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
(§10a), one an authored `abbr` outranks, and one a later definition of the
same term shadowed.

- **A caption on a block quote is now that quote's attribution** (PART 9 §4a,
markup-carve/carve#1159). `> To be` followed by `^ Hamlet` no longer parses as
a `figure` wrapping a `block_quote`; it is a `block_quote` carrying an
`attribution`, and HTML renders `<footer>Hamlet</footer>` inside the
`<blockquote>` rather than a `<figure>` / `<figcaption>` pair. A quote is not
a figure, takes no number, and no longer turns up in a walk for figures. The
Markdown, plain-text, ANSI and Carve writers all carry the attribution, the
AST codec encodes and decodes it, and the ProseMirror bridge carries it in
both directions - without that last one, four corpus documents did not survive
the round trip through the editor model.

- **The HTML importer reads a quote's `<footer>` back as its attribution.** This
renderer emits the attribution that way, so importing it as a second quoted
paragraph meant the engine's own HTML did not round-trip. The LAST `footer` or
`cite` child wins when a quote carries several, and the earlier one stays
ordinary quoted content rather than being dropped - which is what taking the
first and skipping every other used to do. An element holding BLOCK content is
not an attribution: the slot takes inline content, and flattening blocks into
it would run their paragraphs together with no separator, so it stays quoted
content and every word survives. All three engines agree byte for byte on both
rules.

- **Leftover attributes ride the outermost semantic element.**
`[Ctrl+C]{kbd .shortcut #copy}` is
`<kbd class="shortcut" id="copy">Ctrl+C</kbd>` where it was a `<span>`
Expand Down Expand Up @@ -239,27 +216,6 @@ This project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
as a single byte: one tab under a two-column item is that item's content, two
are code. Document-level indented code is unchanged.

- **A block quote's source span covers the attribution it owns** (#1249,
PART 12 §4). The span stopped at the end of the last quoted line, so the
attribution's inline nodes sat outside their own parent in the serialized
AST - a tree contradicting itself, which no rendered output could show.
A container "ends after its last placed child", and the attribution is one,
so the span now reaches the end of the `^` line. Four corpus documents
change; a quote with no attribution is unaffected. Consumers indexing a
quote by `pos` see the wider extent.

- **A quote attribution stays attached to its quote on every target**
(markup-carve/carve#1179, PART 11 §10c). It used to follow the quote as a
sibling separated by a blank line, which kept the words but not what they
mean - read back the attribution was attached to nothing, and a round trip
produced a blockquote with no attribution at all. Markdown now emits a
`<footer>` element inside the quote (that target already writes `<u>`,
`<mark>` and `<ins>` where Markdown has no spelling, and through a CommonMark
reader `<footer>` opens an HTML block rather than being wrapped in a
paragraph, so the rendered HTML matches the HTML target's); the terminal
carries its quote bar onto the attribution line; plain text attaches by
adjacency, dropping the blank line. A quote with no attribution is unchanged.

- **Presentation targets no longer discard authored text** (PART 11 §10e,
markup-carve/carve#1179). `docs/graceful-degradation.md` states the floor as
a MUST - "losing the click is fine; losing the words is not" - and three kinds
Expand Down
12 changes: 4 additions & 8 deletions resources/ast-schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -611,13 +611,6 @@
"$ref": "#/$defs/blockNode"
}
},
"attribution": {
"description": "The source of the quotation (PART 9 \u00a74a). Present when a `^` caption attaches to the quote; it is not a figure caption and takes no number.",
"type": "array",
"items": {
"$ref": "#/$defs/inlineNode"
}
},
"attrs": {
"$ref": "#/$defs/attrs"
},
Expand Down Expand Up @@ -1084,11 +1077,14 @@
"const": "figure"
},
"target": {
"description": "The captioned block: an image, table, code block or paragraph. A captioned block quote is NOT a figure - it is a quote carrying an `attribution` (PART 9 \u00a74a, carve#1159).",
"description": "The captioned block: an image, block quote, table, code block or paragraph. What a captioned host is called and counted as comes from the caption's label, never from the host (PART 9 \u00a74b); for a quote the HTML Standard also requires the attribution outside the `blockquote`.",
"oneOf": [
{
"$ref": "#/$defs/image"
},
{
"$ref": "#/$defs/block_quote"
},
{
"$ref": "#/$defs/table"
},
Expand Down
21 changes: 0 additions & 21 deletions src/Ast/AstCodec.php
Original file line number Diff line number Diff line change
Expand Up @@ -1861,15 +1861,6 @@ private static function spanShape(array $encoded): array
*/
private static function captionShape(array $encoded): array
{
// PART 9 §4a: a quote's `attribution` is inline content on the wire for
// the same reason a caption is - the reference has no `caption` node
// type, and this engine stores one internally. Flattened here so both
// fields answer to one rule rather than two (carve#1159).
$attribution = $encoded['attribution'] ?? null;
if (is_array($attribution) && ($attribution['type'] ?? null) === 'caption') {
$encoded['attribution'] = $attribution['children'] ?? [];
}

$caption = $encoded['caption'] ?? null;
if (!is_array($caption) || ($caption['type'] ?? null) !== 'caption') {
return $encoded;
Expand Down Expand Up @@ -2352,18 +2343,6 @@ private static function captionFromWire(array $data): array
$data['caption'] = ['type' => 'caption', 'children' => $caption];
}

// PART 9 §4a: the mirror of the flattening in `captionShape`. On the
// wire an attribution is inline content; this engine models it with the
// same `caption` block it uses for a figure's caption (carve#1159).
$attribution = $data['attribution'] ?? null;
if (
($data['type'] ?? null) === 'block_quote'
&& is_array($attribution)
&& !isset($attribution['type'])
) {
$data['attribution'] = ['type' => 'caption', 'children' => $attribution];
}

return $data;
}

Expand Down
67 changes: 1 addition & 66 deletions src/Converter/HtmlToCarve.php
Original file line number Diff line number Diff line change
Expand Up @@ -1598,25 +1598,13 @@ protected function processHr(DOMNode $node): string

protected function processBlockquote(DOMElement $node): string
{
// Check for attribution (footer or cite element)
$attributionNode = $this->findBlockquoteAttribution($node);
$attribution = $attributionNode !== null
? trim($this->processChildren($attributionNode))
: null;

// Process content excluding attribution elements, preserving paragraph breaks
// Process content, preserving paragraph breaks.
$parts = [];
foreach ($node->childNodes as $child) {
if ($child instanceof DOMText && trim($child->textContent) === '') {
continue;
}

// Skip the ONE element read as the attribution. Any other footer
// or cite is ordinary quoted content and stays in the body.
if ($child === $attributionNode) {
continue;
}

$part = rtrim($this->processNode($child), "\n");
if ($part !== '') {
$parts[] = $part;
Expand All @@ -1631,64 +1619,11 @@ protected function processBlockquote(DOMElement $node): string
$quoted[] = $line === '' ? '>' : '> ' . rtrim($line);
}

// A `footer`/`cite` is the quote's ATTRIBUTION, and Carve spells that as
// a caption line BELOW the quote (PART 9 §4a, carve#1159) - not as a
// second quoted paragraph, which is ordinary quoted content and comes
// back as such. Without this the renderer's own `<blockquote><footer>`
// did not survive a round trip through this importer.
//
// A caption folds its continuation lines like a paragraph, so a
// multi-line attribution needs the marker on its first line only.
if ($attribution !== null) {
$first = true;
foreach (explode("\n", $attribution) as $line) {
$quoted[] = ($first ? '^ ' : '') . rtrim($line);
$first = false;
}
}

$attrs = $this->formatBlockAttributes($node);

return $attrs . "\n" . implode("\n", $quoted) . "\n\n";
}

/**
* The `footer`/`cite` child that carries the quote's attribution, if any.
*
* The LAST one, because that is the element this renderer emits and the one
* an author puts after the quoted text. A quote has ONE attribution and the
* slot holds inline content, so an earlier footer cannot join it - it stays
* ordinary quoted content rather than being dropped, which is what taking
* the first one and skipping every other did.
*/
protected function findBlockquoteAttribution(DOMElement $node): ?DOMElement
{
$found = null;
foreach ($node->childNodes as $child) {
if (!$child instanceof DOMElement) {
continue;
}

$tag = strtolower($child->tagName);
if (($tag !== 'footer' && $tag !== 'cite') || trim($this->processChildren($child)) === '') {
continue;
}
// The slot holds INLINE content, so an element carrying blocks does
// not fit it. Flattening one would run its paragraphs together with
// no separator; leaving it an ordinary block inside the quote keeps
// every word, which is the better answer when the shape cannot be
// represented.
foreach ($child->childNodes as $inner) {
if ($inner instanceof DOMElement && in_array(strtolower($inner->tagName), $this->blockElements, true)) {
continue 2;
}
}
$found = $child;
}

return $found;
}

/**
* Process MathML element to Djot math syntax
*
Expand Down
21 changes: 0 additions & 21 deletions src/Node/Block/BlockQuote.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,29 +9,8 @@
*/
class BlockQuote extends BlockNode
{
/**
* The source of the quotation (PART 9 SS4a).
*
* A `^` caption on a quote is its ATTRIBUTION, not a figure caption: the
* quote is not a figure, takes no number, and nothing walking the tree for
* figures finds it (carve#1159).
*
* @var \MarkupCarve\Carve\Node\Block\Caption|null
*/
protected ?Caption $attribution = null;

public function getType(): string
{
return 'block_quote';
}

public function getAttribution(): ?Caption
{
return $this->attribution;
}

public function setAttribution(?Caption $attribution): void
{
$this->attribution = $attribution;
}
}
2 changes: 1 addition & 1 deletion src/Node/Block/Figure.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
*
* Used to wrap:
* - Images with captions → <figure><img>...<figcaption>...</figcaption></figure>
* - Blockquotes with attribution → <figure><blockquote>...<figcaption>...</figcaption></figure>
* - Block quotes with captions → <figure><blockquote>...<figcaption>...</figcaption></figure>
*
* Tables with captions use the <caption> element inside the table instead.
*/
Expand Down
40 changes: 17 additions & 23 deletions src/Parser/BlockParser.php
Original file line number Diff line number Diff line change
Expand Up @@ -9209,35 +9209,29 @@ protected function tryParseCaption(Node $parent, array $lines, int $start): ?int
return $linesConsumed;
}

// Handle BlockQuote - the caption is its ATTRIBUTION (PART 9 SS4a)
// Handle BlockQuote - wrap in figure
if ($lastChild instanceof BlockQuote) {
// NO FIGURE WRAPPER. A quote carrying a source is not a figure: it
// takes no number, and nothing walking the tree for figures finds
// it (carve#1159). The quote keeps its own attributes for the same
// reason - there is no outer node to move them to.
$attribution = new Caption();
$attribution->setPos($this->wholeLineSpan($start));
// PARSED WITHOUT CAPTION CONTEXT: a caption's bare `#` is the
// number placeholder, and an attribution has no number to place, so
// SS4a keeps it literal.
$figure = new Figure();

foreach ($lastChild->getAttributes() as $key => $value) {
$figure->setAttribute($key, $value);
$lastChild->removeAttribute($key);
}

$caption = new Caption();
$caption->setPos($this->wholeLineSpan($start));
$this->inlineParser->parse(
$attribution,
$caption,
$captionText,
$start,
false,
true,
$this->contiguousMapFor($start, $lines[$start], $captionText),
);
$lastChild->setAttribution($attribution);
// The attribution is written AFTER the quote, and it is the quote's
// own child, so the quote's span has to reach the end of that line.
// PART 12 §4: a span contains its children's spans, and "a container
// ends after its explicit closer when it has one, otherwise after
// its last placed child". Without this the attribution's inlines sat
// outside their own parent - a tree contradicting itself, which no
// renderer could show and which the containment sweep could not see
// either, because the attribution is not in `getChildren()`
// (carve-php#1249). The table arm above widens for the same reason.
$this->widenSpanTo($lastChild, $attribution->getPos());

$figure->appendChild($lastChild);
$figure->appendChild($caption);

$parent->replaceChild(count($children) - 1, $figure);

return $linesConsumed;
}
Expand Down
15 changes: 0 additions & 15 deletions src/ProseMirror/ProseMirrorRenderer.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@

namespace MarkupCarve\Carve\ProseMirror;

use MarkupCarve\Carve\Node\Block\BlockQuote;
use MarkupCarve\Carve\Node\Block\CodeBlock;
use MarkupCarve\Carve\Node\Block\Div;
use MarkupCarve\Carve\Node\Block\Footnote;
Expand Down Expand Up @@ -204,20 +203,6 @@ protected function renderBlock(Node $node): ?array
: $this->renderBlocks($node->getChildren());
}

if ($node instanceof BlockQuote) {
// Same asymmetry as a table caption, at the other end: a quote's
// attribution is state rather than a child (PART 9 §4a), so walking
// children alone loses the source of the quotation and nothing
// reports it. It goes LAST, where the author wrote it.
$attribution = $node->getAttribution();
if ($attribution !== null) {
$content[] = [
'type' => 'carveCaption',
'content' => $this->renderInlines($attribution->getChildren(), []),
];
}
}

if ($content !== []) {
$out['content'] = $content;
}
Expand Down
9 changes: 0 additions & 9 deletions src/ProseMirror/ProseMirrorToCarve.php
Original file line number Diff line number Diff line change
Expand Up @@ -355,15 +355,6 @@ protected function buildBlock(array $data): ?Node

continue;
}
// The trailing `carveCaption` a quote carries is its ATTRIBUTION
// (PART 9 §4a), which is state on the quote rather than a child -
// appending it would publish a `caption` block inside the quote,
// which no schema names.
if ($built instanceof Caption && $node instanceof BlockQuote) {
$node->setAttribution($built);

continue;
}
$node->appendChild($built);
}

Expand Down
24 changes: 0 additions & 24 deletions src/Renderer/AnsiRenderer.php
Original file line number Diff line number Diff line change
Expand Up @@ -880,30 +880,6 @@ protected function renderBlockQuote(BlockQuote $node): string
$content = $this->renderChildren($node);
$this->blockQuoteDepth--;

// Keeps the styling the caption had while a quote was a figure, so a
// terminal reader sees the same thing in a different place.
//
// PART 11 section 10c T2: it also carries the QUOTE BAR. The bar is
// already this target's marker for "inside the quote", and the
// attribution was the one line in the quote that did not get it - so the
// source read as a separate block that merely happened to follow.
// Nothing new is invented; the prefix the body lines already use is
// applied one line further. The caption's own trailing separator is
// trimmed BEFORE prefixing, or the bar would be drawn on blank lines and
// the quote would appear to continue past its end.
$attribution = $node->getAttribution();
if ($attribution !== null) {
$this->blockQuoteDepth++;
$bar = $this->getBlockQuotePrefix();
$this->blockQuoteDepth--;
$rendered = rtrim($this->renderCaption($attribution), "\n");
$prefixed = implode("\n", array_map(
fn (string $line): string => $bar . $line,
explode("\n", $rendered),
));
$content = rtrim($content, "\n") . "\n" . rtrim($bar) . "\n" . $prefixed . "\n\n";
}

return $content;
}

Expand Down
Loading
Loading