Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
648f37d
Adopt the validator's CommonMark block model (legaldown-validator#49)
claude Sep 24, 2026
1ae2c01
Take section numbers from the validator (legaldown-validator#50)
claude Sep 24, 2026
cc51d27
Ask the validator's is_template instead of keeping a copy
claude Sep 25, 2026
c03e1e7
Render templates with answers (§15.8), through the validator's assembly
claude Sep 25, 2026
06c47cf
Fix code review findings on PR #2
claude Sep 25, 2026
c48f92a
Render nested lists from the validator's model (legaldown-validator#61)
claude Sep 27, 2026
f0e64cc
Fix code review findings on nested lists
claude Sep 27, 2026
eb98b54
Fix second review findings on nested lists
claude Sep 27, 2026
53a50e8
Fix third review findings: nesting limit, list designations
claude Sep 27, 2026
2b55c12
Merge the nested with statements in a test (ruff SIM117)
claude Sep 27, 2026
7f2263a
Build lists and quotes from the validator's block model (validator#71…
claude Sep 28, 2026
c8d51a4
Follow validator#80 and #81: raw-html is the validator's; container h…
claude Sep 28, 2026
1ef8c60
Fix review findings on block-model lists, quotes, and ambiguous refs
claude Sep 28, 2026
be0a0bd
Make render-ref-ambiguous a hint; keep drafting markers out of view
claude Sep 29, 2026
ad89f82
Point the drafting-marker stripping at legaldown-validator#88
claude Sep 29, 2026
ebe34b5
Check every numbered unit for ambiguous designations, in one place
claude Sep 29, 2026
bd1df48
Tailor render-ref-ambiguous advice; keep style labels under the none …
claude Sep 29, 2026
7c72018
Adopt legaldown-validator 0.3.0 and its public API
claude Sep 29, 2026
dd0aeba
Prepare the 0.2.0 release and publish to PyPI
claude Sep 29, 2026
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
81 changes: 81 additions & 0 deletions .github/PUBLISHING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
# Publishing (maintainers)

Release mechanics for this repository. Nothing here is needed to *use* the
renderer — see the [README](../README.md) for that.

`legaldown-render` is published to PyPI by the [`publish`](workflows/publish.yml)
workflow using **Trusted Publishing** (OIDC). No API token is stored in this repository — PyPI
verifies the identity of the workflow run itself.

## One-time PyPI setup

This must exist before the first publish, and it must be done by a PyPI account that will own the
project. Because the project does not exist on PyPI yet, register it as a **pending** publisher.

1. Sign in to <https://pypi.org> → **Your account** → **Publishing** →
*Add a new pending publisher*.
2. Fill in exactly:

| Field | Value |
|---|---|
| PyPI project name | `legaldown-render` |
| Owner | `ForLegalAI` |
| Repository name | `legaldown-render` |
| Workflow name | `publish.yml` |
| Environment name | `pypi` |

3. Repeat on <https://test.pypi.org> with environment name `testpypi` if you want the dry-run path.

The environment names must match, or PyPI rejects the upload.

## One-time GitHub setup

Create the two environments under **Settings → Environments**:

| Environment | Purpose | Suggested protection |
|---|---|---|
| `pypi` | Real releases | Required reviewers, and restrict to the `main` branch / tags |
| `testpypi` | Dry runs | None needed |

Adding a required reviewer to `pypi` means a human approves each upload — worth it, since a PyPI
version number can never be reused once published.

## Cutting a release

1. Bump `__version__` in [`src/legaldown_render/__init__.py`](../src/legaldown_render/__init__.py) — it is the single
source of truth; `pyproject.toml` reads it via `[tool.hatch.version]`.
2. Commit, then publish a GitHub Release with the tag `vX.Y.Z`. Its notes are where this
project records what changed in a release.

The workflow builds the sdist and wheel, runs `twine check --strict`, **verifies the tag matches
`__version__`** (a mismatch fails the run rather than publishing the wrong version), smoke-tests the
built wheel by installing it and invoking the CLI, and uploads to PyPI.

## Dry run

Before a first real release, exercise the whole path against TestPyPI:

```
Actions → Publish → Run workflow → target: testpypi
```

Then verify the result installs cleanly:

```bash
pip install --index-url https://test.pypi.org/simple/ \
--extra-index-url https://pypi.org/simple/ legaldown-render
legaldown-render --version
```

The extra index is needed because the dependencies (`legaldown-validator`, Babel, PyYAML,
markdown-it-py) come from PyPI.

## Notes

- **Version numbers are permanent.** PyPI does not allow re-uploading a version, even after
deletion. Use TestPyPI, or a `.devN`/`.rcN` suffix, to rehearse.
- The distribution is named `legaldown-render`; the import name is `legaldown_render`
(`pip install legaldown-render` → `import legaldown_render`).
- A release of `legaldown-validator` with a new minor version needs a renderer release too: the
dependency is pinned to one minor version (`pyproject.toml`).
- The package ships a `py.typed` marker, so type checkers use its annotations directly.
120 changes: 120 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
name: Publish

# Publishes the distribution to PyPI via Trusted Publishing (OIDC) — no API
# token is stored in this repository. See .github/PUBLISHING.md for the one-time PyPI
# setup that must exist before the first run.
#
# Triggers:
# - publishing a GitHub Release → build, verify, upload to PyPI
# - manual dispatch → build, verify, upload to TestPyPI

on:
release:
types: [published]
workflow_dispatch:
inputs:
target:
description: "Where to upload the built distribution"
required: true
default: testpypi
type: choice
options: [testpypi, pypi]

permissions:
contents: read

concurrency:
group: publish-${{ github.ref }}
cancel-in-progress: false

jobs:
build:
name: Build and verify distribution
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip

- name: Install build tooling
run: pip install build twine

- name: Build sdist and wheel
run: python -m build

- name: Check package metadata renders on PyPI
run: twine check --strict dist/*

- name: Verify the version matches the release tag
if: github.event_name == 'release'
env:
TAG: ${{ github.event.release.tag_name }}
run: |
python - <<'PY'
import os, pathlib, re, sys

tag = os.environ["TAG"].lstrip("v")
init = pathlib.Path("src/legaldown_render/__init__.py").read_text(encoding="utf-8")
version = re.search(r'__version__ = "([^"]+)"', init).group(1)
if version != tag:
sys.exit(
f"::error::Release tag {tag!r} does not match __version__ {version!r}. "
"Bump src/legaldown_render/__init__.py before tagging."
)
print(f"version {version} matches tag {tag}")
PY

- name: Smoke-test the built wheel
run: |
python -m venv /tmp/smoke
/tmp/smoke/bin/pip install --quiet dist/*.whl
/tmp/smoke/bin/legaldown-render --version
/tmp/smoke/bin/python -c "import legaldown_render as r; print(r.__version__, r.SPEC_VERSION, r.CONFORMANCE_LEVEL)"
# A real render: the built-in styles, labels, and locale data are in the wheel.
printf -- '---\ntitle: T\nlanguage: cs\n---\n\n# A\n\nText {{date: 2026-07-01}}.\n' > /tmp/smoke/t.lgd
/tmp/smoke/bin/legaldown-render /tmp/smoke/t.lgd -f text --style continental -o /tmp/smoke/t.txt
cat /tmp/smoke/t.txt

- uses: actions/upload-artifact@v4
with:
name: dist
path: dist/

testpypi:
name: Upload to TestPyPI
needs: build
if: github.event_name == 'workflow_dispatch' && inputs.target == 'testpypi'
runs-on: ubuntu-latest
environment:
name: testpypi
url: https://test.pypi.org/p/legaldown-render
permissions:
id-token: write # required for Trusted Publishing
steps:
- uses: actions/download-artifact@v4
with:
name: dist
path: dist/
- uses: pypa/gh-action-pypi-publish@release/v1
with:
repository-url: https://test.pypi.org/legacy/

pypi:
name: Upload to PyPI
needs: build
if: github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && inputs.target == 'pypi')
runs-on: ubuntu-latest
environment:
name: pypi
url: https://pypi.org/p/legaldown-render
permissions:
id-token: write # required for Trusted Publishing
steps:
- uses: actions/download-artifact@v4
with:
name: dist
path: dist/
- uses: pypa/gh-action-pypi-publish@release/v1
66 changes: 33 additions & 33 deletions CONFORMANCE.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Conformance

`legaldown-render` 0.1.0 targets **Level 2 — Rendering** of the LegalDown specification **0.2**
(§17.3). Core parsing and validation come from `legaldown-validator` 0.2.0, which claims Level 1
`legaldown-render` 0.2.0 targets **Level 2 — Rendering** of the LegalDown specification **0.2**
(§17.3). Core parsing and validation come from `legaldown-validator` 0.3.0, which claims Level 1
— Core. Its own [CONFORMANCE.md](https://github.com/ForLegalAI/legaldown-validator/blob/main/CONFORMANCE.md)
lists the Core rules it covers.

Expand All @@ -20,60 +20,60 @@ It is checked against the specification's own examples and fixtures corpus (`tes
| `{{include:}}` below Full (§17.5) | ✅ Renders `[NOT PROCESSED: include path]` with a Warning (`render-not-processed`) |
| Party and side display rules (§3.6) | ✅ |
| At least one output format (§13.6) | ✅ HTML (recommended) and plain text (optional) |
| Comment stripping (§8.6) | ✅ Including a comment across blank lines, up to the end of its section (see below) |
| A heading in a list item or a quote | ✅ Shown as a bold line: it is not a section (§4.1), so it has no number or anchor |
| Comment stripping (§8.6) | ✅ Inline comments, and comment blocks across any number of lines. As in CommonMark, a comment block left unclosed runs to the end of the document |
| Templates with answers (§15.8) | ✅ Assembled first with `legaldown-validator`'s Assembly capability (§15.7), then rendered: `answers=` / `--answers answers.yaml`. A template that needs other files is refused (see below) |
| Template view without answers (§15.8) | ✅ Conditions marked with style-defined labels; every `{{choose:}}` phrase shown as `[a / b]`; drafting notes styled; alternatives share a number |
| `{{attach:}}` resolves to the declared title without reading the file | ✅ Linked to an attachment placeholder, or to the file when placeholders are omitted |

## SHOULD and MAY features

| Feature | Status |
|---|---|
| List enumeration (§13.2) | ✅ Configurable per depth; can be disabled. Ordered lists are always renumbered. Lists have one level until the validator keeps nesting (see below) |
| List enumeration (§13.2) | ✅ Configurable per depth; can be disabled. Ordered lists are always renumbered. Nested lists keep their structure, so a nested item is referenced as "2.1(b)(i)" |
| Paragraph numbering and section-qualified items (§13.2) | ✅ Style settings; the `continental` style uses both |
| Style templates in a separate file (§13.7) | ✅ YAML, layered and validated; see [docs/style-templates.md](docs/style-templates.md) |
| Signature blocks (§2.2) | ✅ One per party, with `legal_name` and each representative |
| Labels following the document language (§13.7) | ✅ Built in for `en`, `cs`, `de`, `fr`, `pl`, and `sk`; any label can be set by a style |
| `ref-not-enumerated` Warning (§6.3, §16.3) | ✅ |
| `raw-html` Warning; raw HTML never emitted (§8.7) | ✅ No tag is ever emitted. Text between an HTML block's tags renders as text until the validator's model recognises HTML blocks (see below) |
| `raw-html` Warning; raw HTML never emitted (§8.7) | ✅ Nothing is emitted: an HTML block is dropped whole, and an inline tag is dropped with its text kept. The Warning is the validator's, once per block or text that holds raw HTML other than comments |
| Automatic term recognition (§7.4, MAY) | ❌ Not supported |
| Extended tables through raw HTML (§9.2, MAY) | ❌ Not supported; the tags are dropped with the Warning, and the cell text renders as paragraph text |
| Extended tables through raw HTML (§9.2, MAY) | ❌ Not supported; the HTML table is dropped with the Warning |
| Question prompts next to blanks (§15.8, MAY) | ✅ `placeholders.show_prompt` |
| Final check (§15.9, SHOULD) | ✅ `--final` / `final=True`: the validator's `placeholder-unfilled` and `template-construct-present` Errors; with `--strict`, the document is refused |

## Known limitations

- **Templates with answers.** Assembly (§15.7) is a Core capability that `legaldown-validator`
0.2.0 does not provide yet, so a template always renders as its template view.
- **Diagnostics have no line numbers**, because the validator's diagnostics do not carry them yet.
- **Assembly reads no other files.** A template with include fragments, LegalDown attachment
files, or `translations` is refused when rendered with answers, as §17.6 requires below Full.
A template with Errors is refused too, since §15.7.2 defines no output for one.
- **Line numbers.** The validator's diagnostics name their line, and the CLI prints it. The
renderer's own (`render-ref-ambiguous`, `ref-not-enumerated`, `render-not-processed`) do not
yet ([validator#93](https://github.com/ForLegalAI/legaldown-validator/issues/93)).
- **Nesting limit.** The validator reads lists 64 levels deep and quotes 16; deeper, an item's or
quote's content is its text. The renderer follows it, and refuses (`DocumentError`) a document
whose lists, quotes, and inline formatting (emphasis, links) together nest more than 100 levels,
while building it and before resolving or writing it, so no document can exhaust the stack.
- **Ambiguous references.** Each list starts again at its first number, as contracts are usually
drafted, so two lists in one section can both have an item "(a)", and a style can number a
paragraph or an item like a subsection ("1.1"). A `{{ref:}}` to a section, paragraph, or list
item whose designation another of them also reads as renders with a `render-ref-ambiguous`
Warning. It is a hint: it is left out when the two can never appear together (alternatives,
exclusive conditions), but the condition the reference itself stands under is not considered.
Lists in quotes and drafting notes are not counted, and a second use of an identifier is left
to the validator's `anchor-duplicate`.
- **Labels under the `none` scheme.** A style label that shows `{section}` (such as the default
paragraph label `{section}.{n}`) shows the section's heading text there, while `{{ref:}}`
writes the §13.3 designation ("Termination 1"), so the two can differ.
- **Malformed directives** (a §11.2 grammar violation) render their type's marker without a value,
for example `[INVALID AMOUNT]`. The specification defines no marker for this case.
- **One parser.** The renderer builds from the validator's document model only
([ADR 0007](docs/decisions/0007-one-parser-validator-model.md)). Anchors, markers, and the
template view therefore always agree with the validator's diagnostics. The price is that some
structure renders as the validator's model holds it, until that model keeps more
([legaldown-validator#14](https://github.com/ForLegalAI/legaldown-validator/issues/14),
roadmap U1–U2):
- **Nested lists** render as one level of items, so an item is referenced as "2.1(c)", never
"2.1(b)(i)" ([#14](https://github.com/ForLegalAI/legaldown-validator/issues/14), [#16](https://github.com/ForLegalAI/legaldown-validator/issues/16)). The resolver and writers already handle nesting.
- **`*` and `+` bullets and `1)` ordered lists** render as one paragraph ([#21](https://github.com/ForLegalAI/legaldown-validator/issues/21)).
- **Tables:** column alignment is not kept, and an escaped pipe (`\|`) or a pipe inside a
code span splits a cell ([#22](https://github.com/ForLegalAI/legaldown-validator/issues/22)). Rows are padded or trimmed to the header's width.
- **Line breaks** within a paragraph are joined, and a backslash hard break shows as a literal
backslash. Link reference definitions (`[label]: url`) are not resolved
([#25](https://github.com/ForLegalAI/legaldown-validator/issues/25)).
- **A heading inside a multi-line HTML comment** is a section: the comment ends at the heading,
and its closing `-->` shows as text. In an **HTML block**, every tag is dropped and reported (`raw-html`), but the
text between the tags renders as paragraph text ([#23](https://github.com/ForLegalAI/legaldown-validator/issues/23)).
- **A comment across blank lines** is stripped up to its `-->` (the one place the renderer
reads past the model), but the model still holds its content as blocks. So a `{#id}` inside
it is still a valid target for the validator, and a reference to it renders
`[BROKEN REF: id]` with no Error. When the comment ends inside a quote, code block, or table,
the rest of that block renders as plain paragraph text ([#23](https://github.com/ForLegalAI/legaldown-validator/issues/23)).
- **After an empty comment** (`<!-->`, `<!--->`), a directive before a later `-->` on the same
line is shown as its source text, unresolved and unchecked ([#28](https://github.com/ForLegalAI/legaldown-validator/issues/28)).
- **Everything after a `# Signature Block {#signature-block}` heading** is dropped
([#24](https://github.com/ForLegalAI/legaldown-validator/issues/24)).
- **Indented code blocks** are read as paragraphs ([#9](https://github.com/ForLegalAI/legaldown-validator/issues/9)).
structure renders as the validator's model holds it, until that model keeps more (roadmap
U1b):
- **Link reference definitions** (`[label]: url`) render as paragraphs, and the links that use
them as text ([#92](https://github.com/ForLegalAI/legaldown-validator/issues/92)).

## Beyond this level (Full, §17.4)

Expand Down
Loading
Loading