Skip to content

Renderer v0.1: HTML and text output for LegalDown 0.2 - #1

Merged
dvejsada merged 22 commits into
mainfrom
claude/keen-ride-is254j
Sep 24, 2026
Merged

dvejsada merged 22 commits into
mainfrom
claude/keen-ride-is254j

Conversation

@dvejsada

@dvejsada dvejsada commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

Summary

This is the first version of legaldown-render, the reference renderer for LegalDown specification 0.2. It aims at conformance level Rendering (§17.3). Parsing and Core validation come from legaldown-validator 0.2.0. The renderer builds only from the validator's document model, with one parser (ADR 0007).

Not for release yet. Several validator fixes should land first; see the known limitations below.

Pipeline

  1. Parse and validate: parse_document and validate_document.
  2. Build: turns the validator's model into a render tree. Inline Markdown is parsed with markdown-it-py in inline mode only. Directives are protected by sentinels that carry a random nonce for each build.
  3. Resolve:
    • numbering for sections, list items and paragraphs, with designations such as "2.1(b)"
    • cross-references, defined terms, parties and sides, attachments
    • dates, money and durations formatted with Babel; amounts are never rounded
    • the title block and signature blocks
    • the template view (§15.8): conditions, {{choose:}}, drafting notes, alternatives sharing a number
    • every failure marker
  4. Write: html (escaped throughout, with CSS generated from the style) and text. The text writer is designed to serve as the test oracle.

Styles and preferences

  • Style templates are YAML. They are layered in this order: defaults, then the extends chain, then the chosen style, then --set overrides. Every value is validated, and a typo gets a suggested key.
  • Built-in styles: default, continental, outline.
  • Labels are built in for English, Czech, German, French, Polish and Slovak, and follow the document's language (§13.7).
  • Table of contents: the contents setting (enabled, depth, attachments). Entries are plain text, and each one links to its section.
  • Render options: format, locale, strict mode, the final check, and full page or fragment.

Final check (§15.9)

--final or final=True passes the validator's final check through: a remaining blank or template construct is an Error. Combined with --strict, such a document is refused.

Interfaces

  • Python: render() and render_file().
  • The legaldown-render CLI.

Docs

  • README
  • concepts, architecture, style templates, roadmap
  • CONFORMANCE.md
  • ADRs 0001–0007

Known limitations (from the validator's model)

These are tracked on the validator and listed in CONFORMANCE.md:

  • nested lists: #14, #16
  • list markers: #21
  • tables: #22
  • comments and HTML blocks: #23
  • signature-block cutoff: #24
  • hard breaks and link references: #25
  • public API: #26
  • source positions: #27
  • empty comments: #28
  • indented code: #9
  • template assembly with answers (roadmap U4b)

The one deliberate exception to building from the model: a comment left open in a paragraph or a list item is stripped up to its -->, within one section (§8.6).

Testing

  • 494 tests, 1 skipped. They cover:
    • unit tests and regressions
    • golden files for text and HTML
    • the CLI
    • a conformance run over the specification's examples and fixtures corpus
  • ruff check . is clean.
  • The builder's copy of the template decision is checked against the validator's actual value by a test spy.

🤖 Generated with Claude Code

https://claude.ai/code/session_011ZFB1eaR4Fv8vUr2y8RXQt

…cisions

Design phase only, no code. Documents the pipeline (parse/validate via
legaldown-validator, build, resolve, write), the format-neutral resolved
document, the planned package layout, the style template draft, the
milestones, and the upstream changes the renderer needs first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011ZFB1eaR4Fv8vUr2y8RXQt
Implements the pipeline from docs/architecture.md on top of
legaldown-validator 0.2.0:

- build: source -> render tree. The LegalDown layer (directives, defined
  terms, markers, identifiers) comes from the validator; CommonMark
  structure from markdown-it-py, joined through sentinels, with an
  outline check that both parsers agree on the sections.
- resolve: numbering under four schemes and custom level formats, list
  and paragraph labels, item designations such as 2.1(b)(i), anchors,
  every directive with its failure marker, the template view, title
  block, attachment placeholders, and signature blocks.
- style templates: a validated dataclass model, layering through
  extends and per-job --set overrides, three built-in styles, and
  labels in English and Czech.
- writers: plain text (the test oracle) and a self-contained HTML page.
- legaldown-render CLI, golden and unit tests, a conformance run over
  the specification's examples and fixtures, and CI.

Docs updated to match: architecture, styles and preferences, roadmap,
CONFORMANCE.md, and ADR 0006 (styles vs render options).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011ZFB1eaR4Fv8vUr2y8RXQt
- HTML: judge link schemes as a browser parses the URL (tabs, newlines,
  and edge control characters removed), so "java\tscript:" is not a link.
- Build: a defined term's sentinel spans its quotation marks only, so
  emphasis around it keeps pairing with its closers; sentinel characters
  already in the source are protected instead of read as indexes.
- Resolve: an alternative section restarts its subsection numbers;
  alternative paragraphs and list items share a number like sections;
  drop a dead anchor fallback.
- Text writer: an item that opens with a nested list prints its label
  first.
- assert_resolved raises InternalError (CLI exit 70), not TypeError.
- Style overrides: --set values are typed by the setting they target, so
  text settings keep "[__]", "{designation}", or " | " as written;
  dotted and nested overrides merge in order and copy the caller's values.

Each finding has a regression test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011ZFB1eaR4Fv8vUr2y8RXQt
- Build: sentinels carry a random per-build nonce, so no document text
  (literal, entity, or percent-encoded) can pass for one; this replaces
  escaping the sentinel characters. A {{def:}} and its spacing become a
  hidden payload, so literal restoration (code, URLs) gives back the
  source exactly. Emphasis that wraps only a defined term goes with the
  term, leaving its look to the style (§7.2); markers that pair with text
  beyond the term stay. No spacing constant is copied from the validator.
- Link and image titles are inline content, so directives in them resolve.
- Resolve: alternatives share the first one's number wherever they are
  among their siblings, and only when their conditions are mutually
  exclusive (the validator's exclusive(), §15.4).
- --set: YAML-quoted text values are unquoted again.

Each finding has a regression test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011ZFB1eaR4Fv8vUr2y8RXQt
- Build: the hidden {{def:}} sentinel is led by a punctuation character,
  so an underscore emphasis closer before it still closes. Definition
  spans store what they are made of (marks, emphasis) and derive the
  rest, and _protect makes one pass over the sorted directives.
- A defined term in a link title or alt text shows its term but no
  longer takes the definition's anchor, so {{term:}} links always have a
  target.
- Alternatives: sections share a number exactly when the validator's own
  numbering says so, so rendered numbers match ValidationResult.sections.
  Paragraphs and list items follow the same rule: adjacent, same id, a
  valid condition, and a full presence (own and enclosing conditions)
  exclusive with every unit already holding the number.
- --set: a quoted text value that is not valid YAML is a usage error;
  documented how to keep quotation marks in a value.

Each finding has a regression test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011ZFB1eaR4Fv8vUr2y8RXQt
- Markers are placed exactly as the validator places them: in the
  preamble only a paragraph's condition, and only in a template; never
  on a preamble list item. Whether a document is a template is decided
  as the validator decides it, in the builder.
- Hidden sentinels have their own form, so a literal U+2E31 in the
  source is never taken into one (an include paragraph keeps its anchor).
- Headings are numbered with their level clamped to 1-5, as the
  validator numbers them.
- Definition anchors are given out in one place; {{term:}} links are
  settled after resolution from the anchors actually assigned. This
  replaces the hand-kept definition_ids predicate and its duplicate tree
  walkers (new tree.map_tree_inlines).
- The none scheme's designation comes from a text-only pass: no anchors
  spent, references left out at any depth; titles are resolved once.
- Terms nested anywhere in a title or alt text take no anchor.
- --set: only a single quoted YAML string is unquoted; any other value,
  quotes included, is kept as written.

Each finding has a regression test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011ZFB1eaR4Fv8vUr2y8RXQt
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011ZFB1eaR4Fv8vUr2y8RXQt
- Builder: a malformed {{include:}} is not an include-only paragraph, as
  in the validator's is_include_only; a {{choose:}} in frontmatter makes
  a template (read over the validator's own frontmatter fields); a later
  paragraph of a list item places its marker, as the validator's model
  reads it as top-level (legaldown-validator#14), designated as its item.
- The template flag lives on the builder instead of being threaded
  through every block call.
- Resolve the preamble before the sections, so the first definition of a
  term in document order keeps the anchor.
- Headings: five levels throughout. A level-6 heading is stored clamped
  to 5 like the validator numbers it, so writers nest it where its number
  puts it; schemes, heading styles, and validation cover levels 1-5.
- --set: only one pair of matching outer quotes without that quote inside
  is removed; nothing else is interpreted (no escapes, no comments).

Each finding has a regression test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011ZFB1eaR4Fv8vUr2y8RXQt
The render tree is now built from legaldown-validator's Document only
(ADR 0007, superseding the markdown-it-py half of ADR 0002):

- Sections, blocks, list items, quotes, tables, and code come from the
  validator's model; quote content and code inside items are read by the
  validator's parser.
- Markers are placed exactly where the validator's find_markers() places
  them, and the template decision is the validator's own formula over
  those markers. The mirrored position rules, the outline check, and the
  template re-derivation are gone.
- markdown-it-py stays for inline Markdown within one block's text only;
  the sentinel machinery for directives is unchanged.
- All imports beyond the validator's public API live in
  validator_bridge.py, the list for roadmap item U3.

Documents that crashed the outline check (a heading inside an HTML
comment, a "Signature Block" heading) now render as the validator reads
them, and anchors, markers, and the template view always agree with its
diagnostics. Until the validator's model keeps more (legaldown-validator#14),
lists render one level deep, table alignment is not kept, and paragraph
line breaks are joined; CONFORMANCE.md lists these.

Also: numbering.levels accepts at most five formats; counters are sized
for five levels; the resolver's loose-paragraph path is removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011ZFB1eaR4Fv8vUr2y8RXQt
- Quote content and item code are read by the validator's parser as a
  body: never as frontmatter, never cut at a signature-block heading, and
  errors fall back to a paragraph.
- Cutting a marker keeps the space after a lifted {{ref:}}/{{term:}}, and
  the marker is located with comments blanked, so a copy inside a
  trailing comment is not taken for it.
- An item opening with a quote or a code fence renders them (a drafting
  note included), instead of literal text.
- A paragraph that is an HTML block is dropped whole, text between its
  tags included; a joined backslash hard break is a line break again;
  table rows are padded or trimmed to the header's width.
- One lexer cache per render, shared by the bridge and the builder.
- The copied template formula is checked against the validator's own
  value (captured from validate_document) on every example, fixture, and
  test document.
- CONFORMANCE.md and the roadmap link the validator issues filed for the
  parser gaps the renderer inherits (#21-#25) and the API it needs
  (#26, #27).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011ZFB1eaR4Fv8vUr2y8RXQt
Removes the three workarounds the eighth review found fragile, and
follows the validator's model instead, with each gap documented and filed
upstream:

- No HTML-block detection: it dropped real text joined to a comment line,
  and anchors with it. Every tag is still dropped and reported; the text
  between an HTML block's tags now renders (legaldown-validator#23).
- No hard-break rewrite: it fired on any backslash-space, in titles and
  cells too. A joined backslash break shows as written
  (legaldown-validator#25).
- No signature-heading split in quotes and items: it cut fenced code
  and missed setext and conditional headings (legaldown-validator#24).
- The unreachable parse fallback is gone; quote and item text is still
  read as a body, never as frontmatter.
- The validator-template spy moved from the package into tests/.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011ZFB1eaR4Fv8vUr2y8RXQt
- Strip a comment left open in a paragraph up to its closing "-->"
  across the following blocks, within one section, quote or item: the
  one documented exception to following the validator's model (§8.6)
- Leave one space where inline HTML or a comment was dropped
- Count the raw-html Warning per text with dropped HTML, not per tag
- Stop pinning validator bugs #24 and #25 in tests; assert only the
  content that must render
- Assert no escaped script tag is emitted either
- Bind the template spy's arguments by signature
- Fix stale comments and CONFORMANCE rows (comments, extended tables)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011ZFB1eaR4Fv8vUr2y8RXQt
- Take an opener as a comment only when it is not escaped and not the
  empty comment <!--> or <!---> (complete in CommonMark)
- Strip a comment opened in a list item, through the items and blocks
  that follow; a comment ending inside a list keeps the remaining items
- Leave a space where a <br> between two words was dropped
- Skip marker placement for blocks read only for a comment's end
- Document the two gaps left until the validator's model drops such
  comments (legaldown-validator#23), and empty comments (#28)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011ZFB1eaR4Fv8vUr2y8RXQt
The validator's final check reports a remaining blank
(placeholder-unfilled) or template construct (template-construct-present)
as an Error; with --strict, such a document is refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011ZFB1eaR4Fv8vUr2y8RXQt
contents: { enabled, depth (1-5), attachments } lists the sections down
to the depth, then the attachment placeholders, after the title block.
Entries hold plain text, so no link, definition, or anchor from a heading
is repeated; in HTML each entry links to its section. Conditional
sections carry their condition label in the template view. The
"Contents" label follows the document language.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011ZFB1eaR4Fv8vUr2y8RXQt
Every generated word follows the document's language (§13.7). French
labels put a no-break space before the colon. A test checks that every
built-in language sets every label and keeps its {condition} and {file}
fields.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011ZFB1eaR4Fv8vUr2y8RXQt
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011ZFB1eaR4Fv8vUr2y8RXQt
- Contents: filter and indent by how deep a section's number goes, not
  its heading level; keep each heading's formatting (prompts, failure
  markers) but none of its links or anchors; list attachments under
  their heading; leave out an empty heading; emit the contents CSS
  only when the contents are enabled
- Reject a boolean or float for an integer choice such as
  contents.depth
- A per-language labels.colon between a label and its value, so French
  output spaces every colon
- Render the represented_by label: a representative is a labelled row
  of the party's details
- Export Contents from legaldown_render.style; mention the questions
  key in the --final help

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011ZFB1eaR4Fv8vUr2y8RXQt
- A representative with only a name or only a title renders without a
  stray comma
- An empty labels.colon is kept, not replaced by ": "
- Without an attachments label, the contents list attachments at the
  top level instead of under an empty entry
- A term defined in a heading shows in the contents in its style, not
  as a second <dfn>
- The attachments heading has an anchor (ld:attachments, which no
  identifier can take), linked from the contents
- A section's contents depth is the depth of the validator's number
- The contents writer no longer guards against links unlinked() has
  already removed

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011ZFB1eaR4Fv8vUr2y8RXQt
- An identifier with a colon (invalid, reported by the validator)
  never becomes an anchor, so it cannot clash with the renderer's own
  def: and ld:attachments anchors
- A defined term in a heading keeps its formatting in the contents,
  as a span instead of a second <dfn>
- An empty attachments label leaves out the attachments heading
- A representative with neither name nor title has no row
- Date and Place keep a colon's trailing no-break space
- Drop two fallbacks that could never apply

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011ZFB1eaR4Fv8vUr2y8RXQt
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011ZFB1eaR4Fv8vUr2y8RXQt
@dvejsada
dvejsada merged commit bfe4bfe into main Sep 24, 2026
6 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.

2 participants