Skip to content

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

Open
wildthink wants to merge 4 commits into
nodes-app:mainfrom
wildthink:feat/directives-composition
Open

Directives (2/4): styling — font composition, colour, and rich copy#155
wildthink wants to merge 4 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>
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