Skip to content

CND format 0.5.0: rich text and block containers - #13

Merged
Maxanox merged 83 commits into
mainfrom
format-0-5-0
Oct 14, 2026
Merged

Maxanox merged 83 commits into
mainfrom
format-0-5-0

Conversation

@Maxanox

@Maxanox Maxanox commented Oct 14, 2026

Copy link
Copy Markdown
Contributor

Brings CND format 0.5.0 to main: the proposal 0013 implementation, landed on the integration branch through five reviewed pull requests (#8, #9, #10, #11, #12).

What 0.5.0 changes

  • Rich text everywhere: prose fields are RichText with stand-off marks (strong, emph, code, strike, sup, sub, underline, highlight, math, or an open kind), references, citations, footnotes and links.
  • Block containers: list items, term items, table cells, footnotes, quotes, admonitions, figures and headings hold their nested nodes in children. New admonition and layout nodes.
  • Field changes: Numbering{value, name}, table.kind (data / content / null), checked, figure.description, code.title, doc.status / doc.version / doc.extra; tight, content_kind, table.raw and counter_label are gone.
  • Hashing (ADR 0028), traversal with slot and path (§8), validation of marks, spans, ids and versions.
  • Renderers and converters: shared inline layer, Markdown (HTML fallback or text-only), HTML with safe links.
  • Production: Cnd.to_json omits defaults, declaration 0.2.0, builder, matching v2 (ADR 0029), CLI, visitors, cnd.typ kind: hint under the table-kind state key.

0.5.0 is a breaking release (ADR 0027): 0.4 documents are refused with a message naming their version.

Before tagging

The package version is not bumped here; the release (version bump and tag) follows the merge.

Closes CND-15

🤖 Generated with Claude Code

https://claude.ai/code/session_01XxXUiDn2QyzNTYnZpeVFVS

Maxanox and others added 30 commits October 13, 2026 10:25
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XxXUiDn2QyzNTYnZpeVFVS
Move the forward edge classes and RawSource into cnd.core.text, add
Edge, Mark, RichText and Numbering, and regenerate the schema for the
reworded NodeRef description.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XxXUiDn2QyzNTYnZpeVFVS
…onition, layout (proposal 0013)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XxXUiDn2QyzNTYnZpeVFVS
… the schema

Migrate the six top-level fixtures to 0.5.0 (heading refs into text, grid
table to layout, nested list item to nested list node), add
fixtures/elements.cnd, regenerate schema/cnd.schema.json, and port
test_cnd.py and test_schema.py out of collect_ignore. Two tests added:
elements fixture coverage and incoming() reading edges from texts.

No tests deleted; no assertion needed loosening.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XxXUiDn2QyzNTYnZpeVFVS
…h in the context

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XxXUiDn2QyzNTYnZpeVFVS
…t (proposal 0013)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XxXUiDn2QyzNTYnZpeVFVS
…ths in the document hash

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XxXUiDn2QyzNTYnZpeVFVS
…ts twin fixture

Field(default_factory=list|dict) left no default in the generated schema,
so the section 10 rule could not be followed from the schema alone. Use
literal defaults, regenerate the schema and vectors, add
elements_explicit.cnd (every default written out) which must hash like
elements.cnd, list the defaults in spec section 6, walk iter_all() in
Cnd.paginated, clarify iter_nodes path docs, rewrap the NodeRef docstring.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XxXUiDn2QyzNTYnZpeVFVS
…pty marks

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XxXUiDn2QyzNTYnZpeVFVS
…ries; test paginated on footnote-only CNDs

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XxXUiDn2QyzNTYnZpeVFVS
…core

CND 0.5.0 core: rich text, block containers, traversal, validation, hashing
…plain text (proposal 0013)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XxXUiDn2QyzNTYnZpeVFVS
…ription and table kind

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XxXUiDn2QyzNTYnZpeVFVS
…HtmlNodeRenderer ported to 0.5

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XxXUiDn2QyzNTYnZpeVFVS
…, block cells and layouts; text_only option

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XxXUiDn2QyzNTYnZpeVFVS
The list escape-callback test is deleted: render_list_markdown is gone and
list escaping stays pinned by the escape-flag test. The tight-list
assertion is deleted: proposal 0013 removes the field.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XxXUiDn2QyzNTYnZpeVFVS
… markers from every text, pools hold blocks, status/version/extra, text_only

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XxXUiDn2QyzNTYnZpeVFVS
…r line

Under a list, table or quote the marker line read as a lazy continuation
or an extra table row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XxXUiDn2QyzNTYnZpeVFVS
…ences, status/version/extra meta

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XxXUiDn2QyzNTYnZpeVFVS
…fixture; unpark test_converters

Every golden diff is a 0.5 decision: cnd_version, layout flattening and
CSS grid, nested lists keeping their own kind, RichText.lang, table.kind,
footnotes holding blocks, and the new stylesheet rules.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XxXUiDn2QyzNTYnZpeVFVS
…e the fence

Strip exactly one trailing newline before the closing fence; regenerate
the two Markdown goldens whose code blocks gained a blank line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XxXUiDn2QyzNTYnZpeVFVS
…section

A renderer emits only a heading's line and the body walk visits the
section under it, so a heading held by a quote, a list item, an
admonition, a table or layout cell, a figure or a footnote lost its
whole section, while its markers still printed. expand_headings lists
a heading followed by its section, recursively in pre-order; both
renderers render container children through it, and both converters
render footnote blocks through it. Looseness and tightness are counted
on the expanded list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XxXUiDn2QyzNTYnZpeVFVS
…n its own line

A definition's ": ", a task item's checkbox and a linearized row's
"Label: " were glued to the first block. Before a fence that turned the
rest of the document into code; before a quote or an ordered list the
block became paragraph text. The prefix now shares the line only with a
paragraph or an image; before any other block it ends its line and the
block starts on the next one, at the content indent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XxXUiDn2QyzNTYnZpeVFVS
A delimiter's inner neighbours were read from the plain value, but when
a nested mark opens or closes at the edge the real neighbour is that
mark's delimiter or tag. "xab" with emph and strike over "ab" rendered
x*~~ab~~*, which a parser reads with literal asterisks. The content is
now rendered first and its first and last characters decide; a mark
that cannot be delimited still falls back to its tag, or to plain text
under text_only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XxXUiDn2QyzNTYnZpeVFVS
With flanking judged on the rendered edge, a strong under text_only can
fall back to plain text that ends in a nested emph's "*". The guard
against a "*" opener right after a "*" read the syntax chosen, not what
was written, so the next emph opened there and the two runs merged.
It now reads the rendered text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XxXUiDn2QyzNTYnZpeVFVS
…y a blank line

A tight terms list or linearized row puts the next term or cell on the
next line. After a quote or a list that line is a lazy continuation, so
the next term or cell was read as part of the block. Anything other than
one paragraph or one image now makes the terms list loose, and the row
separate its cells with blank lines.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XxXUiDn2QyzNTYnZpeVFVS
A links edge with a text_span now makes the text it spans an
<a class="cnd-link">, through the inline layer's edge_wrap, which the
HTML renderer enables for the links family only; refs, cites and
footnotes keep their marker line. A links edge without a span goes to
the marker line as a link to its href.

Every href goes through one check, cnd.core.urls.is_safe_url: http,
https, mailto or no scheme at all, read as a browser reads it (leading
controls stripped, every tab and newline removed). An unsafe href is
never a link: the spanned text stays plain and a span-less href is
written as text. The Markdown link marker uses the same check, escapes
its text, and escapes its destination so that it parses back to the
href that was checked (no entity or backslash turns into a scheme).

A Markdown HTML table renders its cells with links=False: the
converter already appends every link to the marker line.

Part of CND-36

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XxXUiDn2QyzNTYnZpeVFVS
Maxanox and others added 29 commits October 14, 2026 10:36
CommonMark and GFM compare link and footnote labels after Unicode case
folding, so two CND labels that differ only by case (Fn and fn, ß and
SS) could resolve to one definition. markdown_label now keeps a
letter, digit or safe punctuation only when case folding leaves it
unchanged and percent-encodes everything else, so the mapping stays
injective under folding. Goldens are unchanged.

Closes CND-38

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XxXUiDn2QyzNTYnZpeVFVS
…s comparison

markdown-it compares labels with lower() then upper(), not Unicode case
folding, so dotless i and i still met after the previous commit. A
label character is now kept only when case folding, lowering, and an
upper-then-lower round trip all leave it unchanged. Tests sweep the
whole Basic Multilingual Plane under both comparisons.

Part of CND-38

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XxXUiDn2QyzNTYnZpeVFVS
Keep Markdown labels apart under case-insensitive matching
Proposal 0013 lets a CND omit every field that holds its default. The
writer keeps id, required fields and free-form objects; the round trip
and the content hash are pinned over the whole fixture corpus.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XxXUiDn2QyzNTYnZpeVFVS
…eclaration

Texts take the CND's RichText or a plain string; the link families live
in texts, so hyperlinks are declarable; numbering keeps only the
authored name; list items, term items, cells and footnotes hold
declared nodes and are strict. Version 0.2.0: a 0.1.0 declaration is
refused rather than misread.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XxXUiDn2QyzNTYnZpeVFVS
…ilder

Every nested node is minted an id; heading_path is threaded through
every container; the counter engine numbers headings, figures and
equations in full-walk order and keeps only authored words. Authored
numbering with the engine off is a build error
(numbering-without-engine), never a silent drop. Goldens are written
through Cnd.to_json.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XxXUiDn2QyzNTYnZpeVFVS
…est_reconcile

Matching v1 keeps its passes and keys; the node set is the full walk and
the structural path is the spec section 8 path, with footnote content
keyed by the note's label so a pool reorder moves nothing. The remap and
its guards reach every slot and the footnotes. New vector cases: an
edited list item, two cells as two slots, footnote content.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XxXUiDn2QyzNTYnZpeVFVS
…s id

MATCHER_VERSION is now v2: the same 0.5 inputs pair differently from the
previous matcher (spec section 8 path, full walk, footnote content keyed
by its note's label). A node hash excludes nested nodes, so two
containers of one shape hash alike; in the two hash passes a node with
child slots is keyed on its node hash plus a digest of its descendants'
hashes. The spec node hash is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XxXUiDn2QyzNTYnZpeVFVS
A heading whose section changed has not itself changed (spec section 10),
so a moved heading must stay recognisable. The nested-content digest
applies only to list, terms, table, layout, quote and admonition, whose
own hash carries little content; headings and figures already hash their
text or caption. Restores the original moved-into-a-section expectation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XxXUiDn2QyzNTYnZpeVFVS
A container whose nested content was edited and which also shifted
position missed every earlier pass and lost its id. After path+type, the
still-unmatched list, terms, table, layout, quote and admonition pair on
their own node hash without the digest; twins have already paired through
the digest. Also fixes a stale "v1" in a test docstring.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XxXUiDn2QyzNTYnZpeVFVS
matching.json is the cross-implementation oracle, so every pass needs a
vector. Case 11: a table with one cell edited and a paragraph inserted
above it pairs on its node hash alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XxXUiDn2QyzNTYnZpeVFVS
…test_visitor

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XxXUiDn2QyzNTYnZpeVFVS
…itor

Admonition and layout panels, rich-text details (marks, every link
family read from the node's texts, hyperlinks by href), numbering as
number and counter, task-list counts, code titles, figure descriptions,
document status, version and extra, and footnote blocks in the pool
panel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XxXUiDn2QyzNTYnZpeVFVS
The document panel and the pool panels passed CND values (title, status,
version, extra, footnote and bibliography text) to Rich as plain strings,
which Rich parses as markup: a stray closing tag crashed the render. They
are wrapped in Text now. The document grid also gets a column gap.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XxXUiDn2QyzNTYnZpeVFVS
hash --nodes lists the full walk, as fixtures/hashes.json does; build
writes through Cnd.to_json; inspect displays every 0.5 node type.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XxXUiDn2QyzNTYnZpeVFVS
…hyperlinks; empty parking list

Every test module runs again. Spec section 12 states the shorthand, the
authored numbering name and the build error without the engine, the
moved strictness boundary, and that hyperlinks are declarable through
rich text. The CND-26 wording items are fixed (paragraph example id,
"rendered text"). Proposal 0013 notes that the authoring hint rename
supersedes the content_kind part of ADR 0023.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XxXUiDn2QyzNTYnZpeVFVS
Every read_text and write_text of CND or declaration content in the CLI
and the fixture scripts names its encoding, so a non-UTF-8 locale can
neither misread a file nor write one another implementation cannot read.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XxXUiDn2QyzNTYnZpeVFVS
… shape

A 0.1 declaration failed on "Extra inputs" errors that never named its
version, and `cnd validate` printed ok for a CND stamped 0.4.0 or 9.9.9.

- The CLI loaders read cnd_version and declaration_version from the raw
  data first and refuse another version by name, with the supported one.
- Declaration refuses another declaration_version in a before-validator,
  so a caller of model_validate gets the same single error.
- validate() gains cnd-version-unsupported for a CND built in code with
  another version; spec section 9 lists it.

Tests use main's own minimal.cnd (stamped 0.4.0, a shape 0.4 parsed
unchanged) and article.decl.yaml (0.1.0) as real old documents.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XxXUiDn2QyzNTYnZpeVFVS
build() handed source, doc, raw and nested state_metadata values to the
CND as the declaration's own objects. Mutating the declaration after a
build silently edited the earlier CND, and diff(c1, c2) then reported
nothing changed. build() now works on a deep copy of the declaration.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XxXUiDn2QyzNTYnZpeVFVS
After `cnd.metadata.update(it => it + (kind: "report", revision: "1"))`
and one `cnd.table(..., kind: "data")`, the state was `(revision: "1")`:
the hint's cleanup removed the author's document-level kind, and an
inner hint removed an outer one. The hint now saves the previous value
in a context before its update and restores it after the table, or
removes the key only when it was absent. The body stays outside the
context, at the call site.

The new tests compile a document with `typst query` and skip when the
typst binary is missing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XxXUiDn2QyzNTYnZpeVFVS
…aths

The BaseVisitor docstring told users to walk cnd.footnotes[i].children,
which yields ("nodes", 0) paths that name a body node. visit() gains
full=True, which runs Cnd.iter_all(): the body, then each footnote's
content, with spec section 8 paths and slot "footnotes". The default
stays the body walk. iter_all() now takes max_depth and stop_predicate,
so should_stop_descent prunes inside footnotes too.

Spec section 8 no longer claims `cnd inspect` shows the order a CND
walks in: it shows the body walk only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XxXUiDn2QyzNTYnZpeVFVS
…declaration 0.2.0 only

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XxXUiDn2QyzNTYnZpeVFVS
ADR 0029 extends ADR 0018 and records what matching v2 computes. It
covers the full walk, spec section 8 paths, footnote content keyed by
its note's label, and the container key: node hash plus a descendants
digest, with headings and figures left out. It also covers the last
pass, empty containers and the known limits. The docs index marks 0018
"extended by 0029".

The last pass, which pairs leftover containers on their node hash alone,
now reports matched_by "container-hash" (PASS_CONTAINER_HASH) instead of
"hash", so a caller can tell it from an unchanged move. In
matching.json, the one changed hunk is case 11's table. The match.py
docstring's numbered passes now match its prose. The README, the CLI and
the diff docstring point at ADR 0029.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XxXUiDn2QyzNTYnZpeVFVS
The author still writes `cnd.table(..., kind: "data")`. The state now
carries the hint under the dedicated key `table-kind`, which an emitter
reads and the CND writes as `table.kind`. A document-level `kind` the
author sets therefore never collides with the hint. The hint keeps the
plain set-then-unset pattern: nothing is read back, so no context
update has to settle.

Compile tests run with the typst binary, and skip without it:
- five sequential hinted tables, failing on any convergence warning;
- a document-level kind that survives two hinted tables, while each
  table sees its own table-kind.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XxXUiDn2QyzNTYnZpeVFVS
…state key

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XxXUiDn2QyzNTYnZpeVFVS
CND 0.5.0 production: declaration 0.2.0, builder, matching v2, CLI, visitors
@Maxanox
Maxanox merged commit 215ddee into main Oct 14, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant