Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 13 additions & 6 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions Demo/MarkdownEngineDemo.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand All @@ -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 = "<group>"; };
1A2B3C0000000000000005AA /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
1A2B3C0000000000000010AA /* DemoDirectives.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DemoDirectives.swift; sourceTree = "<group>"; };
1A2B3C0000000000000006AA /* MDE.icon */ = {isa = PBXFileReference; lastKnownFileType = folder.iconcomposer.icon; path = MDE.icon; sourceTree = "<group>"; };
/* End PBXFileReference section */

Expand Down Expand Up @@ -57,6 +59,7 @@
children = (
1A2B3C0000000000000004AA /* MarkdownEngineDemoApp.swift */,
1A2B3C0000000000000005AA /* ContentView.swift */,
1A2B3C0000000000000010AA /* DemoDirectives.swift */,
1A2B3C0000000000000006AA /* MDE.icon */,
);
path = MarkdownEngineDemo;
Expand Down Expand Up @@ -141,6 +144,7 @@
files = (
1A2B3C0000000000000004BB /* MarkdownEngineDemoApp.swift in Sources */,
1A2B3C0000000000000005BB /* ContentView.swift in Sources */,
1A2B3C0000000000000010BB /* DemoDirectives.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand Down
50 changes: 39 additions & 11 deletions Demo/MarkdownEngineDemo/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
///
Expand All @@ -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, \
Expand All @@ -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
Expand Down Expand Up @@ -453,3 +480,4 @@ private let markdownFooter = """
---

"""

160 changes: 160 additions & 0 deletions Demo/MarkdownEngineDemo/DemoDirectives.swift
Original file line number Diff line number Diff line change
@@ -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 {
"<span class=\"icon\" data-symbol=\"\(arguments.positional.first?.asString ?? "")\"></span>"
}
}

// 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 { "<hr class=\"pagebreak\" />" }
}

// 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
}
}
20 changes: 13 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down
Loading
Loading