Directives (2/4): styling — font composition, colour, and rich copy - #155
Merged
luca-chen198 merged 10 commits intoAug 17, 2026
Merged
Conversation
`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>
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.
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>
This was referenced Aug 17, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
PR 2 of 4, and the other half of the pair you asked for in #120. Based on
feat/directives-projection, not onmain— 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
stylereturns datastylereturns aDirectiveStyle— 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.customis 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.
FontDirectiveandColorDirectiveship off by default, the same posture asHighlightExtension.What I moved out, deliberately
DirectivePresentationandDirectiveCompletionare 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.PageBreakDirectivewent with them: its only override ispresentation. The core tests use a self-contained fixture instead, which is better hermetics anyway.Your PR2 requirements
MarkdownHTMLRenderer.html(from:extensions:directives:)takes the registered set, soMarkdownDirective.html(arguments:bodyHTML:)is reachable and rich copy matches the screen. Covered byDirectiveHTMLTests.@font(size: 18){…}) #108 is here — with one change I want to flag.The perf scenario, changed on purpose
The scenario I wrote for #108 asserted wall-clock ratios. After watching a bound of mine turn
mainred in 350b2d3, shipping three more of those would be indefensible, so:scopedWorkIsIdenticalRegardlessOfDocumentSizedigests 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.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.