diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 10a33b3e..255dc6b5 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -7,7 +7,7 @@ Sources/ ├── MarkdownEngine/ # core target — zero deps │ ├── Configuration/ # MarkdownEditorConfiguration + MarkdownEditorTheme │ ├── Extensions/ # the extension seam: MarkdownExtension + bundled opt-ins -│ ├── Directives/ # the directive seam: @font(size: 18){…} — parsing + registry +│ ├── Directives/ # the directive seam: @font(size: 18){…} — parsing, styling, glyphs │ ├── Services/ # 4 protocols, no-op defaults, WikiLinkService │ ├── Parser/ # two-phase AST: BlockParser → InlineParser → DocumentAST (+ token projection) │ ├── Styling/ # MarkdownASTStyler (AST walk) + MarkdownStyler facade for NSImage passes @@ -97,11 +97,18 @@ NAME and TYPED ARGUMENTS rather than delimiters — `@pagebreak`, the marker defaults to `@` and is configurable per registry and per directive. Two forms, both **tree-shaped** — a directive's effect never escapes its own -node: **self-contained** (`@pagebreak`, a leaf) and **container** -(`@font(size: 18){text}`, whose body is re-parsed as markdown). There is -deliberately no "applies to everything after me" form: that would make styling -depend on document position rather than tree position, breaking both the -styler's compose-on-descent model and the block-scoped incremental restyle. +node: **self-contained** (`@pagebreak`, a leaf that draws a glyph in place of +its collapsed source) and **container** (`@font(size: 18){text}`, whose body is +re-parsed as markdown). There is deliberately no "applies to everything after +me" form: that would make styling depend on document position rather than tree +position, breaking both the styler's compose-on-descent model and the +block-scoped incremental restyle. + +The glyph rides the same mechanism inline LaTeX uses: the characters stay in +the text, the first one carries the image and enough kern to occupy its width, +the rest collapse to zero width. A glyph that can't be produced (an unknown SF +Symbol, or `.literal`) leaves the source visible rather than collapsing it to a +gap the user can't see or fix. Container styling lives in `MarkdownASTStyler+Directives.swift`: it resolves the directive, coerces its arguments against the declared schema, and returns the diff --git a/CHANGELOG.md b/CHANGELOG.md index 58fbc83f..55e57ab3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,6 +52,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 its em — its optical centre sits about a quarter of the font size above the lowercase centre, and that gap grows with the size, so box-centring would let a larger mark climb toward the top of its line. +- **Directive glyph presentation**: a self-contained call (`@marker`, + `@glyph(star.fill)`) collapses its source behind an SF Symbol, replacement + text, or an image supplied by the directive's `presentation`, and reveals the + real characters again under the caret. The source is never removed from the + storage — it collapses to zero width the same way inline LaTeX does — so + selection, find, copy, and undo still see it. `Demo/` gains `@icon`, `@flag`, + `@emoji`, and `@pagebreak` as embedder-side directives — anything carrying + curated data or document policy is an app concern, not an engine primitive. ### Changed - An ordered list's painted number no longer reverts to the source digit under diff --git a/Demo/MarkdownEngineDemo.xcodeproj/project.pbxproj b/Demo/MarkdownEngineDemo.xcodeproj/project.pbxproj index 9b78ed01..b9101fc7 100644 --- a/Demo/MarkdownEngineDemo.xcodeproj/project.pbxproj +++ b/Demo/MarkdownEngineDemo.xcodeproj/project.pbxproj @@ -9,6 +9,7 @@ /* Begin PBXBuildFile section */ 1A2B3C0000000000000004BB /* MarkdownEngineDemoApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C0000000000000004AA /* MarkdownEngineDemoApp.swift */; }; 1A2B3C0000000000000005BB /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C0000000000000005AA /* ContentView.swift */; }; + 1A2B3C0000000000000010BB /* DemoDirectives.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C0000000000000010AA /* DemoDirectives.swift */; }; 1A2B3C0000000000000006BB /* MDE.icon in Resources */ = {isa = PBXBuildFile; fileRef = 1A2B3C0000000000000006AA /* MDE.icon */; }; 1A2B3C0000000000000007CC /* MarkdownEngine in Frameworks */ = {isa = PBXBuildFile; productRef = 1A2B3C0000000000000007BB /* MarkdownEngine */; }; 1A2B3C0000000000000008BB /* MarkdownEngineCodeBlocks in Frameworks */ = {isa = PBXBuildFile; productRef = 1A2B3C0000000000000008AA /* MarkdownEngineCodeBlocks */; }; @@ -19,6 +20,7 @@ 1A2B3C0000000000000003DD /* MarkdownEngineDemo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MarkdownEngineDemo.app; sourceTree = BUILT_PRODUCTS_DIR; }; 1A2B3C0000000000000004AA /* MarkdownEngineDemoApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownEngineDemoApp.swift; sourceTree = ""; }; 1A2B3C0000000000000005AA /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; + 1A2B3C0000000000000010AA /* DemoDirectives.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DemoDirectives.swift; sourceTree = ""; }; 1A2B3C0000000000000006AA /* MDE.icon */ = {isa = PBXFileReference; lastKnownFileType = folder.iconcomposer.icon; path = MDE.icon; sourceTree = ""; }; /* End PBXFileReference section */ @@ -57,6 +59,7 @@ children = ( 1A2B3C0000000000000004AA /* MarkdownEngineDemoApp.swift */, 1A2B3C0000000000000005AA /* ContentView.swift */, + 1A2B3C0000000000000010AA /* DemoDirectives.swift */, 1A2B3C0000000000000006AA /* MDE.icon */, ); path = MarkdownEngineDemo; @@ -141,6 +144,7 @@ files = ( 1A2B3C0000000000000004BB /* MarkdownEngineDemoApp.swift in Sources */, 1A2B3C0000000000000005BB /* ContentView.swift in Sources */, + 1A2B3C0000000000000010BB /* DemoDirectives.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Demo/MarkdownEngineDemo/ContentView.swift b/Demo/MarkdownEngineDemo/ContentView.swift index eeaa34c9..0d08e967 100644 --- a/Demo/MarkdownEngineDemo/ContentView.swift +++ b/Demo/MarkdownEngineDemo/ContentView.swift @@ -172,6 +172,19 @@ struct ContentView: View { // configurable via `config.directiveSettings`. config.directives = seamsEnabled ? [FontDirective(), ColorDirective()] : [] + // The second opt-in seam: named inline commands with typed arguments, + // for constructs that need a name and parameters rather than + // delimiters. The marker defaults to `@` and is configurable via + // `config.directiveSettings`. + // Only `Font` and `Color` come from the engine — both are pure + // presentation. `Icon`, `Flag`, `Emoji`, and `PageBreak` live in this + // demo's own `DemoDirectives.swift`, because curated data and print + // semantics are app concerns, not engine primitives. + config.directives = [ + FontDirective(), ColorDirective(), + IconDirective(), FlagDirective(), EmojiDirective(), PageBreakDirective(), + ] + // Toolbar-driven modes. config.rawSourceMode = showRawSource config.readingWidth = useReadingColumn ? 620 : nil @@ -292,6 +305,15 @@ Turn the seams off and both sentences above keep their `==` and `~~` as \ ordinary characters — the core grammar has never heard of them. """ +/// Directive seam demo: `@font(…){…}` and `@color(…){…}` are supplied by the +/// opt-in `FontDirective` and `ColorDirective` registered above. +/// +/// The point of the section is COMPOSITION — a directive contributes a font +/// transform to the styler's walk, so it stacks with whatever encloses it and +/// with whatever it encloses, in both directions. That is why directives are +/// scoped to a body instead of running "from here on": the effect lives in the +/// tree, not in the document position. + /// Directive seam demo: `@font(…){…}` and `@color(…){…}` are supplied by the /// opt-in `FontDirective` and `ColorDirective` registered above. /// @@ -301,14 +323,15 @@ ordinary characters — the core grammar has never heard of them. /// scoped to a body instead of running "from here on": the effect lives in the /// tree, not in the document position. private let directiveSection = """ -## Directives — named, with typed arguments +## Directives -`config.directives = [FontDirective(), ColorDirective()]` +The other opt-in seam: named inline commands with typed arguments, for \ +constructs that need a name and parameters rather than delimiters. \ +Unregistered, `@anything` stays literal text — and a bare `@` in prose or an \ +address like jason@example.com never opens one. -A pair of delimiters can't carry a name and parameters, so this is the second \ -seam rather than more of the first. Sizes can be absolute — \ -@font(size: 24){twenty-four point} — or relative to the surrounding text: \ -@font(size: 0.75em){three-quarter em} and @font(size: 150%){one-and-a-half}. +Sizes can be absolute — @font(size: 24){twenty-four point} — or relative to the \ +surrounding text: @font(size: 0.75em){three-quarter em} and @font(size: 150%){one-and-a-half}. Composition is the whole idea. Inside a directive, markup keeps the \ directive's size: @font(size: 20){**bold**, *italic*, and ***both***}. Outside, \ @@ -319,12 +342,16 @@ the directive keeps its context — the same call in a heading stays bold: Colours work the same way, and directives nest: @color(red){red text}, \ @color(blue){blue text}, and @font(size: 22){@color(purple){big and purple}}. -Put the caret inside any directive to reveal its source; move away and the \ -syntax collapses back to just the styled text — exactly like every other marker. +The other form is self-contained: no body, and it draws a glyph in place of \ +its own source. @icon(star.fill, color: yellow) marks a favourite, \ +@icon(checkmark.circle.fill, color: green) a finished item, \ +@icon(exclamationmark.triangle.fill, color: orange) a warning — sized to \ +whatever text surrounds them, so @font(size: 26){they grow too: @icon(bolt.fill, color: blue)}. -Registered names ONLY, which is what makes the `@` marker safe over an existing \ -corpus: @notregistered(x){y} is literal text right now, and an address like \ -jason@wildthink.com never opens a directive at all. +Put the caret inside any directive to reveal its source; move away and the \ +syntax collapses back to just the styled text or the glyph — exactly like \ +every other marker. The characters are never deleted, so selection, find, \ +copy, and undo all still see them. """ /// Table layout demo: the first table's cells WRAP to the available width @@ -453,3 +480,4 @@ private let markdownFooter = """ --- """ + diff --git a/Demo/MarkdownEngineDemo/DemoDirectives.swift b/Demo/MarkdownEngineDemo/DemoDirectives.swift new file mode 100644 index 00000000..10a3a257 --- /dev/null +++ b/Demo/MarkdownEngineDemo/DemoDirectives.swift @@ -0,0 +1,160 @@ +// +// DemoDirectives.swift +// MarkdownEngineDemo +// +// Directives that belong to an APP, not to the engine. +// +// `MarkdownEngine` ships only `FontDirective` and `ColorDirective` as +// reference implementations, because both are pure presentation. Everything +// here carries something the engine has no business deciding: curated data +// (`@icon`, `@emoji`), or document policy (`@pagebreak` — what a page break +// means is a print concern). +// +// They're also the honest measure of the seam: each is 30–60 lines, including +// its argument schema, its glyph, its argument-value completions, and its +// HTML for rich copy. +// + +import AppKit +import MarkdownEngine + +// MARK: - @icon(star.fill, color: yellow) + +/// An SF Symbol drawn inline, sized to the surrounding text. +struct IconDirective: MarkdownDirective { + + private static let palette: [String: NSColor] = [ + "red": .systemRed, "orange": .systemOrange, "yellow": .systemYellow, + "green": .systemGreen, "mint": .systemMint, "teal": .systemTeal, + "cyan": .systemCyan, "blue": .systemBlue, "indigo": .systemIndigo, + "purple": .systemPurple, "pink": .systemPink, "brown": .systemBrown, + "gray": .systemGray, + ] + + var syntax: DirectiveSyntax { + DirectiveSyntax( + name: "icon", + form: .selfContained, + parameters: [ + .init(label: nil, kind: .keyword([]), isRequired: true, + documentation: "SF Symbol name, e.g. star.fill."), + .init(label: "color", kind: .keyword([]), documentation: "Tint colour."), + ] + ) + } + + func presentation(arguments: DirectiveArguments, context: DirectiveContext) -> DirectivePresentation { + guard !context.isActive, let name = arguments.positional.first?.asString else { return .literal } + return .symbol(name: name, tint: arguments.string("color").flatMap { Self.palette[$0.lowercased()] }) + } + + func html(arguments: DirectiveArguments, bodyHTML: String) -> String { + "" + } +} + +// MARK: - @flag(JP) + +/// A country flag from an ISO region code — a directive whose glyph is +/// COMPUTED rather than drawn from an asset. +/// +/// Dataset-free: codes come from `Locale.Region.isoRegions` and the flag is +/// built from the code's regional-indicator scalars. Nothing to ship, nothing +/// to keep current. +struct FlagDirective: MarkdownDirective { + + var syntax: DirectiveSyntax { + DirectiveSyntax( + name: "flag", + form: .selfContained, + parameters: [.init(label: nil, kind: .keyword([]), isRequired: true, + documentation: "ISO 3166 country code, e.g. JP.")] + ) + } + + func presentation(arguments: DirectiveArguments, context: DirectiveContext) -> DirectivePresentation { + guard !context.isActive, + let code = arguments.positional.first?.asString, + let flag = Self.flag(for: code) else { return .literal } + return .text(flag) + } + + func html(arguments: DirectiveArguments, bodyHTML: String) -> String { + arguments.positional.first?.asString.flatMap(Self.flag(for:)) ?? "" + } + + struct Region { let code: String; let name: String; let flag: String } + + static let regions: [Region] = Locale.Region.isoRegions.compactMap { region in + let code = region.identifier + guard code.count == 2, let flag = flag(for: code) else { return nil } + return Region(code: code, name: Locale.current.localizedString(forRegionCode: code) ?? code, flag: flag) + } + + /// Regional-indicator scalars: `JP` → 🇯🇵. + static func flag(for code: String) -> String? { + let upper = code.uppercased() + guard upper.count == 2 else { return nil } + var scalars = String.UnicodeScalarView() + for scalar in upper.unicodeScalars { + guard scalar.value >= 0x41, scalar.value <= 0x5A, + let indicator = UnicodeScalar(0x1F1E6 + scalar.value - 0x41) else { return nil } + scalars.append(indicator) + } + return String(scalars) + } +} + +// MARK: - @pagebreak + +/// What a page break *means* is a print concern, so it belongs to the app. +struct PageBreakDirective: MarkdownDirective { + + var syntax: DirectiveSyntax { DirectiveSyntax(name: "pagebreak", form: .selfContained) } + + func presentation(arguments: DirectiveArguments, context: DirectiveContext) -> DirectivePresentation { + context.isActive ? .literal : .symbol(name: "arrow.down.to.line", tint: context.theme.mutedText) + } + + func html(arguments: DirectiveArguments, bodyHTML: String) -> String { "
" } +} + +// MARK: - @emoji(tada) + +/// `@emoji(tada)` — the shortest directive here, and the one whose domain +/// is most obviously the app's rather than the engine's. +struct EmojiDirective: MarkdownDirective { + + private static let table: [(name: String, glyph: String)] = [ + ("tada", "🎉"), ("rocket", "🚀"), ("sparkles", "✨"), ("fire", "🔥"), + ("bug", "🐛"), ("wrench", "🔧"), ("book", "📚"), ("bulb", "💡"), + ("warning", "⚠️"), ("check", "✅"), ("cross", "❌"), ("eyes", "👀"), + ("thinking", "🤔"), ("clap", "👏"), ("heart", "❤️"), ("star", "⭐️"), + ("coffee", "☕️"), ("ship", "🚢"), ("lock", "🔒"), ("chart", "📈"), + ] + + var syntax: DirectiveSyntax { + DirectiveSyntax( + name: "emoji", + form: .selfContained, + parameters: [ + .init(label: nil, kind: .keyword([]), isRequired: true, + documentation: "Emoji name, e.g. tada."), + ] + ) + } + + func presentation(arguments: DirectiveArguments, context: DirectiveContext) -> DirectivePresentation { + guard !context.isActive, + let name = arguments.positional.first?.asString, + let glyph = Self.table.first(where: { $0.name == name })?.glyph + else { return .literal } + return .text(glyph) + } + + func html(arguments: DirectiveArguments, bodyHTML: String) -> String { + guard let name = arguments.positional.first?.asString, + let glyph = Self.table.first(where: { $0.name == name })?.glyph else { return "" } + return glyph + } +} diff --git a/README.md b/README.md index c11b80c5..f09a2de1 100644 --- a/README.md +++ b/README.md @@ -318,10 +318,12 @@ and the same call inside a heading keeps the heading's weight. There is no "applies to everything after me" form — a directive's effect is scoped to its own node, which is what keeps per-keystroke restyling block-local. -Self-contained calls parse and claim their span, so nothing inside them is -autolinked or emphasized — but they currently render as their literal source, -and no self-contained directive ships yet. The glyph presentation that would -draw one as a rule or a badge arrives with a later phase. +A self-contained call draws a GLYPH in place of its own source, sized to the +surrounding text: an SF Symbol, replacement text, or an image, chosen by the +directive's `presentation`. The source is never removed — it collapses to zero +width, the same mechanism inline LaTeX uses — so selection, find, copy, and +undo still see the real characters, and the caret entering the call reveals +them. The marker defaults to `@` and is configurable per registry (`config.directiveSettings`) and per directive, and several markers can be @@ -342,13 +344,17 @@ leaves the whole construct literal rather than producing a directive around it: ``` Constructs claimed in the same pass or later (`$…$`, links, emphasis, nesting) -work inside a body. And the engine ships the seam, not a picker: there is no -completion UI for directive names or argument values. +work inside a body. + +The engine ships the seam, not a picker: there is no completion UI for +directive names or argument values. Conform to `MarkdownDirective` to add your own; a typical one is about 30 lines, including its argument schema and HTML. `FontDirective` and `ColorDirective` are reference implementations meant to be read — they are not -registered unless you register them. +registered unless you register them. Directives carrying curated data or +document policy belong to your app; `Demo/` has `@icon`, `@flag`, `@emoji`, +and `@pagebreak` as worked examples, 30–60 lines each. ## Demo diff --git a/Sources/MarkdownEngine/Directives/BuiltinDirectives.swift b/Sources/MarkdownEngine/Directives/BuiltinDirectives.swift index 01909fa9..ca921cc1 100644 --- a/Sources/MarkdownEngine/Directives/BuiltinDirectives.swift +++ b/Sources/MarkdownEngine/Directives/BuiltinDirectives.swift @@ -2,17 +2,17 @@ // BuiltinDirectives.swift // MarkdownEngine // -// Three directives that between them exercise every part of the seam, and -// double as the "is a new command easy to write?" test. Not registered by -// default — the core engine parses pure markdown; embedders opt in the way -// they do for extensions: +// The two reference directives, mirroring how `HighlightExtension` / +// `StrikethroughExtension` ship: not registered by default, opted into the +// same way, and present mainly as templates for writing your own. // // configuration.directives = [FontDirective(), ColorDirective()] // -// `style` and `presentation` are declared here but not yet consulted by the -// styler (Phase 2 / Phase 3). In Phase 1 a container directive's syntax -// shrinks and its body renders as ordinary markdown; a self-contained call -// renders as literal text. +// Both are PURE PRESENTATION — a font transform and a colour. Both are +// containers, so neither draws a glyph; directives that carry curated data +// (icons, flags, emoji) or encode document policy (page breaks) are app +// concerns, not engine primitives, so they belong to the embedder. `Demo/` +// shows what those look like. // import AppKit diff --git a/Sources/MarkdownEngine/Directives/DirectiveScanner.swift b/Sources/MarkdownEngine/Directives/DirectiveScanner.swift index 0c8bb18c..e5741549 100644 --- a/Sources/MarkdownEngine/Directives/DirectiveScanner.swift +++ b/Sources/MarkdownEngine/Directives/DirectiveScanner.swift @@ -47,8 +47,8 @@ struct DirectiveMatch: Equatable { /// Inside the braces; `nil` for a self-contained call. let bodyRange: NSRange? /// Ranges that shrink when the caret leaves: `[prefix, closingBrace]` for - /// a container, empty for a self-contained call (Phase 1 renders those - /// literally — the glyph pass that collapses them lands in Phase 3). + /// a container, empty for a self-contained call — there the whole call is + /// the content, and the glyph pass collapses it at styling time. let markers: [NSRange] /// Range carrying the node's content: the body for a container, the whole /// call for a self-contained one. @@ -150,11 +150,10 @@ enum DirectiveScanner { ) } - // Self-contained: no markers in Phase 1, so the call renders as plain - // literal text instead of collapsing to nothing. It is still CLAIMED, - // so emphasis and autolinking can't fire inside it, and it still - // projects a token — the glyph pass in Phase 3 only has to add - // presentation. + // Self-contained: no markers, so the whole call is the content. It is + // CLAIMED, so emphasis and autolinking can't fire inside it, and it + // projects a token like any other node — the glyph pass at styling + // time only has to add presentation. return DirectiveMatch( nodeID: DirectiveRegistry.nodeID(for: entry.id), range: range, diff --git a/Sources/MarkdownEngine/Directives/MarkdownDirective.swift b/Sources/MarkdownEngine/Directives/MarkdownDirective.swift index 1ec7a295..c7bc3091 100644 --- a/Sources/MarkdownEngine/Directives/MarkdownDirective.swift +++ b/Sources/MarkdownEngine/Directives/MarkdownDirective.swift @@ -8,19 +8,14 @@ // which covers delimiter-shaped constructs (`==x==`, `::: … :::`) and cannot // express a name plus a typed argument list. // -// This file is the PARSING half of the seam: the protocol, the syntax rule, -// and the argument schema. Presentation (font transforms, glyphs) and -// autocomplete extend the protocol in follow-up changes; nothing here styles -// anything yet. -// // Two forms, both TREE-SHAPED — a directive's effect never escapes its own // node: // -// * self-contained — `@pagebreak`, `@date(format: iso)`. A leaf. +// * self-contained — `@pagebreak`, `@date(format: iso)`. A leaf, drawn as +// a glyph in place of its own source. // * container — `@font(size: 18){text}`. The body is re-parsed as -// markdown, and will later be styled with the directive's font transform -// COMPOSED over the inherited font, so `@font(size: 18){**bold**}` comes -// out bold AND 18pt. +// markdown and styled with the directive's font transform COMPOSED over +// the inherited font, so `@font(size: 18){**bold**}` is bold AND 18pt. // // There is deliberately no "applies to everything after me" form. That would // make styling depend on document position rather than tree position, which @@ -249,6 +244,26 @@ public struct DirectiveStyle { } } +/// What a self-contained directive draws in place of its collapsed source. +/// +/// The source text is never removed — it collapses to zero width via the +/// engine's existing clear-colour + negative-kern mechanism (the one inline +/// LaTeX uses), and the glyph is drawn by `MarkdownTextLayoutFragment`. +/// "Markers shrink, they don't disappear" still holds. +public enum DirectivePresentation { + /// Style the source text only — no glyph. The default, and what a + /// directive falls back to when its glyph can't be produced. + case literal + /// An SF Symbol drawn at the directive's position. + case symbol(name: String, tint: NSColor?) + /// Replacement TEXT drawn in the inherited font — an emoji, a flag, a + /// formatted date. Rendered as a glyph rather than substituted into the + /// storage, so the source characters survive for selection and undo. + case text(String) + /// A pre-rendered image; `baselineOffset` matches the LaTeX convention. + case image(NSImage, baselineOffset: CGFloat) +} + /// Everything a directive may read while deciding how to present itself. /// Read-only by construction — a directive cannot reach the text storage. public struct DirectiveContext { @@ -278,15 +293,16 @@ public protocol MarkdownDirective: Sendable { /// Used for dispatch and cache keying — never shown to users. var id: String { get } var syntax: DirectiveSyntax { get } - /// Container form: how the body is styled. Ignored for self-contained. /// Called during styling; must be cheap and synchronous. func style(arguments: DirectiveArguments, context: DirectiveContext) -> DirectiveStyle + /// Self-contained form: what to draw. Ignored for container. + func presentation(arguments: DirectiveArguments, context: DirectiveContext) -> DirectivePresentation + /// Clean-copy path. `bodyHTML` is already escaped / recursively rendered /// (empty for self-contained). func html(arguments: DirectiveArguments, bodyHTML: String) -> String - } public extension MarkdownDirective { @@ -297,10 +313,11 @@ public extension MarkdownDirective { func style(arguments: DirectiveArguments, context: DirectiveContext) -> DirectiveStyle { .inherit } + func presentation(arguments: DirectiveArguments, context: DirectiveContext) -> DirectivePresentation { .literal } + func html(arguments: DirectiveArguments, bodyHTML: String) -> String { bodyHTML.isEmpty ? "" : "\(bodyHTML)" } - } diff --git a/Sources/MarkdownEngine/Styling/MarkdownASTStyler+Directives.swift b/Sources/MarkdownEngine/Styling/MarkdownASTStyler+Directives.swift index a6b7a44a..ad0455eb 100644 --- a/Sources/MarkdownEngine/Styling/MarkdownASTStyler+Directives.swift +++ b/Sources/MarkdownEngine/Styling/MarkdownASTStyler+Directives.swift @@ -2,9 +2,12 @@ // MarkdownASTStyler+Directives.swift // MarkdownEngine // -// Phase 2 of the directive seam: a container directive's body picks up the -// directive's style, and — the part that matters — its font TRANSFORM -// composes over the font inherited at that point in the tree. +// The styling half of the directive seam, in two parts. +// +// A CONTAINER directive's body picks up the directive's style, and — the part +// that matters — its font TRANSFORM composes over the font inherited at that +// point in the tree. A SELF-CONTAINED call has no body to style; it draws a +// glyph in place of its own source instead (see `presentSelfContained`). // // That composition is why directives are tree-shaped. The styler already // threads a font down the walk (heading font → +bold → +italic); a directive @@ -33,7 +36,7 @@ extension MarkdownASTStyler { /// case the caller falls through to ordinary extension-span handling. /// /// Self-contained calls return their inherited font unchanged: they have - /// no body, and their glyph presentation lands in Phase 3. + /// no body, and are handed to the glyph pass instead. static func directiveBodyFont( for node: ExtensionInlineNode, font: NSFont, @@ -55,8 +58,20 @@ extension MarkdownASTStyler { marker: marker ) - // A self-contained call is the whole node — no body to style. - guard !node.markers.isEmpty, node.contentRange.length > 0 else { return font } + // A self-contained call is the whole node — no body to style, but it + // may draw a glyph in place of its collapsed source. + guard !node.markers.isEmpty, node.contentRange.length > 0 else { + let arguments = DirectiveArguments( + parsing: DirectiveScanner.argumentsRange(inPrefix: node.range, of: ctx.ns), + in: ctx.ns, + schema: directive.syntax.parameters + ) + presentSelfContained( + directive.presentation(arguments: arguments, context: context), + node: node, font: font, isActive: context.isActive, ctx: ctx, into: &attrs + ) + return font + } let arguments = DirectiveArguments( parsing: DirectiveScanner.argumentsRange(inPrefix: node.markers[0], of: ctx.ns), @@ -73,6 +88,131 @@ extension MarkdownASTStyler { // it explicitly, so a later pass can't leave part of the span on a // stale font. attrs.append((node.contentRange, [.font: bodyFont])) + // Directive syntax is not prose — no spell-check underlines on it. + for marker in node.markers { attrs.append((marker, [.spellingState: 0])) } return bodyFont } + + // MARK: - Self-contained presentation + + /// Draw a self-contained directive's glyph in place of its source. + /// + /// Mechanically identical to inline LaTeX + /// (`MarkdownStyler+Latex.swift`): the source text is never removed — it + /// collapses to zero width via clear colour, the shrunk marker font, and + /// negative kern, while the FIRST character carries the image plus enough + /// positive kern to occupy the glyph's width. `MarkdownTextLayoutFragment` + /// draws it. Selection, find, copy, and undo all still see the real + /// characters, which is what the "markers shrink, they don't disappear" + /// invariant is protecting. + /// + /// With the caret inside, the source is revealed muted instead — the same + /// flip every other construct performs. + private static func presentSelfContained( + _ presentation: DirectivePresentation, + node: ExtensionInlineNode, + font: NSFont, + isActive: Bool, + ctx: Ctx, + into attrs: inout [StyledRange] + ) { + attrs.append((node.range, [.spellingState: 0])) + + guard !isActive else { + attrs.append((node.range, [.foregroundColor: ctx.theme.mutedText])) + return + } + + let resolved: (image: NSImage, descent: CGFloat)? + switch presentation { + case .literal: + resolved = nil + case .symbol(let name, let tint): + resolved = symbolImage(named: name, tint: tint, font: font).map { image in + // Optically centre the glyph on the text's x-height: + // `descent` is how far the image's bottom sits below the + // baseline, so centring wants (height - xHeight) / 2. + (image, (image.size.height - font.xHeight) / 2) + } + case .text(let string): + // Text renders on the same image channel: substituting characters + // into the storage would violate "the source is never removed". + resolved = textImage(string, font: font).map { ($0, -font.descender) } + case .image(let image, let baselineOffset): + resolved = (image, baselineOffset) + } + + // `.literal`, or a symbol name the system doesn't know: leave the + // source visible rather than collapsing it to nothing. + guard let resolved, node.range.length > 0 else { return } + + let markerFont = ctx.inlineMarkerFont + let imageBounds = CGRect(x: 0, y: resolved.descent, + width: resolved.image.size.width, height: resolved.image.size.height) + + let firstCharRange = NSRange(location: node.range.location, length: 1) + let firstChar = ctx.ns.substring(with: firstCharRange) + attrs.append((firstCharRange, [ + .latexImage: resolved.image, // the engine's generic inline-image + .latexBounds: NSValue(rect: imageBounds), // channel; the name is historical + .foregroundColor: NSColor.clear, + .font: markerFont, + .kern: resolved.image.size.width - HeadingHelpers.textWidth(firstChar, font: markerFont), + ])) + + if node.range.length > 1 { + let restRange = NSRange(location: node.range.location + 1, length: node.range.length - 1) + let restText = ctx.ns.substring(with: restRange) + attrs.append((restRange, [ + .foregroundColor: NSColor.clear, + .font: markerFont, + .kern: -HeadingHelpers.textWidth(restText, font: markerFont), + ])) + } + } + + /// An SF Symbol sized to the inherited font, optionally tinted. `nil` when + /// the system doesn't know the name — the caller then leaves the source + /// visible instead of collapsing it to an empty gap. + private static func symbolImage(named name: String, tint: NSColor?, font: NSFont) -> NSImage? { + let key = "sym|\(name)|\(font.pointSize)|\(tint?.description ?? "-")" as NSString + if let cached = glyphCache.object(forKey: key) { return cached } + var configuration = NSImage.SymbolConfiguration(pointSize: font.pointSize, weight: .regular) + if let tint { + configuration = configuration.applying(NSImage.SymbolConfiguration(paletteColors: [tint])) + } + guard let image = NSImage(systemSymbolName: name, accessibilityDescription: nil)? + .withSymbolConfiguration(configuration) else { return nil } + glyphCache.setObject(image, forKey: key) + return image + } + + /// Rasterise replacement text in the inherited font. + private static func textImage(_ string: String, font: NSFont) -> NSImage? { + guard !string.isEmpty else { return nil } + let key = "txt|\(string)|\(font.fontName)|\(font.pointSize)" as NSString + if let cached = glyphCache.object(forKey: key) { return cached } + let attributed = NSAttributedString(string: string, attributes: [.font: font]) + let size = attributed.size() + guard size.width > 0, size.height > 0 else { return nil } + let image = NSImage(size: CGSize(width: ceil(size.width), height: ceil(size.height))) + image.lockFocus() + attributed.draw(at: .zero) + image.unlockFocus() + glyphCache.setObject(image, forKey: key) + return image + } + + /// Rendered glyphs, keyed by everything that determines the pixels. + /// + /// Restyling re-runs presentation for every visible directive on every + /// keystroke; rasterising text each time is the one part of this path + /// expensive enough to matter. `NSCache` is thread-safe and evicts under + /// pressure, so this needs no invalidation of its own — a changed font or + /// tint simply produces a different key. + private static let glyphCache: NSCache = { + let cache = NSCache() + cache.countLimit = 512 + return cache + }() } diff --git a/Tests/MarkdownEngineTests/DirectiveGlyphTests.swift b/Tests/MarkdownEngineTests/DirectiveGlyphTests.swift new file mode 100644 index 00000000..ad6c55cc --- /dev/null +++ b/Tests/MarkdownEngineTests/DirectiveGlyphTests.swift @@ -0,0 +1,255 @@ +// +// DirectiveGlyphTests.swift +// MarkdownEngineTests +// +// Phase 3 — a self-contained directive draws a glyph in place of its +// collapsed source, on the same mechanism inline LaTeX uses: the characters +// stay in the text, the first one carries the image and enough kern to occupy +// its width, and the rest collapse to nothing. +// +// The failure mode worth guarding is a glyph that can't be produced: the +// source must stay VISIBLE rather than collapsing to an empty gap the user +// can't see, select, or fix. +// + +import AppKit +import Foundation +import Testing +@testable import MarkdownEngine + +@Suite("Directives — self-contained glyphs") +struct DirectiveGlyphTests { + + private let base: CGFloat = 14 + private var fontName: String { NSFont.systemFont(ofSize: 14).fontName } + private var hiddenSize: CGFloat { MarkdownEditorConfiguration.default.markers.hiddenMarkerFontSize } + + /// A directive whose symbol name the system cannot resolve. + private struct BrokenSymbolDirective: MarkdownDirective { + var syntax: DirectiveSyntax { DirectiveSyntax(name: "broken", form: .selfContained) } + func presentation(arguments: DirectiveArguments, context: DirectiveContext) -> DirectivePresentation { + .symbol(name: "definitely.not.a.real.sf.symbol", tint: nil) + } + } + + /// A directive that declines to draw anything. + private struct PlainDirective: MarkdownDirective { + var syntax: DirectiveSyntax { DirectiveSyntax(name: "plain", form: .selfContained) } + } + + /// A directive supplying its own image, sized from an argument. + private struct SwatchDirective: MarkdownDirective { + var syntax: DirectiveSyntax { + DirectiveSyntax(name: "swatch", form: .selfContained, + parameters: [.init(label: "size", kind: .number)]) + } + func presentation(arguments: DirectiveArguments, context: DirectiveContext) -> DirectivePresentation { + let side = arguments.number("size").map { CGFloat($0) } ?? 10 + return .image(NSImage(size: CGSize(width: side, height: side)), baselineOffset: 0) + } + } + + private var configuration: MarkdownEditorConfiguration { + MarkdownEditorConfiguration(directives: [ + MarkerDirective(), BrokenSymbolDirective(), PlainDirective(), SwatchDirective(), + ]) + } + + private func style(_ text: String, caret: Int = -1) -> [StyledRange] { + MarkdownASTStyler.styleAttributes( + text: text, fontName: fontName, fontSize: base, + caretLocation: caret, configuration: configuration + ) + } + + /// Attributes covering the first character of `needle`. + private func firstCharAttributes(_ text: String, _ needle: String, caret: Int = -1) + -> [NSAttributedString.Key: Any] { + let position = (text as NSString).range(of: needle).location + var merged: [NSAttributedString.Key: Any] = [:] + for (range, attributes) in style(text, caret: caret) + where NSLocationInRange(position, range) { + merged.merge(attributes) { _, new in new } + } + return merged + } + + // MARK: - Drawing + + @Test("the glyph rides on the call's first character") + func glyphOnFirstCharacter() { + let attributes = firstCharAttributes("a @marker b", "@marker") + #expect(attributes[.latexImage] is NSImage) + #expect(attributes[.latexBounds] is NSValue) + } + + @Test("the glyph is sized to the inherited font") + func glyphSizedToFont() { + let small = firstCharAttributes("@marker", "@marker")[.latexImage] as? NSImage + let attrs = MarkdownASTStyler.styleAttributes( + text: "# @marker", fontName: fontName, fontSize: base, + caretLocation: -1, configuration: configuration + ) + let position = ("# @marker" as NSString).range(of: "@marker").location + var large: NSImage? + for (range, a) in attrs where NSLocationInRange(position, range) { + if let image = a[.latexImage] as? NSImage { large = image } + } + // A heading's larger font must yield a larger glyph. + #expect(small != nil && large != nil) + #expect((large?.size.height ?? 0) > (small?.size.height ?? 0)) + } + + @Test("the first character carries kern for the glyph's width") + func firstCharacterReservesWidth() { + let attributes = firstCharAttributes("@marker", "@marker") + let image = attributes[.latexImage] as? NSImage + let kern = attributes[.kern] as? CGFloat ?? 0 + // Kern must account for most of the image width (minus the shrunk + // character's own negligible advance). + #expect(kern > 0) + #expect(kern <= (image?.size.width ?? 0)) + } + + @Test("the remaining characters collapse") + func remainderCollapses() { + let text = "@marker" + let tail = (text as NSString).range(of: "marker") + var sawCollapse = false + for (range, attributes) in style(text) where NSIntersectionRange(range, tail).length > 0 { + if (attributes[.font] as? NSFont)?.pointSize == hiddenSize, + (attributes[.kern] as? CGFloat ?? 0) < 0 { + sawCollapse = true + } + } + #expect(sawCollapse) + } + + @Test("a directive-supplied image is used as given") + func suppliedImageIsUsed() { + let attributes = firstCharAttributes("@swatch(size: 24)", "@swatch") + #expect((attributes[.latexImage] as? NSImage)?.size.width == 24) + } + + @Test("arguments reach a self-contained presentation") + func argumentsReachPresentation() { + #expect((firstCharAttributes("@swatch(size: 8)", "@swatch")[.latexImage] as? NSImage)?.size.width == 8) + #expect((firstCharAttributes("@swatch(size: 32)", "@swatch")[.latexImage] as? NSImage)?.size.width == 32) + } + + // MARK: - Caret reveal + + @Test("the caret inside reveals the source and drops the glyph") + func caretRevealsSource() { + let attributes = firstCharAttributes("@marker", "@marker", caret: 3) + #expect(attributes[.latexImage] == nil) + #expect((attributes[.font] as? NSFont)?.pointSize != hiddenSize) + } + + // MARK: - Degradation + + @Test("an unresolvable symbol leaves the source visible, not an empty gap") + func brokenSymbolStaysVisible() { + // The important failure mode: collapsing source we can't replace would + // leave a blank the user can neither see nor fix. + let attributes = firstCharAttributes("@broken", "@broken") + #expect(attributes[.latexImage] == nil) + #expect((attributes[.font] as? NSFont)?.pointSize != hiddenSize) + } + + @Test("a directive declining to draw leaves its source visible") + func literalPresentationStaysVisible() { + let attributes = firstCharAttributes("@plain", "@plain") + #expect(attributes[.latexImage] == nil) + #expect((attributes[.font] as? NSFont)?.pointSize != hiddenSize) + } + + @Test("spell-check is suppressed over a directive call") + func spellCheckSuppressed() { + #expect(firstCharAttributes("@marker", "@marker")[.spellingState] as? Int == 0) + } + + // MARK: - Isolation + + @Test("a glyph does not disturb the surrounding text") + func neighboursUnaffected() { + let text = "before @marker after" + let position = (text as NSString).range(of: "after").location + for (range, attributes) in style(text) where NSLocationInRange(position, range) { + #expect(attributes[.latexImage] == nil) + #expect((attributes[.font] as? NSFont)?.pointSize != hiddenSize) + } + } + + // MARK: - GlyphDirective + + @Test("an icon draws the symbol named in its positional argument") + func iconDrawsNamedSymbol() { + let configuration = MarkdownEditorConfiguration(directives: [GlyphDirective()]) + func image(_ text: String) -> NSImage? { + let position = (text as NSString).range(of: "@glyph").location + var found: NSImage? + for (range, attributes) in MarkdownASTStyler.styleAttributes( + text: text, fontName: fontName, fontSize: base, + caretLocation: -1, configuration: configuration + ) where NSLocationInRange(position, range) { + if let candidate = attributes[.latexImage] as? NSImage { found = candidate } + } + return found + } + #expect(image("@glyph(star.fill)") != nil) + #expect(image("@glyph(checkmark.circle.fill, color: green)") != nil) + // A dotted symbol name survives argument splitting. + #expect(image("@glyph(arrow.down.to.line)") != nil) + // An unknown symbol leaves the source visible. + #expect(image("@glyph(not.a.symbol.at.all)") == nil) + } + + // MARK: - Text presentation + + @Test("a text presentation renders as a glyph, not a storage substitution") + func textPresentationRenders() { + let configuration = MarkdownEditorConfiguration(directives: [RegionDirective()]) + let text = "@region(JP)" + var image: NSImage? + for (range, attributes) in MarkdownASTStyler.styleAttributes( + text: text, fontName: fontName, fontSize: base, + caretLocation: -1, configuration: configuration + ) where NSLocationInRange(0, range) { + if let candidate = attributes[.latexImage] as? NSImage { image = candidate } + } + #expect(image != nil) + // The source characters are still all there. + #expect((text as NSString).length == 11) + } + + @Test("an unresolvable argument leaves the source visible") + func badFlagCodeStaysVisible() { + let configuration = MarkdownEditorConfiguration(directives: [RegionDirective()]) + for (range, attributes) in MarkdownASTStyler.styleAttributes( + text: "@region(ZZZZ)", fontName: fontName, fontSize: base, + caretLocation: -1, configuration: configuration + ) where NSLocationInRange(0, range) { + #expect(attributes[.latexImage] == nil) + } + } + + + @Test("scoped styling matches the full pass for a glyph-bearing paragraph") + func scopedMatchesFull() { + let text = "intro\n\nbefore @marker after\n\noutro" + let ns = text as NSString + let paragraph = ns.paragraphRange(for: ns.range(of: "@marker")) + func digest(_ scoped: [NSRange]?) -> String { + MarkdownASTStyler.styleAttributes( + text: text, fontName: fontName, fontSize: base, + scopedRanges: scoped, configuration: configuration + ) + .filter { NSIntersectionRange($0.range, paragraph).length > 0 } + .map { "\($0.range.location):\($0.range.length)[\($0.attributes.keys.map(\.rawValue).sorted().joined(separator: ","))]" } + .sorted() + .joined(separator: "\n") + } + #expect(digest([paragraph]) == digest(nil)) + } +} diff --git a/Tests/MarkdownEngineTests/DirectiveStylingTests.swift b/Tests/MarkdownEngineTests/DirectiveStylingTests.swift index ef9c942e..5565270f 100644 --- a/Tests/MarkdownEngineTests/DirectiveStylingTests.swift +++ b/Tests/MarkdownEngineTests/DirectiveStylingTests.swift @@ -7,8 +7,8 @@ // // * a container's syntax shrinks when the caret leaves and reveals when it // enters, and the shrink never bleeds into the body; -// * a self-contained call renders as plain literal text (no glyph until -// Phase 3, and crucially nothing that collapses it to nothing); +// * a self-contained call collapses its source behind a glyph and reveals +// it again under the caret, without the characters ever being removed; // * an unknown or unregistered directive id can't crash or restyle a // neighbour; // * a scoped restyle matches the full pass. @@ -22,7 +22,7 @@ import Foundation import Testing @testable import MarkdownEngine -@Suite("Directives — Phase 1 styling") +@Suite("Directives — structural styling") struct DirectiveStylingTests { private let base: CGFloat = 14 @@ -100,16 +100,28 @@ struct DirectiveStylingTests { // MARK: - Self-contained - @Test("a self-contained call renders as literal text — nothing collapses it") - func selfContainedStaysVisible() { + @Test("a self-contained call collapses its source and reveals it under the caret") + func selfContainedFlips() { let text = "before @marker after" - let attrs = style(text) let location = (text as NSString).range(of: "@marker").location - // No shrink font and no negative kern anywhere in the call. - for (range, a) in attrs where NSIntersectionRange(range, NSRange(location: location, length: 7)).length > 0 { - #expect((a[.font] as? NSFont)?.pointSize != hiddenSize) - #expect((a[.kern] as? CGFloat ?? 0) >= 0) + func fonts(caret: Int) -> [CGFloat] { + style(text, caret: caret) + .filter { NSIntersectionRange($0.range, NSRange(location: location, length: 7)).length > 0 } + .compactMap { ($0.attributes[.font] as? NSFont)?.pointSize } } + #expect(fonts(caret: -1).contains(hiddenSize)) // collapsed + #expect(!fonts(caret: location + 2).contains(hiddenSize)) // revealed + } + + @Test("the collapsed source is never removed from the text") + func collapsedSourceSurvives() { + // The whole point of collapsing rather than deleting: selection, find, + // copy, and undo still see the real characters. + let text = "before @marker after" + let covered = style(text) + .filter { NSIntersectionRange($0.range, (text as NSString).range(of: "@marker")).length > 0 } + #expect(!covered.isEmpty) + #expect((text as NSString).range(of: "@marker").length == 7) } // MARK: - Isolation diff --git a/Tests/MarkdownEngineTests/DirectiveTestFixtures.swift b/Tests/MarkdownEngineTests/DirectiveTestFixtures.swift index d7e05126..cf4c0296 100644 --- a/Tests/MarkdownEngineTests/DirectiveTestFixtures.swift +++ b/Tests/MarkdownEngineTests/DirectiveTestFixtures.swift @@ -4,15 +4,21 @@ // // Directives used across the directive suites. // -// Deliberately test-local: this change adds the seam, not any directive, so -// the parser tests declare the shapes they need rather than leaning on a -// bundled implementation. That keeps them testing the SEAM — a schema is a -// schema whether it came from the engine or from an embedder. +// Deliberately test-local: the engine ships only `FontDirective` and +// `ColorDirective` as reference implementations — anything carrying curated +// data or app policy (icons, flags, print semantics) belongs to the embedder. +// So the shapes those would have exercised are declared here instead, which +// also keeps the suites hermetic: they test the SEAM, not the bundled +// directives. A schema is a schema whether it came from the engine or from an +// embedder. // +import AppKit import Foundation @testable import MarkdownEngine +// MARK: - Parsing and argument shapes + /// Container form with a mixed labelled schema — the shape the parser has to /// carry through argument coercion. struct SizedDirective: MarkdownDirective { @@ -43,14 +49,79 @@ struct TintDirective: MarkdownDirective { } } -/// Self-contained form, no arguments. +/// Self-contained with two POSITIONAL parameters — the shape that exercises +/// defaults on positionals, which labelled-only filling used to skip. +struct SelfContainedPair: MarkdownDirective { + var syntax: DirectiveSyntax { DirectiveSyntax(name: "pair", form: .selfContained) } +} + +// MARK: - Presentation and completion shapes + +/// Self-contained, no arguments, draws a fixed symbol. The minimal glyph case. struct MarkerDirective: MarkdownDirective { var syntax: DirectiveSyntax { DirectiveSyntax(name: "marker", form: .selfContained) } + + func presentation(arguments: DirectiveArguments, context: DirectiveContext) -> DirectivePresentation { + context.isActive ? .literal : .symbol(name: "arrow.down.to.line", tint: context.theme.mutedText) + } + func html(arguments: DirectiveArguments, bodyHTML: String) -> String { "
" } } -/// Self-contained with two POSITIONAL parameters — the shape that exercises -/// defaults on positionals, which labelled-only filling used to skip. -struct SelfContainedPair: MarkdownDirective { - var syntax: DirectiveSyntax { DirectiveSyntax(name: "pair", form: .selfContained) } +/// Self-contained, symbol chosen by a positional argument, with a static +/// value-completion list. Stands in for an icon-style directive. +struct GlyphDirective: MarkdownDirective { + static let symbols = ["star.fill", "star", "bolt.fill", "checkmark.circle.fill", "flame.fill"] + + var syntax: DirectiveSyntax { + DirectiveSyntax( + name: "glyph", + form: .selfContained, + parameters: [ + .init(label: nil, kind: .keyword([]), isRequired: true, documentation: "SF Symbol name."), + .init(label: "color", kind: .keyword(["red", "green", "blue"]), documentation: "Tint."), + ] + ) + } + + func presentation(arguments: DirectiveArguments, context: DirectiveContext) -> DirectivePresentation { + guard !context.isActive, let name = arguments.positional.first?.asString else { return .literal } + let tint: NSColor? = switch arguments.string("color") { + case "red": .systemRed + case "green": .systemGreen + case "blue": .systemBlue + default: nil + } + return .symbol(name: name, tint: tint) + } + +} + +/// Self-contained, renders replacement TEXT chosen by argument, with dynamic +/// value completions that match on two fields. Stands in for a flag/emoji +/// style directive — the case whose domain is too large to declare. +struct RegionDirective: MarkdownDirective { + static let table: [(code: String, name: String, glyph: String)] = [ + ("JP", "Japan", "🇯🇵"), ("US", "United States", "🇺🇸"), + ("DE", "Germany", "🇩🇪"), ("BR", "Brazil", "🇧🇷"), + ] + + var syntax: DirectiveSyntax { + DirectiveSyntax( + name: "region", + form: .selfContained, + parameters: [.init(label: nil, kind: .keyword([]), isRequired: true, + documentation: "Region code.")] + ) + } + + func presentation(arguments: DirectiveArguments, context: DirectiveContext) -> DirectivePresentation { + guard !context.isActive, + let code = arguments.positional.first?.asString, + let entry = Self.table.first(where: { $0.code.caseInsensitiveCompare(code) == .orderedSame }) + else { return .literal } + return .text(entry.glyph) + } + } +