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
30 changes: 30 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,36 @@ This project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
On the shared corpus, 791 documents report nothing lost and round-trip to
byte-identical HTML, and 215 report what they lost.

- **Composite figures: a bare `::: figure` container is one figure of ordered
panels** (PART 9 §4c, markup-carve/carve#1122). Its direct captionable
children - the `figure` nodes the unchanged inner caption rules build, and
`table` nodes - are the PANELS, in source order; stray content between them
is preserved in place. The `^ ` caption after the CLOSING fence is the
caption of the whole group - caption placement's sixth host, this container
kind only - with the usual one-optional-blank-line allowance; two blank
lines detach it. The group is ONE numbering unit: `^ Figure #:` draws one
number, panels draw nothing, and a panel with an id resolves `</#id>` as the
group number plus a letter by panel order ("Figure 2a"). A `#` placeholder
in a PANEL caption stays the literal `#` on every target. HTML renders the
class-first `carve-figure-group` / `carve-figure-panel` shape with an
unconditional `carve-figure-panels` div; Markdown, plain text and the
terminal degrade deterministically per PART 11 §10g; the canonical writer
emits the authored form back; AST JSON gains the additive `figure_group`
node (PART 12 §16), and the HTML importer reads the engine's own group
shape back. The ProseMirror bridge has no editor node for it - the
vendored carve-grammars schema map predates §4c - so a group degrades to the
generic container there, keeping every panel and the group caption and
reporting the grouping it could not hold. An opener carrying a quoted title or a `[label]`, and a bare
opener nested inside an open group, stay generic containers - `carve lint`
reports them as `figure-group-opener-metadata` / `figure-group-nested`, and
a panel-caption placeholder as `figure-group-panel-number`.

- **An unresolved caption-number placeholder renders as the literal `#` in
HTML**, matching what the Markdown, plain-text and terminal targets already
emitted. Reachable from a parse only inside a figure group's panel captions;
on the ingest path a `caption_number` without a number now shows the visible
`#` instead of disappearing.

- **`lint_carve` / `lint_carve_with_options`, and the `LintWarning` they
return** (markup-carve/carve#1131, markup-carve/carve#1132). carve-rs' first
lint surface, carrying the two diagnostics PART 9 §10 implies and had nowhere
Expand Down
12 changes: 10 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ new corpus pairs fail CI until the parser and renderer support them.
| 16 | inline extensions (`:type[…]`) | passing |
| 17 | attribute blocks (`{#id .class}`) | passing |
| 18 | YAML frontmatter | passing |
| 318-composite-figures | `::: figure` groups: panels, group caption on the closer, `Figure 2a` crossrefs | passing |

## Library use

Expand Down Expand Up @@ -100,14 +101,21 @@ let warnings = carve::lint_carve("`c`{kbd}\n");
assert_eq!(warnings[0].rule, "semantic-attribute-outside-span");
```

Two rules today, both about the compact semantic span attributes (spec PART 9
§10):
The compact semantic span attribute rules (spec PART 9 §10):

| rule | fires on |
| --- | --- |
| `semantic-attribute-value-ignored` | a value on a reserved name that only selects a wrapper: `[x]{kbd="V"}` renders `<kbd>x</kbd>` and `V` reaches no output |
| `semantic-attribute-outside-span` | a reserved name anywhere other than an ordinary `[content]{attrs}` span, where it stays a raw attribute: `` `c`{kbd} `` renders `<code kbd="">c</code>` |

The composite-figure rules (spec PART 9 §4c):

| rule | fires on |
| --- | --- |
| `figure-group-opener-metadata` | a `::: figure` opener carrying a quoted title or a `[label]`, which stays a generic container - the group has no title or label slot |
| `figure-group-nested` | a bare `::: figure` opener inside an open group's body, which stays a generic container - groups do not nest |
| `figure-group-panel-number` | a `#` placeholder in a PANEL caption, which stays literal - panels are not sequence units |

Both are tier-aware. `abbr`, `time` and `kbd` are reserved in core; `samp`,
`var`, `cite` and `dfn` only become elements once the `SemanticSpan` extension
is registered, and until then they are ordinary attributes whose value reaches
Expand Down
6 changes: 4 additions & 2 deletions resources/prosemirror-schema-map.json
Original file line number Diff line number Diff line change
Expand Up @@ -271,11 +271,13 @@
"raw_text": "raw text is the payload of a raw block, not a node an editor holds",
"escaped_text": "escaping is a source-level concern; the editor holds the escaped character as text",
"smart_punctuation": "smart-typography output is lossy on reparse, so it is not modeled",
"soft_break": "a soft break is whitespace in the ProseMirror model"
"soft_break": "a soft break is whitespace in the ProseMirror model",
"figure_group": "the editor schema has no composite figure; the panels and the group caption survive inside the generic container, but the fact that they were one figure does not"
},
"_provenance": {
"source": "markup-carve/carve-grammars tiptap/schema-map.json",
"commit": "e09b460c23fceaf532f724f3ef73e0b6a355d4f7",
"refresh": "copy the file from carve-grammars and update commit; the schema-map test asserts every AST type still has a decision"
"refresh": "copy the file from carve-grammars and update commit; the schema-map test asserts every AST type still has a decision",
"pendingUpstream": "the `figure_group` entry under unmapped is local: PART 9 section 4c is newer than the schema map, so carve-grammars carries no decision for it yet. Keep it across the next refresh until upstream does."
}
}
22 changes: 22 additions & 0 deletions src/ast.rs
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,7 @@ pub enum BlockNode {
LineBlock(LineBlock),
DefinitionList(DefinitionList),
Figure(Figure),
FigureGroup(FigureGroup),
AbbreviationDef(AbbreviationDef),
LinkReferenceDefinition(LinkReferenceDefinition),
RawBlock(RawBlock),
Expand Down Expand Up @@ -466,6 +467,27 @@ pub enum FigureTarget {
Paragraph(Paragraph),
}

/// A composite figure: one figure-numbering unit holding ordered panels
/// (PART 9 §4c, a bare `::: figure` container).
///
/// `children` are the body's blocks in source order; the PANELS are the
/// `Figure` and `Table` nodes among them, derived by type rather than stored
/// in a second list, and stray non-panel content is preserved in place.
/// Discriminated from `Figure` by the node TYPE: every `Figure` carries a
/// `target`, this node deliberately does not, and it has no title, label or
/// short-caption slot - its one authored metadata channel is the group
/// caption on the closing fence (carve#1118/carve#1121 own the rest).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FigureGroup {
pub attrs: Option<Attrs>,
pub children: Vec<BlockNode>,
/// The group caption (the `^ ` line after the closing fence). `None`
/// means the group is uncaptioned - never an empty placeholder.
pub caption: Option<Vec<InlineNode>>,
/// Span in the original source, when the parser could determine it.
pub pos: Option<Pos>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AbbreviationDef {
pub abbr: String,
Expand Down
22 changes: 22 additions & 0 deletions src/ast_json.rs
Original file line number Diff line number Diff line change
Expand Up @@ -596,6 +596,7 @@ pub(crate) fn block_pos(node: &BlockNode) -> Option<&Pos> {
BlockNode::LineBlock(n) => n.pos.as_ref(),
BlockNode::DefinitionList(n) => n.pos.as_ref(),
BlockNode::Figure(n) => n.pos.as_ref(),
BlockNode::FigureGroup(n) => n.pos.as_ref(),
BlockNode::AbbreviationDef(n) => n.pos.as_ref(),
BlockNode::RawBlock(n) => n.pos.as_ref(),
BlockNode::Comment(n) => n.pos.as_ref(),
Expand Down Expand Up @@ -749,6 +750,21 @@ fn write_block(out: &mut String, node: &BlockNode) {
write_pos_field(&mut w, &n.pos);
w.finish();
}
BlockNode::FigureGroup(n) => {
// PART 12 §16: `children` in source order (a consumer derives the
// panel list by type, the way the renderer does - there is no
// second `panels` key to disagree with them), `caption` only when
// the closer hosted one - absent means uncaptioned, never an
// empty placeholder.
let mut w = typed(out, "figure_group");
w.field("children", |out| write_blocks(out, &n.children));
if let Some(caption) = &n.caption {
w.field("caption", |out| write_inlines(out, caption));
}
write_attrs_field(&mut w, &n.attrs);
write_pos_field(&mut w, &n.pos);
w.finish();
}
BlockNode::LinkReferenceDefinition(n) => {
// PART 12 §10: `label` and `href` are required, `title` and `attrs`
// ride along when the definition line carried them.
Expand Down Expand Up @@ -1584,6 +1600,12 @@ fn decode_block(value: &Json) -> Result<BlockNode, AstJsonError> {
short_caption: optional_inlines(obj, "shortCaption")?,
pos: optional_pos(obj, "figure")?,
})),
"figure_group" => Ok(BlockNode::FigureGroup(FigureGroup {
attrs: optional_attrs(obj)?,
children: decode_blocks(required_array(obj, "figure_group", "children")?)?,
caption: optional_inlines(obj, "caption")?,
pos: optional_pos(obj, "figure_group")?,
})),
"link_reference_definition" => Ok(BlockNode::LinkReferenceDefinition(
LinkReferenceDefinition {
label: required_string(obj, "link_reference_definition", "label")?.to_string(),
Expand Down
8 changes: 8 additions & 0 deletions src/citations.rs
Original file line number Diff line number Diff line change
Expand Up @@ -808,6 +808,14 @@ fn annotate_citations_block(
}
}
}
BlockNode::FigureGroup(g) => {
for child in &mut g.children {
annotate_citations_block(child, defs, mode, has_bib, seen, order, uses);
}
if let Some(caption) = &mut g.caption {
annotate_citations_inline(caption, defs, mode, has_bib, seen, order, uses);
}
}
BlockNode::Figure(f) => {
annotate_citations_inline(&mut f.caption, defs, mode, has_bib, seen, order, uses);
match &mut f.target {
Expand Down
7 changes: 7 additions & 0 deletions src/document_ids.rs
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,13 @@ impl Seeder {
}
self.walk_inlines(&f.caption);
}
BlockNode::FigureGroup(g) => {
self.reserve_attrs(&g.attrs);
self.walk_blocks(&g.children);
if let Some(caption) = &g.caption {
self.walk_inlines(caption);
}
}
BlockNode::Extension(e) => {
self.reserve_attrs(&e.attrs);
self.walk_blocks(&e.children);
Expand Down
8 changes: 8 additions & 0 deletions src/extensions/external_links.rs
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,14 @@ impl ExternalLinks {
self.visit_inlines(&mut f.caption);
self.visit_figure_target(f);
}
BlockNode::FigureGroup(g) => {
if let Some(caption) = &mut g.caption {
self.visit_inlines(caption);
}
for child in &mut g.children {
self.visit_block(child);
}
}
BlockNode::Extension(e) => {
for child in &mut e.children {
self.visit_block(child);
Expand Down
1 change: 1 addition & 0 deletions src/extensions/heading_level_shift.rs
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ fn shift_block(block: &mut BlockNode, shift: u8) {
shift_blocks(&mut quote.children, shift);
}
}
BlockNode::FigureGroup(group) => shift_blocks(&mut group.children, shift),
_ => {}
}
}
Expand Down
7 changes: 7 additions & 0 deletions src/extensions/heading_numbers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@ fn number_blocks(blocks: &mut [BlockNode], in_blockquote: bool, state: &mut Numb
BlockNode::BlockQuote(b) => number_blocks(&mut b.children, true, state),
BlockNode::Div(d) => number_blocks(&mut d.children, in_blockquote, state),
BlockNode::Admonition(a) => number_blocks(&mut a.children, in_blockquote, state),
BlockNode::FigureGroup(g) => number_blocks(&mut g.children, in_blockquote, state),
BlockNode::List(l) => {
for item in &mut l.items {
number_blocks(&mut item.children, in_blockquote, state);
Expand Down Expand Up @@ -334,6 +335,12 @@ fn rewrite_links_blocks(
rewrite_links_blocks(&mut b.children, by_id, opts);
}
}
BlockNode::FigureGroup(g) => {
if let Some(caption) = &mut g.caption {
rewrite_links_inlines(caption, by_id, opts);
}
rewrite_links_blocks(&mut g.children, by_id, opts);
}
BlockNode::Extension(e) => rewrite_links_blocks(&mut e.children, by_id, opts),
_ => {}
}
Expand Down
8 changes: 8 additions & 0 deletions src/extensions/index_terms.rs
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,14 @@ fn rewrite_markers_block(
}
}
}
BlockNode::FigureGroup(g) => {
for child in &mut g.children {
rewrite_markers_block(child, counts, display);
}
if let Some(caption) = &mut g.caption {
rewrite_markers_inline(caption, counts, display);
}
}
BlockNode::Figure(f) => {
rewrite_markers_inline(&mut f.caption, counts, display);
match &mut f.target {
Expand Down
6 changes: 6 additions & 0 deletions src/extensions/smart_quotes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,12 @@ impl SmartQuotes {
FigureTarget::CodeBlock(_) | FigureTarget::Image(_) => {}
}
}
BlockNode::FigureGroup(g) => {
if let Some(caption) = &mut g.caption {
self.visit_inlines(caption);
}
self.visit_blocks(&mut g.children);
}
BlockNode::Extension(e) => self.visit_blocks(&mut e.children),
BlockNode::CodeBlock(_)
| BlockNode::AbbreviationDef(_)
Expand Down
8 changes: 8 additions & 0 deletions src/extensions/tab_normalize.rs
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,14 @@ impl TabNormalize {
self.visit_inlines(&mut f.caption);
self.visit_figure_target(f);
}
BlockNode::FigureGroup(g) => {
if let Some(caption) = &mut g.caption {
self.visit_inlines(caption);
}
for child in &mut g.children {
self.visit_block(child);
}
}
BlockNode::Extension(e) => {
for child in &mut e.children {
self.visit_block(child);
Expand Down
Loading