Skip to content

Enforce the XHTML Strict content model for block/inline nesting#13

Merged
namedgraph merged 5 commits into
masterfrom
feature/xhtml-content-model
Jul 11, 2026
Merged

Enforce the XHTML Strict content model for block/inline nesting#13
namedgraph merged 5 commits into
masterfrom
feature/xhtml-content-model

Conversation

@namedgraph

Copy link
Copy Markdown
Member

Summary

Makes the editor true to the XHTML 1.0 Strict content model (extended with HTML5 figure/figcaption): every nesting the DTD allows is authorable and round-trips intact; every nesting it disallows is normalized at boundaries and reported by lint.

  • src/content-model.xsl (new) — 1:1 transcription of the Strict DTD (Appendix A.1 entities + element declarations) and Appendix B prohibitions; cm:* predicates are the single source of truth consumed by edit, canonical and lint, so verdicts cannot drift
  • src/canonical-xhtml.xsl — two passes: sanitize/normalize, then mode="cm-normalize" coerces to the content model (blockquote stray runs → p, blocks hoisted out of inline-only elements RDFa-preservingly, list/dl/tr coercion, table fostering, Appendix B fixes), reaching the fixed point in one bottom-up pass
  • src/edit.xsl — recursive editability init (text host vs container per the model, at any depth); mixed flow content (<li>text<ul>…</ul></li>) gets an ephemeral p.rdfa-editor-run host and round-trips byte-identical; paste nests blocks inside flow hosts; toolbar inserts follow the caret context
  • Authoring — Tab/Shift+Tab indent/outdent nested lists, progressive Enter/Backspace exits, quote toggle (blockquote is block-only per Strict), host-based block-type select, container-aware merges with composite hard boundaries, flow-cell caret landings
  • src/lint-xhtml.xsl (new) — invalid-nesting, stray-text, prohibited-nesting, unknown-element, surfaced live with the RDFa lint

Storage contract change

blockquote is block-only under Strict: stored bare-text quotes are rewritten to blockquote > p once at load/save. Host apps (LDH) with stored bare-text quotes will see documents change shape on first edit.

Review

An 8-angle adversarial review ran pre-merge; 10 confirmed findings (region-boundary escapes, composite-boundary merge holes, normalization fixed-point/attribute-loss defects, container-blind gestures, DTD transcription fidelity) are fixed in the final commit, each with a regression test.

Testing

  • Headless: 26 extractor + 12 lint + 22 canonical fixtures (incl. byte-identical mixed-li round-trip acceptance)
  • Browser: 11 Playwright suites incl. new nesting.mjs (foundation), authoring.mjs (gestures + review regressions) and invariants.mjs (uniform gesture battery × 9 caret contexts asserting validity/editability/undo invariants)

🤖 Generated with Claude Code

namedgraph and others added 5 commits July 8, 2026 23:18
The editor's flat-block model neither allowed everything the XHTML DTD
allows nor forbade everything it forbids. Nesting decisions now flow
from a single declarative source of truth:

- content-model.xsl (new): 1:1 transcription of the XHTML 1.0 Strict
  DTD (Appendix A.1 entities + element declarations) and the Appendix B
  prohibitions, extended with HTML5 figure/figcaption; cm:* predicates
  consumed by edit, canonical and lint so verdicts cannot drift
- canonical-xhtml.xsl: two passes - sanitize/normalize (content-aware
  div handling, run-wrapper and sectioning unwrap), then cm-normalize
  (blockquote stray runs wrapped in p, blocks hoisted out of inline-only
  elements RDFa-preservingly, ul/dl/tr child coercion, table fostering,
  a-in-a and pre exclusions); region-children block coercion at entry
- edit.xsl: recursive editability init (text host vs container per the
  model, at any depth); mixed flow content like <li>text<ul>...</ul></li>
  gets an ephemeral p.rdfa-editor-run host and round-trips byte-identical;
  invalid host markup normalized once at load; paste nests blocks inside
  flow hosts instead of flattening; toolbar inserts follow the caret
  context (a list inserted in a cell nests in the cell)
- authoring: Tab/Shift+Tab indent/outdent nested lists, progressive
  Enter/Backspace exits, quote toggle wraps/unwraps blockquote (block-only
  per Strict; RDFa-bearing quotes refuse), host-based block-type select,
  container-aware merges, flow-cell caret landings
- lint-xhtml.xsl (new): invalid-nesting, stray-text, prohibited-nesting,
  unknown-element, surfaced live with the RDFa lint

Storage contract change: stored bare-text blockquotes are rewritten to
blockquote > p once (blockquote is block-only under Strict).

New suites: tests/browser/nesting.mjs + authoring.mjs against
tests/fixture-nesting.html; canonical fixtures 13-20, lint fixtures 11-12.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…B4c)

The first item of a list nested in a cell (or quote, dd) hit B5's
top-level-inert rule, so the list could not be removed with Backspace.
New B4c branch: the item merges into the last editable host of the
list's preceding sibling inside the container, the emptied list is
removed and the container collapses back to a plain text host; the
caret rides a surviving text-node reference through both the merge and
the collapse unwrap. Composites (table, figure) stay hard boundaries;
a list with nothing mergeable before it stays inert (B5).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…s-product

Hand-picked scenario tests missed gesture/context combinations (a list
inserted in a cell, run-wrapper editability) - the space is too big to
enumerate. invariants.mjs runs a uniform battery (type, Enter, Backspace,
Tab/Shift+Tab, list/paragraph insert, quote toggle, convert, block paste)
in every caret context (top-level, quote paragraph, leaf/container/nested
list items, leaf and container cells, li-in-td, figcaption) and asserts
properties that hold regardless of outcome: no text outside an editable
host, no nested hosts, chrome top-level only, run wrappers editable, zero
lint issues, and undo unwinding to the exact post-init baseline.

fixture-nesting.html gains a figure block for the figcaption context.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Correctness fixes, each verified by an adversarial review pass and
covered by new regression tests (authoring.mjs section F, canonical
fixtures 21-22, embedded-region fixture):

- Region-boundary clamps: local:item-of, the Tab cell lookup and the
  quote-toggle blockquote lookup now require [exists(local:block-of(.))],
  so a host-page li/td/blockquote wrapping an embedded region is never
  reparented, traversed, grown or dissolved by editor gestures
- Composite hard boundaries on Backspace merges: new local:merge-host-in
  refuses a target inside a table/figure nested at the previous
  item's/container's tail (B4, B2b, B4c) - the gesture stays inert
- N1 rewritten to reach the fixed point in one bottom-up pass (decides
  on the PROCESSED children), split shells keep ALL attributes (an
  <a href> split by a block no longer loses its target), and the
  RDFa-preserving branch demotes blocks recursively via mode="cm-demote"
  (a list becomes nested spans, never a bare li inside a span)
- cm:wrap-inline-runs pulls only text and known-inline elements into its
  inline-only wrapper; known non-inline strays (li outside a list) pass
  through bare for lint instead of becoming manufactured invalid nesting
- E4b anchors the list-exit paragraph before removing the list, so
  exiting a sole-item list nested in a cell stays inside the cell
  (no more dead cell + caret teleport to region end)
- E6 anchors the caption-exit paragraph after the figure/table itself,
  which may be nested inside an item or cell
- A focused image island deletes its figure when it has one, else just
  the image - never the whole containing list/table; the emptied
  container collapses back to a text host
- convert-block's caret guard compares local:host-of (callers pass
  nested hosts), so converting a quote paragraph keeps the caret
- insert-block-at-caret treats figure as a locked composite: inserting
  at a figcaption nests inside the caption, mirroring paste
- content-model.xsl fidelity: form uses %form.content; (no nested form),
  isindex added to the Appendix B button prohibitions

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Both phases plus the review fixes are shipped; CLAUDE.md carries the
living module contracts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@namedgraph namedgraph merged commit 8dccac8 into master Jul 11, 2026
2 checks passed
@namedgraph namedgraph deleted the feature/xhtml-content-model branch July 11, 2026 04:03
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