Parse once into a document model, then render that model. Replaces the single-pass source-to-HTML renderer this package started as.
- Why
The old renderer went straight from source to an HTML string, doing inline work by regex-substituting markup into escaped text and protecting results with placeholder tokens. It worked, but HTML was baked into the parse: a second output format would have meant re-deriving the parse rather than reusing it.
With a tree, =OrgAttributedStringRenderer= is ~140 lines and shares the parse entirely. It is Foundation-only — no SwiftUI — so it works server-side or in a CLI too. A SwiftUI renderer would sit on top of it, in a separate product, so callers wanting HTML never import SwiftUI.
The types mirror orgo's =model.rs= (=OrgElement=/=OrgObject=, =TableRow::{Cells,Rule}=, the same =ListKind=/=Checkbox= vocabulary), which is what would later allow tree-level conformance instead of comparing rendered HTML.
- Consumer impact
None expected. =OrgRenderer.renderToHTML= is now a façade over =OrgParser.parse= plus =OrgHTMLTreeRenderer=; the public API is purely additive (56 new symbols, nothing removed), and both consumers — krz/gitbay-ios and krz/hutch — reference only =OrgRenderer=, =OrgRenderOptions= and =CodeHighlighter=. Both pin this package to =main=, so they pick the change up on their next resolve.
- Evidence
Conformance is unchanged at 11/12 against krz/org-conformance — every case but =outofscope=, which is =scope: out= in the corpus. The gate gained nothing and lost nothing.
Before swapping, the tree renderer was shown equivalent to the one it replaces: every corpus case, each render-option combination, identical URL resolution, unsafe-scheme rejection, and a 28-construct battery drawn from the old renderer's own test inputs. That battery caught three behaviours the corpus never reaches, each since fixed and each of which would otherwise have regressed a consumer:
- =#+CAPTION:=/=#+NAME:= on a non-image block wraps it in ==
- the nested =[[dest][[img]]]= badge form
- bare email autolinks
- One intentional behaviour change
A range whose halves are inactive timestamps, =[a]--[b]=, is now joined into one range; the old renderer joined active ranges only and left =--= as literal text. orgo applies the =--= rule to both bracket kinds, requiring only that the halves agree on activeness, so this is the more correct behaviour rather than a regression. Asserted directly in =joinsInactiveTimestampRanges=. In the corpus it appears only inside =outofscope='s LOGBOOK drawer.
- Size
~1,357 lines deleted against ~1,481 added. =Inline.swift= and =Timestamps.swift= are gone; =Lists.swift= 188 → 16, =Footnotes.swift= 76 → 10, keeping only the helpers the tree parser shares. 63 tests, all passing.
=ARCHITECTURE.md= documents the design and the migration notes.