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