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
2 changes: 1 addition & 1 deletion spec
Submodule spec updated 64 files
+31 −0 CHANGELOG.md
+28 −0 docs/ast-json.md
+9 −0 docs/blocks-and-attributes.md
+16 −1 docs/cheatsheet.md
+9 −0 docs/graceful-degradation.md
+70 −0 docs/html-import.md
+2 −1 docs/implementation-comparison.md
+26 −112 docs/index.md
+8 −4 docs/profiles.md
+34 −0 docs/versioning.md
+29 −29 package-lock.json
+2 −2 package.json
+50 −0 resources/ast-schema.json
+19 −7 resources/ast-value-divergence.txt
+14 −0 resources/engine-pin-drift.txt
+1 −0 resources/example-pages.txt
+412 −0 resources/examples/edge-cases.md
+272 −12 resources/grammar.ebnf
+1 −1 resources/html-import-schema.json
+3 −0 resources/normative-clauses.txt
+86 −1 scripts/spec/html.mjs
+34 −0 scripts/spec/layout.mjs
+5 −0 tests/ast-schema.test.mjs
+20 −0 tests/corpus/318-composite-figures-10.crv
+27 −0 tests/corpus/318-composite-figures-10.html
+7 −0 tests/corpus/318-composite-figures-11.crv
+11 −0 tests/corpus/318-composite-figures-11.html
+17 −0 tests/corpus/318-composite-figures-2.crv
+18 −0 tests/corpus/318-composite-figures-2.html
+7 −0 tests/corpus/318-composite-figures-3.crv
+12 −0 tests/corpus/318-composite-figures-3.html
+5 −0 tests/corpus/318-composite-figures-4.crv
+9 −0 tests/corpus/318-composite-figures-4.html
+12 −0 tests/corpus/318-composite-figures-5.crv
+14 −0 tests/corpus/318-composite-figures-5.html
+7 −0 tests/corpus/318-composite-figures-6.crv
+9 −0 tests/corpus/318-composite-figures-6.html
+4 −0 tests/corpus/318-composite-figures-7.crv
+4 −0 tests/corpus/318-composite-figures-7.html
+9 −0 tests/corpus/318-composite-figures-8.crv
+12 −0 tests/corpus/318-composite-figures-8.html
+7 −0 tests/corpus/318-composite-figures-9.crv
+11 −0 tests/corpus/318-composite-figures-9.html
+11 −0 tests/corpus/318-composite-figures.crv
+13 −0 tests/corpus/318-composite-figures.html
+113 −0 tests/doc-carve-samples.test.mjs
+1 −0 tests/html-import/semantic-span-attributes/expected.ast.json
+1 −0 tests/html-import/semantic-span-attributes/expected.crv
+1 −0 tests/html-import/semantic-span-attributes/expected.report.json
+1 −0 tests/html-import/semantic-span-attributes/input.html
+1 −0 tests/html-import/semantic-span-carve-outs/expected.ast.json
+5 −0 tests/html-import/semantic-span-carve-outs/expected.crv
+1 −0 tests/html-import/semantic-span-carve-outs/expected.report.json
+1 −0 tests/html-import/semantic-span-carve-outs/input.html
+1 −0 tests/html-import/semantic-spans-core/expected.ast.json
+1 −0 tests/html-import/semantic-spans-core/expected.crv
+1 −0 tests/html-import/semantic-spans-core/expected.report.json
+1 −0 tests/html-import/semantic-spans-core/input.html
+1 −0 tests/html-import/semantic-spans-extension/expected.ast.json
+1 −0 tests/html-import/semantic-spans-extension/expected.crv
+1 −0 tests/html-import/semantic-spans-extension/expected.report.json
+1 −0 tests/html-import/semantic-spans-extension/input.html
+1 −0 tests/schema-fields-are-produced.test.mjs
+291 −0 tests/spec/318-composite-figures.test
22 changes: 22 additions & 0 deletions src/ast.ts
Original file line number Diff line number Diff line change
Expand Up @@ -380,6 +380,27 @@ export interface Figure extends BaseNode {
shortCaption?: InlineNode[]
}

/**
* A composite figure: a bare `::: figure` fence (PART 9 §4c).
*
* `children` are ordinary blocks in source order; the PANELS are the `figure`
* and `table` nodes among them, derived by type rather than repeated under a
* second key, so the two can never disagree. `caption` is the group caption
* (the `^ ` line after the CLOSING fence); absent means uncaptioned, not empty.
*
* Discriminated by `type`, deliberately: every `figure` carries a `target`,
* the group does not, and a consumer probing for the missing field instead of
* reading the type string would break silently the day either shape grows a
* field. No `title`, no `label`, no `shortCaption` - that design space belongs
* to markup-carve/carve#1118 and markup-carve/carve#1121 and is not claimed
* here.
*/
export interface FigureGroup extends BaseNode {
type: 'figure_group'
children: BlockNode[]
caption?: InlineNode[]
}

export interface AbbreviationDef extends BaseNode {
type: 'abbreviation_def'
abbr: string
Expand Down Expand Up @@ -436,6 +457,7 @@ export type BlockNode =
| LineBlock
| DefinitionList
| Figure
| FigureGroup
| Image
| AbbreviationDef
| LinkReferenceDefinition
Expand Down
4 changes: 4 additions & 0 deletions src/footnote-numbering.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,10 @@ function walkBlockInlines(
if (node.target.type === 'block_quote' || node.target.type === 'table')
walkBlockInlines(node.target, visit, depth + 1)
break
case 'figure_group':
if (node.caption) visit(node.caption)
node.children.forEach((c) => walkBlockInlines(c, visit, depth + 1))
break
default:
break
}
Expand Down
102 changes: 88 additions & 14 deletions src/heading-ids.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,10 @@ import type {
CaptionNumber,
Document,
Figure,
FigureGroup,
Image,
InlineNode,
Table,
Text,
} from './ast.js'
import { SMART_PUNCTUATION_GLYPHS } from './ast.js'
Expand Down Expand Up @@ -649,6 +651,9 @@ export function resolveHeadingIds(
case 'figure':
if (b.target.type === 'block_quote') assignIds(b.target.children, true)
break
case 'figure_group':
assignIds(b.children, inBlockquote)
break
default:
break
}
Expand Down Expand Up @@ -992,6 +997,10 @@ export function resolveHeadingIds(
if (b.target.type === 'block_quote' || b.target.type === 'table')
walkBlock(b.target, fn)
break
case 'figure_group':
if (b.caption) fn(b.caption)
b.children.forEach((c) => walkBlock(c, fn))
break
default:
break
}
Expand All @@ -1014,18 +1023,19 @@ export function resolveHeadingIds(
// these on an ingested tree (carve#758). What stays here is the crossref
// target registration, which only makes sense while resolution is running.
const numberBlocks = (blocks: BlockNode[]): void => {
numberCaptionsIn(blocks, counters, (labelNodes, next, attrs) => {
numberCaptionsIn(blocks, counters, (labelNodes, next, attrs, suffix) => {
const id = attrs?.id
if (id === undefined || targets.has(id)) return
// Clean "Label N" auto-text: clone the label inlines, trim trailing
// whitespace on the final text node, then append " N". Markup in the
// label is preserved.
// label is preserved. A composite figure's PANEL arrives with a letter
// suffix (`Figure 2a`, §4c) on the group's own number.
const autoNodes = labelNodes.map((n) => ({ ...n })) as InlineNode[]
const last = autoNodes[autoNodes.length - 1]
if (last && last.type === 'text') {
last.value = last.value.replace(RE_TRAILING_LABEL_WS, '')
}
autoNodes.push({ type: 'text', value: ` ${next}` } as Text)
autoNodes.push({ type: 'text', value: ` ${next}${suffix ?? ''}` } as Text)
targets.set(id, autoNodes)
})
}
Expand Down Expand Up @@ -1265,6 +1275,7 @@ export function promoteBlockImages(blocks: BlockNode[], figuresOnly = false): vo
case 'block_quote':
case 'admonition':
case 'div':
case 'figure_group':
promoteBlockImages(b.children, figuresOnly)
break
case 'list':
Expand All @@ -1288,8 +1299,37 @@ type CaptionNumbered = (
labelNodes: InlineNode[],
n: number,
attrs: Attrs | undefined,
/**
* PART 9 §4c: a composite figure's PANEL takes the GROUP's number plus a
* letter (`a`, `b`, …) derived from its order among the panels. The letter
* arrives here as a suffix on the registered auto-text (`Figure 2a`); the
* group's own registration and every non-panel caption pass none.
*/
suffix?: string,
) => void

/**
* The §4c panel letter for panel index `k` (0-based): `a`..`z`, then `aa`,
* `ab`, … - bijective base 26, matching the executable spec's `panelLetter`.
*/
function panelLetter(k: number): string {
let s = ''
k++
while (k > 0) {
k--
s = String.fromCharCode(97 + (k % 26)) + s
k = Math.floor(k / 26)
}
return s
}

/** The §4c panels of a group: its `figure` and `table` children, in order. */
export function figureGroupPanels(group: FigureGroup): (Figure | Table)[] {
return group.children.filter(
(c): c is Figure | Table => c.type === 'figure' || c.type === 'table',
)
}

/**
* Assign `caption_number.n` per label, in document order, over `blocks`.
*
Expand All @@ -1308,49 +1348,83 @@ export function numberCaptionsIn(
counters: Map<string, number>,
onNumbered?: CaptionNumbered,
): void {
const numberCaption = (caption: InlineNode[], attrs: Attrs | undefined): void => {
const numberCaption = (caption: InlineNode[], attrs: Attrs | undefined): number | undefined => {
const idx = caption.findIndex((n) => n.type === 'caption_number')
if (idx === -1) return
if (idx === -1) return undefined
const labelNodes = caption.slice(0, idx)
const label = inlineText(labelNodes).replace(RE_TRAILING_LABEL_WS, '')
const next = (counters.get(label) ?? 0) + 1
counters.set(label, next)
;(caption[idx] as CaptionNumber).n = next
onNumbered?.(labelNodes, next, attrs)
return idx
}

const walk = (bs: BlockNode[]): void => {
// PART 9 §4c: a PANEL of a composite figure is not a sequence unit. Its
// caption's `#` placeholder draws no number and registers nothing - the
// caption_number node STAYS in the tree, un-numbered, and every renderer
// emits its authored spelling (the unresolved-reference precedent: keep the
// typed node, render what the author wrote). Decided here, in the one
// shared numbering pass, so the parse path and the AST-JSON ingest path
// (carve#758) publish the same wire shape as carve-php and carve-rs.
const walk = (bs: BlockNode[], inPanel: boolean): void => {
for (const b of bs) {
if (b.type === 'figure') {
numberCaption(b.caption, b.attrs)
if (!inPanel) numberCaption(b.caption, b.attrs)
} else if (b.type === 'table' && b.caption) {
numberCaption(b.caption, b.attrs)
if (!inPanel) numberCaption(b.caption, b.attrs)
}
switch (b.type) {
case 'block_quote':
case 'admonition':
case 'div':
walk(b.children)
walk(b.children, inPanel)
break
case 'list':
for (const it of b.items) walk(it.children)
for (const it of b.items) walk(it.children, inPanel)
break
case 'definition_list':
for (const it of b.items) for (const d of it.definitions) walk(d)
for (const it of b.items) for (const d of it.definitions) walk(d, inPanel)
break
case 'figure':
// A figure wraps an image / blockquote / table; descend into a
// blockquote or table target so a nested captioned element is
// numbered too (mirrors walkBlock's figure-target descent).
if (b.target.type === 'block_quote') walk(b.target.children)
else if (b.target.type === 'table' && b.target.caption)
if (b.target.type === 'block_quote') walk(b.target.children, inPanel)
else if (b.target.type === 'table' && b.target.caption && !inPanel) {
numberCaption(b.target.caption, b.target.attrs)
}
break
case 'figure_group': {
// The group is ONE numbering unit (§4c): only its own caption draws
// from the sequence, and its draw also registers the panel ids with
// letters - so `</#panel-id>` resolves as "Figure 2a". A group with
// no numbered caption registers nothing for its panels either.
const panels = figureGroupPanels(b)
if (!inPanel && b.caption) {
const labelIdx = numberCaption(b.caption, b.attrs)
if (labelIdx !== undefined && onNumbered) {
const labelNodes = b.caption.slice(0, labelIdx)
const n = (b.caption[labelIdx] as CaptionNumber).n!
panels.forEach((panel, k) => {
onNumbered(labelNodes, n, panel.attrs, panelLetter(k))
})
}
}
// Children walk: panels are not sequence units, and everything a
// panel CONTAINS is suppressed with it; non-panel stray content
// numbers normally, exactly as it would outside the group.
for (const c of b.children) {
const isPanel = c.type === 'figure' || c.type === 'table'
walk([c], inPanel || isPanel)
}
break
}
default:
break
}
}
}

walk(blocks)
walk(blocks, false)
}
1 change: 1 addition & 0 deletions src/heading-level-shift.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ function shiftBlock(node: BlockNode, shift: number): void {
case 'block_quote':
case 'div':
case 'admonition':
case 'figure_group':
node.children.forEach((c) => shiftBlock(c, shift))
break
case 'list':
Expand Down
1 change: 1 addition & 0 deletions src/heading-numbers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@ function walkHeadings(
}
case 'div':
case 'admonition':
case 'figure_group':
descend((b as { children?: unknown }).children, inBlockquote)
break
case 'definition_list': {
Expand Down
1 change: 1 addition & 0 deletions src/heading-reference.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@ function walkBlock(
case 'block_quote':
case 'div':
case 'admonition':
case 'figure_group':
node.children.forEach((c) => walkBlock(c, targets, counts))
break
case 'list':
Expand Down
58 changes: 58 additions & 0 deletions src/html-import.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type {
Attrs,
BlockNode,
Document,
FigureGroup,
InlineNode,
List,
TableCell,
Expand Down Expand Up @@ -362,6 +363,12 @@ class Importer {
}

private figure(node: P5Node, path: string, depth: number, attrs?: Attrs): BlockNode[] {
// Our own composite-figure shape (PART 9 §4c): the group class marks the
// wrapper, the panels div holds the children. Own-output round trip only;
// a foreign nested figure without the class keeps the unwrap below.
if ((this.attr(node, 'class') ?? '').split(/\s+/).includes('carve-figure-group')) {
return this.figureGroup(node, path, depth, attrs)
}
const captionNode = node.childNodes?.find((n) => n.tagName === 'figcaption')
const body = (node.childNodes ?? []).filter((n) => n !== captionNode)
const targets = this.blocks(body, path, depth + 1)
Expand Down Expand Up @@ -515,6 +522,57 @@ class Importer {
)
}
}

/** A copy of `attrs` without one class, `undefined` when nothing remains. */
private stripClass(attrs: Attrs | undefined, className: string): Attrs | undefined {
if (!attrs?.classes) return attrs
const classes = attrs.classes.filter((c) => c !== className)
const next: Attrs = { ...attrs }
if (classes.length) next.classes = classes
else delete next.classes
if (next.id === undefined && next.classes === undefined && next.keyValues === undefined) {
return undefined
}
return next
}

/**
* Our own `carve-figure-group` output back to a `figure_group` node (PART 9
* §4c). The panels div unwraps; a `carve-figure-panel` figure comes back as
* the panel it rendered from - a bare `<figure><table/></figure>` wrapper
* (the table panel, which carries no figcaption) unwraps to the table so the
* table's own caption and attrs stay its own.
*/
private figureGroup(node: P5Node, path: string, depth: number, attrs?: Attrs): BlockNode[] {
const captionNode = node.childNodes?.find((n) => n.tagName === 'figcaption')
const panelsDiv = node.childNodes?.find(
(n) => n.tagName === 'div' && (this.attr(n, 'class') ?? '').split(/\s+/).includes('carve-figure-panels'),
)
const bodyNodes = panelsDiv
? panelsDiv.childNodes ?? []
: (node.childNodes ?? []).filter((n) => n !== captionNode)
const children = this.blocks(bodyNodes, path, depth + 1)
for (let i = 0; i < children.length; i++) {
const child = children[i]!
if (child.type !== 'figure' || !child.attrs?.classes?.includes('carve-figure-panel')) continue
const stripped = this.stripClass(child.attrs, 'carve-figure-panel')
if (stripped) child.attrs = stripped
else delete child.attrs
// The explicit table-panel wrapper renders with no figcaption; the
// generic figure import gave it an empty caption, which is not a shape
// the parser produces - unwrap back to the table itself.
if (child.target.type === 'table' && child.caption.length === 0 && child.attrs === undefined) {
children[i] = child.target
}
}
const group: FigureGroup = { type: 'figure_group', children }
if (captionNode) {
group.caption = this.inlines(captionNode.childNodes ?? [], `${path}/figcaption[1]`, depth + 1)
}
const groupAttrs = this.stripClass(attrs, 'carve-figure-group')
if (groupAttrs) group.attrs = groupAttrs
return [group]
}
}

export function htmlToAst(html: string, options: HtmlImportOptions = {}): HtmlImportResult<Document> {
Expand Down
Loading
Loading