Skip to content

Directives (2/4): styling — font composition, colour, and rich copy - #155

Merged
luca-chen198 merged 10 commits into
nodes-app:mainfrom
wildthink:feat/directives-composition
Aug 17, 2026
Merged

Directives (2/4): styling — font composition, colour, and rich copy#155
luca-chen198 merged 10 commits into
nodes-app:mainfrom
wildthink:feat/directives-composition

Conversation

@wildthink

Copy link
Copy Markdown
Contributor

PR 2 of 4, and the other half of the pair you asked for in #120. Based on feat/directives-projection, not on main — review it as the diff on top of PR1, and it's ready to merge with PR1 whenever you are.

This is where the API from PR1 starts doing something: a registered directive now styles its body instead of rendering as plain text.

Composition, and why style returns data

style returns a DirectiveStyle — a font transform as data, not a closure — which the styler composes over whatever font the enclosing tree already established. So @font(size: 18){**bold**} is bold and 18pt rather than one clobbering the other, and it stays inspectable and cheap on the per-keystroke path. custom is the escape hatch for the rare case that isn't expressible.

That composition is the whole reason for the tree-shaped scope decision back in #108: the transform derives from the enclosing node, never from document position, so it survives block-scoped restyle unchanged.

FontDirective and ColorDirective ship off by default, the same posture as HighlightExtension.

What I moved out, deliberately

DirectivePresentation and DirectiveCompletion are not here, even though PR1's original branch defined them. Shipping their public API now would repeat exactly the objection that put PR1 and PR2 together — API arriving before the behaviour it exists for. They land with PR3 and PR4.

PageBreakDirective went with them: its only override is presentation. The core tests use a self-contained fixture instead, which is better hermetics anyway.

Your PR2 requirements

The perf scenario, changed on purpose

The scenario I wrote for #108 asserted wall-clock ratios. After watching a bound of mine turn main red in 350b2d3, shipping three more of those would be indefensible, so:

  • The load-bearing test is structural and runs on CI. scopedWorkIsIdenticalRegardlessOfDocumentSize digests the styled output of the edited paragraph and requires it byte-identical in a 40-paragraph and a 400-paragraph document. If any pass walked the whole document the in-scope output would differ. That holds on every machine.
  • The timed assertions are opt-in behind MDE_PERF=1, matching what you did to the span-density ones, and keep their numbers for local investigation.

I also rewrote that file's header, which still described the quadratic density behaviour #140 fixed.

The O(edit) answer itself is unchanged from #108: a directive-dense paragraph costs the same scoped restyle in a 400-paragraph document as in a 50-paragraph one. Nothing directive-related scales with the document.

Testing

446 tests green, demo builds and runs. New coverage: font composition over inherited traits, colour resolution, nesting and neighbour isolation, the HTML/pasteboard path, and the perf scenario above.

Still outstanding, not here

#154 — a directive body holding a pre-claimed span (code span or escape) rejects the whole construct. Documented and pinned by tests in PR1, and it belongs with scanLinkFamily's overlap rule rather than with styling. Happy to take it once this pair lands.

wildthink-pub and others added 4 commits August 13, 2026 13:13
`MarkdownExtension` covers delimiter-shaped constructs. What it cannot
express is a construct with a NAME and TYPED ARGUMENTS — `InlineSyntax` is a
pair of delimiter strings, so `@font(size: 18){…}` has no shape there.

This adds `MarkdownDirective` as a parallel seam built to the same isolation
contract: a directive supplies syntax and a parameter schema, never ranges.
Two forms, both tree-shaped, so a directive's effect never escapes its own
node: self-contained (`@pagebreak`) and container (`@font(size: 18){text}`,
whose body is re-parsed as markdown).

There is deliberately no "applies to everything after me" form, even though
that is the obvious reading. It would make styling depend on document
position rather than tree position, which breaks the styler's
compose-on-descent model, and its effect would outlive its own block, which
breaks the block-scoped incremental restyle.

Two decisions are the substance here, and both are about NOT adding surface:

Directives project into the AST as extension-shaped nodes (`InlineNode.ext`)
under a reserved `directive.` id namespace rather than as a new node kind.
`InlineNode`, `buildTree`, `offsetNodes`, `InlineASTAdapter`, `MarkdownToken`,
and `shrinkInlineMarkers` are therefore untouched, and directives inherit
marker shrink, caret reveal, token projection, incremental restyle, and rich
copy unchanged.

`DirectiveRegistry` is carried by `ExtensionRegistry` so its fingerprint folds
into the one grammar fingerprint every parse cache already keys on. There is
no second cache key threaded through the pipeline, and a directive-free
registry produces a byte-identical fingerprint to before, so no existing
document re-parses.

Two rules make the seam safe to enable over an existing corpus: registered
names only (`@home` stays literal unless `home` is registered), and a
left-boundary rule stated as a deny list — only letters and digits reject —
so `name@example.com` never opens a directive while markup delimiters
(`*@font(…){…}*`, `- @pagebreak`) do. An allow list of "opening punctuation"
was tried first and silently dropped every directive abutting markup.

Arguments are coerced against the schema at styling time, not parse time, so
the parser stays geometry-only and a directive-free document pays nothing.

Nothing is styled yet — no directive ships, and a registered one renders as
literal text. Presentation and autocomplete follow separately.

46 lines across 3 existing files; everything else is new.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two review points from nodes-app#120, both confined to DirectiveArguments.swift plus
comments, so neither touches what nodes-app#140 rewrote.

`defaultValue` on a positional parameter did nothing: applyingDefaults
guarded on parameter.label, so only labelled parameters were filled. Since
it is public API, implementing it beats dropping it. Positional defaults
fill by POSITION, which makes them a tail-only affair — given (a, b = 2, c),
`@x(1)` yields 1, 2 and still reports nodes-app#2 missing, because there is no syntax
for "default here, but supply the next one". The missing-positional
diagnostic now names the parameter's own index instead of reporting once at
the count.

The body limitation is documented rather than fixed, as asked, in the
scanner header, at the InlineParser hook, and in the changelog — and pinned
by tests, so the follow-up that lifts it flips them rather than deleting
them. The hook comment now also states that directives match before the
extension loop.

Note the limitation is narrower than the review described: only spans
claimed by an EARLIER pass reject a directive, i.e. code spans and escapes.
`$…$` is claimed in this pass and composes fine inside a body, as do links,
emphasis and nesting. Tests cover both halves.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A container directive's style now applies, and its font TRANSFORM composes
over the font inherited at that point in the tree — in both directions.
`@font(size: 18){**bold**}` is bold AND 18pt; the same call inside a heading
keeps the heading's weight; nested directives stack
(`@scale(by: 2){@font(size: 1.5em){x}}` resolves against the scaled size).

This is why directives are tree-shaped. The styler already threads a font
down its walk; a directive contributes one more step, so every combination
stacks instead of overwriting. A "from here on" directive could not
participate at all — it would have to mutate state between siblings.

Also wires the clean-copy path: `MarkdownHTMLRenderer` and
`MarkdownPasteboardWriter` take directives, recovering arguments from the
same prefix geometry the styler uses, so copied HTML cannot disagree with
what was on screen.

Fixes a boundary bug found by the composition tests: the left-boundary rule
was an ALLOW list of "opening punctuation", which silently dropped every
directive abutting markup — `*@font(size: 18){x}*`, `**…**`, `_…_`,
`- @pagebreak` — because the preceding character is a delimiter that wasn't
listed. Restated as a DENY list: only letters and digits reject, which is all
the email rule ever needed.

`ColorDirective` now resolves standard colour names without an asset catalog,
falling back to `NSColor(named:)` for embedder palettes.

Demo registers `FontDirective` and `ColorDirective` and gains a Directives
section demonstrating absolute/relative sizes, composition in both
directions, nesting, and the email non-match. `@pagebreak` stays out of the
sample until Phase 3 gives it a glyph.

Phase 2 styling lives in its own file; upstream changes are 73 lines across
4 files, of which 16 are the styler hook.

29 new tests, 343 passing, no regressions. Demo builds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
PR2 of the directive seam, carved from the branch in nodes-app#108 to sit on top of
PR1 as requested in review: PR1's API arrives together with the behaviour it
exists for.

A container directive's `style` returns data, not a closure — a font
transform the styler composes over whatever font the enclosing tree already
established, which is why `@font(size: 18){**bold**}` is both. `FontDirective`
and `ColorDirective` ship off by default, the same posture as the bundled
extensions. The HTML renderer takes the directive set so the clean-copy path
renders what the screen shows, which the review flagged as a PR2 requirement.

Restores the styling types PR1 trimmed (`DirectiveFontTransform`,
`DirectiveStyle`, `DirectiveContext`) and the `style` protocol requirement.
Presentation and completion stay out — they are PR3 and PR4, and shipping
their public API here would repeat the "API without behaviour" objection that
put PR1 and PR2 together in the first place. `PageBreakDirective` goes with
them; the core tests use their own self-contained fixture instead.

The directive-heavy restyle perf scenario asked for in nodes-app#108 lands here too,
with its timed assertions OPT-IN behind MDE_PERF. The load-bearing test is
structural — the styled output of the edited paragraph must be byte-identical
in a 40- and a 400-paragraph document — because a wall-clock ratio is not
portable and turned main red once already. Its header no longer describes the
quadratic density behaviour that nodes-app#140 fixed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
luca-chen198 and others added 6 commits August 17, 2026 13:50
With the caret inside `[text](url)`, the four brackets are tinted
`mutedText` but the target between two of them keeps body color — the
loudest ink in the document, on the one run that is machine-readable
rather than prose, and that disappears again the moment the caret leaves.

Every other construct here mutes its syntax when it reveals it: the `#` of
a heading, the `>` of a quote, the list markers, and — in this very
function — the brackets themselves. The target was the exception.

`mutedText` is `secondaryLabelColor`, so it stays readable for editing; the
label keeps the faded link ink and, as before, no `.link` attribute while
active.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
The identical guard inside `DirectiveScanner.match` never runs early
enough. `match` is too large to inline, so the call, the indirect return
buffer for a ~200-byte `DirectiveMatch?` and an outlined ARC helper all
execute per unclaimed character before the callee gets to test the
registry -- a cost paid in full by every document that registers no
directive at all.

Measured against `main` with an `-O` binary, minimum of three rounds of
nine runs each:

                        main      before     after
  inline scan (200k)    3.910 ms  4.981 ms   4.022 ms   +27.4% -> +2.9%
  document parse (400k) 14.877 ms 16.313 ms  14.955 ms   +9.7% -> +0.5%

That recovers 89.5% of the inline-scan cost and 94.6% of the
document-parse cost. The AST checksums are identical across all three
builds, so this drops a call and nothing else.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`ExtensionRegistry.fingerprint` folds in the directive registry, so a
directive-only change already reaches this branch and drops the parse
cache -- but the branch copied only `extensions`, leaving the restyle it
triggers to run against the PREVIOUS directive list.

Registering at construction was never affected (`makeNSView` assigns the
whole configuration). Changing the list at runtime silently did nothing,
which is exactly the shape an embedder's "directives on/off" setting
would take.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Picks up the two fixes added to this branch's base: hoisting the
directive emptiness test out of DirectiveScanner.match (the callee's own
guard is unreachable before the call cost is paid), and syncing
directives / directiveSettings on a live configuration change.
…es-app#120)

`MarkdownExtension` handles delimiter-shaped constructs and cannot express one with a name and typed arguments. `MarkdownDirective` is the parallel seam for `@font(size: 18){…}`, built to the same isolation contract: syntax and schema in, never ranges.

Directives add no node kind. A match projects as an `InlineNode.ext` under a reserved `directive.` id prefix, so `InlineNode`, `buildTree`, `offsetNodes`, `InlineASTAdapter`, `MarkdownToken` and `shrinkInlineMarkers` are untouched and directives inherit marker shrink, caret reveal, token projection and incremental restyle rather than reimplementing them.

`DirectiveRegistry` is carried by `ExtensionRegistry` so its fingerprint folds into the one grammar fingerprint every parse cache already keys on — no second cache key, and a directive-free registry keeps the fingerprint it had before directives existed. Only parse-affecting fields participate.

Safety over an existing corpus rests on two rules: registered names only, and a left boundary stated as a deny list (only letters and digits reject), which is all the email rule needs and cannot silently drop directives abutting markup. Rejection is always total.

Reviewed with a differential harness over 180 corpus cases x 12 surfaces: with no directive registered the AST, token projection, HTML, rich-copy body and styled attribute runs are byte-identical to the merge-base.

Two fixes were added during review: the emptiness test is hoisted to the call site in `matchClaimedSpan` (the callee's own guard sits behind a non-inlinable call that costs ~7 ns per unclaimed character, measured at +19% on the document parse of a library registering nothing), and `updateNSView` now syncs `directives`/`directiveSettings` alongside `extensions` — without which a runtime change restyled against the old list and the fingerprint never converged, re-firing a full-document restyle on every update pass.
nodes-app#120 landed as a squash, so its content reached main under a new sha while
this branch still carries the original commits. The shared merge-base is
therefore still f6137df and git saw the directive files as added on both
sides.

Resolution, all three mechanical:
- MarkdownDirective.swift (add/add): took this branch's version, which is a
  strict superset of main's -- 78 lines added, none removed.
- ARCHITECTURE.md, CHANGELOG.md: main contributed nothing at the conflict
  point (its directive text arrived with the squash, above the marker), so
  this branch's styling paragraphs stand as written.

MarkdownASTStyler.swift auto-merged: main's link-target muting from nodes-app#156 sits
in styleLink and the directive branch sits in styleInlines, on disjoint
ranges.
@luca-chen198
luca-chen198 merged commit 547c97e into nodes-app:main Aug 17, 2026
1 check passed
wildthink pushed a commit to wildthink/swift-markdown-engine that referenced this pull request Aug 17, 2026
nodes-app#120 and nodes-app#155 merged upstream, so the copies of those phases on this branch
are superseded by the reviewed versions. Every directive file conflicted
add/add — the two histories are disjoint for them — so each was arbitrated
rather than taken from one side:

  upstream wins outright (review fixes; phases 3/4 never touched them):
    DirectiveArguments, DirectiveScanner, InlineParser,
    DirectiveArgumentTests, DirectiveParserTests, DirectivePerformanceTests

  ours (strict supersets of the merged versions):
    MarkdownDirective, BuiltinDirectives, MarkdownASTStyler+Directives,
    DirectiveStylingTests

  hand-merged:
    DirectiveTestFixtures — union; upstream's argument/parser tests need
      SizedDirective/TintDirective/SelfContainedPair, which this branch had
      dropped when the reference directives moved to Demo/
    README, ARCHITECTURE, ContentView — upstream RELOCATED the extension and
      directive sections (d26b5a2, af8e637, d826f8a). Taking our side would
      have duplicated them, so upstream's placement stands and the phase 3/4
      material was folded into it.
    CHANGELOG — upstream's two entries verbatim, phases 3 and 4 appended.

DirectiveStylingTests.selfContainedStaysVisible asserted a self-contained call
renders as literal text with nothing collapsing it. Phase 3's glyph pass is
exactly that change, so it is replaced by the collapse/reveal pair rather than
deleted, and the suite is no longer named "Phase 1 styling".

488 tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.

3 participants