87 Commits
Author SHA1 Message Date
Puranjay Savar Mattas 2d0972de1e feat: create inline (anchored) comments from a text selection
Right-click a selection -> "Comment on Selection..." -> opens the
comments sheet straight into composing, with a removable chip showing
what's being anchored to (clearing it falls back to a plain
document-level comment). Posts via comments.create with anchorText
set to the selection.

Only wired on rendered panes (main reader pane, Split View's preview
pane) - not the raw-markdown pane, since a selection there would
capture literal Markdown syntax as the anchor instead of clean prose.

NSMenuItem has no closure initializer, so this needed a small
target-is-self subclass (ClosureMenuItem) to wire a Swift closure
into onBuildContextMenu's NSMenu without a separate selector per
action.

Also moved the comments sheet's .sheet(...) modifier off the toolbar
badge button (which only exists once the doc already has comments)
onto the view root, since a document with zero comments still needs
to be able to open the sheet to create its first one. Reader now
refetches its own comment list after any create/reply via the sheet's
new onCommentsChanged callback, so a freshly anchored comment's inline
marker appears without reopening the document.
2026-08-20 21:52:13 +01:00
Puranjay Savar Mattas b31d49bb7f feat: drafts, publish flow, and full comment threading
Drafts + Publish:
- New Home tab backed by documents.drafts (undocumented request shape
  confirmed from a live network capture, not the OpenAPI spec).
- Reader toolbar menu now shows Publish...  for an unpublished
  document instead of an unconditional Unpublish (which could
  previously be tapped on a draft at all). Publish opens a
  MoveDocumentSheet-style collection/parent picker, pre-filled from
  the draft's own collectionId/parentDocumentId when it already has
  one. Publishing itself is documents.update(publish: true,
  collectionId:) for the collection placement, plus a second
  documents.move call only when a specific parent document was also
  picked (documents.update has no parentDocumentId field).
- New Document defaults to Draft (collectionId/publish both now
  optional on CreateDocumentRequest, previously collectionId was
  required so a draft couldn't be created from this sheet at all).
  Contextual entry points (right-click a collection/document) still
  pre-fill that location, but now show a warning that doing so
  auto-publishes.
- Fixed onDeleted only popping the reader's nav path without telling
  the sidebar to refresh - Delete/Archive/Unpublish/Move all left the
  sidebar showing stale state until an unrelated trigger (the 45s
  poll, navigating away and back) happened to catch it up.

Comments:
- Replies (comments.create with parentCommentId, one level of nesting
  same as Outline's own limit) and emoji reactions
  (comments.add_reaction/remove_reaction, confirmed against Outline's
  server source - not in the spec, and return {success: true} rather
  than the updated comment, so a toggle refetches via comments.info
  for the real post-toggle state) plus a document-level "new comment"
  composer, since replying needs something to reply to.
- Inline anchor markers: a new engine-side mechanism
  (CommentAnchorQuery/CommentAnchorRect/onCommentAnchorRectsChange)
  resolves an anchored comment's anchorText to an on-screen rect via
  the same viewRect utility the code-block copy button uses, kept in
  sync on typing/resize/reflow the same way the code-block and image
  positioning fixes earlier this session are. Renders as a thin blue
  bar next to the commented text; tapping it opens the comments sheet
  scrolled and highlighted to that thread. First-occurrence text
  search only (Outline's API returns no position data, and no
  prefix/suffix on read) - creating new anchored comments from this
  app still isn't supported.
- Toolbar badge: tighter offset so the count doesn't clip past the
  icon, caps at "10+".
2026-08-20 21:31:36 +01:00
Puranjay Savar Mattas 277e5fb4ae feat: add comment marker (list + resolve/unresolve)
Wraps Outline's Comments API in OutlineKit for the first time -
comments.list is documented in the vendored spec; comments.resolve/
unresolve are not, confirmed real against Outline's own server source
instead of guessed. Comment bodies are ProseMirror documents (data),
not plain text - added a small recursive JSONValue tree plus a
best-effort plainText() walk for display, since nothing here needs to
write comment bodies back (out of scope for this pass, see
DocumentCommentsSheet's doc comment).

Reader toolbar gets a marker (bubble icon + count badge) only when
the document actually has comments, per explicit instruction that
this should be a simple symbol rather than a true in-document gutter
marker at each comment's anchor position - anchoring is plain-text-
substring-based server-side and doesn't map cleanly onto this app's
own Markdown rendering. Opens a read-only comment list with a
Resolve/Unresolve toggle per the confirmed scope for this pass; no
creating or replying yet.
2026-08-20 20:52:44 +01:00
Puranjay Savar Mattas 1a58d91bb3 fix: images resize with window, blend toolbar into content
Images sized themselves once at restyle time and never got
re-measured on a pure window/pane resize (no text change, no image
fingerprint change) - so they'd stay whatever width they were last
styled at. The engine already had the fix for exactly this shape of
problem for wide tables (a stamped .scrollableBlockFullRange
attribute triggers a targeted restyle on width change), and the
shared image-rendering helper already had a restyleOnWidthChange flag
to opt into it - just never passed at the image call sites. Wired it
on for both ![]() and ![[embed]] rendering.

Also makes the titlebar/toolbar strip blend into the sidebar's
background instead of reading as a separate bar, matching Mail/Notes/
Finder - titlebarAppearsTransparent + fullSizeContentView via a small
NSViewRepresentable, same "reach into NSWindow directly" pattern
applyMacAppearance() already uses since SwiftUI's WindowGroup has no
direct API for either.
2026-08-20 15:04:50 +01:00
Puranjay Savar Mattas ccbd84c05b feat: add Image Playground and fix Markdown image rendering
Image Playground integration: reader toolbar button ("Create Image
with Image Playground") opens the system generator, seeded with the
current text selection when there is one. Result uploads through the
same attachments.create/upload flow as everything else and inserts
Outline's own stable ![](/api/attachments.redirect?id=...) reference
at the caret, via a new generic TextInsertionRequest/
pendingTextInsertion mechanism on NativeTextViewWrapper (nothing
previously let an embedder insert text into the editor from outside
at all).

Along the way, found and fixed a real pre-existing gap: standard
![alt](url) Markdown images never rendered anywhere in the app -
services.images was never wired to anything but the no-op default, so
every such image silently fell back to dimmed raw source. Added
OutlineAPIClient.fetchAuthenticatedFile (Bearer-authed GET, for
attachments.redirect and similar) and OutlineImageProvider, a real
EmbeddedImageProvider backed by it, wired into every render surface
(reader, split-view preview, present sheet, collection overview).

Also fixes two Split View bugs surfaced while building this: the
raw-source pane was a plain SwiftUI TextEditor with no selection or
insertion hook, so the Image Playground button couldn't see a
highlighted selection there and generated images had nowhere to land;
replaced it with NativeTextViewWrapper in rawSourceMode (same engine,
no styling overhead, but now selection/insertion work like every
other pane). Also moved onSelectedTextChange's firing point earlier
in the delegate, since it was previously placed after the
rawSourceMode early-return and so could never fire for a raw-mode
editor at all. And images sized off a possibly-not-yet-settled text
container width during Split View's frequent per-keystroke rebuilds,
sticking at the wrong size until the document was reopened - now
re-measured once more a tick after layout settles.

Also reorganizes Settings: Command Palette and its full-workspace-
search toggle move out of Editor into a new Navigation section, since
they're about finding things, not about how documents are edited.
2026-08-20 14:49:34 +01:00
Puranjay Savar Mattas 69837b3471 chore: visionOS support removed 2026-08-20 14:24:16 +01:00
Puranjay Savar Mattas b60e7ec94e feat: wire smart text replacements, autocomplete, Writing Tools toggles
Adds TextSubstitutionPolicy/TextCompletionPolicy/WritingToolsPolicy to
MarkdownEditorConfiguration (previously hardcoded AppKit calls with no
config surface). Outline's existing synced smartText preference now
actually drives smart quotes/dashes. Autocomplete and Writing Tools
get new local-only Settings -> Editor toggles (writingToolsBehavior
was already on unconditionally with no way to turn it off).
2026-08-20 13:53:53 +01:00
Puranjay Savar Mattas 3c3a5865bd fix: seed code-block cache on programmatic document load
updateCodeBlockSelection was only ever called from the two AppKit
text-delegate callbacks (textDidChange / selection-changed), never
from the programmatic rebuildTextStorageAndStyle load path. Line
number gutters (and the built-in copy-code button) stayed empty on a
cold document open until the user's first click or keystroke.
Threads the rebuild's own parsed document into the same call instead
of leaving cachedCodeBlockTokens empty until the next real edit.
2026-08-20 13:49:43 +01:00
Puranjay Savar Mattas 707dde14a9 docs: credit swift-markdown-engine in README 2026-08-20 13:30:20 +01:00
Puranjay Savar Mattas 6e1ffb96fd chore: vendor swift-markdown-engine as plain tracked source
Convert from git submodule to plain vendored copy. No push access to
upstream nodes-app/swift-markdown-engine meant our local fix commit
(05c1720) was stranded and unreachable from any remote on fresh
clones/CI. Vendoring as plain files folds it into normal repo history
instead.
2026-08-20 13:26:13 +01:00
Puranjay Savar Mattas 1552a3cd23 Merge branch 'main' into feature/document-editing 2026-08-20 00:43:27 +01:00
Puranjay Savar Mattas cc5b329d8e Merge pull request 'legal: relicense from MIT to Business Source License 1.1' (#12) from legal/bsl-1.1-license into main
Reviewed-on: #12
2026-08-20 00:42:25 +01:00
Puranjay Savar Mattas da890e88fd legal: push BSL change date out to 10 years (2036-08-20) 2026-08-20 00:31:27 +01:00
Puranjay Savar Mattas c04e4d542c legal: relicense from MIT to Business Source License 1.1
Switches from a fully permissive license to BSL 1.1 (the same family
Outline's own server uses, for the same underlying reason): personal
use, self-hosting, and non-commercial forks stay explicitly allowed,
but shipping a competing hosted/distributed product off this code, or
distributing a fork under branding that claims official/affiliated
status, now requires a separate commercial agreement. Converts
automatically to Apache License 2.0 on the change date in LICENSE.

Adds an explicit trademark notice for the "Outpost" name/logo,
independent of the code license — the specific thing this was meant to
close off (a fork getting relabeled as an official/endorsed product).

README gets a plain-English summary of what changed; CONTRIBUTING gets
a one-line note that PRs are contributed under the same terms.
2026-08-20 00:28:32 +01:00
Puranjay Savar Mattas cc27a860e5 Merge branch 'main' into feature/document-editing 2026-08-20 00:12:55 +01:00
Puranjay Savar Mattas d2e213b516 Merge pull request 'chore: vendor swift-markdown-engine as a local submodule' (#11) from chore/vendor-markdown-engine-submodule into main
Reviewed-on: #11
2026-08-20 00:09:01 +01:00
Puranjay Savar Mattas 226d6fb748 chore: switch Xcode to the local swift-markdown-engine submodule
Removed the remote XCRemoteSwiftPackageReference and pointed
MarkdownEngine/MarkdownEngineCodeBlocks/MarkdownEngineLatex at the
XCLocalSwiftPackageReference for Vendor/swift-markdown-engine instead.
Package.resolved drops the now-irrelevant remote pin (HighlighterSwift
and SwiftMath stay remote, only swift-markdown-engine itself moved
local).

Also bumps the submodule pointer to include three warning fixes made
directly in the vendored source (var->let, unused local, #selector) —
first real edits to the package now that it's locally editable.
2026-08-20 00:09:01 +01:00
Puranjay Savar Mattas 44a2aff050 chore: vendor swift-markdown-engine as a git submodule
Pinned to e5f7607 (v0.12.0), the exact commit already resolved in
Outpost.xcodeproj's Package.resolved — no version change, just gives it
a local, versioned checkout instead of only existing as an Xcode-managed
remote package cache. Brings its own ARCHITECTURE.md along at
Vendor/swift-markdown-engine/ARCHITECTURE.md.

Xcode side still needs a manual follow-up: remove the remote
"swift-markdown-engine" package reference and add
Vendor/swift-markdown-engine as a local package instead (File > Add
Package Dependencies > Add Local...). Not done here — pbxproj package
references aren't safely hand-editable without a build to verify against.
2026-08-20 00:09:00 +01:00
Puranjay Savar MattasandClaude Sonnet 5 d44e4dd4ec WIP(editor): syntax highlighting + code block line numbers
Syntax highlighting: wires the already-pinned HighlighterSwiftBridge
(MarkdownEngineCodeBlocks) into services.syntaxHighlighter on every
NativeTextViewWrapper, shared via one CodeSyntaxHighlighting.shared
instance (JSContext init is expensive, don't build one per text view).

Line numbers: app-side CodeBlockLineNumberGutter overlay positioned via
onCodeBlockSelectionChange's rect, widens codeBlock.horizontalIndent to
make room. Marked WIP: line-number overlays (and the engine's own
built-in copy-button overlay) stay empty on cold document load until
the user's first click/keystroke — a real gap in swift-markdown-engine's
rebuild path (cachedCodeBlockTokens is only ever seeded by AppKit text-
delegate callbacks, not the programmatic text-binding rebuild), not
fixable from the app side without patching the package. See
TODO.local.md for the full trace and options.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 23:44:50 +01:00
Puranjay Savar Mattas 195dc2cc59 feat(editor): Remember previous location + Command Palette (⌘K)
Remember previous location (Preferences): persists collection +
document chain (or Home) to UserDefaults on every navigation change,
gated on the preference. Restored once per launch by resolving the
stored IDs back through the API, stopping at the first failure
(deleted doc, offline, etc.) rather than aborting the whole restore —
a partial chain beats falling all the way back to Home. Cleared on
sign-out so switching accounts can't restore a stale location.

Command Palette (new Settings → Editor section, not synced to
Outline): ⌘K opens a floating overlay, arrow-key/click navigation,
Enter or click to select. Always searches locally, never a
per-keystroke network request:
- Lightweight (default): live listCollections + listViewedDocuments
  fetch once on open.
- Full Workspace (opt-in, requires Full Local Sync on): reads
  CachingOutlineAPIClient's local cache directly — zero network calls,
  includes nested sub-documents now that Full Local Sync actually
  caches them (see the paired OutlineKit commit).

Fixed through live testing, in order found:
- Focus: the search field wasn't reliably first responder the instant
  ⌘K opened it (also the likely source of several AppKit
  CA-transaction console warnings) — added a short delay before
  focusing.
- Full Workspace "no results": was sequential one-collection-at-a-time
  fetching before the cache-read redesign: withTaskGroup made it
  concurrent, and reading from the cache instead of the network made
  it moot.
- Arrow keys not moving selection: .onKeyPress was on the outer card,
  but the focused TextField swallowed the events before they could
  bubble up. Moved the handlers directly onto the TextField.
- Search ranking: exact-phrase-only matching meant a title like "Test
  Plan Document" never matched a "test document" query at all
  (filtered out, not just ranked low), making it look like collections
  always won. Added a fallback tier: every word of a multi-word query
  present anywhere in the title still matches, ranked below
  exact/prefix/substring hits.
- foregroundStyle(.secondary vs .orange) ternary: HierarchicalShapeStyle
  vs Color type mismatch, fixed with AnyShapeStyle on both branches.
2026-08-19 17:14:49 +01:00
Puranjay Savar Mattas bef194d493 fix(outlinekit): Full Local Sync now recurses into nested documents
performFullSync only ever cached each collection's root-level documents
— a document's own sub-documents were never cached at all, so nested
content stayed unreachable offline even with a full sync (and even
after implicitly re-fetching individual documents, since the walk that
seeds the cache never visited them). Now recurses depth-first into
every document's children via cacheDocumentTree, terminating naturally
once a branch runs out of sub-documents.

Also caches each collection individually under "collection:<id>" (was
only ever cached as part of a paginated list blob), and adds
OfflineCacheStore.loadAll(keyPrefix:) plus
cachedDocumentsIndex()/cachedCollectionsIndex() on
CachingOutlineAPIClient to read everything back as a flat local index
— no per-id lookup needed, no network involved.

Found and fixed a real infinite-recursion bug in the process: an
existing test's stub ignored parentDocumentId and kept returning the
same root documents at every recursion depth, hanging swift test
indefinitely once the real code started recursing into children. Fixed
the stub, added a dedicated regression test for the recursive behavior
itself. 78/78 tests passing.
2026-08-19 17:14:28 +01:00
Puranjay Savar Mattas bdde8642f7 feat(editor): Split View (raw Markdown / live preview), remove Sub-documents
New Outpost-local Settings → Editor section (not synced to Outline,
same as Appearance) with a Split View toggle: raw Markdown source on
the left (plain TextEditor, not the rendering engine), the same rich
rendering used everywhere else in the app on the right, read-only,
live-updating off the same text binding.

Fixed a real layout bug before shipping it: the split view was nested
inside the page-level ScrollView, which proposes unbounded height to
its content, so a minHeight just resolved to exactly that minimum
instead of filling the window. Restructured so Split View bypasses the
outer scroll entirely (title fixed at top, HSplitView taking every
remaining pixel below it) — each pane already scrolls itself, so
nesting it inside another unbounded scroll container was fighting
itself for height. Normal single-pane reading/editing untouched.

Known follow-up, not attempted: scroll position between the two panes
isn't synchronized — the editor package exposes no scroll hook, so
doing this for real means introspecting its private view hierarchy.

Also removed the "Sub-documents" section from the reader per explicit
request — the childrenSection view, and the now-unnecessary
listDocuments(parentDocumentId:) fetch backing it in the view model.
2026-08-19 15:42:35 +01:00
Puranjay Savar Mattas 1229678c00 feat(editor): wire up Separate Editing preference
Preferences → Separate Editing now actually drives document reader
behavior, not just a saved-but-inert server setting:

- On (default, today's existing behavior): unchanged — explicit
  Edit/Done toggle, save on Done.
- Off: no Edit/Done affordance — document is always editable directly
  (no per-document permission field exists server-side to pre-check
  against, so an unauthorized edit just fails to save rather than
  being blocked client-side). Edits autosave 1.5s after typing pauses,
  through the same offline-queue-aware updateDocument path the
  explicit save already used. Guarded against firing a pointless save
  right after opening a document, and against clobbering newer
  keystrokes typed while a debounced save is still in flight.

Prerequisite: SessionStore now caches OutlineUserPreferences to
UserDefaults, loaded on launch before any network call — this needed
to stay valid on a cold offline launch, not just live in memory from
the last successful fetch. Still read-only while offline, unchanged.
2026-08-19 15:29:38 +01:00
Puranjay Savar Mattas 0da26e0fed Merge pull request 'fix(about): About Outpost opens Settings' About section, no popup window' (#10) from fix/about-menu-no-popup into main
Reviewed-on: #10
2026-08-19 14:22:40 +01:00
Puranjay Savar Mattas 6ffba3be02 fix(about): About Outpost opens Settings' About section, no popup window
"About Outpost" in the app menu used to open a separate standalone
window (Window(id: "about") wrapping AboutView). Now just opens Settings
and jumps straight to the About section instead — same content
(AboutInfoView, unchanged), one less window/code path to maintain.

Also removed the "Check for Updates…" button — deferred per explicit
instruction, not deleted for a real reason beyond "not now." A simple
button can come back later; no update-checking logic was ever built; ,
nothing else to clean up alongside it.
2026-08-19 14:08:43 +01:00
Puranjay Savar Mattas 187f2eaa25 Merge pull request 'docs: fix wiki links to include trailing .-' (#9) from docs/wiki-link-fix into main
Reviewed-on: #9
2026-08-19 02:43:10 +01:00
Puranjay Savar Mattas 3fadd008f4 Merge branch 'main' into docs/wiki-link-fix 2026-08-19 02:42:56 +01:00
Puranjay Savar Mattas 410049d161 docs: fix wiki links, they need a trailing .- to resolve correctly
Confirmed live against the actual Gitea wiki — hyperlinks to
hyphenated wiki page names 404 without a trailing .-, e.g.
.../wiki/Privacy-Policy needs to be .../wiki/Privacy-Policy.-
2026-08-19 02:40:43 +01:00
Puranjay Savar Mattas 5301187413 Merge pull request 'docs: README - logo, TestFlight link, drop Status, expand Privacy' (#8) from docs/readme-testflight-privacy into main
Reviewed-on: #8
2026-08-19 02:38:12 +01:00
Puranjay Savar Mattas fe275ac17c docs: README — logo, real TestFlight link, drop Status, expand Privacy
- Logo and a TestFlight download badge at the top.
- Removed the Status/phase checklist — CLAUDE.md already tracks this,
  duplicating it in the README just goes stale.
- Requirements corrected to what the project actually targets right
  now (Xcode 27+, macOS 27+) instead of stale Xcode 16/iOS 17 numbers.
- Privacy section expanded with a real inline summary, linking out to
  the wiki for the full Privacy Policy, Terms of Service, and Data
  Processing Statement rather than duplicating them as repo files.
2026-08-19 02:26:36 +01:00
Puranjay Savar Mattas 710a6617f5 Merge pull request 'Settings parity: Profile, Preferences, Notifications, Passkeys, API & Access' (#7) from feature/settings-parity into main
Reviewed-on: #7
2026-08-18 17:13:52 +01:00
Puranjay Savar Mattas 55c23afaff chore: bump version to 0.0.4 2026-08-18 17:01:17 +01:00
Puranjay Savar Mattas f07c5055fa fix(settings): style Coming Soon as a capsule badge instead of plain text 2026-08-18 16:59:06 +01:00
Puranjay Savar Mattas 8af860cd7e feat(settings): mark Workspace category Coming Soon in the sidebar
Shows a small tertiary-styled 'Coming Soon' next to the category header
whenever every section under it is still !isImplemented — not hardcoded
to Workspace specifically, so it stops on its own once real Workspace
sections start landing instead of needing a manual follow-up removal.
2026-08-18 16:58:14 +01:00
Puranjay Savar Mattas 78e3566a8e fix(settings): show offline hint on Passkeys, clearer API key web-only message
Passkeys was the only implemented section missing the standard offline
banner. Reworded the API key create/delete popup to state plainly it's
only supported on Outline's web version, matching Passkeys' own phrasing
style instead of the more roundabout original wording.
2026-08-18 16:54:45 +01:00
Puranjay Savar Mattas 64e970fc92 fix(settings): API key create/delete need Outline's web session, not this app
Same limitation as Passkeys — apiKeys.create/apiKeys.delete need
Outline's cookie+CSRF web session, not this app's Bearer-token auth.
"New API Key…" and the per-row trash button now show an explanatory
popup instead of attempting a request that doesn't work.

The real create sheet, reveal-once flow, delete confirmation, and their
backing functions are untouched and still fully built/tested at the
OutlineKit layer — only the two trigger points were redirected, each
marked with a TODO pointing back to how to re-enable them (swap the
button action back) once there's a supported native auth path or
Outline adds Bearer support for these two endpoints.
2026-08-18 16:53:46 +01:00
Puranjay Savar Mattas 1e541979e9 fix(settings): sidebar version footer — alpha tag, offline handling, auto-refresh
Outpost's version was missing the -ALPHA suffix About already shows —
extracted OutpostVersion (Support/) as the one shared source for both,
so a third divergent copy can't happen the way AboutInfoView's own doc
comment already warns against for its two call sites.

Outline's version now accounts for the manual Offline Mode toggle too,
not just real connectivity, shows "Outline — offline" instead of just
disappearing when nothing's been fetched yet, and re-fetches
automatically via .task(id: isEffectivelyOnline) whenever connectivity
changes — previously a one-shot fetch on first sidebar mount only.
2026-08-18 16:51:13 +01:00
Puranjay Savar Mattas 26b0d7b118 fix(settings): grey out API & Access while offline
New API Key/delete were already gated on isEffectivelyOnline, but
nothing visually signaled offline state the way Preferences/Profile do
— added the same offline hint banner plus dimmed+disabled the key list
itself, and gated the sheet's own Create button too (in case Offline
Mode gets toggled on while the sheet is already open).
2026-08-18 16:49:45 +01:00
Puranjay Savar Mattas 45c950d474 feat(settings): API key create, reveal-once, and delete
Create: name + expiration picker (No expiration/1/3/6 months/1 year,
computed client-side and sent as expiresAt — omitted entirely for no
expiration, confirmed live that's what produces a non-expiring key).

Reveal: plaintext value only ever shown in a dedicated one-time sheet,
separate from the persisted apiKeys list (which is refreshed from the
server right after creating, so it never carries the value at all).
Requires clicking Copy before the confirm button unlocks, copies to the
system pasteboard, and clears the value from @State the moment the sheet
closes however it closes (explicit confirm, Escape, or otherwise) via
onDisappear — not just hidden behind dismissed UI.

Delete: confirmation dialog naming the key, per-row spinner while in
flight.

Both New API Key and per-row delete disable while offline, consistent
with every other server-synced action in Settings.
2026-08-18 16:48:12 +01:00
Puranjay Savar Mattas 044a706bd4 feat(outlinekit): add apiKeys.create/delete
value is only ever present in the create response (confirmed live —
apiKeys.list never includes it), so the model and call sites treat it
as a one-time reveal, not persisted state. expiresAt omitted (not null)
for a non-expiring key, matches synthesized Encodable's default
encodeIfPresent behavior.
2026-08-18 16:45:18 +01:00
Puranjay Savar Mattas fa22ac616b feat(settings): move Outline version to sidebar footer, drop Installation section
Removed the Integrations & Installation category and its lone
Installation section entirely — Outline's server version now shows in
the settings sidebar footer instead, alongside Outpost's own version
(installation.info, fetched once when the sidebar appears). About stays
where it was, unaffected.
2026-08-18 16:39:19 +01:00
Puranjay Savar Mattas adfa477ecf feat(settings): Installation section
Server version + up-to-date/behind indicator via installation.info.
2026-08-18 14:20:26 +01:00
Puranjay Savar Mattas dc780e420f feat(settings): API & Access section (personal keys, read-only)
Lists existing personal API keys via apiKeys.list (name, masked last4,
created/last-used dates) with a link to Outline's developer docs.
Creation/revocation deferred per instruction — read-only for this pass.
2026-08-18 14:19:50 +01:00
Puranjay Savar Mattas 6eb780d1bd feat(settings): Passkeys section
Informational only, per Outline itself — passkey/WebAuthn registration
needs a browser context, so this stays web-only rather than a
placeholder for missing native functionality.
2026-08-18 14:18:26 +01:00
Puranjay Savar Mattas 78a0e31897 feat(settings): mark Passkeys, API & Access, Installation implemented 2026-08-18 14:17:55 +01:00
Puranjay Savar Mattas e3ad8650c6 feat(outlinekit): add apiKeys.list and installation.info plumbing
Read-only for now, per instruction to defer key creation/revocation.
Both confirmed against live network captures.
2026-08-18 14:17:41 +01:00
Puranjay Savar Mattas 4faa23f79e chore(xcode): sync project signing config after bundle ID rename
Picks up Xcode's own automatic-signing additions from setting up the
Development provisioning profile in the IDE (CODE_SIGN_IDENTITY,
PROVISIONING_PROFILE_SPECIFIER, ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS,
Info.plist display name/category keys), alongside the earlier
com.psmattas.OutpostApp bundle ID and CW6GQT9SK5 team fixes.
2026-08-18 13:52:32 +01:00
Puranjay Savar Mattas 09e1ab8f65 fix(auth): properly clean the login logo edge, previous pass left a fringe
Plain -fuzz -transparent white color-keyed every pixel in the whole
image close enough to white by color distance — including an
antialiased blend inside the artwork itself (where the light beam meets
the window frame), which punched a stray transparent hole there, and
still left a faint opaque fringe ring around the outer edge since the
binary cutoff didn't fully consume the antialiasing ramp.

Redone with a flood fill seeded from a corner pixel instead: only
transforms the background region actually connected to that seed (the
four corners + thin white border, confirmed contiguous), leaving
disconnected internal antialiased pixels untouched. Then eroded the
alpha channel by ~2px to consume the remaining boundary ramp cleanly.
Verified against both dark and light composited backgrounds, and
confirmed the beam/window-frame area is no longer punctured.
2026-08-18 12:39:42 +01:00
Puranjay Savar Mattas bec8135c74 fix(auth): remove white corners around the login logo
The App Store marketing icon source (outpost-ios-1024.png) is a flat
opaque square with the squircle baked in and pure-white corners outside
it — meant to be masked by the OS everywhere it's normally shown as an
app icon, but nothing masks it when used as a plain in-app image.
Color-keyed white to transparent (fuzz 8%, safe here since nothing in
the actual artwork is white/near-white) so the corners disappear instead
of showing a white border.
2026-08-18 12:30:05 +01:00
Puranjay Savar Mattas fcb72ba9d6 fix(auth): login logo was blank, AppIcon app-icon set isn't Image()-loadable
Image("AppIcon") resolved to nothing at runtime (confirmed live) — App
Icon-type asset catalog entries aren't reliably retrievable through
Image(_:)/UIImage(named:) the way a normal Image Set is. Added AppLogo,
a plain Image Set duplicating the same outpost-ios-1024.png artwork, and
pointed AuthHeaderView at that instead.
2026-08-18 12:22:16 +01:00
Puranjay Savar Mattas 2b0924be04 fix(auth): use the real app icon on the login screen, not a placeholder
AuthHeaderView was still a generic gradient-circle + SF Symbol book icon
from before AppIcon.appiconset had real branded artwork — never swapped
out once the actual icon was added. Now renders Image("AppIcon") directly
so the login screen can't drift from whatever the Dock/Home Screen icon
actually is.
2026-08-18 12:09:52 +01:00
Puranjay Savar Mattas 931338c9d3 fix(session): don't show signed-in UI when only half the session survived
SessionStore.init treated a Keychain token alone as "signed in," without
checking the paired serverURL in UserDefaults also existed. UserDefaults
is scoped to the app's sandboxed container (keyed by bundle ID), while
Keychain items can survive a reinstall or bundle ID change independently
of it — hit this live after renaming the bundle ID: Keychain still had
the old token, the new container had no serverURL, so isSignedIn came
back true with apiClient nil. App landed on Home with nothing able to
load instead of the login screen.

Now requires both to be present to consider the session valid, and clears
whichever half survived otherwise so a fresh sign-in rewrites both
consistently.
2026-08-18 12:07:02 +01:00
Puranjay Savar Mattas 2740eaeaaf feat(settings): Notifications document-access-requested toggle
Closes out the Notifications page — all 13 event toggles + the All
notifications master toggle now wired.
2026-08-17 21:53:06 +01:00
Puranjay Savar Mattas 6986601b8b feat(settings): Notifications invited-to-collection/export-completed toggles 2026-08-17 21:52:58 +01:00
Puranjay Savar Mattas ddd6701446 feat(settings): Notifications invite-accepted/invited-to-document toggles 2026-08-17 21:52:50 +01:00
Puranjay Savar Mattas aee09cb60a feat(settings): Notifications reaction-added/collection-created toggles 2026-08-17 21:52:42 +01:00
Puranjay Savar Mattas 944d0bf373 feat(settings): Notifications group-mentions/resolved toggles 2026-08-17 21:52:33 +01:00
Puranjay Savar Mattas a1df1b3d3d feat(settings): Notifications comment-posted/mentioned toggles
"Mentioned" groups comments.mentioned + documents.mentioned under one
visible toggle, per Outline's own settings copy.
2026-08-17 21:52:25 +01:00
Puranjay Savar Mattas 8faa94f3cc feat(settings): Notifications document-published/document-updated toggles 2026-08-17 21:52:15 +01:00
Puranjay Savar Mattas bf1e28c9ac feat(settings): Notifications section skeleton + All-notifications toggle
Marks .notifications implemented, adds the shared setNotifications save
path (per-event subscribe/unsubscribe, sequential calls for toggles that
group more than one wire event type) and the master "All notifications"
row. Individual event toggles land in follow-up commits.
2026-08-17 21:52:07 +01:00
Puranjay Savar Mattas c05b787bb7 feat(session): track user notification settings
Same pattern as language/preferences — Notifications settings page reads
current subscribed state from here.
2026-08-17 21:51:24 +01:00
Puranjay Savar Mattas 960919227b feat(outlinekit): add notification subscription plumbing
NotificationEventType (wire keys confirmed live), OutlineUser.notification
Settings dictionary, subscribeToNotifications/unsubscribeFromNotifications
(users.notificationsSubscribe/Unsubscribe, nil eventType = all — confirmed
against the "All notifications" master toggle live too).
2026-08-17 21:51:07 +01:00
Puranjay Savar Mattas a11be29ba9 feat(settings): grey out server-synced settings while offline
Profile's name/avatar and every Preferences toggle (except Appearance,
which is local-only) now disable + show a hint when isEffectivelyOnline
is false, instead of letting a save silently fail. Matches how Share/
Permissions/Search already behave — these are a "needs a real connection"
category, not queued through the offline write queue (infrequent writes,
not worth a second offline-sync path for).
2026-08-17 21:49:11 +01:00
Puranjay Savar Mattas 564cce0637 fix(outlinekit): correct preferences wire keys to the real server shape
All preference toggles showed as off regardless of server state, and
saving notificationBadge 400'd with "notificationBadge: Invalid Input" —
the guessed wire keys/values from the earlier speculative commit were
wrong. Fixed against a live network capture of Outline's own web app
toggling every one of these settings:

- separateEditing -> seamlessEdit, and inverted (seamlessEdit is
  separate editing's negation, confirmed by toggling it live)
- showCommentMarker -> commentsInGutter
- smartText -> enableSmartText
- notificationBadge values -> "disabled"/"indicator"/"count", not the
  guessed "none"/"unread"/"all"
- rememberLastPath/useCursorPointer/codeBlockLineNumbers were already
  correct

Also preserves fullWidthDocuments (a real preference this app has no UI
for) on round-trip, since the client always sends the whole preferences
object back on save — dropping an unrecognized key during decode would
otherwise silently clear it the next time any toggle here gets saved.
2026-08-17 21:35:17 +01:00
Puranjay Savar Mattas 7e34d58a81 fix(settings): language picker breaks on a server locale not in our list
Live warning: "en_GB" invalid tag, undefined Picker display — the curated
OutlineLocale.all didn't include it. Added en_GB explicitly, and made the
Picker's item list always include whatever code the server actually
reports (falling back to the code itself as the label) so any future
unlisted locale degrades gracefully instead of breaking the control.
2026-08-17 21:26:12 +01:00
Puranjay Savar Mattas 384dfe3cc1 feat(settings): Preferences delete-account action
Danger subsection, confirmation dialog before calling users.delete
(deleteAccount()), signs out locally on success. Closes out the
Preferences page.
2026-08-15 20:31:50 +01:00
Puranjay Savar Mattas cac11f0a22 feat(settings): Preferences notification-badge picker
Closes out the Behavior subsection. Uses NotificationBadgeStyle
(None/Unread Indicator/Unread Count) — wire values are a guess same as
the other preference keys, flagged in OutlineUserPreferences.
2026-08-15 20:31:24 +01:00
Puranjay Savar Mattas 78e1c158aa feat(settings): Preferences smart-text-replacements toggle 2026-08-15 20:31:11 +01:00
Puranjay Savar Mattas eeb6dc2564 feat(settings): Preferences remember-previous-location toggle 2026-08-15 20:31:05 +01:00
Puranjay Savar Mattas 286fe80e58 feat(settings): Preferences separate-editing toggle
Starts the Behavior subsection.
2026-08-15 20:30:58 +01:00
Puranjay Savar Mattas 32600bf081 feat(settings): Preferences show-comment-marker toggle
Closes out the Display subsection.
2026-08-15 20:30:51 +01:00
Puranjay Savar Mattas bdb1b4977f feat(settings): Preferences show-line-numbers toggle 2026-08-15 20:30:44 +01:00
Puranjay Savar Mattas f5cfb53f96 feat(settings): Preferences use-pointer-cursor toggle 2026-08-15 20:30:38 +01:00
Puranjay Savar Mattas 73602d1ade feat(settings): shared save path for preference toggles
savePreference(_:) reads the current OutlineUserPreferences, flips one
field, sends the whole object via updateUserPreferences. Every Behavior/
Display toggle added next reuses this instead of its own copy of the
same read-modify-write.
2026-08-15 20:30:31 +01:00
Puranjay Savar Mattas a3651a9364 feat(settings): Preferences appearance setting
Reuses the existing local Appearance color-scheme picker instead of a
second divergent implementation — this has always been a device-side
preference in this app, not a server-synced one.
2026-08-15 20:30:08 +01:00
Puranjay Savar Mattas cfdae2f9ba feat(settings): Preferences section skeleton + Language setting
Marks .preferences implemented, adds the section header/description and
a Display subsection. Language is real and wired end-to-end (users.update
language field, high confidence per Outline's public API docs) — the rest
of the toggles land in follow-up commits, one setting at a time.
2026-08-15 20:29:54 +01:00
Puranjay Savar Mattas fdb4ba5c06 feat(session): track user language + preferences
Mirrors the existing name/avatar/id fields — Settings' Preferences page
needs somewhere to read current values from and applyUpdatedProfile
already refreshes everything else on save.
2026-08-15 20:29:06 +01:00
Puranjay Savar Mattas eb4e1472b9 feat(outlinekit): add language/preferences plumbing for Preferences settings
OutlineUser gains language + preferences fields, new updateUserLanguage/
updateUserPreferences/deleteAccount client methods. Preference wire keys
are best-effort against Outline's own naming, same speculative treatment
as OutlinePin/OutlineDocumentMember — expect a correction round once
tested against a live server.
2026-08-15 20:28:38 +01:00
Puranjay Savar Mattas cd6277f5aa fix(avatar): attach the Bearer token — the real bug, not propagation delay
Your network capture of the working web-app request showed session
cookies (accessToken, authelia_session) on the attachments.redirect
call. This app authenticates every other request with an Authorization:
Bearer header instead — AvatarBadge's fetch never attached one, sending
a bare unauthenticated GET. Almost certainly a 401 the whole time, for
every avatar image, not just a freshly-uploaded one — a failed fetch
and "no avatar set" render identically here (placeholder icon, no
visible error), so there was nothing on screen to reveal it before now.

Uses KeychainTokenStore() directly, same keychain entry SessionStore
already reads, rather than threading a token through every AvatarBadge
call site. Kept the retry loop from the previous attempt too — genuinely
useful insurance against upload-consistency timing, just not the actual
cause here.
2026-08-15 16:44:45 +01:00
Puranjay Savar Mattas bde6be91ca fix(avatar): retry the redirect fetch instead of giving up on one failure
You confirmed the whole upload chain works — the new avatar shows
instantly on Outline's web app — but this app kept showing the old/
placeholder picture in both Settings and the sidebar, which read the
same underlying session.userAvatarURL. Since that state is provably
correct (the same URL that works on web), the bug has to be in how
AvatarBadge loads it, not in the update itself.

Leading theory: a freshly-uploaded attachment's redirect URL can fail
on the very first request right after upload — self-hosted storage
behind a reverse proxy isn't necessarily instantly consistent —  and
AvatarBadge's .task(id:) only ever fires once per URL with no retry,
so a single transient failure right after uploading would leave the
placeholder showing forever even though the exact same URL works fine
moments later (which lines up with it looking fine on a fresh web
load). Now retries twice with a short delay and explicitly checks the
HTTP status before treating the body as image data, instead of
silently accepting whatever came back. Also drops any URLCache
involvement (.reloadIgnoringLocalCacheData) as a second, independent
possible cause, cheap to rule out at the same time.

Couldn't confirm this is the actual root cause without being able to
run the app — worth retesting.
2026-08-15 16:37:44 +01:00
Puranjay Savar Mattas cb8bbd53c4 fix(profile): refresh from the server every time the page opens
SessionStore only ever populated from auth.info once per app launch
— a name (or avatar) changed elsewhere, like Outline's web app, never
reached it until a full quit-and-relaunch. Profile now refetches via
currentUser() every time it's opened and applies the result, same as
any live edit made from this app already does. Fails silently offline,
same as everything else that needs a live read.
2026-08-15 16:31:40 +01:00
Puranjay Savar Mattas 90df5f056d fix: missing UniformTypeIdentifiers import for NSOpenPanel content types 2026-08-15 16:27:39 +01:00
Puranjay Savar Mattas 077042295b feat(profile): avatar upload/remove with crop editor, name editing
Profile now has a real avatar row (AvatarBadge + Upload Photo…/Remove),
wired through last commit's presigned-upload backend: pick a file via
NSOpenPanel, crop/rotate/zoom in a new AvatarCropperView, upload,
point users.update at the result, then best-effort delete whatever
attachment the previous avatar pointed to so replacing/removing a
photo doesn't leak an orphaned blob in Outline's storage every time.

AvatarCropperView builds its on-screen preview and its final exported
image from the exact same SwiftUI view composition (just instantiated
twice — once for display, once through ImageRenderer) rather than a
separately hand-derived set of crop math for a higher resolution —
that's deliberate: there's no way to visually verify a second
independent set of transform math agrees with what the user actually
saw and confirmed without running the app, so making the export
WYSIWYG by construction was the safer choice here.

Name is now editable too (TextField + Save, users.update name-only).
Email is read-only with a note pointing to Outline's web app instead —
per instruction, changing it here isn't supported since email is
tied to sign-in.

SessionStore gained userId (never stored before — needed for every
users.update call) and applyUpdatedProfile(_:), so a successful
change reflects immediately in the account footer and everywhere
else without waiting for the next auth.info refresh.
2026-08-15 16:27:01 +01:00
Puranjay Savar Mattas 052db93d69 feat(profile): name update + attachment cleanup backend
UpdateUserNameRequest (users.update, name only) and deleteAttachment
(attachments.delete, id-only — same shape as every other simple
delete in this API, not yet confirmed live) so the app can clean up
the previous avatar attachment when replacing or removing it instead
of leaking an orphaned blob in Outline's storage every time. 2 new
tests, 65/65 passing.
2026-08-15 16:23:43 +01:00
Puranjay Savar Mattas 373c681c10 feat(profile): avatar upload/remove backend (attachments + users.update)
Adds the presigned-upload plumbing Outline's own web client uses for
any file upload, not just avatars: attachments.create requests an
upload target (server-assigned key, ACL, a short-lived signed form),
then a direct multipart POST to that target (uploadUrl/form) actually
uploads the bytes — confirmed against a live server's own request/
response shapes pulled from network capture. MultipartFormDataBuilder
is a pure, fully-tested function (no network) building that S3-style
presigned-POST body.

uploadAttachmentFile deliberately sends no Bearer/CSRF header — the
presigned form's `sig` field is what authorizes that specific request,
same as an S3 presigned POST; flagged as best-effort pending live
confirmation, cheap to add if the server turns out to also want one.

UpdateUserAvatarRequest (users.update, avatar only) needed a custom
encode(to:) — Swift's synthesized Encodable omits nil Optional keys
via encodeIfPresent, but removing an avatar needs a literal
"avatarUrl": null in the body, not the key missing. Confirmed the
request shape (id + avatarUrl) from the captured request's
Content-Length matching that shape and no shorter alternative.

6 new tests (multipart body structure/field ordering, explicit-null
encoding, create/upload/update round trip) — 63/63 passing.
2026-08-15 16:19:33 +01:00
Puranjay Savar Mattas 2e6de4baf4 fix(settings): label our own settings group "Outpost"
The general category (Appearance, Offline & Sync, Advanced, About)
sat unlabeled at the top of the sidebar, ambiguous next to the named
Outline categories below it. Now has its own "Outpost" header.
2026-08-15 16:06:05 +01:00
Puranjay Savar Mattas 67bef76716 feat(settings): grouped sidebar matching Outline's own settings categories
SettingsSection now carries a SettingsCategory (general/account/
workspace/integrationsInstallation) and SettingsSidebarList renders it
as a grouped, headered list — general (ours: Appearance, Offline &
Sync, Advanced, About) unlabeled at the top, then Account (Profile,
Preferences, Notifications, Passkeys, API & Access), Workspace
(Details, Authentication, Security, AI, Members, Groups, Templates,
Emojis, Applications, Shared, Links, Webhooks, Import, Export), and
Integrations & Installation (Installation).

Nav skeleton only, per instruction — everything except the ones
already built (renamed the old flat "Account" page to "Profile", its
natural new home; content unchanged) shows a "Coming Soon" placeholder
until each section's real content is specified and built one at a
time.
2026-08-15 16:04:03 +01:00
205 changed files with 31821 additions and 293 deletions
+6
View File
@@ -69,3 +69,9 @@ Issues and PRs use two label prefixes:
## Questions
Open an issue — this is a single-repo project, there's no separate issue tracker to route to.
---
## License
Outpost is licensed under the [Business Source License 1.1](./LICENSE), not a traditional OSI open-source license — see the [README's License section](./README.md#license) for what that means in practice. By submitting a PR, you agree your contribution is licensed under the same terms as the rest of the project.
+118 -17
View File
@@ -1,22 +1,123 @@
MIT License
Business Source License 1.1
Copyright (c) 2026 Puranjay Savar Mattas
Parameters
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
Licensor: Puranjay Savar Mattas
Licensed Work: Outpost
The Licensed Work is (c) 2026 Puranjay Savar Mattas
Additional Use Grant: You may use, copy, modify, and self-host the Licensed
Work, and build and distribute your own modified or
unmodified copies of it, for personal, educational, or
internal non-commercial purposes.
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
You may not, without a separate commercial agreement
with the Licensor:
(a) offer the Licensed Work, or any modified or
unmodified version of it, as a hosted or
distributed product or service to third parties,
whether for a fee or free of charge; or
(b) distribute the Licensed Work, or any modified or
unmodified version of it, under a name, logo, or
branding that states or implies it is an official,
endorsed, or affiliated product of Outline, Inc.
or of any other third party, or that removes or
obscures its origin as an independent,
unaffiliated project.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
Change Date: 2036-08-20
Change License: Apache License, Version 2.0
For information about alternative licensing arrangements for the Licensed
Work, contact the Licensor.
Notice
The Business Source License (this document, or the "License") is not an
Open Source license. However, the Licensed Work will eventually be made
available under an Open Source License, as stated in this License.
License text copyright (c) 2017 MariaDB Corporation Ab, All Rights Reserved.
"Business Source License" is a trademark of MariaDB Corporation Ab.
-----------------------------------------------------------------------------
Business Source License 1.1
Terms
The Licensor hereby grants you the right to copy, modify, create derivative
works, redistribute, and make non-production use of the Licensed Work. The
Licensor may make an Additional Use Grant, above, permitting limited
production use.
Effective on the Change Date, or the fourth anniversary of the first
publicly available distribution of a specific version of the Licensed Work
under this License, whichever comes first, the Licensor hereby grants you
rights under the terms of the Change License, and the rights granted in the
paragraph above terminate.
If your use of the Licensed Work does not comply with the requirements
currently in effect as described in this License, you must purchase a
commercial license from the Licensor, its affiliated entities, or authorized
resellers, or you must refrain from using the Licensed Work.
All copies of the original and modified Licensed Work, and derivative works
of the Licensed Work, are subject to this License. This License applies
separately for each version of the Licensed Work and the Change Date may
vary for each version of the Licensed Work released by Licensor.
You must conspicuously display this License on each original or modified
copy of the Licensed Work. If you receive the Licensed Work in original or
modified form from a third party, the terms and conditions set forth in
this License apply to your use of that work.
Any use of the Licensed Work in violation of this License will automatically
terminate your rights under this License for the current and all other
versions of the Licensed Work.
This License does not grant you any right in any trademark or logo of
Licensor or its affiliates (provided that you may use a trademark or logo
of Licensor as expressly required by this License).
TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON
AN "AS IS" BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS,
EXPRESS OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND
TITLE.
MariaDB hereby grants you permission to use this License's text to license
your works, and to refer to it using the trademark "Business Source
License", as long as you comply with the Covenants of Licensor below.
Covenants of Licensor
In consideration of the right to use this License's text and the "Business
Source License" name and trademark, Licensor covenants to MariaDB, and to
all other recipients of the licensed work to be provided by Licensor:
1. To specify as the Change License the GPL Version 2.0 or any later
version, or a license that is compatible with GPL Version 2.0 or a later
version, where "compatible" means that software provided under the
Change License can be included in a program with software provided
under GPL Version 2.0 or a later version. Licensor may specify
additional Change Licenses without limitation.
2. To either: (a) specify an additional grant of rights to use that does
not impose any additional restriction on the right granted in this
License, as the Additional Use Grant; or (b) insert the text "None" to
specify a Change License.
3. To specify a Change Date.
4. Not to modify this License in any other way.
-----------------------------------------------------------------------------
Trademark Notice
"Outpost" and any associated logo are trademarks of the Licensor. This
License does not grant permission to use them to identify or market any
product, service, or distribution of the Licensed Work — modified or
unmodified — that is not published by the Licensor, including forks. See
the Additional Use Grant above.
@@ -92,6 +92,12 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
}
}
public func listDrafts(_ request: ListDraftsRequest) async throws -> [OutlineDocument] {
try await cachedFetch(key: "documentsDrafts:\(request.offset):\(request.limit)") {
try await self.live.listDrafts(request)
}
}
public func listCollections(offset: Int, limit: Int) async throws -> [OutlineCollection] {
try await cachedFetch(key: "collections:\(offset):\(limit)") {
try await self.live.listCollections(offset: offset, limit: limit)
@@ -303,6 +309,34 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
try await live.listViews(request)
}
public func listComments(_ request: ListCommentsRequest) async throws -> [OutlineComment] {
try await live.listComments(request)
}
public func commentInfo(id: String) async throws -> OutlineComment {
try await live.commentInfo(id: id)
}
public func createComment(_ request: CreateCommentRequest) async throws -> OutlineComment {
try await live.createComment(request)
}
public func resolveComment(id: String) async throws -> OutlineComment {
try await live.resolveComment(id: id)
}
public func unresolveComment(id: String) async throws -> OutlineComment {
try await live.unresolveComment(id: id)
}
public func addReaction(commentId: String, emoji: String) async throws {
try await live.addReaction(commentId: commentId, emoji: emoji)
}
public func removeReaction(commentId: String, emoji: String) async throws {
try await live.removeReaction(commentId: commentId, emoji: emoji)
}
public func addDocumentUser(_ request: AddDocumentUserRequest) async throws -> OutlineMembership {
try await live.addDocumentUser(request)
}
@@ -335,6 +369,66 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
try await live.currentUser()
}
public func createAttachment(_ request: CreateAttachmentRequest) async throws -> CreateAttachmentResult {
try await live.createAttachment(request)
}
public func uploadAttachmentFile(_ result: CreateAttachmentResult, fileData: Data) async throws {
try await live.uploadAttachmentFile(result, fileData: fileData)
}
public func fetchAuthenticatedFile(path: String) async throws -> Data {
try await live.fetchAuthenticatedFile(path: path)
}
public func deleteAttachment(id: String) async throws {
try await live.deleteAttachment(id: id)
}
public func updateUserAvatar(_ request: UpdateUserAvatarRequest) async throws -> OutlineUser {
try await live.updateUserAvatar(request)
}
public func updateUserName(_ request: UpdateUserNameRequest) async throws -> OutlineUser {
try await live.updateUserName(request)
}
public func updateUserLanguage(_ request: UpdateUserLanguageRequest) async throws -> OutlineUser {
try await live.updateUserLanguage(request)
}
public func updateUserPreferences(_ request: UpdateUserPreferencesRequest) async throws -> OutlineUser {
try await live.updateUserPreferences(request)
}
public func deleteAccount() async throws {
try await live.deleteAccount()
}
public func subscribeToNotifications(eventType: NotificationEventType?) async throws -> OutlineUser {
try await live.subscribeToNotifications(eventType: eventType)
}
public func unsubscribeFromNotifications(eventType: NotificationEventType?) async throws -> OutlineUser {
try await live.unsubscribeFromNotifications(eventType: eventType)
}
public func listApiKeys(_ request: ListApiKeysRequest) async throws -> [OutlineAPIKey] {
try await live.listApiKeys(request)
}
public func createApiKey(_ request: CreateApiKeyRequest) async throws -> OutlineAPIKey {
try await live.createApiKey(request)
}
public func deleteApiKey(id: String) async throws {
try await live.deleteApiKey(id: id)
}
public func installationInfo() async throws -> OutlineInstallationInfo {
try await live.installationInfo()
}
// MARK: - Sync management (Settings surface)
public func pendingOperations() async -> [PendingOperationSummary] {
@@ -377,6 +471,15 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
/// existing cached-read methods already do the caching as a side effect,
/// this just has to drive the walk and separately cache each document by
/// id (`listDocuments`'s cache key is the list, not the individual doc).
///
/// Recurses into every document's children, not just collections' own
/// root-level documents a document with sub-documents used to leave
/// them uncached entirely (only reachable if something else happened to
/// open them individually first). Also caches each collection under its
/// own `"collection:<id>"` key (previously only cached as part of the
/// paginated list blob), so both are individually enumerable afterward
/// via `OfflineCacheStore.loadAll(keyPrefix:)` see
/// `cachedDocumentsIndex()`/`cachedCollectionsIndex()`.
public func performFullSync() async -> FullSyncSummary {
var documentsCount = 0
var errors: [String] = []
@@ -401,28 +504,64 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
}
for collection in collections {
var offset = 0
let limit = 100
while true {
let documents: [OutlineDocument]
do {
documents = try await listDocuments(collectionId: collection.id, parentDocumentId: nil, offset: offset, limit: limit)
} catch {
errors.append("\(collection.name): \(errorDescription(error))")
break
}
for document in documents {
await cacheDocument(document)
}
documentsCount += documents.count
guard documents.count == limit else { break }
offset += limit
}
await cacheCollection(collection)
let result = await cacheDocumentTree(collectionId: collection.id, parentDocumentId: nil, collectionName: collection.name)
documentsCount += result.count
errors.append(contentsOf: result.errors)
}
return FullSyncSummary(collectionsCount: collections.count, documentsCount: documentsCount, errors: errors, finishedAt: Date())
}
/// Caches every document under `parentDocumentId` (`nil` = a
/// collection's root level) and recurses into each one's own children,
/// depth-first, until a branch runs out of sub-documents. Returns a
/// plain `(count, errors)` pair rather than mutating shared state across
/// `await` boundaries, since this calls itself recursively.
private func cacheDocumentTree(
collectionId: String,
parentDocumentId: String?,
collectionName: String
) async -> (count: Int, errors: [String]) {
var count = 0
var errors: [String] = []
var offset = 0
let limit = 100
while true {
let documents: [OutlineDocument]
do {
documents = try await listDocuments(collectionId: collectionId, parentDocumentId: parentDocumentId, offset: offset, limit: limit)
} catch {
errors.append("\(collectionName): \(errorDescription(error))")
break
}
for document in documents {
await cacheDocument(document)
count += 1
let childResult = await cacheDocumentTree(collectionId: collectionId, parentDocumentId: document.id, collectionName: collectionName)
count += childResult.count
errors.append(contentsOf: childResult.errors)
}
guard documents.count == limit else { break }
offset += limit
}
return (count, errors)
}
/// Every individually cached document from the last Full Local Sync
/// empty if a sync has never run (or found nothing). Purely a local
/// SwiftData read, no network involved.
public func cachedDocumentsIndex() async -> [OutlineDocument] {
let payloads = await cache.loadAll(keyPrefix: "document:")
return payloads.compactMap { try? decoder.decode(OutlineDocument.self, from: $0) }
}
/// Every individually cached collection from the last Full Local Sync.
public func cachedCollectionsIndex() async -> [OutlineCollection] {
let payloads = await cache.loadAll(keyPrefix: "collection:")
return payloads.compactMap { try? decoder.decode(OutlineCollection.self, from: $0) }
}
// MARK: - Helpers
private func cachedFetch<T: Codable>(key: String, fetch: () async throws -> T) async throws -> T {
@@ -31,6 +31,15 @@ public actor OfflineCacheStore {
return try? modelContext.fetch(descriptor).first?.payload
}
/// Everything cached under a key prefix e.g. every individually
/// cached document (`"document:<id>"`) or collection
/// (`"collection:<id>"`) after a Full Local Sync, for building a local
/// search index without a per-item exact-key lookup.
public func loadAll(keyPrefix: String) -> [Data] {
let descriptor = FetchDescriptor<CachedPayload>(predicate: #Predicate { $0.key.starts(with: keyPrefix) })
return ((try? modelContext.fetch(descriptor)) ?? []).map(\.payload)
}
/// Used to drop a temporary `pending-*` document's cache entry once a
/// queued create syncs and the server hands back the real id the
/// placeholder key would otherwise sit around as a dead orphan forever.
@@ -13,6 +13,9 @@ public protocol OutlineAPIClient: Sendable {
func documentsList(_ request: DocumentsListRequest) async throws -> [OutlineDocument]
/// Documents the current user has recently viewed. Backed by `documents.viewed`.
func listViewedDocuments(offset: Int, limit: Int) async throws -> [OutlineDocument]
/// Draft (unpublished) documents belonging to the current user. Backed
/// by `documents.drafts`.
func listDrafts(_ request: ListDraftsRequest) async throws -> [OutlineDocument]
/// Full-text search with snippets/ranking. Backed by `documents.search`.
func searchDocuments(_ request: DocumentSearchRequest) async throws -> [OutlineDocumentSearchResult]
/// Title-only search faster, no snippets. Backed by `documents.search_titles`.
@@ -48,6 +51,20 @@ public protocol OutlineAPIClient: Sendable {
/// Historical view records, not live presence. Backed by `views.list`.
func listViews(_ request: ListViewsRequest) async throws -> [OutlineView]
/// See `OutlineComment`. `resolve`/`unresolve`/`add_reaction`/
/// `remove_reaction` are confirmed real against Outline's own server
/// source but aren't in the vendored spec.
func listComments(_ request: ListCommentsRequest) async throws -> [OutlineComment]
func commentInfo(id: String) async throws -> OutlineComment
func createComment(_ request: CreateCommentRequest) async throws -> OutlineComment
func resolveComment(id: String) async throws -> OutlineComment
func unresolveComment(id: String) async throws -> OutlineComment
/// Reaction endpoints return `{success: true}`, not the updated
/// comment callers refetch via `commentInfo` for the fresh
/// `reactions` array.
func addReaction(commentId: String, emoji: String) async throws
func removeReaction(commentId: String, emoji: String) async throws
/// See `OutlineMembership`/`OutlineDocumentMember` `add` is confirmed
/// from Outline's official docs, the rest are best-effort.
func addDocumentUser(_ request: AddDocumentUserRequest) async throws -> OutlineMembership
@@ -69,4 +86,48 @@ public protocol OutlineAPIClient: Sendable {
func deleteStar(id: String) async throws
func currentUser() async throws -> OutlineUser
/// Two-step presigned upload: this requests where/how to upload,
/// `uploadAttachmentFile` performs the actual multipart POST to that
/// target. See `OutlineAttachment`/`CreateAttachmentResult`.
func createAttachment(_ request: CreateAttachmentRequest) async throws -> CreateAttachmentResult
func uploadAttachmentFile(_ result: CreateAttachmentResult, fileData: Data) async throws
/// Fetches raw bytes from an authenticated, server-relative GET path
/// e.g. `/api/attachments.redirect?id=<uuid>`, the reference Outline's
/// own editor embeds for uploaded images in document Markdown. Unlike
/// `post`'s RPC endpoints, this is a GET that 302-redirects to the
/// actual (often presigned, cross-host) storage URL; `path` is resolved
/// against the client's base URL, same as `uploadAttachmentFile`'s
/// `uploadUrl` handling.
func fetchAuthenticatedFile(path: String) async throws -> Data
/// Best-effort matches the shape every other simple `id`-only delete
/// in this API uses (`pins.delete`, `stars.delete`, ), not confirmed
/// against a live server specifically for attachments yet.
func deleteAttachment(id: String) async throws
/// `users.update`, avatar only. See `UpdateUserAvatarRequest`.
func updateUserAvatar(_ request: UpdateUserAvatarRequest) async throws -> OutlineUser
/// `users.update`, name only. See `UpdateUserNameRequest`.
func updateUserName(_ request: UpdateUserNameRequest) async throws -> OutlineUser
/// `users.update`, language only. See `UpdateUserLanguageRequest`.
func updateUserLanguage(_ request: UpdateUserLanguageRequest) async throws -> OutlineUser
/// `users.update`, preferences only. See `UpdateUserPreferencesRequest`.
func updateUserPreferences(_ request: UpdateUserPreferencesRequest) async throws -> OutlineUser
/// Backed by `users.delete` self-service account deletion, no
/// confirmation code param confirmed live, matches every other simple
/// no-body delete in this API.
func deleteAccount() async throws
/// `nil` targets every notification event. See `NotificationEventType`.
func subscribeToNotifications(eventType: NotificationEventType?) async throws -> OutlineUser
func unsubscribeFromNotifications(eventType: NotificationEventType?) async throws -> OutlineUser
/// Settings API & Access.
func listApiKeys(_ request: ListApiKeysRequest) async throws -> [OutlineAPIKey]
/// The returned `OutlineAPIKey.value` is the only time the full
/// plaintext key is ever available the caller is responsible for
/// displaying it once and then discarding it.
func createApiKey(_ request: CreateApiKeyRequest) async throws -> OutlineAPIKey
func deleteApiKey(id: String) async throws
/// Settings Installation. Self-hosted server version info.
func installationInfo() async throws -> OutlineInstallationInfo
}
@@ -59,6 +59,10 @@ public actor LiveOutlineAPIClient: OutlineAPIClient {
try await post("documents.viewed", body: PaginationParams(offset: offset, limit: limit))
}
public func listDrafts(_ request: ListDraftsRequest) async throws -> [OutlineDocument] {
try await post("documents.drafts", body: request)
}
public func searchDocuments(_ request: DocumentSearchRequest) async throws -> [OutlineDocumentSearchResult] {
try await post("documents.search", body: request)
}
@@ -175,6 +179,34 @@ public actor LiveOutlineAPIClient: OutlineAPIClient {
try await post("views.list", body: request)
}
public func listComments(_ request: ListCommentsRequest) async throws -> [OutlineComment] {
try await post("comments.list", body: request)
}
public func commentInfo(id: String) async throws -> OutlineComment {
try await post("comments.info", body: StarIDParams(id: id))
}
public func createComment(_ request: CreateCommentRequest) async throws -> OutlineComment {
try await post("comments.create", body: request)
}
public func resolveComment(id: String) async throws -> OutlineComment {
try await post("comments.resolve", body: StarIDParams(id: id))
}
public func unresolveComment(id: String) async throws -> OutlineComment {
try await post("comments.unresolve", body: StarIDParams(id: id))
}
public func addReaction(commentId: String, emoji: String) async throws {
try await postForSuccess("comments.add_reaction", body: CommentReactionParams(id: commentId, emoji: emoji))
}
public func removeReaction(commentId: String, emoji: String) async throws {
try await postForSuccess("comments.remove_reaction", body: CommentReactionParams(id: commentId, emoji: emoji))
}
public func addDocumentUser(_ request: AddDocumentUserRequest) async throws -> OutlineMembership {
try await post("documents.add_user", body: request)
}
@@ -229,6 +261,142 @@ public actor LiveOutlineAPIClient: OutlineAPIClient {
try await post("users.info", body: EmptyParams())
}
public func createAttachment(_ request: CreateAttachmentRequest) async throws -> CreateAttachmentResult {
try await post("attachments.create", body: request)
}
/// No Bearer/CSRF header attached here, deliberately the presigned
/// `form.sig` field (short-lived, scoped to this exact upload key) is
/// what authorizes this specific request, the same way an S3 presigned
/// POST works. Best-effort against a live server: if it turns out the
/// self-hosted local-storage backend also wants a bearer token here,
/// that's a one-line addition once confirmed, not a design change.
public func uploadAttachmentFile(_ result: CreateAttachmentResult, fileData: Data) async throws {
let (body, contentType) = MultipartFormDataBuilder.build(
fields: result.form,
fileFieldName: "file",
fileName: result.attachment.name ?? "avatar",
fileData: fileData,
fileContentType: result.form["Content-Type"] ?? "application/octet-stream"
)
// `uploadUrl` is a host-relative path (e.g. "/api/files.create") on
// a self-hosted local-storage backend, not an absolute S3 URL
// resolving against `baseURL` handles both: `URL(string:relativeTo:)`
// replaces the whole path for a leading-slash relative string per
// RFC 3986, same resolution already used for user/team avatar URLs.
guard let uploadURL = URL(string: result.uploadUrl, relativeTo: baseURL)?.absoluteURL else {
throw OutlineAPIError.transport(URLError(.badURL))
}
var request = URLRequest(url: uploadURL)
request.httpMethod = "POST"
request.setValue(contentType, forHTTPHeaderField: "Content-Type")
request.httpBody = body
let data: Data
let response: HTTPURLResponse
do {
(data, response) = try await httpClient.send(request)
} catch let error as OutlineAPIError {
throw error
} catch {
throw OutlineAPIError.transport(error)
}
guard (200...299).contains(response.statusCode) else {
let errorEnvelope = try? decoder.decode(OutlineErrorEnvelope.self, from: data)
throw OutlineAPIError.server(status: response.statusCode, message: errorEnvelope?.message ?? errorEnvelope?.error)
}
}
public func fetchAuthenticatedFile(path: String) async throws -> Data {
guard let token = try? tokenStore.token() else {
throw OutlineAPIError.tokenUnavailable
}
// Same host-relative-or-absolute resolution as uploadAttachmentFile's uploadUrl.
guard let url = URL(string: path, relativeTo: baseURL)?.absoluteURL else {
throw OutlineAPIError.transport(URLError(.badURL))
}
var request = URLRequest(url: url)
// URLSession's default redirect handling drops Authorization on a
// cross-host redirect (same as a browser dropping cookies on one)
// exactly what's wanted here: authorize the request to Outline's own
// `attachments.redirect`, not the presigned storage URL it 302s to.
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
let data: Data
let response: HTTPURLResponse
do {
(data, response) = try await httpClient.send(request)
} catch let error as OutlineAPIError {
throw error
} catch {
throw OutlineAPIError.transport(error)
}
guard (200...299).contains(response.statusCode) else {
switch response.statusCode {
case 401:
throw OutlineAPIError.unauthorized
case 404:
throw OutlineAPIError.notFound
default:
throw OutlineAPIError.server(status: response.statusCode, message: nil)
}
}
return data
}
public func deleteAttachment(id: String) async throws {
try await postForSuccess("attachments.delete", body: StarIDParams(id: id))
}
public func updateUserAvatar(_ request: UpdateUserAvatarRequest) async throws -> OutlineUser {
try await post("users.update", body: request)
}
public func updateUserName(_ request: UpdateUserNameRequest) async throws -> OutlineUser {
try await post("users.update", body: request)
}
public func updateUserLanguage(_ request: UpdateUserLanguageRequest) async throws -> OutlineUser {
try await post("users.update", body: request)
}
public func updateUserPreferences(_ request: UpdateUserPreferencesRequest) async throws -> OutlineUser {
try await post("users.update", body: request)
}
public func deleteAccount() async throws {
try await postForSuccess("users.delete", body: EmptyParams())
}
public func subscribeToNotifications(eventType: NotificationEventType?) async throws -> OutlineUser {
try await post("users.notificationsSubscribe", body: NotificationSubscriptionRequest(eventType: eventType?.rawValue))
}
public func unsubscribeFromNotifications(eventType: NotificationEventType?) async throws -> OutlineUser {
try await post("users.notificationsUnsubscribe", body: NotificationSubscriptionRequest(eventType: eventType?.rawValue))
}
public func listApiKeys(_ request: ListApiKeysRequest) async throws -> [OutlineAPIKey] {
try await post("apiKeys.list", body: request)
}
public func createApiKey(_ request: CreateApiKeyRequest) async throws -> OutlineAPIKey {
try await post("apiKeys.create", body: request)
}
public func deleteApiKey(id: String) async throws {
try await postForSuccess("apiKeys.delete", body: StarIDParams(id: id))
}
public func installationInfo() async throws -> OutlineInstallationInfo {
try await post("installation.info", body: EmptyParams())
}
private func post<Body: Encodable, Response: Decodable>(_ path: String, body: Body) async throws -> Response {
guard let token = try? tokenStore.token() else {
throw OutlineAPIError.tokenUnavailable
@@ -388,6 +556,11 @@ private struct StarIDParams: Encodable {
let id: String
}
private struct CommentReactionParams: Encodable {
let id: String
let emoji: String
}
private struct MoveDocumentResponse: Decodable {
let documents: [OutlineDocument]?
let collections: [OutlineCollection]?
@@ -0,0 +1,69 @@
import Foundation
/// Minimal recursive JSON tree for fields the API returns as genuinely
/// arbitrary/untyped JSON (`type: object` with no fixed schema in the
/// OpenAPI spec) currently just `Comment.data`, a ProseMirror document.
/// Decode-only: nothing in this codebase writes comment bodies back yet.
public enum JSONValue: Decodable, Sendable {
case string(String)
case number(Double)
case bool(Bool)
case object([String: JSONValue])
case array([JSONValue])
case null
public init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
if container.decodeNil() {
self = .null
} else if let value = try? container.decode(Bool.self) {
self = .bool(value)
} else if let value = try? container.decode(Double.self) {
self = .number(value)
} else if let value = try? container.decode(String.self) {
self = .string(value)
} else if let value = try? container.decode([String: JSONValue].self) {
self = .object(value)
} else if let value = try? container.decode([JSONValue].self) {
self = .array(value)
} else {
throw DecodingError.dataCorruptedError(in: container, debugDescription: "Unsupported JSON value")
}
}
}
extension JSONValue {
/// Best-effort plain-text extraction from a ProseMirror-shaped document
/// walks `content` arrays, concatenates `text` node strings, and adds a
/// trailing newline after known block-level node types so paragraphs
/// don't run together. Good enough for a read-only comment list; not a
/// real ProseMirror renderer (no marks, no lists/tables structure).
public func plainText() -> String {
switch self {
case .object(let fields):
if case .string(let text)? = fields["text"] {
return text
}
let childText: String
if case .array(let items)? = fields["content"] {
childText = items.map { $0.plainText() }.joined()
} else {
childText = ""
}
if case .string(let type)? = fields["type"], Self.blockTypes.contains(type) {
return childText + "\n"
}
return childText
case .array(let items):
return items.map { $0.plainText() }.joined()
case .string(let value):
return value
case .number, .bool, .null:
return ""
}
}
private static let blockTypes: Set<String> = [
"paragraph", "heading", "listItem", "blockquote", "codeBlock", "list_item", "code_block"
]
}
@@ -0,0 +1,28 @@
import Foundation
/// The subset of Outline's notification event types this app exposes a
/// toggle for. Wire values confirmed live (captured `users.update`
/// responses showing the full `notificationSettings` dictionary after
/// toggling each one in Outline's own web app). The server tracks more
/// event types than this app has UI for (`revisions.create`,
/// `emails.onboarding`, `emails.features` were also seen live) those are
/// left alone since `users.notificationsSubscribe`/`Unsubscribe` are
/// per-event, not a whole-object replace like `preferences`, so there's no
/// clobbering risk in only covering a subset.
public enum NotificationEventType: String, CaseIterable, Sendable {
case documentPublish = "documents.publish"
case documentUpdate = "documents.update"
case commentCreate = "comments.create"
case commentMentioned = "comments.mentioned"
case documentMentioned = "documents.mentioned"
case commentGroupMentioned = "comments.group_mentioned"
case documentGroupMentioned = "documents.group_mentioned"
case commentResolve = "comments.resolve"
case reactionCreate = "reactions.create"
case collectionCreate = "collections.create"
case emailsInviteAccepted = "emails.invite_accepted"
case documentAddUser = "documents.add_user"
case collectionAddUser = "collections.add_user"
case emailsExportCompleted = "emails.export_completed"
case accessRequestCreate = "access_requests.create"
}
@@ -0,0 +1,40 @@
import Foundation
/// A personal API key (Settings API & Access). Only the last 4 characters
/// of the actual token are ever returned by the server there's no way to
/// see a full key again after creation, matching every other API-key UI
/// convention.
public struct OutlineAPIKey: Codable, Identifiable, Hashable, Sendable {
public let id: String
public let name: String
public let last4: String?
public let scope: [String]?
public let createdAt: Date
public let expiresAt: Date?
public let lastActiveAt: Date?
/// The full plaintext key present *only* in `apiKeys.create`'s
/// response, confirmed live: `apiKeys.list` never includes it, matching
/// "shown once at creation" being enforced server-side, not just a
/// client-side UI convention this app has to uphold on its own.
public let value: String?
public init(
id: String,
name: String,
last4: String? = nil,
scope: [String]? = nil,
createdAt: Date,
expiresAt: Date? = nil,
lastActiveAt: Date? = nil,
value: String? = nil
) {
self.id = id
self.name = name
self.last4 = last4
self.scope = scope
self.createdAt = createdAt
self.expiresAt = expiresAt
self.lastActiveAt = lastActiveAt
self.value = value
}
}
@@ -0,0 +1,26 @@
import Foundation
/// One uploaded file created via a two-step presigned upload
/// (`attachments.create` for the upload target, then a direct POST to
/// `uploadUrl`/`form`). Confirmed live against a self-hosted instance's
/// local-storage backend; `size` comes back as a string there, not a
/// number same "don't trust the vendored spec's implied types" lesson as
/// everywhere else this codebase has hit it.
public struct OutlineAttachment: Decodable, Identifiable, Sendable {
public let id: String
public let documentId: String?
public let contentType: String?
public let name: String?
public let url: String?
public let size: String?
}
/// `attachments.create`'s response everything needed to perform the
/// actual upload. `form` fields (Content-Type, key, acl, sig, `_csrf`, )
/// must all be included as their own multipart parts, in the same request
/// as the file itself, POSTed to `uploadUrl`.
public struct CreateAttachmentResult: Decodable, Sendable {
public let attachment: OutlineAttachment
public let uploadUrl: String
public let form: [String: String]
}
@@ -0,0 +1,46 @@
import Foundation
/// A comment (or reply, via `parentCommentId`) on a document. Backed by
/// `comments.*` `create`/`info`/`update`/`delete`/`list` are in the
/// vendored OpenAPI spec; `resolve`/`unresolve`/`add_reaction`/
/// `remove_reaction` are not (confirmed against Outline's own server
/// source instead same "spec mirror is incomplete" lesson as
/// `OutlinePin`). `data` is the comment body as a ProseMirror document,
/// not plain text see `JSONValue.plainText()`.
public struct OutlineComment: Decodable, Identifiable, Sendable {
public let id: String
public let data: JSONValue
public let documentId: String
public let parentCommentId: String?
public let createdAt: Date
public let createdBy: OutlineUser?
public let updatedAt: Date?
public let resolvedAt: Date?
public let resolvedBy: OutlineUser?
public let reactions: [ReactionSummary]
/// The document text this comment is anchored to only populated when
/// the request set `includeAnchorText: true`; `nil` for a document-level
/// (non-anchored) comment either way. No prefix/suffix comes back on
/// read (only accepted as create-time disambiguation input), so
/// re-finding this text in the rendered document is inherently
/// best-effort first occurrence wins, same as Outline's own create
/// behavior when nothing else disambiguates.
public let anchorText: String?
public var isResolved: Bool { resolvedAt != nil }
public var bodyText: String {
data.plainText().trimmingCharacters(in: .whitespacesAndNewlines)
}
}
/// One emoji's worth of reactions on a comment grouped by emoji server-side
/// (`ReactionSummary` in Outline's own source), not one object per reaction.
public struct ReactionSummary: Codable, Sendable, Equatable {
public let emoji: String
public let userIds: [String]
public init(emoji: String, userIds: [String]) {
self.emoji = emoji
self.userIds = userIds
}
}
@@ -0,0 +1,11 @@
import Foundation
/// `installation.info` the self-hosted server's own version, confirmed
/// live. `policies` (a separate top-level array alongside `data` in the raw
/// response) isn't modeled here not used by this app's Installation
/// settings page.
public struct OutlineInstallationInfo: Decodable, Sendable {
public let version: String
public let latestVersion: String
public let versionsBehind: Int
}
@@ -6,18 +6,30 @@ public struct OutlineUser: Codable, Identifiable, Hashable, Sendable {
public let email: String?
public let avatarUrl: String?
public let role: String?
public let language: String?
public let preferences: OutlineUserPreferences?
/// Keyed by `NotificationEventType`'s raw values, plus event types this
/// app has no UI for kept as a flexible dictionary rather than a
/// fixed struct for that reason. `true` means subscribed.
public let notificationSettings: [String: Bool]?
public init(
id: String,
name: String,
email: String? = nil,
avatarUrl: String? = nil,
role: String? = nil
role: String? = nil,
language: String? = nil,
preferences: OutlineUserPreferences? = nil,
notificationSettings: [String: Bool]? = nil
) {
self.id = id
self.name = name
self.email = email
self.avatarUrl = avatarUrl
self.role = role
self.language = language
self.preferences = preferences
self.notificationSettings = notificationSettings
}
}
@@ -0,0 +1,109 @@
import Foundation
/// `User.preferences` a free-form JSON blob on Outline's own `User` row,
/// not a fixed-shape API resource. Wire key names below are confirmed
/// against a live server's own web app traffic (captured toggling every
/// Preferences setting one at a time), not guessed see the `CodingKeys`
/// mapping for the two that don't match this struct's own property names.
///
/// The server also validates `preferences` against a known key allowlist
/// and rejects the whole `users.update` call (not just the bad field) if
/// any key it doesn't recognize is present confirmed live via a
/// `"notificationBadge: Invalid Input"` error when an earlier, wrong value
/// was sent. That's also why `fullWidthDocuments` is kept here even though
/// this app has no UI for it yet: this app always sends the *whole*
/// preferences object back on every save (see
/// `UpdateUserPreferencesRequest`), so silently dropping an unknown key
/// during decode would permanently clear it the next time any other
/// preference here gets saved.
public struct OutlineUserPreferences: Codable, Hashable, Sendable {
public var rememberLastPath: Bool?
/// App-facing polarity: `true` means "separate editing mode is on"
/// the opposite of the wire's own `seamlessEdit` (seamless editing and
/// separate editing modes are each other's negation), inverted in
/// `init(from:)`/`encode(to:)` so nothing outside this file has to
/// remember that.
public var separateEditing: Bool?
public var useCursorPointer: Bool?
public var codeBlockLineNumbers: Bool?
public var showCommentMarker: Bool?
public var smartText: Bool?
/// One of `NotificationBadgeStyle`'s raw values.
public var notificationBadge: String?
/// No UI in this app yet preserved purely so saving any other
/// preference here doesn't clobber it. See the type doc comment.
public var fullWidthDocuments: Bool?
public init(
rememberLastPath: Bool? = nil,
separateEditing: Bool? = nil,
useCursorPointer: Bool? = nil,
codeBlockLineNumbers: Bool? = nil,
showCommentMarker: Bool? = nil,
smartText: Bool? = nil,
notificationBadge: String? = nil,
fullWidthDocuments: Bool? = nil
) {
self.rememberLastPath = rememberLastPath
self.separateEditing = separateEditing
self.useCursorPointer = useCursorPointer
self.codeBlockLineNumbers = codeBlockLineNumbers
self.showCommentMarker = showCommentMarker
self.smartText = smartText
self.notificationBadge = notificationBadge
self.fullWidthDocuments = fullWidthDocuments
}
private enum CodingKeys: String, CodingKey {
case rememberLastPath
case seamlessEdit
case useCursorPointer
case codeBlockLineNumbers
case commentsInGutter
case enableSmartText
case notificationBadge
case fullWidthDocuments
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
rememberLastPath = try container.decodeIfPresent(Bool.self, forKey: .rememberLastPath)
separateEditing = try container.decodeIfPresent(Bool.self, forKey: .seamlessEdit).map { !$0 }
useCursorPointer = try container.decodeIfPresent(Bool.self, forKey: .useCursorPointer)
codeBlockLineNumbers = try container.decodeIfPresent(Bool.self, forKey: .codeBlockLineNumbers)
showCommentMarker = try container.decodeIfPresent(Bool.self, forKey: .commentsInGutter)
smartText = try container.decodeIfPresent(Bool.self, forKey: .enableSmartText)
notificationBadge = try container.decodeIfPresent(String.self, forKey: .notificationBadge)
fullWidthDocuments = try container.decodeIfPresent(Bool.self, forKey: .fullWidthDocuments)
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encodeIfPresent(rememberLastPath, forKey: .rememberLastPath)
try container.encodeIfPresent(separateEditing.map { !$0 }, forKey: .seamlessEdit)
try container.encodeIfPresent(useCursorPointer, forKey: .useCursorPointer)
try container.encodeIfPresent(codeBlockLineNumbers, forKey: .codeBlockLineNumbers)
try container.encodeIfPresent(showCommentMarker, forKey: .commentsInGutter)
try container.encodeIfPresent(smartText, forKey: .enableSmartText)
try container.encodeIfPresent(notificationBadge, forKey: .notificationBadge)
try container.encodeIfPresent(fullWidthDocuments, forKey: .fullWidthDocuments)
}
}
/// App-icon unread indicator style. Wire values confirmed live (captured
/// setting all three from Outline's own web app).
public enum NotificationBadgeStyle: String, CaseIterable, Identifiable, Sendable {
case none = "disabled"
case unreadIndicator = "indicator"
case unreadCount = "count"
public var id: String { rawValue }
public var label: String {
switch self {
case .none: return "None"
case .unreadIndicator: return "Unread Indicator"
case .unreadCount: return "Unread Count"
}
}
}
@@ -0,0 +1,40 @@
import Foundation
/// Builds the multipart/form-data body for Outline's presigned-upload
/// targets (`attachments.create`'s `uploadUrl`/`form`) an S3-style
/// presigned POST: every `form` field has to ride along as its own part in
/// the same request as the file, not as query params or headers.
enum MultipartFormDataBuilder {
static func build(
fields: [String: String],
fileFieldName: String,
fileName: String,
fileData: Data,
fileContentType: String,
boundary: String = "Boundary-\(UUID().uuidString)"
) -> (body: Data, contentType: String) {
var body = Data()
for (key, value) in fields {
body.append("--\(boundary)\r\n".utf8Data)
body.append("Content-Disposition: form-data; name=\"\(key)\"\r\n\r\n".utf8Data)
body.append(value.utf8Data)
body.append("\r\n".utf8Data)
}
body.append("--\(boundary)\r\n".utf8Data)
body.append(
"Content-Disposition: form-data; name=\"\(fileFieldName)\"; filename=\"\(fileName)\"\r\n".utf8Data
)
body.append("Content-Type: \(fileContentType)\r\n\r\n".utf8Data)
body.append(fileData)
body.append("\r\n".utf8Data)
body.append("--\(boundary)--\r\n".utf8Data)
return (body, "multipart/form-data; boundary=\(boundary)")
}
}
private extension String {
var utf8Data: Data { Data(utf8) }
}
@@ -0,0 +1,21 @@
import Foundation
/// `apiKeys.create`. `expiresAt: nil` (the key omitted entirely, not sent as
/// literal `null`) confirmed live to mean no expiration Swift's
/// synthesized `Encodable` already omits `nil` optionals via
/// `encodeIfPresent`, so no custom `encode(to:)` is needed here the way
/// `UpdateUserAvatarRequest` needed one for the opposite case.
public struct CreateApiKeyRequest: Encodable, Sendable {
public let name: String
public let expiresAt: Date?
/// `nil`/omitted grants full access confirmed live (every key created
/// without a scope came back with unrestricted access). A specific
/// scope is a list of allowed API paths, e.g. `["/api/documents.info"]`.
public let scope: [String]?
public init(name: String, expiresAt: Date? = nil, scope: [String]? = nil) {
self.name = name
self.expiresAt = expiresAt
self.scope = scope
}
}
@@ -0,0 +1,19 @@
import Foundation
/// Requests an upload target for a new file not the upload itself, see
/// `OutlineAPIClient.uploadAttachmentFile`. `documentId: nil` is what a
/// user-avatar upload sends (confirmed live); a real value scopes the
/// attachment to a document instead (e.g. an inline image embed).
public struct CreateAttachmentRequest: Encodable, Sendable {
public let name: String
public let contentType: String
public let size: Int
public let documentId: String?
public init(name: String, contentType: String, size: Int, documentId: String? = nil) {
self.name = name
self.contentType = contentType
self.size = size
self.documentId = documentId
}
}
@@ -0,0 +1,26 @@
import Foundation
/// `parentCommentId: nil` creates a document-level (top-level) comment;
/// non-nil creates a reply (Outline supports one level of nesting a
/// reply's own `parentCommentId` should always be a top-level comment's
/// id, never another reply's). `anchorText` creates an inline (anchored)
/// comment instead of a document-level one the first occurrence of that
/// exact substring in the document's plain text is used, same as Outline's
/// own web editor; `anchorPrefix`/`anchorSuffix` aren't sent (this app has
/// no UI for choosing between multiple identical occurrences). `text` is
/// the documented markdown convenience field for `data` (a ProseMirror
/// document) simplest path for plain-text comment bodies, no need to
/// construct ProseMirror JSON by hand.
public struct CreateCommentRequest: Encodable, Sendable {
public let documentId: String
public let parentCommentId: String?
public let text: String
public let anchorText: String?
public init(documentId: String, parentCommentId: String? = nil, text: String, anchorText: String? = nil) {
self.documentId = documentId
self.parentCommentId = parentCommentId
self.text = text
self.anchorText = anchorText
}
}
@@ -3,14 +3,16 @@ import Foundation
public struct CreateDocumentRequest: Codable, Sendable {
public let title: String
public let text: String
public let collectionId: String
/// `nil` (with `parentDocumentId` also `nil`) creates a draft Outline
/// requires one of the two to publish at all, regardless of `publish`.
public let collectionId: String?
public let parentDocumentId: String?
public let publish: Bool
public init(
title: String,
text: String,
collectionId: String,
collectionId: String? = nil,
parentDocumentId: String? = nil,
publish: Bool = true
) {
@@ -0,0 +1,14 @@
import Foundation
/// `apiKeys.list` matches every other paginated `.list` endpoint's flat
/// offset/limit convention (e.g. `ListSharesRequest`), not the nested
/// `pagination` object that only appears in list *responses*.
public struct ListApiKeysRequest: Encodable, Sendable {
public let offset: Int
public let limit: Int
public init(offset: Int = 0, limit: Int = 25) {
self.offset = offset
self.limit = limit
}
}
@@ -0,0 +1,18 @@
import Foundation
public struct ListCommentsRequest: Encodable, Sendable {
public let documentId: String
public let offset: Int
public let limit: Int
/// Include each anchored comment's `anchorText` (the document text it's
/// attached to) in the response. Off by default it's extra payload
/// only needed when actually positioning inline markers.
public let includeAnchorText: Bool
public init(documentId: String, offset: Int = 0, limit: Int = 100, includeAnchorText: Bool = false) {
self.documentId = documentId
self.offset = offset
self.limit = limit
self.includeAnchorText = includeAnchorText
}
}
@@ -0,0 +1,11 @@
import Foundation
public struct ListDraftsRequest: Encodable, Sendable {
public let offset: Int
public let limit: Int
public init(offset: Int = 0, limit: Int = 25) {
self.offset = offset
self.limit = limit
}
}
@@ -0,0 +1,13 @@
import Foundation
/// `users.notificationsSubscribe` / `users.notificationsUnsubscribe`. A
/// `nil` `eventType` targets every notification event at once confirmed
/// live via Outline's own "All notifications" master toggle, which sends
/// no `eventType` at all.
public struct NotificationSubscriptionRequest: Encodable, Sendable {
public let eventType: String?
public init(eventType: String? = nil) {
self.eventType = eventType
}
}
@@ -7,6 +7,13 @@ public struct UpdateDocumentRequest: Codable, Sendable {
public let append: Bool?
public let fullWidth: Bool?
public let insightsEnabled: Bool?
/// Moves the document to this collection. Combined with `publish: true`,
/// this is how a draft (no `collectionId`, or one it just hasn't left
/// yet) gets published into a specific collection in one call.
public let collectionId: String?
/// Publishes a draft, making it visible to other workspace members.
/// Documented as a no-op if the document is already published.
public let publish: Bool?
public init(
id: String,
@@ -14,7 +21,9 @@ public struct UpdateDocumentRequest: Codable, Sendable {
text: String? = nil,
append: Bool? = nil,
fullWidth: Bool? = nil,
insightsEnabled: Bool? = nil
insightsEnabled: Bool? = nil,
collectionId: String? = nil,
publish: Bool? = nil
) {
self.id = id
self.title = title
@@ -22,5 +31,7 @@ public struct UpdateDocumentRequest: Codable, Sendable {
self.append = append
self.fullWidth = fullWidth
self.insightsEnabled = insightsEnabled
self.collectionId = collectionId
self.publish = publish
}
}
@@ -0,0 +1,28 @@
import Foundation
/// `users.update`, scoped to just the avatar. Custom `encode(to:)` because
/// Swift's synthesized `Encodable` uses `encodeIfPresent` for `Optional`
/// properties, which *omits* the key entirely when the value is `nil`
/// removing the avatar needs a literal `"avatarUrl": null` in the request
/// body, not the key missing. `id` is required: confirmed live (the
/// captured request body's length only matches `{"id": "...", "avatarUrl":
/// ...}`, not a shorter shape without it).
public struct UpdateUserAvatarRequest: Encodable, Sendable {
public let id: String
public let avatarUrl: String?
public init(id: String, avatarUrl: String?) {
self.id = id
self.avatarUrl = avatarUrl
}
private enum CodingKeys: String, CodingKey {
case id, avatarUrl
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(id, forKey: .id)
try container.encode(avatarUrl, forKey: .avatarUrl)
}
}
@@ -0,0 +1,12 @@
import Foundation
/// `users.update`, language only.
public struct UpdateUserLanguageRequest: Encodable, Sendable {
public let id: String
public let language: String
public init(id: String, language: String) {
self.id = id
self.language = language
}
}
@@ -0,0 +1,12 @@
import Foundation
/// `users.update`, name only.
public struct UpdateUserNameRequest: Encodable, Sendable {
public let id: String
public let name: String
public init(id: String, name: String) {
self.id = id
self.name = name
}
}
@@ -0,0 +1,14 @@
import Foundation
/// `users.update`, preferences only. Sends the *whole* preferences object
/// back (not a single changed key) avoids needing to know whether the
/// server deep-merges a partial `preferences` body or replaces it outright.
public struct UpdateUserPreferencesRequest: Encodable, Sendable {
public let id: String
public let preferences: OutlineUserPreferences
public init(id: String, preferences: OutlineUserPreferences) {
self.id = id
self.preferences = preferences
}
}
@@ -29,6 +29,7 @@ private final class StubOutlineAPIClient: OutlineAPIClient, @unchecked Sendable
func documentsList(_ request: DocumentsListRequest) async throws -> [OutlineDocument] { throw NotStubbed() }
func listViewedDocuments(offset: Int, limit: Int) async throws -> [OutlineDocument] { throw NotStubbed() }
func listDrafts(_ request: ListDraftsRequest) async throws -> [OutlineDocument] { throw NotStubbed() }
func searchDocuments(_ request: DocumentSearchRequest) async throws -> [OutlineDocumentSearchResult] { throw NotStubbed() }
func searchDocumentTitles(_ request: DocumentSearchTitlesRequest) async throws -> [OutlineDocument] { throw NotStubbed() }
func createDocument(_ request: CreateDocumentRequest) async throws -> OutlineDocument {
@@ -71,6 +72,13 @@ private final class StubOutlineAPIClient: OutlineAPIClient, @unchecked Sendable
func listSubscriptions(_ request: ListSubscriptionsRequest) async throws -> [OutlineSubscription] { throw NotStubbed() }
func deleteSubscription(id: String) async throws { throw NotStubbed() }
func listViews(_ request: ListViewsRequest) async throws -> [OutlineView] { throw NotStubbed() }
func listComments(_ request: ListCommentsRequest) async throws -> [OutlineComment] { throw NotStubbed() }
func commentInfo(id: String) async throws -> OutlineComment { throw NotStubbed() }
func createComment(_ request: CreateCommentRequest) async throws -> OutlineComment { throw NotStubbed() }
func resolveComment(id: String) async throws -> OutlineComment { throw NotStubbed() }
func unresolveComment(id: String) async throws -> OutlineComment { throw NotStubbed() }
func addReaction(commentId: String, emoji: String) async throws { throw NotStubbed() }
func removeReaction(commentId: String, emoji: String) async throws { throw NotStubbed() }
func addDocumentUser(_ request: AddDocumentUserRequest) async throws -> OutlineMembership { throw NotStubbed() }
func removeDocumentUser(_ request: RemoveDocumentUserRequest) async throws { throw NotStubbed() }
func documentUsers(_ request: ListDocumentUsersRequest) async throws -> [OutlineDocumentMember] { throw NotStubbed() }
@@ -89,6 +97,21 @@ private final class StubOutlineAPIClient: OutlineAPIClient, @unchecked Sendable
func listStars(_ request: ListStarsRequest) async throws -> [OutlineStar] { throw NotStubbed() }
func deleteStar(id: String) async throws { throw NotStubbed() }
func currentUser() async throws -> OutlineUser { throw NotStubbed() }
func createAttachment(_ request: CreateAttachmentRequest) async throws -> CreateAttachmentResult { throw NotStubbed() }
func uploadAttachmentFile(_ result: CreateAttachmentResult, fileData: Data) async throws { throw NotStubbed() }
func fetchAuthenticatedFile(path: String) async throws -> Data { throw NotStubbed() }
func deleteAttachment(id: String) async throws { throw NotStubbed() }
func updateUserAvatar(_ request: UpdateUserAvatarRequest) async throws -> OutlineUser { throw NotStubbed() }
func updateUserName(_ request: UpdateUserNameRequest) async throws -> OutlineUser { throw NotStubbed() }
func updateUserLanguage(_ request: UpdateUserLanguageRequest) async throws -> OutlineUser { throw NotStubbed() }
func updateUserPreferences(_ request: UpdateUserPreferencesRequest) async throws -> OutlineUser { throw NotStubbed() }
func deleteAccount() async throws { throw NotStubbed() }
func subscribeToNotifications(eventType: NotificationEventType?) async throws -> OutlineUser { throw NotStubbed() }
func unsubscribeFromNotifications(eventType: NotificationEventType?) async throws -> OutlineUser { throw NotStubbed() }
func listApiKeys(_ request: ListApiKeysRequest) async throws -> [OutlineAPIKey] { throw NotStubbed() }
func createApiKey(_ request: CreateApiKeyRequest) async throws -> OutlineAPIKey { throw NotStubbed() }
func deleteApiKey(id: String) async throws { throw NotStubbed() }
func installationInfo() async throws -> OutlineInstallationInfo { throw NotStubbed() }
}
private struct StubTransportError: Error {}
@@ -407,8 +430,13 @@ final class CachingOutlineAPIClientTests: XCTestCase {
func testPerformFullSyncCachesEachDocumentIndividually() async throws {
let stub = StubOutlineAPIClient()
stub.listCollectionsHandler = { offset, _ in offset == 0 ? [self.makeCollection(id: "col-1")] : [] }
stub.listDocumentsHandler = { _, _, offset, _ in
offset == 0 ? [self.makeDocument(id: "doc-1"), self.makeDocument(id: "doc-2")] : []
// Must return empty for any non-nil parentDocumentId (no children)
// performFullSync now recurses into every document's own children,
// so a stub that ignores parentDocumentId and always returns the
// same root documents regardless would recurse into itself forever.
stub.listDocumentsHandler = { _, parentDocumentId, offset, _ in
guard parentDocumentId == nil else { return [] }
return offset == 0 ? [self.makeDocument(id: "doc-1"), self.makeDocument(id: "doc-2")] : []
}
let cache = try makeCache()
let sut = CachingOutlineAPIClient(live: stub, cache: cache)
@@ -423,6 +451,34 @@ final class CachingOutlineAPIClientTests: XCTestCase {
XCTAssertEqual(cachedDoc.id, "doc-2")
}
func testPerformFullSyncRecursesIntoNestedDocuments() async throws {
let stub = StubOutlineAPIClient()
stub.listCollectionsHandler = { offset, _ in offset == 0 ? [self.makeCollection(id: "col-1")] : [] }
// doc-1 (root) -> doc-2 (child of doc-1) -> doc-3 (grandchild)
// regression test for the real gap this fixed: only root-level
// documents were ever cached before, so a document's own
// sub-documents were never reachable offline at all unless
// something else happened to open them individually first.
stub.listDocumentsHandler = { _, parentDocumentId, offset, _ in
guard offset == 0 else { return [] }
switch parentDocumentId {
case nil: return [self.makeDocument(id: "doc-1")]
case "doc-1": return [self.makeDocument(id: "doc-2")]
case "doc-2": return [self.makeDocument(id: "doc-3")]
default: return []
}
}
let cache = try makeCache()
let sut = CachingOutlineAPIClient(live: stub, cache: cache)
let summary = await sut.performFullSync()
XCTAssertEqual(summary.documentsCount, 3)
stub.documentInfoHandler = { _ in throw StubTransportError() }
let cachedGrandchild = try await sut.documentInfo(id: "doc-3")
XCTAssertEqual(cachedGrandchild.id, "doc-3")
}
// MARK: - Offline document creation
func testCreateDocumentQueuesAndReturnsUsableDocumentWhenOffline() async throws {
@@ -846,6 +846,137 @@ final class LiveOutlineAPIClientTests: XCTestCase {
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/views.list")
}
func testListCommentsDecodesCommentsAndExtractsPlainTextFromProseMirrorData() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{
"data": [
{
"id": "comment-1",
"documentId": "doc-1",
"parentCommentId": null,
"createdAt": "2026-01-01T00:00:00.000Z",
"createdBy": { "id": "user-1", "name": "Jane Doe" },
"updatedAt": "2026-01-01T00:00:00.000Z",
"resolvedAt": null,
"resolvedBy": null,
"reactions": [
{ "emoji": "\u{1F44D}", "userIds": ["user-2", "user-3"] }
],
"data": {
"type": "doc",
"content": [
{
"type": "paragraph",
"content": [
{ "type": "text", "text": "Sounds great" }
]
}
]
}
}
]
}
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
let comments = try await client.listComments(ListCommentsRequest(documentId: "doc-1"))
XCTAssertEqual(comments.first?.id, "comment-1")
XCTAssertEqual(comments.first?.bodyText, "Sounds great")
XCTAssertFalse(comments.first?.isResolved ?? true)
XCTAssertEqual(comments.first?.reactions.first?.emoji, "\u{1F44D}")
XCTAssertEqual(comments.first?.reactions.first?.userIds, ["user-2", "user-3"])
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/comments.list")
}
func testResolveCommentDecodesResolvedComment() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{
"data": {
"id": "comment-1",
"documentId": "doc-1",
"parentCommentId": null,
"createdAt": "2026-01-01T00:00:00.000Z",
"createdBy": { "id": "user-1", "name": "Jane Doe" },
"updatedAt": "2026-01-01T00:00:00.000Z",
"resolvedAt": "2026-01-02T00:00:00.000Z",
"resolvedBy": { "id": "user-2", "name": "John Roe" },
"reactions": [],
"data": { "type": "doc", "content": [] }
}
}
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
let comment = try await client.resolveComment(id: "comment-1")
XCTAssertTrue(comment.isResolved)
XCTAssertEqual(comment.resolvedBy?.name, "John Roe")
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/comments.resolve")
}
func testCreateCommentSendsParentCommentIdForAReply() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{
"data": {
"id": "comment-2",
"documentId": "doc-1",
"parentCommentId": "comment-1",
"createdAt": "2026-01-01T00:00:00.000Z",
"createdBy": { "id": "user-1", "name": "Jane Doe" },
"updatedAt": "2026-01-01T00:00:00.000Z",
"resolvedAt": null,
"resolvedBy": null,
"reactions": [],
"data": { "type": "doc", "content": [] }
}
}
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
let reply = try await client.createComment(
CreateCommentRequest(documentId: "doc-1", parentCommentId: "comment-1", text: "Agreed")
)
XCTAssertEqual(reply.parentCommentId, "comment-1")
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/comments.create")
}
func testAddReactionPostsIdAndEmoji() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{ "success": true }
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
try await client.addReaction(commentId: "comment-1", emoji: "\u{1F44D}")
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/comments.add_reaction")
}
func testCreatePinDecodesPin() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
@@ -993,6 +1124,490 @@ final class LiveOutlineAPIClientTests: XCTestCase {
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/users.list")
}
func testUpdateUserAvatarSendsExplicitNullWhenRemoving() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{
"data": {
"id": "user-1",
"name": "Jane Doe",
"avatarUrl": null
}
}
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
let user = try await client.updateUserAvatar(UpdateUserAvatarRequest(id: "user-1", avatarUrl: nil))
XCTAssertNil(user.avatarUrl)
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/users.update")
let sentBody = try XCTUnwrap(httpClient.lastRequest?.httpBody)
let sentJSON = try XCTUnwrap(String(data: sentBody, encoding: .utf8))
// The whole point of UpdateUserAvatarRequest's custom encode(to:)
// Swift's synthesized Encodable would have omitted the key entirely
// for a nil Optional instead of sending a literal null.
XCTAssertTrue(sentJSON.contains("\"avatarUrl\":null"), "expected an explicit null, got: \(sentJSON)")
}
func testUpdateUserAvatarSendsTheNewURLWhenSet() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{
"data": {
"id": "user-1",
"name": "Jane Doe",
"avatarUrl": "/api/attachments.redirect?id=abc"
}
}
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
let user = try await client.updateUserAvatar(
UpdateUserAvatarRequest(id: "user-1", avatarUrl: "/api/attachments.redirect?id=abc")
)
XCTAssertEqual(user.avatarUrl, "/api/attachments.redirect?id=abc")
let sentBody = try XCTUnwrap(httpClient.lastRequest?.httpBody)
let sentJSON = try XCTUnwrap(String(data: sentBody, encoding: .utf8))
XCTAssertTrue(sentJSON.contains("\"id\":\"user-1\""))
}
func testUpdateUserNameSendsRequestAndDecodesResult() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{
"data": {
"id": "user-1",
"name": "New Name"
}
}
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
let user = try await client.updateUserName(UpdateUserNameRequest(id: "user-1", name: "New Name"))
XCTAssertEqual(user.name, "New Name")
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/users.update")
}
func testUpdateUserLanguageSendsRequestAndDecodesResult() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{
"data": {
"id": "user-1",
"name": "Jane Doe",
"language": "fr_FR"
}
}
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
let user = try await client.updateUserLanguage(UpdateUserLanguageRequest(id: "user-1", language: "fr_FR"))
XCTAssertEqual(user.language, "fr_FR")
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/users.update")
}
func testUpdateUserPreferencesSendsWholeObjectAndDecodesResult() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{
"data": {
"id": "user-1",
"name": "Jane Doe",
"preferences": { "rememberLastPath": true, "useCursorPointer": true }
}
}
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
let preferences = OutlineUserPreferences(rememberLastPath: true, useCursorPointer: true)
let user = try await client.updateUserPreferences(UpdateUserPreferencesRequest(id: "user-1", preferences: preferences))
XCTAssertEqual(user.preferences?.rememberLastPath, true)
XCTAssertEqual(user.preferences?.useCursorPointer, true)
let sentBody = try JSONSerialization.jsonObject(with: httpClient.lastRequest!.httpBody!) as? [String: Any]
let sentPreferences = sentBody?["preferences"] as? [String: Any]
XCTAssertEqual(sentPreferences?["rememberLastPath"] as? Bool, true)
XCTAssertEqual(sentPreferences?["useCursorPointer"] as? Bool, true)
}
/// Locks in the wire mapping confirmed against a live server's own web
/// app traffic: `seamlessEdit`/`commentsInGutter`/`enableSmartText` are
/// the real keys (not `separateEditing`/`showCommentMarker`/`smartText`
/// this struct exposes), and `seamlessEdit` is the *negation* of this
/// app's `separateEditing`.
func testOutlineUserPreferencesDecodesRealWireKeys() throws {
let json = """
{
"seamlessEdit": false,
"commentsInGutter": true,
"enableSmartText": true,
"rememberLastPath": true,
"useCursorPointer": true,
"codeBlockLineNumbers": false,
"notificationBadge": "indicator",
"fullWidthDocuments": true
}
""".data(using: .utf8)!
let preferences = try JSONDecoder().decode(OutlineUserPreferences.self, from: json)
XCTAssertEqual(preferences.separateEditing, true, "seamlessEdit: false means separate editing is ON")
XCTAssertEqual(preferences.showCommentMarker, true)
XCTAssertEqual(preferences.smartText, true)
XCTAssertEqual(preferences.rememberLastPath, true)
XCTAssertEqual(preferences.useCursorPointer, true)
XCTAssertEqual(preferences.codeBlockLineNumbers, false)
XCTAssertEqual(preferences.notificationBadge, "indicator")
XCTAssertEqual(preferences.fullWidthDocuments, true)
}
func testOutlineUserPreferencesEncodesRealWireKeysAndInvertsSeparateEditing() throws {
var preferences = OutlineUserPreferences()
preferences.separateEditing = true
preferences.showCommentMarker = false
preferences.smartText = true
preferences.fullWidthDocuments = true
let data = try JSONEncoder().encode(preferences)
let object = try JSONSerialization.jsonObject(with: data) as? [String: Any]
XCTAssertEqual(object?["seamlessEdit"] as? Bool, false, "separateEditing: true must encode as seamlessEdit: false")
XCTAssertEqual(object?["commentsInGutter"] as? Bool, false)
XCTAssertEqual(object?["enableSmartText"] as? Bool, true)
XCTAssertEqual(object?["fullWidthDocuments"] as? Bool, true)
XCTAssertNil(object?["separateEditing"], "must not leak this app's own field name onto the wire")
XCTAssertNil(object?["showCommentMarker"])
XCTAssertNil(object?["smartText"])
}
func testSubscribeToNotificationsSendsEventTypeAndDecodesResult() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{
"data": {
"id": "user-1",
"name": "Jane Doe",
"notificationSettings": { "documents.publish": true }
}
}
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
let user = try await client.subscribeToNotifications(eventType: .documentPublish)
XCTAssertEqual(user.notificationSettings?["documents.publish"], true)
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/users.notificationsSubscribe")
let sentBody = try JSONSerialization.jsonObject(with: httpClient.lastRequest!.httpBody!) as? [String: Any]
XCTAssertEqual(sentBody?["eventType"] as? String, "documents.publish")
}
func testUnsubscribeFromNotificationsWithNilEventTypeTargetsAll() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{
"data": {
"id": "user-1",
"name": "Jane Doe",
"notificationSettings": { "documents.publish": false }
}
}
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
_ = try await client.unsubscribeFromNotifications(eventType: nil)
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/users.notificationsUnsubscribe")
let sentBody = try JSONSerialization.jsonObject(with: httpClient.lastRequest!.httpBody!) as? [String: Any]
XCTAssertNil(sentBody?["eventType"])
}
func testListApiKeysDecodesResult() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{
"pagination": { "limit": 25, "offset": 0 },
"data": [
{
"id": "c3eec545-6d38-4065-90dc-b6c96a551445",
"name": "Outpost",
"scope": null,
"last4": "dl1h",
"createdAt": "2026-08-12T18:44:32.467Z",
"updatedAt": "2026-08-12T18:44:32.467Z",
"expiresAt": null,
"lastActiveAt": "2026-08-18T01:01:36.553Z"
}
]
}
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
let keys = try await client.listApiKeys(ListApiKeysRequest())
XCTAssertEqual(keys.count, 1)
XCTAssertEqual(keys.first?.name, "Outpost")
XCTAssertEqual(keys.first?.last4, "dl1h")
XCTAssertNil(keys.first?.expiresAt)
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/apiKeys.list")
}
func testInstallationInfoDecodesResult() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{
"data": { "version": "1.9.2", "latestVersion": "1.9.2", "versionsBehind": 0 },
"policies": []
}
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
let info = try await client.installationInfo()
XCTAssertEqual(info.version, "1.9.2")
XCTAssertEqual(info.versionsBehind, 0)
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/installation.info")
}
func testCreateApiKeyOmitsExpiresAtWhenNilAndDecodesValue() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{
"data": {
"id": "5655fb3e-2a8c-4f2c-9cae-bbd910ef8683",
"name": "test",
"scope": null,
"value": "ol_api_Xqx9Jti7xUunb5b8bXh29vHmBngqjJl3Id0DGv",
"last4": "0DGv",
"createdAt": "2026-08-18T15:39:29.872Z",
"expiresAt": null,
"lastActiveAt": null
}
}
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
let key = try await client.createApiKey(CreateApiKeyRequest(name: "test"))
XCTAssertEqual(key.value, "ol_api_Xqx9Jti7xUunb5b8bXh29vHmBngqjJl3Id0DGv")
XCTAssertNil(key.expiresAt)
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/apiKeys.create")
let sentBody = try JSONSerialization.jsonObject(with: httpClient.lastRequest!.httpBody!) as? [String: Any]
XCTAssertEqual(sentBody?["name"] as? String, "test")
XCTAssertNil(sentBody?["expiresAt"], "omitting expiresAt (not sending null) is what produces a non-expiring key")
XCTAssertNil(sentBody?["scope"])
}
func testCreateApiKeySendsExpiresAtWhenProvided() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{
"data": {
"id": "04e204fb-51a4-4b54-aed1-2056dfd576d7",
"name": "Test",
"scope": null,
"value": "ol_api_aeYcOts7I2sJXw3zaztjydRM3W3W89TBAtcLts",
"last4": "cLts",
"createdAt": "2026-08-18T15:38:22.928Z",
"expiresAt": "2026-11-16T23:59:59.999Z",
"lastActiveAt": null
}
}
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
let expiresAt = Date(timeIntervalSince1970: 1_795_000_000)
_ = try await client.createApiKey(CreateApiKeyRequest(name: "Test", expiresAt: expiresAt))
let sentBody = try JSONSerialization.jsonObject(with: httpClient.lastRequest!.httpBody!) as? [String: Any]
XCTAssertNotNil(sentBody?["expiresAt"])
}
func testDeleteApiKeySendsRequest() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{ "success": true }
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
try await client.deleteApiKey(id: "5655fb3e-2a8c-4f2c-9cae-bbd910ef8683")
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/apiKeys.delete")
let sentBody = try JSONSerialization.jsonObject(with: httpClient.lastRequest!.httpBody!) as? [String: Any]
XCTAssertEqual(sentBody?["id"] as? String, "5655fb3e-2a8c-4f2c-9cae-bbd910ef8683")
}
func testDeleteAccountSendsRequest() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{ "success": true }
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
try await client.deleteAccount()
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/users.delete")
}
func testDeleteAttachmentSendsRequest() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{ "success": true }
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
try await client.deleteAttachment(id: "attach-1")
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/attachments.delete")
}
func testCreateAttachmentDecodesUploadTargetAndFormFields() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{
"data": {
"attachment": {
"id": "attach-1",
"documentId": null,
"contentType": "image/jpeg",
"name": "avatar.jpg",
"url": "/api/attachments.redirect?id=attach-1",
"size": "59304"
},
"uploadUrl": "/api/files.create",
"form": {
"Content-Type": "image/jpeg",
"key": "uploads/user-1/attach-1/avatar.jpg",
"acl": "public-read"
}
}
}
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
let result = try await client.createAttachment(
CreateAttachmentRequest(name: "avatar.jpg", contentType: "image/jpeg", size: 59304)
)
XCTAssertEqual(result.attachment.id, "attach-1")
XCTAssertEqual(result.attachment.size, "59304")
XCTAssertEqual(result.uploadUrl, "/api/files.create")
XCTAssertEqual(result.form["acl"], "public-read")
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/attachments.create")
}
func testUploadAttachmentFilePostsToTheResolvedRelativeURL() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{ "success": true }
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
let uploadTarget = CreateAttachmentResult(
attachment: OutlineAttachment(
id: "attach-1",
documentId: nil,
contentType: "image/jpeg",
name: "avatar.jpg",
url: "/api/attachments.redirect?id=attach-1",
size: "3"
),
uploadUrl: "/api/files.create",
form: ["Content-Type": "image/jpeg", "key": "uploads/attach-1"]
)
try await client.uploadAttachmentFile(uploadTarget, fileData: Data("abc".utf8))
// Resolved against baseURL's host, not appended onto "/api/<baseURL-relative-path>".
XCTAssertEqual(httpClient.lastRequest?.url?.absoluteString, "https://outline.example.com/api/files.create")
XCTAssertNil(httpClient.lastRequest?.value(forHTTPHeaderField: "Authorization"))
let contentType = httpClient.lastRequest?.value(forHTTPHeaderField: "Content-Type")
XCTAssertTrue(contentType?.hasPrefix("multipart/form-data; boundary=") ?? false)
}
func testMissingTokenThrowsTokenUnavailable() async throws {
let httpClient = MockHTTPClient()
let client = LiveOutlineAPIClient(
@@ -0,0 +1,48 @@
import XCTest
@testable import OutlineKit
final class MultipartFormDataBuilderTests: XCTestCase {
func testBuildIncludesEveryFieldAndTheFile() throws {
let fileData = Data("fake-jpeg-bytes".utf8)
let (body, contentType) = MultipartFormDataBuilder.build(
fields: ["key": "uploads/abc", "acl": "public-read", "Content-Type": "image/jpeg"],
fileFieldName: "file",
fileName: "avatar.jpg",
fileData: fileData,
fileContentType: "image/jpeg",
boundary: "TestBoundary"
)
let bodyString = String(decoding: body, as: UTF8.self)
XCTAssertEqual(contentType, "multipart/form-data; boundary=TestBoundary")
XCTAssertTrue(bodyString.contains("Content-Disposition: form-data; name=\"key\""))
XCTAssertTrue(bodyString.contains("uploads/abc"))
XCTAssertTrue(bodyString.contains("Content-Disposition: form-data; name=\"acl\""))
XCTAssertTrue(bodyString.contains("public-read"))
XCTAssertTrue(bodyString.contains("Content-Disposition: form-data; name=\"file\"; filename=\"avatar.jpg\""))
XCTAssertTrue(bodyString.contains("fake-jpeg-bytes"))
XCTAssertTrue(bodyString.hasPrefix("--TestBoundary\r\n"))
XCTAssertTrue(bodyString.hasSuffix("--TestBoundary--\r\n"))
}
func testFileFieldComesAfterAllFormFields() throws {
let (body, _) = MultipartFormDataBuilder.build(
fields: ["a": "1", "b": "2"],
fileFieldName: "file",
fileName: "x.jpg",
fileData: Data("bytes".utf8),
fileContentType: "image/jpeg",
boundary: "B"
)
let bodyString = String(decoding: body, as: UTF8.self)
let fieldsRange = bodyString.range(of: "name=\"a\"")
let fileRange = bodyString.range(of: "name=\"file\"")
XCTAssertNotNil(fieldsRange)
XCTAssertNotNil(fileRange)
if let fieldsRange, let fileRange {
XCTAssertTrue(fieldsRange.lowerBound < fileRange.lowerBound)
}
}
}
+68 -52
View File
@@ -7,10 +7,11 @@
objects = {
/* Begin PBXBuildFile section */
FA7596B230366A1D0000167E /* MarkdownEngine in Frameworks */ = {isa = PBXBuildFile; productRef = FA7596B130366A1D0000167E /* MarkdownEngine */; };
FA7596B430366A1D0000167E /* MarkdownEngineCodeBlocks in Frameworks */ = {isa = PBXBuildFile; productRef = FA7596B330366A1D0000167E /* MarkdownEngineCodeBlocks */; };
FA7596B630366A1D0000167E /* MarkdownEngineLatex in Frameworks */ = {isa = PBXBuildFile; productRef = FA7596B530366A1D0000167E /* MarkdownEngineLatex */; };
FADBE087303734FE001E69F0 /* ImagePlayground.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FADBE086303734FE001E69F0 /* ImagePlayground.framework */; settings = {ATTRIBUTES = (Weak, ); }; };
FAF99C44302CF1BD00C9949F /* OutlineKit in Frameworks */ = {isa = PBXBuildFile; productRef = FAF99C43302CF1BD00C9949F /* OutlineKit */; };
FAF99CAF302D120500C9949F /* MarkdownEngine in Frameworks */ = {isa = PBXBuildFile; productRef = FAF99CAE302D120500C9949F /* MarkdownEngine */; };
FAF99CB1302D120500C9949F /* MarkdownEngineCodeBlocks in Frameworks */ = {isa = PBXBuildFile; productRef = FAF99CB0302D120500C9949F /* MarkdownEngineCodeBlocks */; };
FAF99CB3302D120500C9949F /* MarkdownEngineLatex in Frameworks */ = {isa = PBXBuildFile; productRef = FAF99CB2302D120500C9949F /* MarkdownEngineLatex */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -31,6 +32,7 @@
/* End PBXContainerItemProxy section */
/* Begin PBXFileReference section */
FADBE086303734FE001E69F0 /* ImagePlayground.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ImagePlayground.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS27.0.sdk/System/Library/Frameworks/ImagePlayground.framework; sourceTree = DEVELOPER_DIR; };
FAF99C18302CE96100C9949F /* Outpost.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Outpost.app; sourceTree = BUILT_PRODUCTS_DIR; };
FAF99C27302CE96200C9949F /* OutpostTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = OutpostTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
FAF99C31302CE96200C9949F /* OutpostUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = OutpostUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
@@ -59,9 +61,10 @@
isa = PBXFrameworksBuildPhase;
files = (
FAF99C44302CF1BD00C9949F /* OutlineKit in Frameworks */,
FAF99CB1302D120500C9949F /* MarkdownEngineCodeBlocks in Frameworks */,
FAF99CB3302D120500C9949F /* MarkdownEngineLatex in Frameworks */,
FAF99CAF302D120500C9949F /* MarkdownEngine in Frameworks */,
FA7596B430366A1D0000167E /* MarkdownEngineCodeBlocks in Frameworks */,
FA7596B630366A1D0000167E /* MarkdownEngineLatex in Frameworks */,
FADBE087303734FE001E69F0 /* ImagePlayground.framework in Frameworks */,
FA7596B230366A1D0000167E /* MarkdownEngine in Frameworks */,
);
};
FAF99C24302CE96200C9949F /* Frameworks */ = {
@@ -77,12 +80,21 @@
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
FADBE085303734FE001E69F0 /* Frameworks */ = {
isa = PBXGroup;
children = (
FADBE086303734FE001E69F0 /* ImagePlayground.framework */,
);
name = Frameworks;
sourceTree = "<group>";
};
FAF99C0F302CE96100C9949F = {
isa = PBXGroup;
children = (
FAF99C1A302CE96100C9949F /* Outpost */,
FAF99C2A302CE96200C9949F /* OutpostTests */,
FAF99C34302CE96200C9949F /* OutpostUITests */,
FADBE085303734FE001E69F0 /* Frameworks */,
FAF99C19302CE96100C9949F /* Products */,
);
sourceTree = "<group>";
@@ -116,9 +128,9 @@
name = Outpost;
packageProductDependencies = (
FAF99C43302CF1BD00C9949F /* OutlineKit */,
FAF99CAE302D120500C9949F /* MarkdownEngine */,
FAF99CB0302D120500C9949F /* MarkdownEngineCodeBlocks */,
FAF99CB2302D120500C9949F /* MarkdownEngineLatex */,
FA7596B130366A1D0000167E /* MarkdownEngine */,
FA7596B330366A1D0000167E /* MarkdownEngineCodeBlocks */,
FA7596B530366A1D0000167E /* MarkdownEngineLatex */,
);
productName = Outpost;
productReference = FAF99C18302CE96100C9949F /* Outpost.app */;
@@ -200,7 +212,7 @@
minimizedProjectReferenceProxies = 1;
packageReferences = (
FAF99C42302CF1BD00C9949F /* XCLocalSwiftPackageReference "OutlineKit" */,
FAF99CAD302D120500C9949F /* XCRemoteSwiftPackageReference "swift-markdown-engine" */,
FA7596B030366A1D0000167E /* XCLocalSwiftPackageReference "Vendor/swift-markdown-engine" */,
);
preferredProjectObjectVersion = 77;
productRefGroup = FAF99C19302CE96100C9949F /* Products */;
@@ -299,7 +311,7 @@
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = B95H74ZDY6;
DEVELOPMENT_TEAM = CW6GQT9SK5;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
@@ -361,7 +373,7 @@
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = B95H74ZDY6;
DEVELOPMENT_TEAM = CW6GQT9SK5;
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
@@ -385,15 +397,20 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
CODE_SIGN_ENTITLEMENTS = Outpost/Outpost.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = B95H74ZDY6;
DEVELOPMENT_TEAM = CW6GQT9SK5;
ENABLE_APP_SANDBOX = YES;
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES;
ENABLE_USER_SELECTED_FILES = readonly;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = Outpost;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity";
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
@@ -408,19 +425,21 @@
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
MACOSX_DEPLOYMENT_TARGET = 27.0;
MARKETING_VERSION = 0.0.3;
PRODUCT_BUNDLE_IDENTIFIER = com.psmattas.Outpost;
MARKETING_VERSION = 0.0.4;
PRODUCT_BUNDLE_IDENTIFIER = com.psmattas.OutpostApp;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
REGISTER_APP_GROUPS = YES;
SDKROOT = auto;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator";
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx";
SUPPORTS_MACCATALYST = NO;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2,7";
TARGETED_DEVICE_FAMILY = "1,2";
XROS_DEPLOYMENT_TARGET = 27.0;
};
name = Debug;
@@ -430,15 +449,20 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
CODE_SIGN_ENTITLEMENTS = Outpost/Outpost.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = B95H74ZDY6;
DEVELOPMENT_TEAM = CW6GQT9SK5;
ENABLE_APP_SANDBOX = YES;
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES;
ENABLE_USER_SELECTED_FILES = readonly;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = Outpost;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity";
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
@@ -453,19 +477,21 @@
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
MACOSX_DEPLOYMENT_TARGET = 27.0;
MARKETING_VERSION = 0.0.3;
PRODUCT_BUNDLE_IDENTIFIER = com.psmattas.Outpost;
MARKETING_VERSION = 0.0.4;
PRODUCT_BUNDLE_IDENTIFIER = com.psmattas.OutpostApp;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
REGISTER_APP_GROUPS = YES;
SDKROOT = auto;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator";
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx";
SUPPORTS_MACCATALYST = NO;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2,7";
TARGETED_DEVICE_FAMILY = "1,2";
XROS_DEPLOYMENT_TARGET = 27.0;
};
name = Release;
@@ -476,7 +502,7 @@
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = B95H74ZDY6;
DEVELOPMENT_TEAM = CW6GQT9SK5;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 27.0;
MACOSX_DEPLOYMENT_TARGET = 27.0;
@@ -502,7 +528,7 @@
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = B95H74ZDY6;
DEVELOPMENT_TEAM = CW6GQT9SK5;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 27.0;
MACOSX_DEPLOYMENT_TARGET = 27.0;
@@ -527,7 +553,7 @@
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = B95H74ZDY6;
DEVELOPMENT_TEAM = CW6GQT9SK5;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 27.0;
MACOSX_DEPLOYMENT_TARGET = 27.0;
@@ -552,7 +578,7 @@
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = B95H74ZDY6;
DEVELOPMENT_TEAM = CW6GQT9SK5;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 27.0;
MACOSX_DEPLOYMENT_TARGET = 27.0;
@@ -610,43 +636,33 @@
/* End XCConfigurationList section */
/* Begin XCLocalSwiftPackageReference section */
FA7596B030366A1D0000167E /* XCLocalSwiftPackageReference "Vendor/swift-markdown-engine" */ = {
isa = XCLocalSwiftPackageReference;
relativePath = "Vendor/swift-markdown-engine";
};
FAF99C42302CF1BD00C9949F /* XCLocalSwiftPackageReference "OutlineKit" */ = {
isa = XCLocalSwiftPackageReference;
relativePath = OutlineKit;
};
/* End XCLocalSwiftPackageReference section */
/* Begin XCRemoteSwiftPackageReference section */
FAF99CAD302D120500C9949F /* XCRemoteSwiftPackageReference "swift-markdown-engine" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/nodes-app/swift-markdown-engine";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 0.12.0;
};
};
/* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
FA7596B130366A1D0000167E /* MarkdownEngine */ = {
isa = XCSwiftPackageProductDependency;
productName = MarkdownEngine;
};
FA7596B330366A1D0000167E /* MarkdownEngineCodeBlocks */ = {
isa = XCSwiftPackageProductDependency;
productName = MarkdownEngineCodeBlocks;
};
FA7596B530366A1D0000167E /* MarkdownEngineLatex */ = {
isa = XCSwiftPackageProductDependency;
productName = MarkdownEngineLatex;
};
FAF99C43302CF1BD00C9949F /* OutlineKit */ = {
isa = XCSwiftPackageProductDependency;
productName = OutlineKit;
};
FAF99CAE302D120500C9949F /* MarkdownEngine */ = {
isa = XCSwiftPackageProductDependency;
package = FAF99CAD302D120500C9949F /* XCRemoteSwiftPackageReference "swift-markdown-engine" */;
productName = MarkdownEngine;
};
FAF99CB0302D120500C9949F /* MarkdownEngineCodeBlocks */ = {
isa = XCSwiftPackageProductDependency;
package = FAF99CAD302D120500C9949F /* XCRemoteSwiftPackageReference "swift-markdown-engine" */;
productName = MarkdownEngineCodeBlocks;
};
FAF99CB2302D120500C9949F /* MarkdownEngineLatex */ = {
isa = XCSwiftPackageProductDependency;
package = FAF99CAD302D120500C9949F /* XCRemoteSwiftPackageReference "swift-markdown-engine" */;
productName = MarkdownEngineLatex;
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = FAF99C10302CE96100C9949F /* Project object */;
@@ -1,5 +1,5 @@
{
"originHash" : "f233fa96f0c6bdcdbf87f726af38f25704f6d46a156a27dfac47541baa63bf97",
"originHash" : "4127e8224149bef00a33500e8db49748a735e1c07626f2255faea52554afef5e",
"pins" : [
{
"identity" : "highlighterswift",
@@ -10,15 +10,6 @@
"version" : "3.1.0"
}
},
{
"identity" : "swift-markdown-engine",
"kind" : "remoteSourceControl",
"location" : "https://github.com/nodes-app/swift-markdown-engine",
"state" : {
"revision" : "e5f7607fc4021181056ef7a09dbb7573dc0237d9",
"version" : "0.12.0"
}
},
{
"identity" : "swiftmath",
"kind" : "remoteSourceControl",
+21
View File
@@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "outpost-ios-1024.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 440 KiB

+7 -39
View File
@@ -2,29 +2,17 @@
import AppKit
import SwiftUI
/// Bare content (icon, name, version, links) with no window chrome reused
/// by both the standalone "About Outpost" window (`AboutView`, the standard
/// macOS app-menu affordance) and the Settings page's own About section, so
/// the two can't drift out of sync.
/// Bare content (icon, name, version, links) with no window chrome the
/// macOS "About Outpost" app-menu command now opens Settings' own About
/// section directly (no separate popup window), so this is its only caller.
struct AboutInfoView: View {
private let repositoryURL = URL(string: "https://git.psmattas.com/psmattas/Outpost")!
private let releasesURL = URL(string: "https://git.psmattas.com/psmattas/Outpost/releases")!
var appName: String {
Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String ?? "Outpost"
}
/// Bumped alongside `MARKETING_VERSION` in the Xcode project kept out
/// of the bundle version itself since `CFBundleShortVersionString` is
/// expected to stay a plain dotted-numeric string, not `0.0.1-ALPHA`.
private let releaseStage = "ALPHA"
var versionString: String {
let shortVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0.0.1"
let buildNumber = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "1"
let stageSuffix = releaseStage.isEmpty ? "" : "-\(releaseStage)"
return "Version \(shortVersion)\(stageSuffix) (\(buildNumber))"
}
var versionString: String { OutpostVersion.fullVersionString }
private var copyrightYear: String {
String(Calendar.current.component(.year, from: Date()))
@@ -55,35 +43,15 @@ struct AboutInfoView: View {
.fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: .infinity)
VStack(spacing: 10) {
Link(destination: repositoryURL) {
Label("View Source on Git", systemImage: "link")
}
.font(.callout)
Button("Check for Updates…") {
checkForUpdates()
}
Link(destination: repositoryURL) {
Label("View Source on Git", systemImage: "link")
}
.font(.callout)
Text("© \(copyrightYear) Puranjay Savar Mattas")
.font(.caption2)
.foregroundStyle(.tertiary)
}
}
// No Sparkle-style in-app updater yet this just opens the releases page
// on the self-hosted Gitea instance so the user can check/download manually.
private func checkForUpdates() {
NSWorkspace.shared.open(releasesURL)
}
}
struct AboutView: View {
var body: some View {
AboutInfoView()
.padding(32)
.frame(width: 320)
}
}
#endif
@@ -0,0 +1,114 @@
#if os(macOS)
import AppKit
import SwiftUI
/// Crop/rotate/zoom editor shown after picking a photo, before it's
/// uploaded pan (drag), zoom (pinch or the slider), and 90°-increment
/// rotate, all inside a circular mask matching how the avatar actually
/// renders everywhere else in the app.
///
/// The on-screen preview and the final exported image are built from the
/// exact same view composition (`avatarContent`), just instantiated once
/// for display and once inside an `ImageRenderer` that's deliberate:
/// hand-deriving a separate set of crop-math for a higher-resolution
/// render would risk it silently disagreeing with what the user actually
/// saw and confirmed, and there's no way to visually verify that
/// agreement without running the app. Reusing the identical view tree
/// makes the export WYSIWYG by construction instead of by careful math.
struct AvatarCropperView: View {
let sourceImage: NSImage
let onConfirm: (Data) -> Void
let onCancel: () -> Void
@State private var scale: CGFloat = 1
@State private var offset: CGSize = .zero
@State private var rotationDegrees: Double = 0
@GestureState private var dragTranslation: CGSize = .zero
/// Used for both the live preview and the exported image see the
/// type-level doc comment for why that's the same size, not two.
private let diameter: CGFloat = 320
var body: some View {
VStack(spacing: 20) {
Text("Edit Photo")
.font(.headline)
ZStack {
avatarContent
.clipShape(Circle())
Circle()
.strokeBorder(Color.primary.opacity(0.15), lineWidth: 1)
}
.frame(width: diameter, height: diameter)
.contentShape(Circle())
.gesture(
DragGesture()
.updating($dragTranslation) { value, state, _ in state = value.translation }
.onEnded { value in
offset.width += value.translation.width
offset.height += value.translation.height
}
)
HStack(spacing: 16) {
Button {
withAnimation(.easeInOut(duration: 0.2)) { rotationDegrees -= 90 }
} label: {
Image(systemName: "rotate.left")
}
.help("Rotate left")
Slider(value: $scale, in: 1...4)
.frame(width: 140)
Button {
withAnimation(.easeInOut(duration: 0.2)) { rotationDegrees += 90 }
} label: {
Image(systemName: "rotate.right")
}
.help("Rotate right")
}
HStack {
Button("Cancel", role: .cancel, action: onCancel)
Spacer()
Button("Use Photo") {
if let data = renderFinalImage() {
onConfirm(data)
}
}
.buttonStyle(.borderedProminent)
}
}
.padding(24)
.frame(width: 360)
}
/// Aspect-fills `sourceImage` into a `diameter`×`diameter` square, then
/// applies the user's pan/zoom/rotation on top identical between the
/// live preview and the final render (see the type-level doc comment).
private var avatarContent: some View {
Image(nsImage: sourceImage)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: diameter, height: diameter)
.scaleEffect(scale)
.rotationEffect(.degrees(rotationDegrees))
.offset(x: offset.width + dragTranslation.width, y: offset.height + dragTranslation.height)
.frame(width: diameter, height: diameter)
.clipped()
}
@MainActor
private func renderFinalImage() -> Data? {
let content = avatarContent
.clipShape(Circle())
.frame(width: diameter, height: diameter)
let renderer = ImageRenderer(content: content)
renderer.scale = 2 // @2x so it isn't a blurry 320px avatar on Retina displays
guard let nsImage = renderer.nsImage else { return nil }
return nsImage.jpegData(compressionQuality: 0.9)
}
}
#endif
@@ -1,15 +1,34 @@
#if os(macOS)
import SwiftUI
import OutlineKit
/// Swapped into the real sidebar's content slot (search field, collections
/// tree, account footer) while Settings is open same sidebar, different
/// content, rather than a separate mini sidebar nested inside a page. "Done"
/// clears `AppNavigation.isShowingSettings`, which puts the collections tree
/// back.
///
/// Grouped by `SettingsCategory` `general` (ours) sits under an "Outpost"
/// header at the top, then Outline's own Account/Workspace groups, matching
/// the settings page structure of the Outline web app. Outline's own server
/// version has no dedicated section (there used to be an Integrations &
/// Installation category for just that) it's cheap enough to show
/// unconditionally in the footer here instead, alongside Outpost's own
/// version.
struct SettingsSidebarList: View {
@Binding var selection: SettingsSection?
let onDone: () -> Void
@Environment(SessionStore.self) private var session
@AppStorage(CachingOutlineAPIClient.offlineModeDefaultsKey) private var isOfflineModeEnabled = false
@State private var outlineVersion: String?
/// Mirrors `SettingsView`'s own check a real dropped connection or
/// the manual Offline Mode toggle both mean there's no server to ask.
private var isEffectivelyOnline: Bool {
session.networkMonitor.isOnline && !isOfflineModeEnabled
}
var body: some View {
VStack(spacing: 0) {
HStack {
@@ -22,20 +41,75 @@ struct SettingsSidebarList: View {
Divider()
List(SettingsSection.allCases, selection: $selection) { section in
Label(section.title, systemImage: section.icon)
.tag(section)
List(selection: $selection) {
ForEach(SettingsCategory.allCases) { category in
let sections = SettingsSection.allCases.filter { $0.category == category }
Section {
ForEach(sections) { section in
Label(section.title, systemImage: section.icon)
.tag(section)
}
} header: {
if let title = category.title {
HStack(spacing: 6) {
Text(title)
// Not hardcoded to `.workspace` specifically
// stays correct on its own as sections get
// built, only shows while every section in
// the category is still `!isImplemented`.
if sections.allSatisfy({ !$0.isImplemented }) {
Text("Coming Soon")
.font(.system(size: 9, weight: .semibold))
.foregroundStyle(.secondary)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(.secondary.opacity(0.15), in: Capsule())
}
}
}
}
}
}
.listStyle(.sidebar)
Divider()
versionFooter
Divider()
Button("Done", action: onDone)
.keyboardShortcut(.cancelAction)
.buttonStyle(.borderedProminent)
.frame(maxWidth: .infinity)
.padding(12)
}
// Keyed to connectivity, not a one-shot `.task {}` reconnecting
// (or turning the manual Offline Mode toggle back off) re-fires
// this automatically instead of leaving the footer stuck on
// whatever it last knew, or blank, until Settings is reopened.
.task(id: isEffectivelyOnline) { await refreshOutlineVersion() }
}
private var versionFooter: some View {
VStack(alignment: .leading, spacing: 2) {
Text("Outpost \(OutpostVersion.displayString)")
if let outlineVersion {
Text("Outline \(outlineVersion)")
} else if !isEffectivelyOnline {
Text("Outline — offline")
}
}
.font(.caption2)
.foregroundStyle(.tertiary)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 16)
.padding(.vertical, 8)
}
private func refreshOutlineVersion() async {
guard isEffectivelyOnline, let apiClient = session.apiClient else { return }
outlineVersion = try? await apiClient.installationInfo().version
}
}
#endif
File diff suppressed because it is too large Load Diff
+11 -15
View File
@@ -3,21 +3,17 @@ import SwiftUI
struct AuthHeaderView: View {
var body: some View {
VStack(spacing: 12) {
ZStack {
Circle()
.fill(
LinearGradient(
colors: [Color.accentColor, Color.accentColor.opacity(0.6)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.frame(width: 64, height: 64)
Image(systemName: "text.book.closed.fill")
.font(.system(size: 26, weight: .semibold))
.foregroundStyle(.white)
}
.shadow(color: Color.accentColor.opacity(0.35), radius: 12, y: 6)
// A plain Image Set, not the AppIcon *app icon* asset App Icon
// sets aren't reliably resolvable through Image(_:)/UIImage
// (named:) at runtime (confirmed live: showed nothing). This is
// the same source artwork (outpost-ios-1024.png) duplicated
// into a normal image set so SwiftUI can actually load it.
Image("AppLogo")
.resizable()
.scaledToFit()
.frame(width: 72, height: 72)
.clipShape(RoundedRectangle(cornerRadius: 72 * 0.2237, style: .continuous))
.shadow(color: .black.opacity(0.25), radius: 12, y: 6)
VStack(spacing: 4) {
Text("Welcome to Outpost")
@@ -1,6 +1,7 @@
#if os(macOS)
import SwiftUI
import MarkdownEngine
import MarkdownEngineCodeBlocks
import OutlineKit
/// Read-only for now document/overview editing isn't wired up yet. Uses
@@ -9,21 +10,34 @@ import OutlineKit
/// `isSelectable` and link-opening both still need to work.
struct CollectionOverviewContent: View {
@State private var markdown: String
@State private var imageProvider: OutlineImageProvider
/// See `DocumentReaderView`'s `imageReloadTick`.
@State private var imageReloadTick = 0
init(collection: OutlineCollection) {
init(apiClient: OutlineAPIClient, collection: OutlineCollection) {
_markdown = State(initialValue: collection.description ?? "")
_imageProvider = State(initialValue: OutlineImageProvider(apiClient: apiClient))
}
var body: some View {
ScrollView {
NativeTextViewWrapper(
text: $markdown,
configuration: .init(heightBehavior: .fitsContent),
configuration: .init(
services: .init(images: imageProvider, syntaxHighlighter: CodeSyntaxHighlighting.shared),
heightBehavior: .fitsContent
),
isEditable: false
)
.padding()
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.animation(nil, value: imageReloadTick)
.task {
imageProvider.onImageLoaded = {
Task { @MainActor in imageReloadTick += 1 }
}
}
}
}
#endif
@@ -3,6 +3,7 @@ import SwiftUI
import OutlineKit
struct CollectionOverviewView: View {
let apiClient: OutlineAPIClient
let collection: OutlineCollection
@State private var viewModel: DocumentsViewModel
@State private var selectedTab: CollectionTab = .overview
@@ -25,6 +26,7 @@ struct CollectionOverviewView: View {
searchQuery: Binding<String>,
onOpenDocument: @escaping (OutlineDocument) -> Void
) {
self.apiClient = apiClient
self.collection = collection
_viewModel = State(initialValue: DocumentsViewModel(apiClient: apiClient, collection: collection))
_searchViewModel = State(initialValue: DocumentTitleSearchViewModel(apiClient: apiClient, collectionId: collection.id))
@@ -59,7 +61,7 @@ struct CollectionOverviewView: View {
if !trimmedSearchQuery.isEmpty {
searchResultsList
} else if selectedTab == .overview {
CollectionOverviewContent(collection: collection)
CollectionOverviewContent(apiClient: apiClient, collection: collection)
} else {
documentList
}
@@ -0,0 +1,247 @@
#if os(macOS)
import SwiftUI
import OutlineKit
/// K. Settings Editor Command Palette. Always searches locally, never a
/// per-keystroke network request. Two data-source modes:
///
/// - Lightweight (default): a live `listCollections` + `listViewedDocuments`
/// fetch once when the palette opens two small requests, near-instant,
/// works with no setup.
/// - Full Workspace: reads `CachingOutlineAPIClient`'s local SwiftData cache
/// directly (`cachedDocumentsIndex()`/`cachedCollectionsIndex()`) zero
/// network calls at all, and includes every nested sub-document, not just
/// collection roots. Requires Full Local Sync to actually have populated
/// that cache first (gated in Settings the toggle here is disabled
/// without it); this view doesn't trigger a sync itself.
struct CommandPaletteView: View {
let apiClient: OutlineAPIClient
let cachingClient: CachingOutlineAPIClient?
let fullWorkspaceSearch: Bool
let onSelectDocument: (OutlineDocument) -> Void
let onSelectCollection: (OutlineCollection) -> Void
let onDismiss: () -> Void
@State private var query = ""
@State private var collections: [OutlineCollection] = []
@State private var documents: [OutlineDocument] = []
@State private var isLoading = true
@State private var selectedIndex = 0
@FocusState private var isSearchFieldFocused: Bool
private enum Result: Identifiable {
case collection(OutlineCollection)
case document(OutlineDocument)
var id: String {
switch self {
case .collection(let collection): return "collection-\(collection.id)"
case .document(let document): return "document-\(document.id)"
}
}
}
private var results: [Result] {
let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else {
// No query yet: surface collections first, then the most
// recent/full-workspace documents as-is, capped so the panel
// doesn't dump the entire workspace with nothing typed.
return (collections.map(Result.collection) + documents.map(Result.document))
.prefix(20)
.map { $0 }
}
let scored: [(Result, Int)] = collections.compactMap { collection in
matchScore(collection.name, query: trimmed).map { (Result.collection(collection), $0) }
} + documents.compactMap { document in
matchScore(document.title, query: trimmed).map { (Result.document(document), $0) }
}
return scored.sorted { $0.1 < $1.1 }.prefix(30).map(\.0)
}
/// Lower is better exact match, then prefix match, then earliest
/// contiguous-substring position, then (for multi-word queries) every
/// word present somewhere in the title in any order. That last tier is
/// what makes "test document" find a title like "Test Plan Document"
/// requiring the exact phrase contiguously (the previous behavior)
/// meant a title with anything between the words never matched at all,
/// which looked like "documents never show up, only collections" any
/// time the real title didn't happen to contain the typed phrase
/// verbatim. `nil` means no match at all. Still deliberately not a full
/// fuzzy/Levenshtein algorithm good enough for document/collection
/// titles without the unpredictability that brings.
private func matchScore(_ title: String, query: String) -> Int? {
let haystack = title.lowercased()
let needle = query.lowercased()
if haystack == needle { return 0 }
if haystack.hasPrefix(needle) { return 1 }
if let range = haystack.range(of: needle) {
return 2 + haystack.distance(from: haystack.startIndex, to: range.lowerBound)
}
let words = needle.split(separator: " ").map(String.init)
guard words.count > 1, words.allSatisfy({ haystack.contains($0) }) else { return nil }
let totalPosition = words.reduce(0) { partial, word in
guard let range = haystack.range(of: word) else { return partial }
return partial + haystack.distance(from: haystack.startIndex, to: range.lowerBound)
}
return 100 + totalPosition
}
var body: some View {
ZStack {
Color.black.opacity(0.001) // catches clicks outside the card to dismiss
.onTapGesture { onDismiss() }
VStack(spacing: 0) {
HStack(spacing: 8) {
Image(systemName: "magnifyingglass")
.foregroundStyle(.secondary)
TextField("Search documents and collections…", text: $query)
.textFieldStyle(.plain)
.font(.title3)
.focused($isSearchFieldFocused)
.onChange(of: query) { selectedIndex = 0 }
.onSubmit { selectCurrent() }
// Attached directly on the field itself, not an
// ancestor confirmed live that .onKeyPress on the
// outer card never saw arrow-key events at all while
// this TextField actually held focus, the up/down
// presses just went nowhere. Escape still needs its
// own handler below since this one only covers
// whichever view is actually focused.
.onKeyPress(.downArrow) { moveSelection(by: 1); return .handled }
.onKeyPress(.upArrow) { moveSelection(by: -1); return .handled }
.onKeyPress(.escape) { onDismiss(); return .handled }
if isLoading {
ProgressView().controlSize(.small)
}
}
.padding(14)
Divider()
if results.isEmpty {
ContentUnavailableView(
isLoading ? "Loading…" : "No Results",
systemImage: isLoading ? "ellipsis" : "magnifyingglass"
)
.frame(height: 160)
} else {
ScrollViewReader { scrollProxy in
ScrollView {
LazyVStack(alignment: .leading, spacing: 0) {
ForEach(Array(results.enumerated()), id: \.element.id) { index, result in
resultRow(result, isSelected: index == selectedIndex)
.id(index)
.contentShape(Rectangle())
.onTapGesture {
selectedIndex = index
selectCurrent()
}
}
}
.padding(6)
}
.frame(maxHeight: 360)
.onChange(of: selectedIndex) { _, newValue in
scrollProxy.scrollTo(newValue, anchor: .center)
}
}
}
}
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 14, style: .continuous))
.overlay(RoundedRectangle(cornerRadius: 14, style: .continuous).strokeBorder(.separator))
.frame(width: 560)
.shadow(color: .black.opacity(0.3), radius: 24, y: 12)
}
.task {
// The window/responder chain isn't always ready to accept a
// first-responder change in the same instant this view is
// inserted confirmed live: setting this synchronously on
// appear left the field unfocused until manually clicked
// (also the likely source of several "entangle context after
// pre-commit" / CA-transaction warnings in the console, which
// are exactly what fighting AppKit for first-responder status
// mid-commit looks like). A one-frame-ish delay is enough for
// the overlay's insertion to settle first.
try? await Task.sleep(for: .milliseconds(50))
isSearchFieldFocused = true
await loadResults()
}
}
private func resultRow(_ result: Result, isSelected: Bool) -> some View {
HStack(spacing: 10) {
switch result {
case .collection(let collection):
// Reuses the sidebar's own icon logic (emoji vs Outline's
// icon-key-to-SF-Symbol mapping vs fallback) instead of
// guessing `collection.icon` isn't a raw SF Symbol name.
CollectionRowView(collection: collection)
.labelStyle(.iconOnly)
.frame(width: 20)
VStack(alignment: .leading, spacing: 1) {
Text(collection.name)
.lineLimit(1)
Text("Collection")
.font(.caption2)
.foregroundStyle(.secondary)
}
case .document(let document):
if let emoji = document.emoji {
Text(emoji).frame(width: 20)
} else {
Image(systemName: "doc.text")
.foregroundStyle(.secondary)
.frame(width: 20)
}
VStack(alignment: .leading, spacing: 1) {
Text(document.title.isEmpty ? "Untitled" : document.title)
.lineLimit(1)
Text("Document")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
Spacer()
}
.padding(.horizontal, 10)
.padding(.vertical, 8)
.background(isSelected ? Color.accentColor.opacity(0.15) : .clear, in: RoundedRectangle(cornerRadius: 8, style: .continuous))
}
private func moveSelection(by delta: Int) {
guard !results.isEmpty else { return }
selectedIndex = max(0, min(results.count - 1, selectedIndex + delta))
}
private func selectCurrent() {
guard results.indices.contains(selectedIndex) else { return }
switch results[selectedIndex] {
case .collection(let collection): onSelectCollection(collection)
case .document(let document): onSelectDocument(document)
}
onDismiss()
}
private func loadResults() async {
isLoading = true
defer { isLoading = false }
if fullWorkspaceSearch {
// Purely local SwiftData reads no network at all, and (since
// Full Local Sync now recurses into every document's children)
// this includes nested sub-documents the live per-collection
// fetch never could. Empty if a sync has never actually run.
collections = await cachingClient?.cachedCollectionsIndex() ?? []
documents = await cachingClient?.cachedDocumentsIndex() ?? []
return
}
async let fetchedCollections = (try? apiClient.listCollections(offset: 0, limit: 250)) ?? []
async let fetchedRecent = (try? apiClient.listViewedDocuments(offset: 0, limit: 30)) ?? []
collections = await fetchedCollections
documents = await fetchedRecent
}
}
#endif
@@ -6,6 +6,7 @@ struct ContentView_macOS: View {
@Environment(SessionStore.self) private var session
@Environment(AppNavigation.self) private var navigation
@AppStorage(CachingOutlineAPIClient.offlineModeDefaultsKey) private var isOfflineModeEnabled = false
@AppStorage("outpost.commandPaletteFullWorkspaceSearch") private var isCommandPaletteFullWorkspaceSearch = false
/// The landing state no collection selected yet is what Home actually
/// means, so this starts `true` rather than auto-selecting the first
/// collection the way this used to work.
@@ -23,6 +24,11 @@ struct ContentView_macOS: View {
/// Home's "New Document" buttons) every expanded sidebar row reloads
/// itself in response. See `CollectionDocumentsOutline.externalRefreshToken`.
@State private var documentsChangedToken = 0
/// Guards the restore-on-launch attempt to exactly once per app launch
/// without this, `mainContent`'s `.task` would re-run (and
/// re-navigate out from under the user) every time it reappears, e.g.
/// after a trip through Settings.
@State private var hasAttemptedLocationRestore = false
private var trimmedGlobalQuery: String {
globalSearchQuery.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -147,6 +153,38 @@ struct ContentView_macOS: View {
if newValue != nil {
isShowingHome = false
}
persistLastLocationIfEnabled()
}
.onChange(of: documentPath) { _, _ in
persistLastLocationIfEnabled()
}
.onChange(of: isShowingHome) { _, _ in
persistLastLocationIfEnabled()
}
// Once per launch, before the user has a chance to navigate
// manually restores whatever `restoreLastLocationIfEnabled`
// finds, or leaves today's Home default alone if there's nothing
// to restore (preference off, nothing stored yet, or resolution
// fails e.g. a deleted document/collection or being offline).
.task {
guard !hasAttemptedLocationRestore else { return }
hasAttemptedLocationRestore = true
await restoreLastLocationIfEnabled()
}
.overlay {
if navigation.isShowingCommandPalette, let apiClient = session.apiClient {
CommandPaletteView(
apiClient: apiClient,
cachingClient: session.cachingClient,
fullWorkspaceSearch: isCommandPaletteFullWorkspaceSearch,
onSelectDocument: openDocument,
onSelectCollection: { collection in
selectedCollection = collection
replaceDocumentPath(with: [])
},
onDismiss: { navigation.isShowingCommandPalette = false }
)
}
}
}
@@ -160,6 +198,63 @@ struct ContentView_macOS: View {
replaceDocumentPath(with: [])
}
// MARK: - Remember previous location (Preferences Remember previous location)
private static let lastLocationDefaultsKey = "outline.lastLocation"
/// What gets persisted `isHome` disambiguates "was on Home" from "no
/// collection selected yet" (the latter only otherwise happens on the
/// brief `ContentUnavailableView` placeholder state), since both would
/// otherwise look identical (`collectionId == nil`).
private struct LastLocation: Codable {
var isHome: Bool
var collectionId: String?
var documentIds: [String]
}
/// Called from every navigation-changing `.onChange` cheap to persist
/// on every change rather than debouncing, this is just a small JSON
/// blob in `UserDefaults`, not a network call.
private func persistLastLocationIfEnabled() {
guard session.userPreferences?.rememberLastPath == true else { return }
let location = LastLocation(isHome: isShowingHome, collectionId: selectedCollection?.id, documentIds: documentPath.map(\.id))
guard let data = try? JSONEncoder().encode(location) else { return }
UserDefaults.standard.set(data, forKey: Self.lastLocationDefaultsKey)
}
/// Resolves IDs back into real `OutlineCollection`/`OutlineDocument`
/// objects via the API stored IDs alone aren't enough to populate
/// `selectedCollection`/`documentPath` directly. Resolves the document
/// chain in order and stops at the first failure (deleted document,
/// offline, etc.) rather than aborting the whole restore whatever
/// prefix of the chain resolved successfully is still a better landing
/// spot than falling all the way back to Home.
private func restoreLastLocationIfEnabled() async {
guard session.userPreferences?.rememberLastPath == true,
let apiClient = session.apiClient,
let data = UserDefaults.standard.data(forKey: Self.lastLocationDefaultsKey),
let location = try? JSONDecoder().decode(LastLocation.self, from: data)
else { return }
// A pure "was on Home, nothing pushed" location needs no action
// Home is already the default state before this ever runs.
guard location.collectionId != nil || !location.documentIds.isEmpty else { return }
if let collectionId = location.collectionId {
guard let collection = try? await apiClient.collectionInfo(id: collectionId) else { return }
selectedCollection = collection
isShowingHome = false
}
var resolvedChain: [OutlineDocument] = []
for documentId in location.documentIds {
guard let document = try? await apiClient.documentInfo(id: documentId) else { break }
resolvedChain.append(document)
}
if !resolvedChain.isEmpty {
replaceDocumentPath(with: resolvedChain)
}
}
@ViewBuilder
private var contextualSearchField: some View {
if isContextualSearchExpanded || !contextualSearchQuery.isEmpty {
@@ -350,6 +445,13 @@ struct ContentView_macOS: View {
if !documentPath.isEmpty {
documentPath.removeLast()
}
// Delete/Archive/Unpublish/Move all change what
// should show in the sidebar tree this only
// popped the reader before, leaving the sidebar
// showing the document until some unrelated
// trigger (the 45s poll, navigating away and
// back) happened to refresh it.
documentsChangedToken += 1
},
onDocumentCreated: { documentsChangedToken += 1 }
)
@@ -0,0 +1,425 @@
#if os(macOS)
import SwiftUI
import OutlineKit
/// Document-level and anchored comments + single-level replies + emoji
/// reactions + resolve/unresolve. Composing new comments/replies is still
/// plain text only (no bold/italic/lists/etc.) sent through the `text`
/// (markdown) convenience field, not a hand-built ProseMirror `data`
/// document.
@MainActor
struct DocumentCommentsSheet: View {
@Environment(\.dismiss) private var dismiss
let apiClient: OutlineAPIClient
let document: OutlineDocument
/// Set when opened by tapping an inline anchor marker scrolls to and
/// briefly highlights that comment/reply. `nil` opens unfocused (the
/// plain toolbar marker).
var focusedCommentId: String? = nil
/// Set when opened via "Comment on Selection" from the editor's
/// right-click menu the selected text to anchor a NEW comment to.
/// The first occurrence of this exact substring in the document is
/// what the server (and later, this app's own inline marker) anchors
/// to; no prefix/suffix disambiguation UI for multiple identical
/// occurrences.
var pendingAnchorText: String? = nil
/// Called after any successful create/reply/resolve/reaction lets the
/// reader refresh its own lightweight copy (inline anchor markers, the
/// toolbar badge count) without waiting for the document to be reopened.
var onCommentsChanged: (() -> Void)? = nil
/// A small curated set rather than the full system emoji picker quick
/// taps for the common reactions, matching how most chat apps default.
private static let quickReactions = ["👍", "❤️", "😂", "🎉", "😮", "😢"]
@State private var comments: [OutlineComment] = []
@State private var isLoading = false
@State private var errorMessage: String?
@State private var actionErrorMessage: String?
@State private var currentUserId: String?
@State private var newCommentText = ""
@State private var isPostingNewComment = false
/// Mutable mirror of `pendingAnchorText` lets the user clear it (fall
/// back to a plain document-level comment) without touching the init
/// param itself.
@State private var composingAnchorText: String?
@State private var replyingToThreadId: String?
@State private var replyText = ""
@State private var isPostingReply = false
@State private var resolvingCommentIds: Set<String> = []
@State private var reactingCommentIds: Set<String> = []
@State private var reactionPickerCommentId: String?
private struct CommentThread: Identifiable {
let top: OutlineComment
let replies: [OutlineComment]
var id: String { top.id }
}
private var threads: [CommentThread] {
let topLevel = comments.filter { $0.parentCommentId == nil }.sorted { $0.createdAt < $1.createdAt }
return topLevel.map { top in
let replies = comments
.filter { $0.parentCommentId == top.id }
.sorted { $0.createdAt < $1.createdAt }
return CommentThread(top: top, replies: replies)
}
}
var body: some View {
VStack(alignment: .leading, spacing: 0) {
HStack {
Text("Comments")
.font(.headline)
Spacer()
Button("Done") { dismiss() }
}
.padding()
Divider()
Group {
if isLoading && comments.isEmpty {
ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity)
} else if let errorMessage {
ContentUnavailableView {
Label("Couldn't Load Comments", systemImage: "exclamationmark.triangle")
} description: {
Text(errorMessage)
} actions: {
Button("Retry") { Task { await load() } }
}
} else if threads.isEmpty {
ContentUnavailableView {
Label("No Comments", systemImage: "bubble.left.and.bubble.right")
} description: {
Text("This document has no comments yet.")
}
} else {
ScrollViewReader { proxy in
List(threads) { thread in
threadSection(thread)
}
.task {
guard let focusedCommentId else { return }
// The list needs a beat to lay out before a
// scrollTo lands correctly on first appear.
try? await Task.sleep(for: .milliseconds(50))
withAnimation {
proxy.scrollTo(focusedCommentId, anchor: .center)
}
}
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
Divider()
newCommentComposer
}
.frame(width: 480, height: 560)
.task { await load() }
.task { currentUserId = try? await apiClient.currentUser().id }
.task { composingAnchorText = pendingAnchorText }
.alert("Couldn't Complete Action", isPresented: .constant(actionErrorMessage != nil)) {
Button("OK") { actionErrorMessage = nil }
} message: {
Text(actionErrorMessage ?? "")
}
}
// MARK: - Thread
private func threadSection(_ thread: CommentThread) -> some View {
VStack(alignment: .leading, spacing: 8) {
commentRow(thread.top, isReply: false)
ForEach(thread.replies) { reply in
commentRow(reply, isReply: true)
.padding(.leading, 20)
}
if replyingToThreadId == thread.id {
replyComposer(for: thread)
.padding(.leading, 20)
} else {
Button("Reply") {
replyingToThreadId = thread.id
replyText = ""
}
.buttonStyle(.plain)
.font(.caption.weight(.semibold))
.foregroundStyle(.blue)
.padding(.leading, 20)
}
}
.padding(.vertical, 6)
}
private func commentRow(_ comment: OutlineComment, isReply: Bool) -> some View {
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 6) {
Text(comment.createdBy?.name ?? "Unknown")
.font(.callout.weight(.semibold))
Spacer()
Text(comment.createdAt, format: .relative(presentation: .named))
.font(.caption)
.foregroundStyle(.secondary)
}
Text(comment.bodyText.isEmpty ? "(empty)" : comment.bodyText)
.font(.callout)
.foregroundStyle(comment.bodyText.isEmpty ? .secondary : .primary)
if !comment.reactions.isEmpty {
reactionChips(comment)
}
HStack(spacing: 12) {
Button {
reactionPickerCommentId = comment.id
} label: {
Image(systemName: "face.smiling")
}
.buttonStyle(.plain)
.foregroundStyle(.secondary)
.popover(isPresented: Binding(
get: { reactionPickerCommentId == comment.id },
set: { if !$0 { reactionPickerCommentId = nil } }
)) {
quickReactionPicker(comment)
}
if !isReply {
if comment.isResolved {
Label("Resolved", systemImage: "checkmark.circle.fill")
.font(.caption)
.foregroundStyle(.green)
}
Spacer()
if resolvingCommentIds.contains(comment.id) {
ProgressView().controlSize(.small)
} else {
Button(comment.isResolved ? "Unresolve" : "Resolve") {
Task { await toggleResolved(comment) }
}
.buttonStyle(.plain)
.font(.caption.weight(.semibold))
.foregroundStyle(.blue)
}
} else {
Spacer()
}
}
}
.padding(6)
.background(
comment.id == focusedCommentId ? Color.accentColor.opacity(0.12) : Color.clear,
in: RoundedRectangle(cornerRadius: 6)
)
.id(comment.id)
}
private func reactionChips(_ comment: OutlineComment) -> some View {
HStack(spacing: 4) {
ForEach(comment.reactions, id: \.emoji) { reaction in
let mine = currentUserId.map(reaction.userIds.contains) ?? false
Button {
Task { await toggleReaction(comment, emoji: reaction.emoji, currentlyReacted: mine) }
} label: {
Text("\(reaction.emoji) \(reaction.userIds.count)")
.font(.caption)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(mine ? Color.accentColor.opacity(0.2) : Color.gray.opacity(0.15), in: Capsule())
}
.buttonStyle(.plain)
.disabled(reactingCommentIds.contains(comment.id))
}
}
}
private func quickReactionPicker(_ comment: OutlineComment) -> some View {
HStack(spacing: 8) {
ForEach(Self.quickReactions, id: \.self) { emoji in
let mine = currentUserId.map { userId in
comment.reactions.first { $0.emoji == emoji }?.userIds.contains(userId) ?? false
} ?? false
Button {
reactionPickerCommentId = nil
Task { await toggleReaction(comment, emoji: emoji, currentlyReacted: mine) }
} label: {
Text(emoji)
.font(.title2)
.opacity(mine ? 1 : 0.5)
}
.buttonStyle(.plain)
}
}
.padding(10)
}
// MARK: - Composers
private var newCommentComposer: some View {
VStack(alignment: .leading, spacing: 6) {
if let composingAnchorText {
HStack(spacing: 6) {
Image(systemName: "text.quote")
.foregroundStyle(.blue)
Text(composingAnchorText)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.tail)
Spacer()
Button {
self.composingAnchorText = nil
} label: {
Image(systemName: "xmark.circle.fill")
}
.buttonStyle(.plain)
.foregroundStyle(.secondary)
.help("Remove — this will post as a document-level comment instead")
}
.padding(.horizontal, 6)
.padding(.vertical, 4)
.background(Color.blue.opacity(0.1), in: RoundedRectangle(cornerRadius: 6))
}
HStack(alignment: .bottom, spacing: 8) {
TextField(
composingAnchorText == nil ? "Add a comment…" : "Comment on this text…",
text: $newCommentText,
axis: .vertical
)
.textFieldStyle(.roundedBorder)
.lineLimit(1...4)
.onSubmit { Task { await postNewComment() } }
if isPostingNewComment {
ProgressView().controlSize(.small)
} else {
Button("Post") {
Task { await postNewComment() }
}
.disabled(newCommentText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
}
}
.padding()
}
private func replyComposer(for thread: CommentThread) -> some View {
HStack(alignment: .bottom, spacing: 8) {
TextField("Reply…", text: $replyText, axis: .vertical)
.textFieldStyle(.roundedBorder)
.lineLimit(1...4)
.onSubmit { Task { await postReply(to: thread) } }
if isPostingReply {
ProgressView().controlSize(.small)
} else {
Button("Send") {
Task { await postReply(to: thread) }
}
.disabled(replyText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
Button("Cancel") {
replyingToThreadId = nil
replyText = ""
}
.buttonStyle(.plain)
.foregroundStyle(.secondary)
}
}
}
// MARK: - Actions
private func load() async {
isLoading = true
defer { isLoading = false }
do {
comments = try await apiClient.listComments(ListCommentsRequest(documentId: document.id))
} catch {
errorMessage = outlineErrorMessage(error, fallback: "Couldn't load comments for this document.")
}
}
private func postNewComment() async {
let text = newCommentText.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty else { return }
isPostingNewComment = true
defer { isPostingNewComment = false }
do {
let created = try await apiClient.createComment(
CreateCommentRequest(documentId: document.id, text: text, anchorText: composingAnchorText)
)
comments.append(created)
composingAnchorText = nil
newCommentText = ""
onCommentsChanged?()
} catch {
actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't post this comment.")
}
}
private func postReply(to thread: CommentThread) async {
let text = replyText.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty else { return }
isPostingReply = true
defer { isPostingReply = false }
do {
let created = try await apiClient.createComment(
CreateCommentRequest(documentId: document.id, parentCommentId: thread.id, text: text)
)
comments.append(created)
replyText = ""
replyingToThreadId = nil
onCommentsChanged?()
} catch {
actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't post this reply.")
}
}
private func toggleResolved(_ comment: OutlineComment) async {
resolvingCommentIds.insert(comment.id)
defer { resolvingCommentIds.remove(comment.id) }
do {
let updated = comment.isResolved
? try await apiClient.unresolveComment(id: comment.id)
: try await apiClient.resolveComment(id: comment.id)
replace(updated)
} catch {
actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't update this comment.")
}
}
/// Reaction endpoints return `{success: true}`, not the updated comment
/// (see `OutlineAPIClient.addReaction`) refetch via `commentInfo` for
/// the real post-toggle `reactions` array instead of guessing the merge
/// locally (another user reacting concurrently would make a guess wrong).
private func toggleReaction(_ comment: OutlineComment, emoji: String, currentlyReacted: Bool) async {
reactingCommentIds.insert(comment.id)
defer { reactingCommentIds.remove(comment.id) }
do {
if currentlyReacted {
try await apiClient.removeReaction(commentId: comment.id, emoji: emoji)
} else {
try await apiClient.addReaction(commentId: comment.id, emoji: emoji)
}
let refreshed = try await apiClient.commentInfo(id: comment.id)
replace(refreshed)
} catch {
actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't update that reaction.")
}
}
private func replace(_ updated: OutlineComment) {
if let index = comments.firstIndex(where: { $0.id == updated.id }) {
comments[index] = updated
}
}
}
#endif
@@ -1,6 +1,7 @@
#if os(macOS)
import SwiftUI
import MarkdownEngine
import MarkdownEngineCodeBlocks
import OutlineKit
/// Distraction-free reading view no toolbar/sidebar chrome, larger type.
@@ -16,11 +17,17 @@ struct DocumentPresentSheet: View {
@State private var text: String
@State private var isLoading = false
@State private var errorMessage: String?
@State private var imageProvider: OutlineImageProvider
/// See `DocumentReaderView`'s `imageReloadTick` same "force an
/// `updateNSView` re-pass so the engine notices `fingerprint()` changed"
/// mechanism, needed here too since this sheet renders its own images.
@State private var imageReloadTick = 0
init(apiClient: OutlineAPIClient, document: OutlineDocument) {
self.apiClient = apiClient
self.document = document
_text = State(initialValue: document.text)
_imageProvider = State(initialValue: OutlineImageProvider(apiClient: apiClient))
}
var body: some View {
@@ -44,7 +51,10 @@ struct DocumentPresentSheet: View {
.padding(.bottom, 8)
NativeTextViewWrapper(
text: $text,
configuration: .init(heightBehavior: .fitsContent),
configuration: .init(
services: .init(images: imageProvider, syntaxHighlighter: CodeSyntaxHighlighting.shared),
heightBehavior: .fitsContent
),
isEditable: false
)
.font(.system(size: 18))
@@ -67,6 +77,12 @@ struct DocumentPresentSheet: View {
}
.frame(minWidth: 800, minHeight: 600)
.background(.background)
.animation(nil, value: imageReloadTick)
.task {
imageProvider.onImageLoaded = {
Task { @MainActor in imageReloadTick += 1 }
}
}
.task { await load() }
}
@@ -3,7 +3,11 @@ import AppKit
import SwiftUI
import UniformTypeIdentifiers
import MarkdownEngine
import MarkdownEngineCodeBlocks
import OutlineKit
#if canImport(ImagePlayground)
import ImagePlayground
#endif
/// `NSSavePanel`/`NSPrintOperation`/`NSPasteboard` in the action functions
/// below must run on the main thread see the identical note on
@@ -13,6 +17,54 @@ struct DocumentReaderView: View {
@Environment(SessionStore.self) private var session
@Environment(StarStore.self) private var starStore
@AppStorage(CachingOutlineAPIClient.offlineModeDefaultsKey) private var isOfflineModeEnabled = false
/// Local-only Outpost setting (Settings Editor), not synced to
/// Outline see `SettingsView.editorDetail`.
@AppStorage("outpost.splitViewEnabled") private var isSplitViewEnabled = false
@AppStorage("outpost.autocompleteEnabled") private var isAutocompleteEnabled = true
@AppStorage("outpost.writingToolsEnabled") private var isWritingToolsEnabled = true
@AppStorage("outpost.imagePlaygroundEnabled") private var isImagePlaygroundEnabled = true
/// `ImagePlaygroundViewController.isAvailable` gates on both OS version
/// (macOS 15.1+) and actual device/region support (Apple Intelligence
/// eligibility) a supported OS with an unsupported Mac still reports
/// `false`, so this is the one check that matters, not just `#available`.
private var isImagePlaygroundSupported: Bool {
#if canImport(ImagePlayground)
if #available(macOS 15.1, *) {
return ImagePlaygroundViewController.isAvailable
}
#endif
return false
}
/// Outline's own "Show line numbers" preference (synced, read via
/// `session.userPreferences`, not `@AppStorage` this one's the
/// server's, not a local-only Outpost setting). No `@Environment`-in-`init`
/// problem here since this is read directly in the view, not the
/// view model.
private var showCodeBlockLineNumbers: Bool {
session.userPreferences?.codeBlockLineNumbers ?? false
}
/// Widened left indent reserved for the number gutter when line numbers
/// are on (default is 12pt, just enough margin, no room for digits).
private static let lineNumberGutterWidth: CGFloat = 32
private var editorCodeBlockStyle: CodeBlockStyle {
showCodeBlockLineNumbers ? .init(horizontalIndent: Self.lineNumberGutterWidth) : .default
}
/// Outline's own "Smart text replacements" preference (synced) smart
/// quotes/dashes while typing. Only meaningful on the editable pane.
private var editorTextSubstitution: TextSubstitutionPolicy {
let enabled = session.userPreferences?.smartText ?? false
return .init(quoteSubstitution: enabled, dashSubstitution: enabled)
}
/// Local-only Outpost settings (Settings Editor) not synced to
/// Outline, same as Split View above.
private var editorTextCompletion: TextCompletionPolicy {
.init(isEnabled: isAutocompleteEnabled)
}
private var editorWritingTools: WritingToolsPolicy {
.init(isEnabled: isWritingToolsEnabled)
}
@State private var viewModel: DocumentReaderViewModel
let apiClient: OutlineAPIClient
@@ -31,16 +83,70 @@ struct DocumentReaderView: View {
let onDocumentCreated: () -> Void
@State private var isShowingUnpublishConfirmation = false
@State private var isShowingPublishSheet = false
@State private var isShowingArchiveConfirmation = false
@State private var isShowingDeleteConfirmation = false
@State private var isShowingMoveSheet = false
@State private var isShowingHistorySheet = false
@State private var isShowingInsightsSheet = false
@State private var isShowingCommentsSheet = false
/// Fetched once on load, purely to drive the toolbar marker/badge and
/// the inline anchor bars below the sheet itself fetches its own copy
/// independently (see `DocumentCommentsSheet`), same as every other
/// self-contained sheet in this file.
@State private var loadedComments: [OutlineComment] = []
/// Comment id to scroll/focus to when the sheet opens set when an
/// inline anchor bar is tapped, `nil` for the plain toolbar marker.
@State private var focusedCommentId: String?
/// Resolved on-screen positions for each anchored comment's text, from
/// `NativeTextViewWrapper.onCommentAnchorRectsChange`.
@State private var commentAnchorRects: [CommentAnchorRect] = []
/// Set from "Comment on Selection" in the editor's right-click menu
/// the sheet opens straight into composing a new anchored comment.
@State private var pendingCommentAnchorText: String?
private var commentCount: Int? {
loadedComments.isEmpty ? nil : loadedComments.count
}
private var commentAnchorQueries: [CommentAnchorQuery] {
loadedComments.compactMap { comment in
guard let anchorText = comment.anchorText, !anchorText.isEmpty else { return nil }
return CommentAnchorQuery(id: comment.id, anchorText: anchorText)
}
}
@State private var isShowingPresentSheet = false
@State private var isShowingSearchSheet = false
@State private var isShowingShareSheet = false
@State private var isShowingNewDocumentSheet = false
@State private var isShowingImagePlayground = false
@State private var actionErrorMessage: String?
/// Pushed into the main editable pane's `NativeTextViewWrapper` to
/// insert an Image Playground result's Markdown reference at the caret.
@State private var pendingTextInsertion: TextInsertionRequest?
/// Live-updated by `onSelectedTextChange` on the main editable pane;
/// `nil` when the selection is empty (caret only, nothing highlighted).
@State private var currentSelectedText: String?
/// Resolves `![alt](url)` images in this document shared by both
/// panes (main + split-view preview), since they render the same text.
@State private var imageProvider: OutlineImageProvider
/// Bumped by `imageProvider.onImageLoaded`. Not read for its value
/// just referenced via `.animation(nil, value:)` so SwiftUI re-evaluates
/// this view (and so `NativeTextViewWrapper.updateNSView` re-runs and
/// notices the provider's `fingerprint()` changed) once an async image
/// load completes. The engine has no polling of its own for this.
@State private var imageReloadTick = 0
/// Snapshot of `currentSelectedText` taken the moment the Image
/// Playground button is pressed the sheet's seed shouldn't shift if
/// the user's selection happens to change while it's open.
@State private var imagePlaygroundSeedText: String?
/// Populated live by `NativeTextViewWrapper`'s `onCodeBlockSelectionChange`
/// one array per instance (main pane, split-view preview pane), since
/// each lays the same text out at a different width and gets different
/// rects. Only non-empty when `showCodeBlockLineNumbers` is on (see its
/// doc comment for why the gutter needs `codeBlock.horizontalIndent`
/// widened, which is gated on the same flag).
@State private var readerCodeBlocks: [CodeBlockSelection] = []
@State private var previewCodeBlocks: [CodeBlockSelection] = []
init(
apiClient: OutlineAPIClient,
@@ -51,7 +157,13 @@ struct DocumentReaderView: View {
) {
self.apiClient = apiClient
self.document = document
// `separateEditingEnabled` can't be read from `@Environment` here
// environment values aren't populated yet inside a view's `init`,
// only from `body` onward. Defaults to `true` (today's only
// behavior) and gets set for real in `.task` below once `session`
// is actually available.
_viewModel = State(initialValue: DocumentReaderViewModel(apiClient: apiClient, document: document))
_imageProvider = State(initialValue: OutlineImageProvider(apiClient: apiClient))
self.onOpenChild = onOpenChild
self.onDeleted = onDeleted
self.onDocumentCreated = onDocumentCreated
@@ -66,44 +178,26 @@ struct DocumentReaderView: View {
session.networkMonitor.isOnline && !isOfflineModeEnabled
}
/// Split View needs the full window height (each pane scrolls itself),
/// which an unbounded page-level `ScrollView` can't give it a
/// `minHeight` inside one just resolves to exactly that minimum, not
/// "fill available space", since there's no bounded space to fill.
/// Only switches over once there's real content to show; loading/error
/// states still go through the normal scrolling layout.
private var canShowSplitView: Bool {
isSplitViewEnabled
&& viewModel.isEffectivelyEditable
&& viewModel.errorMessage == nil
&& !(viewModel.isLoading && viewModel.text.isEmpty)
}
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 12) {
if viewModel.isEditing {
TextField("Title", text: $viewModel.title)
.font(.largeTitle.weight(.bold))
.textFieldStyle(.plain)
}
if viewModel.isLoading && viewModel.text.isEmpty {
ProgressView()
.frame(maxWidth: .infinity)
} else if let errorMessage = viewModel.errorMessage {
ContentUnavailableView {
Label("Couldn't Load Document", systemImage: "exclamationmark.triangle")
} description: {
Text(errorMessage)
} actions: {
Button("Retry") {
Task { await viewModel.loadFullContent() }
}
}
} else {
NativeTextViewWrapper(
text: $viewModel.text,
configuration: .init(heightBehavior: .fitsContent),
documentId: viewModel.documentId,
isEditable: viewModel.isEditing
)
if !viewModel.children.isEmpty {
childrenSection
}
}
Group {
if canShowSplitView {
splitViewContent
} else {
scrollingReaderContent
}
.padding()
.frame(maxWidth: viewModel.isFullWidth ? .infinity : 900)
.frame(maxWidth: .infinity)
}
.overlay(alignment: .topTrailing) {
if viewModel.isLoading && !viewModel.text.isEmpty {
@@ -127,16 +221,62 @@ struct DocumentReaderView: View {
DocumentShareSheet(apiClient: apiClient, documentId: viewModel.documentId)
}
Button {
Task { await viewModel.toggleEditing() }
} label: {
if viewModel.isSaving {
ProgressView().controlSize(.small)
} else {
Text(viewModel.isEditing ? "Done" : "Edit")
// Toolbar marker only shows once there's actually
// something to point at. Anchored comments additionally get
// an inline vertical bar next to their text (see
// `commentAnchorMarkers`/`onCommentAnchorRectsChange`
// below) this button opens the sheet unfocused, showing
// every comment/thread.
if let commentCount, commentCount > 0 {
Button {
focusedCommentId = nil
pendingCommentAnchorText = nil
isShowingCommentsSheet = true
} label: {
Image(systemName: "bubble.left.and.bubble.right")
}
.help("\(commentCount) Comment\(commentCount == 1 ? "" : "s")")
.disabled(!isEffectivelyOnline)
.overlay(alignment: .topTrailing) {
Text(commentCount > 10 ? "10+" : "\(commentCount)")
.font(.system(size: 8, weight: .bold))
.foregroundStyle(.white)
.padding(2)
.frame(minWidth: 12, minHeight: 12)
.background(.blue, in: Circle())
.offset(x: 0, y: -1)
}
}
.disabled(viewModel.isSaving)
if isImagePlaygroundEnabled && isImagePlaygroundSupported {
Button {
imagePlaygroundSeedText = currentSelectedText
isShowingImagePlayground = true
} label: {
Image(systemName: "sparkles")
}
.help("Create Image with Image Playground")
.disabled(!viewModel.isEffectivelyEditable)
}
if viewModel.separateEditingEnabled {
Button {
Task { await viewModel.toggleEditing() }
} label: {
if viewModel.isSaving {
ProgressView().controlSize(.small)
} else {
Text(viewModel.isEditing ? "Done" : "Edit")
}
}
.disabled(viewModel.isSaving)
} else if viewModel.isSaving {
// No Edit/Done affordance when documents are always
// editable this is the only feedback that an autosave
// is actually happening.
ProgressView().controlSize(.small)
.help("Saving…")
}
Button {
isShowingNewDocumentSheet = true
@@ -159,13 +299,35 @@ struct DocumentReaderView: View {
.id(menuIdentity)
}
}
.task {
imageProvider.onImageLoaded = {
Task { @MainActor in imageReloadTick += 1 }
}
}
.animation(nil, value: imageReloadTick)
.task { await viewModel.loadFullContent() }
// See the doc comment on `DocumentReaderViewModel.separateEditingEnabled`
// for why this can't just be read at `init` time.
.task { viewModel.separateEditingEnabled = session.userPreferences?.separateEditing ?? true }
.onChange(of: viewModel.text) {
guard !viewModel.separateEditingEnabled else { return }
viewModel.scheduleAutosave()
}
.onChange(of: viewModel.title) {
guard !viewModel.separateEditingEnabled else { return }
viewModel.scheduleAutosave()
}
.task {
await viewModel.loadPinAndSubscriptionState()
}
.task {
await viewModel.loadInsightsEnabledState()
}
.task {
loadedComments = (try? await apiClient.listComments(
ListCommentsRequest(documentId: viewModel.documentId, includeAnchorText: true)
)) ?? []
}
.task {
while !Task.isCancelled {
await viewModel.loadViewers()
@@ -213,6 +375,36 @@ struct DocumentReaderView: View {
} message: {
Text(actionErrorMessage ?? "")
}
.sheet(isPresented: $isShowingCommentsSheet) {
DocumentCommentsSheet(
apiClient: apiClient,
document: document,
focusedCommentId: focusedCommentId,
pendingAnchorText: pendingCommentAnchorText,
onCommentsChanged: {
Task {
loadedComments = (try? await apiClient.listComments(
ListCommentsRequest(documentId: viewModel.documentId, includeAnchorText: true)
)) ?? loadedComments
}
}
)
}
.sheet(isPresented: $isShowingPublishSheet) {
PublishDocumentSheet(apiClient: apiClient, document: document) {
// Stays in the reader unlike Move/Unpublish, publishing
// doesn't make the document any less visible from here, so
// there's no reason to pop back like onDeleted() does
// elsewhere. Refresh from the server rather than guessing
// the new collectionId locally (moveDocument may have run
// as a second step inside the sheet).
await viewModel.loadFullContent()
// The doc previously had no collectionId (or a different
// one) the sidebar tree for its new collection needs to
// know it exists now, same signal "New Document" sends.
onDocumentCreated()
}
}
.sheet(isPresented: $isShowingMoveSheet) {
MoveDocumentSheet(apiClient: apiClient, document: document) {
onDeleted()
@@ -236,6 +428,59 @@ struct DocumentReaderView: View {
onOpenChild(child)
}
}
.modifier(ImagePlaygroundPresenter(
isPresented: $isShowingImagePlayground,
seedText: imagePlaygroundSeedText,
seedTitle: viewModel.title.isEmpty ? "Untitled" : viewModel.title,
onCompletion: { url in handleGeneratedImage(url) }
))
}
/// Adds "Comment on Selection" to the right-click menu when there's an
/// actual (non-empty) selection to anchor to `currentSelectedText` is
/// kept live by `onSelectedTextChange` on the same pane. Inserted at the
/// top since it's the primary reason to right-click selected text here,
/// matching Outline's own web editor surfacing comment as the first
/// selection action.
private func addCommentMenuItem(to menu: NSMenu) -> NSMenu {
guard let selection = currentSelectedText, !selection.isEmpty else { return menu }
let item = ClosureMenuItem(title: "Comment on Selection…") {
pendingCommentAnchorText = selection
focusedCommentId = nil
isShowingCommentsSheet = true
}
menu.insertItem(item, at: 0)
menu.insertItem(.separator(), at: 1)
return menu
}
/// Uploads an Image Playground result the same way the reader would any
/// other attachment (`attachments.create` presigned target, then the
/// direct file POST see `OutlineAPIClient.uploadAttachmentFile`), then
/// inserts the hosted image's Markdown reference at the caret.
private func handleGeneratedImage(_ localURL: URL) {
Task {
do {
let data = try Data(contentsOf: localURL)
let created = try await apiClient.createAttachment(.init(
name: localURL.lastPathComponent,
contentType: "image/png",
size: data.count,
documentId: viewModel.documentId
))
try await apiClient.uploadAttachmentFile(created, fileData: data)
// Outline's own editor never embeds the raw (presigned/storage)
// upload URL in document Markdown it writes this stable
// redirect-by-id reference instead, which keeps resolving
// correctly even if the underlying storage URL rotates/expires.
pendingTextInsertion = TextInsertionRequest(
documentId: viewModel.documentId,
text: "\n\n![](/api/attachments.redirect?id=\(created.attachment.id))\n\n"
)
} catch {
actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't add the generated image.")
}
}
}
/// Every toggle-backed piece of state shown as a checkmark inside
@@ -272,31 +517,157 @@ struct DocumentReaderView: View {
}
}
private var childrenSection: some View {
VStack(alignment: .leading, spacing: 8) {
Divider()
.padding(.vertical, 4)
Text("Sub-documents")
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
ForEach(viewModel.children) { child in
Button {
onOpenChild(child)
} label: {
DocumentRowView(document: child)
/// Today's single-pane layout page-level `ScrollView` wrapping title +
/// content, used for the normal reading/editing view, and for every
/// loading/error state regardless of Split View.
private var scrollingReaderContent: some View {
ScrollView {
VStack(alignment: .leading, spacing: 12) {
if viewModel.isEffectivelyEditable {
TextField("Title", text: $viewModel.title)
.font(.largeTitle.weight(.bold))
.textFieldStyle(.plain)
}
.buttonStyle(.plain)
.padding(.vertical, 4)
if child.id != viewModel.children.last?.id {
Divider()
if viewModel.isLoading && viewModel.text.isEmpty {
ProgressView()
.frame(maxWidth: .infinity)
} else if let errorMessage = viewModel.errorMessage {
ContentUnavailableView {
Label("Couldn't Load Document", systemImage: "exclamationmark.triangle")
} description: {
Text(errorMessage)
} actions: {
Button("Retry") {
Task { await viewModel.loadFullContent() }
}
}
} else {
ZStack(alignment: .topLeading) {
NativeTextViewWrapper(
text: $viewModel.text,
pendingTextInsertion: $pendingTextInsertion,
configuration: .init(
services: .init(images: imageProvider, syntaxHighlighter: CodeSyntaxHighlighting.shared),
codeBlock: editorCodeBlockStyle,
textSubstitution: editorTextSubstitution,
textCompletion: editorTextCompletion,
writingTools: editorWritingTools,
heightBehavior: .fitsContent
),
documentId: viewModel.documentId,
isEditable: viewModel.isEffectivelyEditable,
onBuildContextMenu: { menu, _ in addCommentMenuItem(to: menu) },
onCodeBlockSelectionChange: { readerCodeBlocks = $0 },
onSelectedTextChange: { currentSelectedText = $0 },
commentAnchorQueries: commentAnchorQueries,
onCommentAnchorRectsChange: { commentAnchorRects = $0 }
)
if showCodeBlockLineNumbers {
ForEach(readerCodeBlocks) { selection in
CodeBlockLineNumberGutter(selection: selection, gutterWidth: Self.lineNumberGutterWidth)
}
}
ForEach(commentAnchorRects) { anchor in
CommentAnchorMarker(rect: anchor.rect) {
focusedCommentId = anchor.id
pendingCommentAnchorText = nil
isShowingCommentsSheet = true
}
}
}
}
}
.padding()
.frame(maxWidth: viewModel.isFullWidth ? .infinity : 900)
.frame(maxWidth: .infinity)
}
}
/// Split View's layout title fixed at the top (not part of either
/// scrolling pane), `splitEditorView` filling every remaining pixel of
/// the window below it. No outer `ScrollView` here on purpose: each
/// pane already scrolls itself, and nesting that inside another
/// unbounded scroll container is exactly what was capping both panes
/// at a fixed height instead of spanning the window.
private var splitViewContent: some View {
VStack(alignment: .leading, spacing: 12) {
TextField("Title", text: $viewModel.title)
.font(.largeTitle.weight(.bold))
.textFieldStyle(.plain)
.padding([.horizontal, .top])
splitEditorView
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
/// Left is the literal Markdown source in `rawSourceMode` (no syntax
/// hiding/styling, but still the real engine needed so selection
/// tracking and caret-position insertion, e.g. from the Image Playground
/// button, work here the same as everywhere else); right is the same
/// rich rendering used everywhere else in the app, read-only, bound to
/// the same `viewModel.text` so it updates live as the left side is
/// typed into.
///
/// Scroll position between the two panes is **not** synchronized the
/// only way to do that would be reaching into `NativeTextViewWrapper`'s
/// private internal view hierarchy to find its scroll view (the package
/// exposes no scroll position/delegate hook at all), which is fragile
/// enough to break silently on a package update. Flagged as a known
/// follow-up, not attempted here.
private var splitEditorView: some View {
HSplitView {
NativeTextViewWrapper(
text: $viewModel.text,
pendingTextInsertion: $pendingTextInsertion,
configuration: .init(rawSourceMode: true),
fontName: "SFMono-Regular",
documentId: viewModel.documentId,
isEditable: viewModel.isEffectivelyEditable,
onSelectedTextChange: { currentSelectedText = $0 }
)
.padding(8)
.frame(minWidth: 300, maxWidth: .infinity, maxHeight: .infinity)
ScrollView {
ZStack(alignment: .topLeading) {
NativeTextViewWrapper(
text: $viewModel.text,
configuration: .init(
services: .init(images: imageProvider, syntaxHighlighter: CodeSyntaxHighlighting.shared),
codeBlock: editorCodeBlockStyle,
heightBehavior: .fitsContent
),
documentId: viewModel.documentId,
isEditable: false,
onBuildContextMenu: { menu, _ in addCommentMenuItem(to: menu) },
onCodeBlockSelectionChange: { previewCodeBlocks = $0 },
onSelectedTextChange: { currentSelectedText = $0 },
commentAnchorQueries: commentAnchorQueries,
onCommentAnchorRectsChange: { commentAnchorRects = $0 }
)
if showCodeBlockLineNumbers {
ForEach(previewCodeBlocks) { selection in
CodeBlockLineNumberGutter(selection: selection, gutterWidth: Self.lineNumberGutterWidth)
}
}
ForEach(commentAnchorRects) { anchor in
CommentAnchorMarker(rect: anchor.rect) {
focusedCommentId = anchor.id
pendingCommentAnchorText = nil
isShowingCommentsSheet = true
}
}
}
.padding(8)
.frame(maxWidth: .infinity, alignment: .topLeading)
}
.frame(minWidth: 300, maxWidth: .infinity, maxHeight: .infinity)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
@ViewBuilder
private var menuContent: some View {
Button(starStore.isStarred(documentId: viewModel.documentId) ? "Unstar" : "Star") {
@@ -309,8 +680,10 @@ struct DocumentReaderView: View {
Divider()
Button(viewModel.isEditing ? "Done Editing" : "Edit") {
Task { await viewModel.toggleEditing() }
if viewModel.separateEditingEnabled {
Button(viewModel.isEditing ? "Done Editing" : "Edit") {
Task { await viewModel.toggleEditing() }
}
}
// Membership management now lives in DocumentShareSheet's "People
// with access" section, alongside the share link same sheet,
@@ -330,10 +703,17 @@ struct DocumentReaderView: View {
Task { await duplicate() }
}
.disabled(!isEffectivelyOnline)
Button("Unpublish") {
isShowingUnpublishConfirmation = true
if viewModel.publishedAt == nil {
Button("Publish…") {
isShowingPublishSheet = true
}
.disabled(!isEffectivelyOnline)
} else {
Button("Unpublish") {
isShowingUnpublishConfirmation = true
}
.disabled(!isEffectivelyOnline)
}
.disabled(!isEffectivelyOnline)
Button("Archive…") {
isShowingArchiveConfirmation = true
}
@@ -531,4 +911,127 @@ struct DocumentReaderView: View {
operation.run()
}
}
/// One code block's number gutter, positioned absolutely over a
/// `NativeTextViewWrapper` via `CodeBlockSelection.rect` same overlay
/// pattern MarkdownEngine's own `CodeBlockButton` uses.
///
/// `selection.rect` spans the WHOLE fenced block (open fence line + content
/// + close fence line), matching what the engine actually lays out the
/// fence lines render with invisible (`.clear`) text once the caret leaves
/// the block, but they don't collapse to zero height, so the block is
/// always exactly `content line count + 2` rows tall. `selection.code` is
/// content only, so the row height and number positions below both account
/// for that phantom top/bottom row explicitly instead of dividing by the
/// content line count alone (which would drift the numbers upward, more so
/// per line, the taller the block).
///
/// Known limitation, accepted rather than fixable app-side: a content line
/// that soft-wraps onto a second visual row (MarkdownEngine always
/// char-wraps code blocks, no way to opt out without forking the package)
/// throws this off every row below it reads one line low. Documented in
/// TODO.local.md alongside the same package's other gaps.
/// Thin vertical bar next to an anchored comment's text same visual
/// language as Word/Google Docs' margin comment indicators. Tapping opens
/// the comments sheet focused to that thread.
private struct CommentAnchorMarker: View {
let rect: CGRect
let onTap: () -> Void
private static let width: CGFloat = 3
private static let gap: CGFloat = 4
private static let hitTargetWidth: CGFloat = 16
var body: some View {
Color.clear
.frame(width: Self.hitTargetWidth, height: max(rect.height, 4))
.contentShape(Rectangle())
.overlay {
RoundedRectangle(cornerRadius: 1.5)
.fill(Color.blue.opacity(0.6))
.frame(width: Self.width)
}
.position(
x: rect.minX - Self.gap - Self.width / 2,
y: rect.minY + rect.height / 2
)
.onTapGesture(perform: onTap)
.help("View comment")
}
}
private struct CodeBlockLineNumberGutter: View {
let selection: CodeBlockSelection
let gutterWidth: CGFloat
/// `selection.code` (`token.contentRange`) always ends with exactly one
/// trailing `\n` per content line the range runs right up to the
/// start of the closing fence's own line, so the newline that ends the
/// last content line is included, but there's never an unterminated
/// final line to add one more for. Counting `\n` characters directly
/// (not `.components(separatedBy:).count`, which is one too many
/// whenever the string ends in the separator) is what makes a
/// single-line block read "1", not "2".
private var contentLineCount: Int {
max(1, selection.code.reduce(into: 0) { count, char in if char == "\n" { count += 1 } })
}
var body: some View {
let totalRows = CGFloat(contentLineCount + 2)
let rowHeight = selection.rect.height / totalRows
ForEach(0..<contentLineCount, id: \.self) { line in
Text("\(line + 1)")
.font(.system(size: 10, design: .monospaced))
.foregroundStyle(.secondary)
.frame(width: gutterWidth - 6, alignment: .trailing)
.position(
x: selection.rect.minX + (gutterWidth - 6) / 2,
// +1.5 rows: skip the invisible open-fence row, then
// center within this content row.
y: selection.rect.minY + rowHeight * (CGFloat(line) + 1.5)
)
}
.allowsHitTesting(false)
}
}
/// Applies `.imagePlaygroundSheet` only where it exists (macOS 15.1+, and
/// only once the `ImagePlayground` framework is actually linked in Xcode
/// see `SETUP.md`). A no-op modifier everywhere else, so this file stays
/// valid to build before that link-up happens.
private struct ImagePlaygroundPresenter: ViewModifier {
@Binding var isPresented: Bool
/// Highlighted document text at the moment the button was pressed, if
/// any seeds Image Playground's prompt instead of opening blank.
let seedText: String?
/// Document title, used as the concept's title when `seedText` is used.
let seedTitle: String
let onCompletion: (URL) -> Void
func body(content: Content) -> some View {
#if canImport(ImagePlayground)
if #available(macOS 15.1, *) {
let concepts: [ImagePlaygroundConcept] = {
guard let seedText, !seedText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
return []
}
return [ImagePlaygroundConcept.extracted(from: seedText, title: seedTitle)]
}()
content.imagePlaygroundSheet(
isPresented: $isPresented,
concepts: concepts,
onCompletion: { url in
isPresented = false
onCompletion(url)
},
onCancellation: { isPresented = false }
)
} else {
content
}
#else
content
#endif
}
}
#endif
@@ -9,8 +9,10 @@ final class DocumentReaderViewModel {
var emoji: String?
var text: String
var collectionId: String?
var parentDocumentId: String?
/// `nil` = draft (not published/visible to other workspace members).
var publishedAt: Date?
var isFullWidth = false
var children: [OutlineDocument] = []
var isLoading = false
var errorMessage: String?
@@ -18,6 +20,38 @@ final class DocumentReaderViewModel {
var isSaving = false
var saveErrorMessage: String?
/// Snapshot of the preference, set once via `.task` right after the
/// view appears (can't be read from `@Environment` inside the view's
/// own `init`) rather than a live binding to `SessionStore` matches
/// how `isFullWidth` etc. are already seeded from the document at init
/// rather than observed reactively. A change made in Settings while a
/// document is already open takes effect the next document opened, not
/// mid-session; an acceptable tradeoff for how rarely this gets
/// toggled versus the complexity of threading a live preference
/// reference through every reader instance.
var separateEditingEnabled: Bool
/// The single source of truth the view reads for both "show the title
/// field" and "is the text view editable" when separate editing is
/// off there's no Edit/Done mode at all, the document is just always
/// editable (assuming permission; there's no per-document permission
/// field to pre-check against, so an unauthorized edit simply fails to
/// save rather than being blocked client-side up front).
var isEffectivelyEditable: Bool {
separateEditingEnabled ? isEditing : true
}
private var autosaveTask: Task<Void, Never>?
/// Tracks the last known-synced-with-the-server values so
/// `scheduleAutosave()` can no-op when called just because `text`/
/// `title` were reassigned *from* a server response (initial load, or
/// a completed save) rather than actually edited without this, every
/// document open in the always-editable mode would fire one pointless
/// autosave round-trip immediately, re-sending exactly what was just
/// received.
private var lastSyncedText: String
private var lastSyncedTitle: String
/// Recent viewers, `views.list` filtered to entries that actually have a
/// `lastViewedAt` this is historical/aggregated view data, not live
/// "viewing right now" presence (that needs the Hocuspocus collaboration
@@ -38,14 +72,19 @@ final class DocumentReaderViewModel {
let documentId: String
private let apiClient: OutlineAPIClient
init(apiClient: OutlineAPIClient, document: OutlineDocument) {
init(apiClient: OutlineAPIClient, document: OutlineDocument, separateEditingEnabled: Bool = true) {
self.apiClient = apiClient
self.documentId = document.id
self.title = document.title
self.emoji = document.emoji
self.text = document.text
self.collectionId = document.collectionId
self.parentDocumentId = document.parentDocumentId
self.publishedAt = document.publishedAt
self.isFullWidth = document.fullWidth ?? false
self.separateEditingEnabled = separateEditingEnabled
self.lastSyncedText = document.text
self.lastSyncedTitle = document.title
}
/// The list endpoint's copy of a document isn't guaranteed to be the full,
@@ -61,17 +100,14 @@ final class DocumentReaderViewModel {
emoji = full.emoji
text = full.text
collectionId = full.collectionId
parentDocumentId = full.parentDocumentId
publishedAt = full.publishedAt
isFullWidth = full.fullWidth ?? false
lastSyncedText = full.text
lastSyncedTitle = full.title
} catch {
errorMessage = "Couldn't load this document. Check your connection and try again."
}
children = (try? await apiClient.listDocuments(
collectionId: nil,
parentDocumentId: documentId,
offset: 0,
limit: 100
)) ?? []
}
func loadViewers() async {
@@ -146,20 +182,55 @@ final class DocumentReaderViewModel {
}
}
/// Turning editing off saves; turning it on is just a mode switch.
/// Turning editing off saves; turning it on is just a mode switch. Only
/// meaningful when `separateEditingEnabled` the always-editable path
/// uses `scheduleAutosave()` instead.
func toggleEditing() async {
guard isEditing else {
isEditing = true
return
}
await save()
if saveErrorMessage == nil {
isEditing = false
}
}
/// Debounced save for the always-editable (separate editing off) path
/// cancels any pending save and starts a fresh countdown on every call,
/// so a save only actually fires once typing pauses, not on every
/// keystroke. Goes through the same `updateDocument` call the explicit
/// Done-button save uses, which is already offline-queue-aware
/// (`CachingOutlineAPIClient`), so autosave while offline just queues
/// like any other edit instead of needing separate handling here.
func scheduleAutosave() {
guard text != lastSyncedText || title != lastSyncedTitle else { return }
autosaveTask?.cancel()
autosaveTask = Task { [weak self] in
try? await Task.sleep(for: .seconds(1.5))
guard let self, !Task.isCancelled else { return }
await self.save()
}
}
private func save() async {
isSaving = true
saveErrorMessage = nil
defer { isSaving = false }
let sentTitle = title
let sentText = text
do {
let updated = try await apiClient.updateDocument(UpdateDocumentRequest(id: documentId, title: title, text: text))
title = updated.title
text = updated.text
isEditing = false
let updated = try await apiClient.updateDocument(UpdateDocumentRequest(id: documentId, title: sentTitle, text: sentText))
// Only reconcile with the server's response if nothing changed
// locally while the request was in flight otherwise this
// would clobber keystrokes typed during a debounced autosave's
// round trip. Whatever's newer goes out on the next autosave
// cycle regardless, since `scheduleAutosave()` keeps getting
// re-triggered by continued typing.
if title == sentTitle { title = updated.title }
if text == sentText { text = updated.text }
lastSyncedTitle = sentTitle
lastSyncedText = sentText
} catch {
saveErrorMessage = outlineErrorMessage(error, fallback: "Couldn't save this document.")
}
@@ -71,7 +71,7 @@ struct NewDocumentSheet: View {
ProgressView().frame(maxWidth: .infinity)
} else {
Picker("Collection", selection: $selectedCollectionID) {
Text("Choose a collection").tag(String?.none)
Text("Draft (not published)").tag(String?.none)
ForEach(collections) { collection in
Text(collection.name).tag(Optional(collection.id))
}
@@ -86,6 +86,12 @@ struct NewDocumentSheet: View {
}
.labelsHidden()
.disabled(selectedCollectionID == nil || isLoadingDestinationDocuments)
if selectedCollectionID != nil {
Label("This will publish the document immediately, making it visible to your workspace.", systemImage: "exclamationmark.triangle")
.font(.caption)
.foregroundStyle(.orange)
}
}
if let errorMessage {
@@ -97,18 +103,24 @@ struct NewDocumentSheet: View {
HStack {
Spacer()
Button("Cancel", role: .cancel) { dismiss() }
Button("Create") {
Button(selectedCollectionID == nil ? "Save as Draft" : "Create & Publish") {
Task { await create() }
}
.keyboardShortcut(.defaultAction)
.disabled(selectedCollectionID == nil || isCreating)
.disabled(isCreating)
}
}
.padding(20)
.frame(width: 380)
.task {
await loadCollections()
selectedCollectionID = initialCollectionID ?? initialParentDocument?.collectionId ?? collections.first?.id
// "By default" means a bare New Document (Home, reader
// toolbar) starts as a draft no `collections.first` fallback
// like there used to be. A contextual entry point (right-click
// a specific collection/document) still pre-fills that
// location, since that already carries clear placement intent;
// the publish warning above still applies to it either way.
selectedCollectionID = initialCollectionID ?? initialParentDocument?.collectionId
selectedParentID = initialParentDocument?.id
isTitleFocused = true
}
@@ -147,7 +159,6 @@ struct NewDocumentSheet: View {
}
private func create() async {
guard let selectedCollectionID else { return }
isCreating = true
defer { isCreating = false }
do {
@@ -156,7 +167,12 @@ struct NewDocumentSheet: View {
title: title.isEmpty ? "Untitled" : title,
text: "",
collectionId: selectedCollectionID,
parentDocumentId: selectedParentID
parentDocumentId: selectedParentID,
// No collection/parent selected = draft; Outline can't
// publish without one of the two regardless of this
// flag, but setting it explicitly rather than relying
// on that keeps intent obvious at the call site.
publish: selectedCollectionID != nil
)
)
onCreated(document)
@@ -0,0 +1,144 @@
#if os(macOS)
import SwiftUI
import OutlineKit
/// Destination picker for publishing a draft same collection-then-
/// optional-parent-document shape as `MoveDocumentSheet`, since Outline's
/// own web app treats "where does this go" identically for both actions.
/// Pre-selects the document's own `collectionId`/`parentDocumentId` when it
/// already has one (a draft can already belong to a collection without
/// being published) rather than starting blank, per explicit instruction.
///
/// Publishing itself is two calls, not one: `documents.update(publish:
/// true, collectionId:)` does the actual publish and top-level collection
/// placement in one round-trip (confirmed from a live network capture);
/// nesting under a specific parent document isn't a documented
/// `documents.update` field, so that part reuses `documents.move` the
/// same already-working mechanism `MoveDocumentSheet` uses as a second
/// step, only when a parent was actually chosen.
@MainActor
struct PublishDocumentSheet: View {
@Environment(\.dismiss) private var dismiss
let apiClient: OutlineAPIClient
let document: OutlineDocument
let onPublished: () async -> Void
@State private var collections: [OutlineCollection] = []
@State private var selectedCollectionID: String?
@State private var rootDocuments: [OutlineDocument] = []
@State private var selectedParentID: String?
@State private var isLoadingCollections = false
@State private var isLoadingDestinationDocuments = false
@State private var isPublishing = false
@State private var errorMessage: String?
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text("Publish \"\(document.title.isEmpty ? "Untitled" : document.title)\"")
.font(.headline)
Text("Choose where this document should live once published.")
.font(.callout)
.foregroundStyle(.secondary)
if isLoadingCollections {
ProgressView().frame(maxWidth: .infinity)
} else {
Picker("Collection", selection: $selectedCollectionID) {
Text("Choose a collection").tag(String?.none)
ForEach(collections) { collection in
Text(collection.name).tag(Optional(collection.id))
}
}
.labelsHidden()
Picker("Location", selection: $selectedParentID) {
Text("Collection root").tag(String?.none)
ForEach(rootDocuments.filter { $0.id != document.id }) { candidate in
Text(candidate.title.isEmpty ? "Untitled" : candidate.title).tag(Optional(candidate.id))
}
}
.labelsHidden()
.disabled(selectedCollectionID == nil || isLoadingDestinationDocuments)
}
if let errorMessage {
Text(errorMessage)
.font(.callout)
.foregroundStyle(.red)
}
HStack {
Spacer()
Button("Cancel", role: .cancel) { dismiss() }
Button("Publish") {
Task { await publish() }
}
.disabled(selectedCollectionID == nil || isPublishing)
}
}
.padding(20)
.frame(width: 360)
.task {
await loadCollections()
selectedCollectionID = document.collectionId
selectedParentID = document.parentDocumentId
}
.task(id: selectedCollectionID) {
await loadRootDocuments()
}
}
private func loadCollections() async {
isLoadingCollections = true
defer { isLoadingCollections = false }
do {
collections = try await apiClient.listCollections(offset: 0, limit: 100)
} catch {
errorMessage = outlineErrorMessage(error, fallback: "Couldn't load collections.")
}
}
private func loadRootDocuments() async {
guard let selectedCollectionID else {
rootDocuments = []
return
}
isLoadingDestinationDocuments = true
defer { isLoadingDestinationDocuments = false }
do {
rootDocuments = try await apiClient.listDocuments(
collectionId: selectedCollectionID,
parentDocumentId: nil,
offset: 0,
limit: 100
)
} catch {
errorMessage = outlineErrorMessage(error, fallback: "Couldn't load destination documents.")
}
}
private func publish() async {
guard let selectedCollectionID else { return }
isPublishing = true
defer { isPublishing = false }
do {
_ = try await apiClient.updateDocument(
UpdateDocumentRequest(id: document.id, collectionId: selectedCollectionID, publish: true)
)
// Only a documents.move call actually supports parentDocumentId
// skip it entirely when publishing straight to the collection root,
// the update above already placed it there.
if let selectedParentID {
try await apiClient.moveDocument(
MoveDocumentRequest(id: document.id, collectionId: selectedCollectionID, parentDocumentId: selectedParentID)
)
}
await onPublished()
dismiss()
} catch {
errorMessage = outlineErrorMessage(error, fallback: "Couldn't publish this document.")
}
}
}
#endif
+1
View File
@@ -5,6 +5,7 @@ enum HomeTab: String, CaseIterable, Identifiable {
case popular = "Popular"
case recentlyUpdated = "Recently Updated"
case createdByMe = "Created by Me"
case drafts = "Drafts"
var id: String { rawValue }
}
@@ -10,6 +10,7 @@ final class HomeViewModel {
private(set) var popular: [OutlineDocument] = []
private(set) var recentlyUpdated: [OutlineDocument] = []
private(set) var createdByMe: [OutlineDocument] = []
private(set) var drafts: [OutlineDocument] = []
var isLoadingPinned = false
var isLoadingTab = false
@@ -32,6 +33,7 @@ final class HomeViewModel {
case .popular: popular
case .recentlyUpdated: recentlyUpdated
case .createdByMe: createdByMe
case .drafts: drafts
}
}
@@ -115,6 +117,8 @@ final class HomeViewModel {
return try await apiClient.documentsList(
DocumentsListRequest(userId: userId, sort: "createdAt", direction: "DESC", limit: 25)
)
case .drafts:
return try await apiClient.listDrafts(ListDraftsRequest(limit: 25))
}
}
@@ -124,6 +128,7 @@ final class HomeViewModel {
case .popular: popular = documents
case .recentlyUpdated: recentlyUpdated = documents
case .createdByMe: createdByMe = documents
case .drafts: drafts = documents
}
}
+39 -8
View File
@@ -18,8 +18,8 @@ struct OutpostApp: App {
@AppStorage("outpost.appearance") private var appearance: AppAppearance = .system
#if os(macOS)
@Environment(\.openWindow) private var openWindow
@State private var isShowingLogoutConfirmation = false
@AppStorage("outpost.commandPaletteEnabled") private var isCommandPaletteEnabled = true
#endif
var body: some Scene {
@@ -34,13 +34,15 @@ struct OutpostApp: App {
.onAppear { applyMacAppearance() }
.onChange(of: appearance) { _, _ in applyMacAppearance() }
.logoutConfirmationDialog(isPresented: $isShowingLogoutConfirmation, session: session)
.background(TransparentTitlebarWindowAccessor())
#endif
}
#if os(macOS)
.commands {
CommandGroup(replacing: .appInfo) {
Button("About Outpost") {
openWindow(id: "about")
navigation.selectedSettingsSection = .about
navigation.isShowingSettings = true
}
}
// No `Settings {}` scene anymore Settings renders inside the
@@ -60,16 +62,21 @@ struct OutpostApp: App {
}
.disabled(!session.isSignedIn)
}
// Settings Editor Command Palette gates this disabled
// (not just a no-op) when the user's turned it off, matching
// how Settings/Log Out already disable rather than silently
// do nothing.
CommandGroup(after: .newItem) {
Button("Command Palette…") {
navigation.isShowingCommandPalette = true
}
.keyboardShortcut("k")
.disabled(!session.isSignedIn || !isCommandPaletteEnabled)
}
}
#endif
#if os(macOS)
Window("About Outpost", id: "about") {
AboutView()
.disablesFullScreen()
}
.windowResizability(.contentSize)
Window("Keyboard Shortcuts", id: "keyboard-shortcuts") {
KeyboardShortcutsView()
.disablesFullScreen()
@@ -95,3 +102,27 @@ struct OutpostApp: App {
}
#endif
}
#if os(macOS)
/// Makes the titlebar/toolbar strip blend into the sidebar's own background
/// instead of reading as a separate bar same look as Mail/Notes/Finder.
/// SwiftUI's `WindowGroup` exposes no direct hook for this, so this reaches
/// into the underlying `NSWindow` the way `applyMacAppearance()` above
/// reaches into `NSApp` for the same reason (no SwiftUI-level API exists).
/// A `View` (not the window itself) is what actually needs to extend under
/// the now-transparent titlebar `.fullSizeContentView` just makes room;
/// the sidebar's `.background` already does the rest with no other change.
private struct TransparentTitlebarWindowAccessor: NSViewRepresentable {
func makeNSView(context: Context) -> NSView {
let view = NSView()
DispatchQueue.main.async {
guard let window = view.window else { return }
window.titlebarAppearsTransparent = true
window.styleMask.insert(.fullSizeContentView)
}
return view
}
func updateNSView(_ nsView: NSView, context: Context) {}
}
#endif
+107 -4
View File
@@ -1,27 +1,128 @@
import Observation
enum SettingsSection: String, CaseIterable, Identifiable, Hashable {
case appearance, account, offlineSync, advanced, about
/// Top-level groupings shown as section headers in `SettingsSidebarList`.
/// `general` holds everything that's ours, not Outline's own settings
/// categories labeled "Outpost" so it reads as clearly distinct from the
/// Outline-sourced groups below it.
enum SettingsCategory: String, CaseIterable, Identifiable {
case general
case account
case workspace
var id: String { rawValue }
var title: String? {
switch self {
case .general: return "Outpost"
case .account: return "Account"
case .workspace: return "Workspace"
}
}
}
/// One entry in the Settings sidebar. Mirrors Outline's own settings
/// categories (Account/Workspace) so this app's settings read as a native
/// counterpart to the web app's, plus a `general` group for things that are
/// ours and don't map onto Outline's structure (offline/sync, advanced,
/// about, appearance). Outline's own version info moved to the sidebar
/// footer (`SettingsSidebarList`) instead of a standalone
/// Integrations & Installation section.
///
/// Most of the Account/Workspace cases are navigation-only for now
/// `SettingsView` renders a "Coming Soon" placeholder for anything not
/// explicitly built yet. Content lands section by section.
enum SettingsSection: String, CaseIterable, Identifiable, Hashable {
// General (ours)
case appearance, editor, navigation, offlineSync, advanced, about
// Account
case profile, preferences, notifications, passkeys, apiAccess
// Workspace
case details, authentication, security, ai, members, groups, templates, emojis, applications, shared, links, webhooks, importData, exportData
var id: String { rawValue }
var category: SettingsCategory {
switch self {
case .appearance, .editor, .navigation, .offlineSync, .advanced, .about:
return .general
case .profile, .preferences, .notifications, .passkeys, .apiAccess:
return .account
case .details, .authentication, .security, .ai, .members, .groups, .templates, .emojis, .applications, .shared, .links, .webhooks, .importData, .exportData:
return .workspace
}
}
var title: String {
switch self {
case .appearance: return "Appearance"
case .account: return "Account"
case .editor: return "Editor"
case .navigation: return "Navigation"
case .offlineSync: return "Offline & Sync"
case .advanced: return "Advanced"
case .about: return "About"
case .profile: return "Profile"
case .preferences: return "Preferences"
case .notifications: return "Notifications"
case .passkeys: return "Passkeys"
case .apiAccess: return "API & Access"
case .details: return "Details"
case .authentication: return "Authentication"
case .security: return "Security"
case .ai: return "AI"
case .members: return "Members"
case .groups: return "Groups"
case .templates: return "Templates"
case .emojis: return "Emojis"
case .applications: return "Applications"
case .shared: return "Shared"
case .links: return "Links"
case .webhooks: return "Webhooks"
case .importData: return "Import"
case .exportData: return "Export"
}
}
var icon: String {
switch self {
case .appearance: return "paintbrush"
case .account: return "person.crop.circle"
case .editor: return "square.split.2x1"
case .navigation: return "command"
case .offlineSync: return "arrow.triangle.2.circlepath"
case .advanced: return "wrench.and.screwdriver"
case .about: return "info.circle"
case .profile: return "person.crop.circle"
case .preferences: return "gearshape"
case .notifications: return "bell"
case .passkeys: return "key"
case .apiAccess: return "chevron.left.forwardslash.chevron.right"
case .details: return "building.2"
case .authentication: return "lock"
case .security: return "shield"
case .ai: return "sparkles"
case .members: return "person.2"
case .groups: return "person.3"
case .templates: return "doc.on.doc"
case .emojis: return "face.smiling"
case .applications: return "app.badge"
case .shared: return "square.and.arrow.up.on.square"
case .links: return "link"
case .webhooks: return "bolt.horizontal"
case .importData: return "square.and.arrow.down"
case .exportData: return "square.and.arrow.up"
}
}
/// Everything actually built so far everything else in Account/
/// Workspace renders a "Coming Soon" placeholder until its content is
/// specified and built.
var isImplemented: Bool {
switch self {
case .appearance, .editor, .navigation, .offlineSync, .advanced, .about, .profile, .preferences, .notifications, .passkeys, .apiAccess:
return true
default:
return false
}
}
}
@@ -37,4 +138,6 @@ enum SettingsSection: String, CaseIterable, Identifiable, Hashable {
final class AppNavigation {
var isShowingSettings = false
var selectedSettingsSection: SettingsSection? = .appearance
/// K, see `OutpostApp`'s `CommandGroup` and `CommandPaletteView`.
var isShowingCommandPalette = false
}
+77 -3
View File
@@ -6,14 +6,25 @@ import OutlineKit
@Observable
final class SessionStore {
private static let serverURLDefaultsKey = "outline.serverURL"
/// Preferences now drive real editor behavior (separate editing, etc.),
/// not just a settings screen they need to survive a cold launch with
/// no network, not just live in memory from the last successful fetch.
/// Still read-only while offline (Settings already gates every toggle
/// on `isEffectivelyOnline`) this only makes the *last known* values
/// available, never lets them be changed without a server round-trip.
private static let userPreferencesDefaultsKey = "outline.userPreferences"
private let tokenStore: TokenStoring
private let defaults: UserDefaults
var isSignedIn: Bool
private(set) var userId: String?
var userName: String?
var userEmail: String?
var userAvatarURL: URL?
var userLanguage: String?
var userPreferences: OutlineUserPreferences?
var userNotificationSettings: [String: Bool]?
var teamName: String?
var teamAvatarURL: URL?
private(set) var apiClient: OutlineAPIClient?
@@ -35,11 +46,28 @@ final class SessionStore {
init(tokenStore: TokenStoring = KeychainTokenStore(), defaults: UserDefaults = .standard) {
self.tokenStore = tokenStore
self.defaults = defaults
self.isSignedIn = (try? tokenStore.token()) != nil
self.cacheStore = (try? OfflineCacheStore.makeContainer()).map(OfflineCacheStore.init(modelContainer:))
if isSignedIn, let serverURL {
(apiClient, cachingClient) = Self.makeAPIClient(serverURL: serverURL, tokenStore: tokenStore, cache: cacheStore)
let hasToken = (try? tokenStore.token()) != nil
let storedServerURL = defaults.string(forKey: Self.serverURLDefaultsKey).flatMap(URL.init(string:))
if hasToken, let storedServerURL {
isSignedIn = true
(apiClient, cachingClient) = Self.makeAPIClient(serverURL: storedServerURL, tokenStore: tokenStore, cache: cacheStore)
userPreferences = Self.loadCachedPreferences(defaults: defaults)
} else {
// Keychain and the sandboxed UserDefaults container don't
// always survive together a Keychain item written by an
// older-signed build can outlive a reinstall that wipes the
// container (or vice versa), leaving a token with no server or
// a server with no token. Clear whichever half survived rather
// than showing a broken "signed in" UI with no working
// apiClient a fresh sign-in rewrites both consistently.
if hasToken {
try? tokenStore.clear()
}
defaults.removeObject(forKey: Self.serverURLDefaultsKey)
isSignedIn = false
}
}
@@ -50,6 +78,24 @@ final class SessionStore {
isSignedIn = true
}
/// `static` (not an instance method) so `init` can call it before every
/// stored property has a value same reason `makeAPIClient` is static.
private static func loadCachedPreferences(defaults: UserDefaults) -> OutlineUserPreferences? {
guard let data = defaults.data(forKey: userPreferencesDefaultsKey) else { return nil }
return try? JSONDecoder().decode(OutlineUserPreferences.self, from: data)
}
/// `nil` clears the cache instead of writing a `null` happens whenever
/// a fresh fetch legitimately comes back with no preferences set, so a
/// stale cached value from a previous account/state can't linger.
private func cachePreferences(_ preferences: OutlineUserPreferences?) {
guard let preferences, let data = try? JSONEncoder().encode(preferences) else {
defaults.removeObject(forKey: Self.userPreferencesDefaultsKey)
return
}
defaults.set(data, forKey: Self.userPreferencesDefaultsKey)
}
private static func makeAPIClient(
serverURL: URL,
tokenStore: TokenStoring,
@@ -68,13 +114,22 @@ final class SessionStore {
try? tokenStore.clear()
defaults.removeObject(forKey: Self.serverURLDefaultsKey)
isSignedIn = false
userId = nil
userName = nil
userEmail = nil
userAvatarURL = nil
userLanguage = nil
userPreferences = nil
userNotificationSettings = nil
teamName = nil
teamAvatarURL = nil
apiClient = nil
cachingClient = nil
defaults.removeObject(forKey: Self.userPreferencesDefaultsKey)
// Same key `ContentView_macOS` persists "Remember previous
// location" under cleared here too so switching accounts/servers
// can't restore a stale location that belongs to a different sign-in.
defaults.removeObject(forKey: "outline.lastLocation")
}
/// Re-fetches user/workspace name/logo on relaunch, when the token survived but this
@@ -85,13 +140,32 @@ final class SessionStore {
apply(user: auth.user, team: auth.team, serverURL: serverURL)
}
/// Settings calls this after a successful name/avatar change so the
/// sidebar's account footer and everywhere else reading these reflect
/// it immediately, without waiting for the next `auth.info` refresh.
func applyUpdatedProfile(_ user: OutlineUser) {
guard let serverURL else { return }
userId = user.id
userName = user.name
userAvatarURL = user.avatarUrl.flatMap { URL(string: $0, relativeTo: serverURL)?.absoluteURL }
userLanguage = user.language
userPreferences = user.preferences
cachePreferences(user.preferences)
userNotificationSettings = user.notificationSettings
}
private func apply(user: OutlineUser, team: OutlineTeam, serverURL: URL) {
userId = user.id
userName = user.name
userEmail = user.email
// Outline can return either an absolute URL or a server-relative path
// (e.g. `/api/files.get?key=...`) for avatarUrl resolve against the
// configured server so relative paths don't fail as "unsupported URL".
userAvatarURL = user.avatarUrl.flatMap { URL(string: $0, relativeTo: serverURL)?.absoluteURL }
userLanguage = user.language
userPreferences = user.preferences
cachePreferences(user.preferences)
userNotificationSettings = user.notificationSettings
teamName = team.name
teamAvatarURL = team.avatarUrl.flatMap { URL(string: $0, relativeTo: serverURL)?.absoluteURL }
}
+38 -2
View File
@@ -1,4 +1,5 @@
import SwiftUI
import OutlineKit
#if os(macOS)
import AppKit
@@ -41,11 +42,46 @@ struct AvatarBadge: View {
.task(id: avatarURL) {
loadedImage = nil
guard let avatarURL else { return }
guard let (data, _) = try? await URLSession.shared.data(from: avatarURL) else { return }
loadedImage = PlatformImage(data: data)
loadedImage = await Self.loadImage(from: avatarURL)
}
}
/// The real fix, confirmed against a live network capture: every other
/// request this app makes attaches `Authorization: Bearer <token>`
/// this one never did, sending a bare unauthenticated GET. Outline's
/// browser session authenticates `attachments.redirect` via cookies
/// instead, which a native app doesn't have; the API-token equivalent
/// is the same Bearer header every RPC call already uses. Almost
/// certainly means no avatar image (not just a freshly-uploaded one)
/// has ever actually loaded in this app a 401 and a "no avatar set"
/// look identical here, both just fall back to the placeholder icon
/// with nothing on screen to flag it as an error.
///
/// The retry loop is a secondary, independent hardening cheap
/// insurance against a self-hosted reverse-proxied storage backend not
/// being instantly consistent right after an upload kept alongside
/// the auth fix rather than instead of it.
private static func loadImage(from url: URL) async -> PlatformImage? {
var request = URLRequest(url: url)
request.cachePolicy = .reloadIgnoringLocalCacheData
if let token = try? KeychainTokenStore().token() {
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
}
for attempt in 0..<3 {
if attempt > 0 {
try? await Task.sleep(for: .milliseconds(400))
}
if let (data, response) = try? await URLSession.shared.data(for: request),
let httpResponse = response as? HTTPURLResponse,
(200...299).contains(httpResponse.statusCode),
let image = PlatformImage(data: data) {
return image
}
}
return nil
}
private func platformImage(_ image: PlatformImage) -> Image {
#if os(macOS)
Image(nsImage: image)
+25
View File
@@ -0,0 +1,25 @@
#if os(macOS)
import AppKit
/// `NSMenuItem` has no closure-based initializer the standard AppKit
/// pattern is a small subclass that's its own target/action, so callers can
/// just pass a Swift closure instead of wiring up a selector by hand.
final class ClosureMenuItem: NSMenuItem {
private let handler: () -> Void
init(title: String, handler: @escaping () -> Void) {
self.handler = handler
super.init(title: title, action: #selector(invokeHandler), keyEquivalent: "")
self.target = self
}
@available(*, unavailable)
required init(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
@objc private func invokeHandler() {
handler()
}
}
#endif
@@ -0,0 +1,11 @@
#if os(macOS)
import MarkdownEngineCodeBlocks
/// One `HighlighterSwiftBridge` for the whole app. It owns a JavaScriptCore
/// context (expensive to spin up) plus its own highlight cache, so every
/// `NativeTextViewWrapper` should share this instance rather than each
/// constructing its own.
enum CodeSyntaxHighlighting {
static let shared = HighlighterSwiftBridge()
}
#endif
+14
View File
@@ -0,0 +1,14 @@
#if os(macOS)
import AppKit
extension NSImage {
/// `NSImage` has no built-in JPEG encoder (unlike `UIImage`) routes
/// through a bitmap representation to get one.
func jpegData(compressionQuality: CGFloat) -> Data? {
guard let tiffData = tiffRepresentation, let bitmap = NSBitmapImageRep(data: tiffData) else {
return nil
}
return bitmap.representation(using: .jpeg, properties: [.compressionFactor: compressionQuality])
}
}
#endif
@@ -0,0 +1,84 @@
#if os(macOS)
import AppKit
import MarkdownEngine
import OutlineKit
/// Resolves `![alt](url)` Markdown image references for the editor.
///
/// `EmbeddedImageProvider.image(for:)` is called synchronously from the
/// styling pipeline and must return immediately it can't `await` a
/// network fetch inline. So a miss kicks off an async load in the
/// background, caches the result, and calls `onImageLoaded` (on the main
/// thread) once it lands; the embedder is responsible for turning that into
/// a real SwiftUI update (see `DocumentReaderView`'s `imageReloadTick`) so
/// `updateNSView` runs again, notices `fingerprint()` changed, and
/// restyles the engine has no polling of its own.
///
/// `url` is usually a server-relative path like
/// `/api/attachments.redirect?id=<uuid>` Outline's own stable reference
/// for an uploaded attachment, which needs the same Bearer auth as every
/// other OutlineKit request (`OutlineAPIClient.fetchAuthenticatedFile`).
/// A plain absolute `http(s)://` URL (an external image someone pasted) is
/// fetched directly instead, no auth attached.
///
/// Thread-safety mirrors `HighlighterSwiftBridge`: `NSCache` for the image
/// store (inherently thread-safe), a lock for the small bit of state that
/// isn't (`version`, `inFlight`) no actor isolation, since the engine may
/// call `image(for:)`/`fingerprint()` from whatever thread is styling.
final class OutlineImageProvider: EmbeddedImageProvider, @unchecked Sendable {
private let apiClient: OutlineAPIClient
private let cache = NSCache<NSString, NSImage>()
private let lock = NSLock()
private var inFlight: Set<String> = []
private var version = 0
/// Set by the embedder; called on the main thread whenever a load
/// completes and `fingerprint()` has changed.
var onImageLoaded: (@Sendable () -> Void)?
init(apiClient: OutlineAPIClient) {
self.apiClient = apiClient
}
func image(for reference: EmbeddedImageRequest) -> NSImage? {
let url = reference.name
if let cached = cache.object(forKey: url as NSString) {
return cached
}
beginLoad(url)
return nil
}
func fingerprint() -> AnyHashable {
lock.withLock { version }
}
private func beginLoad(_ url: String) {
let alreadyLoading: Bool = lock.withLock {
if inFlight.contains(url) { return true }
inFlight.insert(url)
return false
}
guard !alreadyLoading else { return }
Task {
defer { lock.withLock { inFlight.remove(url) } }
do {
let data: Data
if url.hasPrefix("http://") || url.hasPrefix("https://"), let externalURL = URL(string: url) {
(data, _) = try await URLSession.shared.data(from: externalURL)
} else {
data = try await apiClient.fetchAuthenticatedFile(path: url)
}
guard let image = NSImage(data: data) else { return }
cache.setObject(image, forKey: url as NSString)
lock.withLock { version += 1 }
onImageLoaded?()
} catch {
// Best-effort: a failed load just leaves the Markdown source
// visible (the engine's existing fallback for `image(for:)
// == nil`), no separate error UI for an inline image fetch.
}
}
}
}
#endif
+35
View File
@@ -0,0 +1,35 @@
import Foundation
/// Outline's interface-language options. Not exhaustive Outline accepts
/// community translations via its own translation portal, so the real list
/// on any given server can be longer than this; this covers the common
/// cases and falls back to showing whatever code the server already has
/// set even if it isn't in this list.
struct OutlineLocale: Identifiable, Hashable {
let code: String
let label: String
var id: String { code }
static let all: [OutlineLocale] = [
OutlineLocale(code: "en_US", label: "English (US)"),
OutlineLocale(code: "en_GB", label: "English (UK)"),
OutlineLocale(code: "de_DE", label: "Deutsch"),
OutlineLocale(code: "fr_FR", label: "Français"),
OutlineLocale(code: "es_ES", label: "Español"),
OutlineLocale(code: "pt_PT", label: "Português"),
OutlineLocale(code: "pt_BR", label: "Português (Brasil)"),
OutlineLocale(code: "it_IT", label: "Italiano"),
OutlineLocale(code: "nl_NL", label: "Nederlands"),
OutlineLocale(code: "pl_PL", label: "Polski"),
OutlineLocale(code: "ru_RU", label: "Русский"),
OutlineLocale(code: "ja_JP", label: "日本語"),
OutlineLocale(code: "ko_KR", label: "한국어"),
OutlineLocale(code: "zh_CN", label: "中文 (简体)"),
OutlineLocale(code: "zh_TW", label: "中文 (繁體)"),
]
static func label(for code: String) -> String {
all.first(where: { $0.code == code })?.label ?? code
}
}
+31
View File
@@ -0,0 +1,31 @@
import Foundation
/// Single source of truth for how Outpost's own version is formatted
/// used by both the About page and the Settings sidebar footer, so they
/// can't drift out of sync the way `AboutInfoView` was already written to
/// avoid for its own two call sites.
enum OutpostVersion {
/// Bumped alongside `MARKETING_VERSION` in the Xcode project kept out
/// of the bundle version itself since `CFBundleShortVersionString` is
/// expected to stay a plain dotted-numeric string, not `0.0.1-ALPHA`.
static let releaseStage = "ALPHA"
static var shortVersion: String {
Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0.0.1"
}
static var buildNumber: String {
Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "1"
}
/// e.g. `"0.0.3-ALPHA"` for compact display (sidebar footer).
static var displayString: String {
let stageSuffix = releaseStage.isEmpty ? "" : "-\(releaseStage)"
return "\(shortVersion)\(stageSuffix)"
}
/// e.g. `"Version 0.0.3-ALPHA (1)"` for the About page.
static var fullVersionString: String {
"Version \(displayString) (\(buildNumber))"
}
}
+32 -14
View File
@@ -1,27 +1,29 @@
# Outpost
<p align="center">
<img src="Outpost/Assets.xcassets/AppLogo.imageset/outpost-ios-1024.png" width="120" alt="Outpost logo">
</p>
<h1 align="center">Outpost</h1>
<p align="center">
<a href="https://testflight.apple.com/join/y1mYcYAM">
<img src="https://img.shields.io/badge/Download-TestFlight-0D96F6?style=for-the-badge&logo=apple&logoColor=white" alt="Download on TestFlight">
</a>
</p>
A native Apple ecosystem client for [Outline](https://github.com/outline/outline) — built for iOS, iPadOS, and macOS from a single SwiftUI codebase, aiming for full editing parity with Outline's web app, including realtime collaborative editing.
> **Early alpha — macOS only for now.** Expect missing features and rough edges. iOS/iPadOS support is planned but not in the current build. See the [releases page](https://git.psmattas.com/psmattas/Outpost/releases) for changelogs, and [open an issue](https://git.psmattas.com/psmattas/Outpost/issues) if you hit anything.
## Why
Outline's web app is great, but there's no native Apple client with full editing parity. This project connects to a self-hosted Outline instance over its REST API and realtime collaboration socket to provide a proper native experience across the Apple ecosystem.
## Status
Early development. See `CLAUDE.md` for the current architecture and phased build plan.
- [ ] Phase 1 — Auth, browse, search, REST-only editing
- [ ] Phase 2 — Realtime collaborative editing (Yjs/Hocuspocus)
- [ ] Phase 3 — Offline cache, tables, embeds, comments, macOS polish
## Requirements
- Xcode 16+
- iOS 17+ / iPadOS 17+ / macOS 14+
- Xcode 27+ (currently developed against an Xcode 27 beta — this is a hard minimum, not a suggestion)
- macOS 27+. iOS/iPadOS support is planned but not in the current build (see the alpha note above) — same 27+ minimum will apply once it lands
- A self-hosted (or hosted) Outline instance with API access
> They will be updated. These are old requirements.
## Setup
1. Clone the repo and open the `.xcodeproj` in Xcode.
@@ -40,7 +42,23 @@ Parts of this codebase are AI-assisted (built with the help of AI coding tools).
## License
This project is a client only — it does not include, vendor, or redistribute any of Outline's (BSL 1.1 licensed) server source. See [`LICENSE`](./LICENSE) for this repository's own license.
Outpost itself is licensed under the [Business Source License 1.1](./LICENSE) — the same license family Outline's own server uses, for the same reason. In short: free to read, self-host, and modify for personal or non-commercial use; not free to repackage as a competing hosted product or to distribute under a name/branding that claims official or affiliated status. It converts automatically to Apache License 2.0 on the change date stated in [`LICENSE`](./LICENSE). "Outpost" and its logo are trademarks of the project — see the license's trademark notice.
This project is a client only — it does not include, vendor, or redistribute any of Outline's own (also BSL 1.1 licensed) server source.
## Credits
The native markdown editor is built on [swift-markdown-engine](https://github.com/nodes-app/swift-markdown-engine) by Luca Chen, licensed under Apache License 2.0. It's vendored directly in [`Vendor/swift-markdown-engine`](./Vendor/swift-markdown-engine) (see its [`LICENSE`](./Vendor/swift-markdown-engine/LICENSE)); local fixes made in that copy haven't been upstreamed yet.
## Privacy
Outpost collects nothing about you — no analytics, no telemetry, no crash reporting of its own, no age or demographic data, nothing. The only thing stored locally is your Outline server URL and API token (in the device Keychain) and, optionally, a local offline cache of what you've viewed. Everything else goes straight from your device to whatever Outline server you configure — there's no backend in between, and the developer has no access to your data or your server.
Full policy, terms of service, and data-processing statement are on the [wiki](https://git.psmattas.com/psmattas/Outpost/wiki):
- [Privacy Policy](https://git.psmattas.com/psmattas/Outpost/wiki/Privacy-Policy.-)
- [Terms of Service](https://git.psmattas.com/psmattas/Outpost/wiki/Terms-of-Service.-)
- [Data Processing Statement](https://git.psmattas.com/psmattas/Outpost/wiki/Data-Processing-Statement.-)
## Not affiliated with Outline
+32
View File
@@ -0,0 +1,32 @@
name: CI
on:
push:
branches: [main]
pull_request:
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
name: Build & Test (macOS)
runs-on: macos-15
steps:
- name: Check out
uses: actions/checkout@v4
- name: Select Xcode
uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: latest-stable
- name: Show Swift version
run: swift --version
- name: Build
run: swift build -v
- name: Test
run: swift test --parallel
+17
View File
@@ -0,0 +1,17 @@
# macOS
.DS_Store
# Swift Package Manager
/.build
/.swiftpm/xcode
Package.resolved
# Xcode
xcuserdata/
DerivedData/
*.xcodeproj/project.xcworkspace/xcuserdata/
*.xcodeproj/xcuserdata/
# Generated documentation
/docs
*.doccarchive
+4
View File
@@ -0,0 +1,4 @@
version: 1
builder:
configs:
- documentation_targets: [MarkdownEngine]
+183
View File
@@ -0,0 +1,183 @@
# Architecture
## Source layout
```bash
Sources/
├── MarkdownEngine/ # core target — zero deps
│ ├── Configuration/ # MarkdownEditorConfiguration + MarkdownEditorTheme
│ ├── Extensions/ # the extension seam: MarkdownExtension + bundled opt-ins
│ ├── 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
│ ├── Renderer/ # LayoutBridge, MarkdownTextLayoutFragment, EmbeddedImageCache
│ ├── Input/ # MarkdownInputHandler + MarkdownListHandler
│ ├── TextView/
│ │ ├── NativeTextViewWrapper.swift # SwiftUI entry point (NSViewRepresentable)
│ │ ├── NativeTextViewContainer.swift # the scroll view's documentView: header band + text column stacking
│ │ ├── ScrollingHeaderController.swift # scroll-away header: hosting, collapse/expand, teardown
│ │ ├── ClampedScrollView.swift # scroll range clamped to real content height
│ │ ├── NativeTextView/ # AppKit subclass + UX extensions (paste, drag-select, …)
│ │ └── Coordinator/ # NSTextViewDelegate split by concern (restyling, find, …)
│ └── MarkdownEngine.docc/ # DocC catalog
├── MarkdownEngineCodeBlocks/ # opt-in SPM product — pulls in HighlighterSwift
│ └── HighlighterSwiftBridge.swift # SyntaxHighlighter conformance
└── MarkdownEngineLatex/ # opt-in SPM product — pulls in SwiftMath
└── SwiftMathBridge.swift # LatexRenderer conformance
```
The rest of this file is a per-directory tour, in the order text flows
through the engine.
## [`Parser/`](Sources/MarkdownEngine/Parser): text → AST → tokens
A two-phase AST pipeline following CommonMark's model — block structure first,
inline content second. There is no regex tokenizer anymore; the structural
regexes are gone, replaced by hand-written scanners and a real syntax tree.
1. **`BlockParser`** splits the document into a flat, gap-free (tiling)
sequence of `Block`s: `heading`, `paragraph`, `blockquote`, `list`,
`fencedCode`, `blockLatex`, `table`, `thematicBreak`, `blank`. Hand-written
line scanners. It memoizes the last parse (UTF-16 buffer cache) so the
per-keystroke callers share one line-scan.
2. **`InlineParser`** turns a single inline-bearing block's text into an inline
AST (`[InlineNode]`) with correct CommonMark precedence: code spans →
escapes → link family (`![[…]]`, `[[…]]`, `![…](…)`, `[…](…)`, `~~…~~`,
`$…$`) → emphasis (`*`/`_` delimiter runs) → `buildTree`. Each pass claims
spans only in regions not already claimed, so there are never partial
overlaps and the tree is a clean containment tree. That invariant is also
what keeps the pass linear in span count: claimed ranges are consulted
through a cursor rather than rescanned, and `buildTree` derives containment
from a sort instead of comparing spans pairwise.
3. **`MarkdownAST` / `DocumentAST.parse`** combines the two into the semantic
document AST — `[BlockNode]`, each inline-bearing block carrying its parsed
`[InlineNode]` children in absolute document coordinates. `BlockNode`,
`InlineNode`, and `ListItem` are defined here.
**Tokens are now a projection of the AST, not the source of truth.**
`MarkdownTokenizer` is just a namespace; its entry point `parseTokensViaAST`
(implemented in **`BlockScopedTokenizer`** — the live tokenization pipeline)
walks each `BlockParser` block and emits the legacy flat `[MarkdownToken]`
shape: block-level tokens (heading, blockquote, fenced code, table, block
LaTeX) come from **`BlockLevelTokenizer`** (hand scanners), inline tokens from
the AST via **`InlineASTAdapter`** (`[InlineNode]``[MarkdownToken]`). Token
shapes are reproduced 1:1 from the old regex tokenizer (parity-checked), so the
consumers that still read tokens — the NSImage render passes, code-block
handling, `MarkdownInputHandler`, `ContextMenu`, and `MarkdownDetection`
(caret-aware active-token indices) — keep working unchanged.
**Invariant:** Ranges everywhere are absolute UTF-16 `NSRange`s into the source
(the editor is TextKit-2 / `NSTextView`-based, so UTF-16 offsets are the native
currency).
**Invariant:** Parsing is incremental. With `scopedRanges`, `DocumentAST.parse`
parses inlines only for blocks intersecting the edit, and `BlockScopedTokenizer`
memoizes per-block tokens (substring → tokens, FIFO-capped) — so a keystroke
re-parses one block, not the whole document (≈ O(edit)).
## [`Extensions/`](Sources/MarkdownEngine/Extensions): opt-in constructs beyond pure markdown
`MarkdownExtension` contributes an inline span form (`InlineSyntax`, e.g.
`==highlight==`), a fenced block form (`BlockSyntax`, e.g. `::: … :::`), or
both — plus content attributes and an HTML wrapper for the clean-copy path.
Registered via `MarkdownEditorConfiguration.extensions`; unregistered syntax
stays literal text. Extensions never emit ranges — the parser derives all
geometry — and every parse cache keys on the registry fingerprint, so the
registered set can change at runtime.
**Invariant:** built-in constructs always classify first; an extension can
never take text away from core markdown.
## [`Services/`](Sources/MarkdownEngine/Services): how does the engine talk to your app?
`MarkdownEditorServices.swift` declares the four service protocols. Each is
called synchronously when its construct is styled or rendered: `WikiLinkResolver`
while styling wiki-links, `EmbeddedImageProvider` from the image-embed render
pass, `SyntaxHighlighter` from code styling, `LatexRenderer` from the LaTeX
render passes.
`WikiLinkService.swift` handles the dual-form storage / display transform —
storage is `[[Name|<id>]]`, display is `[[Name]]`. The coordinator runs it both
ways every time `rebuildTextStorageAndStyle()` fires.
**Invariant:** Service callbacks are synchronous. If an embedder's
implementation is slow, it caches (both bundled bridges do); the engine never
async-renders.
**Invariant:** Wiki-link storage and display are different strings. Display IDs
never leak into the binding.
## [`Styling/`](Sources/MarkdownEngine/Styling): how does the AST become attributes?
`MarkdownASTStyler.styleAttributes()` is the live styler. It walks the document
AST and emits `[StyledRange]`, **composing** attributes on descent: a heading
sets a large bold font, descending into bold adds the bold trait (keeping the
size), into italic adds italic — so nested / combined inline styles stack
instead of overwriting each other. (Composition is what the old flat pass
pipeline got wrong, e.g. the shrinking bold in `# **n*o*des**`.)
`MarkdownStyler.styleAttributes()` (`MarkdownStyler.swift:43`) is now a thin
facade: it builds the `StylingContext`, runs the AST styler for all text
styling, then appends the passes that still render **NSImages** and therefore
still consume tokens — block / inline LaTeX (`+Latex`), image embeds and image
links (`+Images`), and rendered tables (`+Tables`). `MarkdownStyler+TaskCheckboxes`
and `+BulletMarkers` no longer style (the AST styler does); they keep only the
caret / selection range helpers (`taskSyntaxRange`, `bulletSyntaxRange`,
`hrLineRange`) the text-view delegate uses.
If the coordinator passes `scopedRanges`, only the intersecting blocks are
re-styled — the optimization that keeps per-keystroke restyling cheap.
**Invariant:** Markers shrink, they don't disappear. Inactive markers render at
`hiddenMarkerFontSize`; they're never removed from text storage. Every
selection / copy / find / undo bug downstream traces back to violating this.
## [`Renderer/`](Sources/MarkdownEngine/Renderer): TextKit 2 layout
Thin wrappers around `NSTextLayoutManager` (`LayoutBridge.swift`), a custom
`MarkdownTextLayoutFragment` for precise positioning, and `EmbeddedImageCache`
keyed by an embedder-supplied fingerprint so images and LaTeX results
invalidate when the embedder says so.
## [`Input/`](Sources/MarkdownEngine/Input): typing-time helpers
`MarkdownInputHandler.swift` handles auto-wrap for `$…$` / `$$…$$` / `![[…]]`.
`MarkdownListHandler.swift` handles list continuation, indent / outdent, and
task-checkbox toggling on Enter / Tab / Backspace. Both run synchronously inside
the text-view delegate.
## [`TextView/`](Sources/MarkdownEngine/TextView): NSTextView + SwiftUI bridge
The entry point is `NativeTextViewWrapper.swift` — an `NSViewRepresentable` that
owns the coordinator and the configured text view.
The scroll view's `documentView` is **always** `NativeTextViewContainer`, never
the text view itself. The container stacks up to three kinds of siblings in a
flipped coordinate space: the optional scroll-away header band at the top (a
clipped `NSHostingView` managed by `ScrollingHeaderController`, reserved height
mirrored into `container.headerHeight`), the `NativeTextView` at
`y = headerHeight` (centered at a fixed width when
`configuration.readingWidth` is set), and — in reading-column mode — the
full-width wide-table breakout overlays. Anything that converts between
text-view-local rects and scroll/document space must lift by the text view's
origin inside the container (`convert(_:to:)` or `frame.origin`); see
`viewRect(forCharacterRange:)` and the find-in-document paths for the pattern.
Two sub-folders matter:
- `NativeTextView/` — extensions on the AppKit subclass (paste, drag-select
boost, spell policy, caret workarounds, frame/overscroll management)
- `Coordinator/``NSTextViewDelegate` glue, split by concern (restyling,
writing-tools, find, code-blocks, inline selection, autocorrect)
Application of `[StyledRange]` to text storage happens in
`Coordinator/NativeTextViewCoordinator+Restyling.swift`
`rebuildTextStorageAndStyle()`, which tokenizes via `parseTokensViaAST` and
calls `MarkdownStyler.styleAttributes()`.
## [`Configuration/`](Sources/MarkdownEngine/Configuration): the tunables
`MarkdownEditorConfiguration` is a struct of structs — one nested group per
concern (headings, codeBlock, blockLatex, overscroll, markers, lists, …) —
passed by reference into the styler via the `StylingContext`.
`MarkdownEditorTheme` is its colour sub-field.
+377
View File
@@ -0,0 +1,377 @@
# Changelog
All notable changes to swift-markdown-engine are documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [0.12.0] - 2026-08-10
### Added
- `onPersistScrollOffset` / `restoreScrollOffset` on `NativeTextViewWrapper`
scroll memory an embedder can keep somewhere that outlives the editor. The
engine's own per-document offsets live on the coordinator, so an embedder that
routes to a different screen and back lost them: nothing recorded the offset on
the way out (there was no `dismantleNSView` at all), and the restore was gated
on a document switch, which a remount is not — `makeCoordinator` seeds
`documentId`, so the first update pass never looks like one. Teardown now hands
the offset over, and the restore is latched instead of gated, retrying for a
bounded few passes because the first pass after a remount still carries the
embedder's empty buffer. Both closures are asked at call time, so the
embedder's own retention rules can see changes made on the way out. Passing
neither leaves behavior unchanged.
- `NSAttributedString.Key.markdownBlockBackground` — a background painted
across the whole line box by `MarkdownTextLayoutFragment` instead of the
glyph box AppKit's `.backgroundColor` covers. Embedder extensions can use it
wherever a fill should read as a block.
### Changed
- **Inline parse cost is linear in the spans per region, not quadratic.** Every
pass after the first consulted the claimed ranges by scanning the whole array
— once per character in `scanEscapes` and `collectDelimiterRuns`, once per
candidate in `scanLinkFamily` — and `buildTree` decided containment by testing
each span against every other. The passes walk the string left to right and
claimed ranges never partially overlap, so a cursor over the sorted ranges answers both
questions in amortised constant time, and sorting spans by start ascending /
length descending turns containment into a single ordered walk. A paragraph of
240 code spans parses in 0.5ms rather than 33ms; 6x the spans now costs 6x the
parse instead of ~30x. Affects every claimed-span construct — code, escapes,
links, images, wiki links, inline LaTeX, emphasis, and extension spans. No
parse result changes.
- `==highlight==` fills the line box. AppKit paints `.backgroundColor` over
ascent + descent only, so the marker fell short of the line height by the
leading plus `paragraph.lineHeightExtraSpacing`, and a highlight that wrapped
came out as a stack of bands. `HighlightExtension` returns
`.markdownBlockBackground` now, so the block is continuous at any font size.
Table cells rasterize their own text and keep the glyph-box fill.
### Fixed
- Bare URLs and emails survive rich copy as real links. The editor styler
linkifies them with `NSDataDetector`, but the HTML renderer emitted them as
plain text, so the pasteboard's HTML/RTF/web-archive flavors carried no anchor
at all, and whether a copied URL arrived clickable was left to the receiving
app — Apple Mail runs its own detection and linkifies anyway, a consumer that
takes the rich flavor verbatim pastes dead text. `MarkdownHTMLRenderer` now
wraps detector matches in `<a href>` (emails as `mailto:`) using the same
system detector as the styler; the RTF and web-archive flavors are derived
from that HTML, so all three inherit the link. Explicit `[title](url)` links
were already correct; a URL-shaped run inside a link's own title stays plain
so anchors never nest, and code spans remain excluded, matching the styler.
Table cells are unaffected: they render no inline markup on the copy path.
- Markdown link labels may hold inline code and escaped punctuation —
``[`App`](/tmp/App.swift:56)`` stayed literal. Code spans and escapes are
claimed before links so they stay opaque, and the link pass rejected every
candidate overlapping a claimed span, including one lying entirely inside the
label. Spans contained in the label are permitted now, links act as
containers when the tree is built, and partial overlaps or spans crossing the
label boundary are still rejected.
- Initially narrow tables reflow when the editor width shrinks instead of
retaining stale image geometry until an unrelated full restyle.
## [0.11.0] - 2026-07-31
### Added
- **Ordered lists render their position.** An item's number is computed from its
place in the run and painted over the source digits, so typing, deleting,
merging and pasting renumber live. The `.md` file is never rewritten — the
source stays valid CommonMark whatever it says. The whole source marker is
hidden as one unit and the slot is kerned to the display width, so the dot
travels with the digits at any digit count; the raw digits are revealed while
they are edited.
- `MarkdownEditorConfiguration.cursorFollowsSpanInk` (opt-in, off by default):
the caret and the I-beam take the ink of the extension span they sit in. It
matters for an extension that INVERTS its content — dark ink on a light block
— where both cursors are otherwise drawn in the block's own color and
disappear inside it. `InvertedIBeamCursor` recolors the live `NSCursor.iBeam`
image, which keeps the system shape and the user's pointer size.
### Fixed
- Find-in-document no longer erases other backgrounds. Clearing its highlights
removed `.backgroundColor` across the whole document, which blanked extension
spans, code fences and table cells until some unrelated restyle repainted
them. Find now marks its own backgrounds and restyles only the paragraphs it
touched.
### Performance
- **Large notes open ~14× faster.** Measured on a 346k-char / 5,241-block note
(Release): first open 19.5 s → 1.35 s, warm open 1.1 s → 590 ms, switching
away 440 ms → 115 ms. Styling is built on a detached string and transferred
with one `setAttributedString` — per-key `addAttribute` on live TextKit-2
storage left weak tombstones in Foundation's attribute-intern table, which
turned quadratic. The restyle apply uses a paragraph overlap index above 32
paragraphs, the redundant second full-document parse and the re-entrant
full-document layout during rebuild are gone, and the SwiftMath render cache
persists to disk (717 ms → 35 ms on relaunch, byte-identical geometry).
- **Editing long ordered lists.** One Return in an 800-item loose list:
944 ms → 67 ms (was quadratic in list length). Typing in a 1,600-item ordered
list: 89 ms → 41 ms per key. Resolving the caret ink across 1,600 spans:
0.134 ms → 0.002 ms per keystroke.
### Known limitations
- A list item's continuation line is a paragraph and ends the run, so a
multi-line item switches numbering off below it.
- A loose list keeps stale numbers after a pure digit edit (a digit edit is not
classified as structural).
- Ordered task items (`1. [ ] x`) consume a number but render none.
## [0.10.1] - 2026-07-22
### Added
- Custom SF Symbols for task checkboxes: `MarkdownEditorConfiguration` accepts
custom unchecked and checked symbols for `- [ ]` / `- [x]` task-list items
(opt-in; the defaults are unchanged).
### Fixed
- List markers no longer disappear while a selection covers them, and selecting
a list item now reveals its raw marker syntax like other inline constructs.
- An unclosed ``` fence no longer swallows the rest of the document: typing an
opening fence above existing content left every block below it (tables,
block LaTeX, thematic breaks, links) unrendered until the closing fence was
typed. A fence now forms a code block only once its closing fence exists.
## [0.10.0] - 2026-07-15
### Added
- **Extension seam**: opt-in constructs beyond pure markdown. A
`MarkdownExtension` contributes an inline form (`==highlight==`), a fenced
block form (`::: … :::`), or both — plus content attributes and an HTML
wrapper for the clean-copy path; register instances via
`MarkdownEditorConfiguration.extensions`. Extensions never emit ranges — the
parser derives all geometry, so a misbehaving extension can at worst restyle
its own construct. Marker/fence hiding, caret reveal, incremental restyle,
table cells, and rich copy are handled generically. Registered extensions can
change at runtime; all parse caches key on the registry.
- `HighlightExtension` (`==text==`) and `StrikethroughExtension` (`~~text~~`),
the former built-ins repackaged as extensions, and `ContainerExtension`
(`::: … :::`), the first fenced block extension.
### Changed
- **Breaking**: `==highlight==` and `~~strikethrough~~` are no longer part of
the core grammar. Unregistered, the syntax stays literal text. To keep the
previous behavior:
`configuration.extensions = [HighlightExtension(), StrikethroughExtension()]`.
The formatting actions (context menu, `applyHighlightRequest` /
`applyStrikethroughRequest`) still insert/remove the markers either way;
construct detection (toggle-off, selection state) requires the extension.
### Fixed
- A pre-existing incremental-parse gap surfaced by the seam review:
backspace-joining two paragraphs could leave transiently wrong styling
(extra spacing or a stray emphasis/code span across the join) until the next
edit re-parsed the region.
## [0.9.0] - 2026-07-13
### Added
- `MarkdownEditorConfiguration.rawSourceMode`: present the document as raw
Markdown source — no syntax hiding, no markdown styling, and no wiki-link
display transform (`[[Name|UUID]]` shows verbatim). The editor keeps base
font/paragraph styling and stays fully editable; smart Markdown input
handling (list continuation, `$$`/`![[` auto-wrap, ⇧⇥ outdent) is disabled
while raw. Runtime switching is supported and rebuilds the document
immediately; the current document's undo stack is dropped on a switch
because undo actions recorded against the other mode's display text would
replay at stale ranges. Default `false` — existing embedders are unaffected.
- Find & replace: two optional bus notifications, `replaceCurrent` (replace the
focused match and advance) and `replaceAll` (replace every match in one undo
step, back-to-front so ranges stay valid). Both edit the engine's displayed
text with proper `shouldChangeText`/`didChangeText` undo registration and
report the remaining count via `findResults`. Purely additive — embedders that
don't set the bus names are unaffected.
- Clean clipboard: ⌘C copies the selection as rich text (RTF + `com.apple.webarchive`)
built from the AST rather than the raw storage form, and paste converts HTML to
Markdown. Wiki-link `[[Name|UUID]]` side-channels no longer leak into copied text.
### Fixed
- Find/jump scroll now works without a reading column. The TextKit 2
fragment-enumeration scroll path (with `.ensuresLayout`) runs universally
instead of only when `readingWidth` was set; the unreliable
`NSTextView.scrollRangeToVisible` (which routes through the absent TextKit 1
layout manager for off-screen content) is now only the last-resort fallback.
- Inline syntax markers (`**`, `*`, `~~`, `==`) now use `mutedText` foreground
color while the caret is inside the corresponding span, matching the existing
behavior of inline code backticks and link/wiki-link brackets. This makes
highlight `==` markers visually distinct from body text in edit state.
- Web links `[text](url)` now share the wiki-link "edit zone": clicking the outer
~30% of the link's first/last visible character places the caret just outside the
markers (before `[` / after `)`) and reveals the source for editing instead of
navigating, matching `[[…]]` behavior. Previously the edit zone only resolved
`.wikiLink` tokens, so a web link dropped the caret between its brackets and did
not reveal. Middle-of-link clicks still navigate; read-only links stay navigable.
- Auto-linking (`NSDataDetector`) no longer linkifies a URL that sits inside a
markdown or wiki link's own range. A link's `(url)` previously got its own
competing `.link` attribute on top of the link — making the raw URL independently
navigable and offsetting the click edit zone. Bare URLs outside links still
autolink; URLs inside code were already excluded.
- Wiki links: UUID-robust labels/embeds and keyboard navigation in the inline
autocomplete list.
### Contributors
- Find/jump scroll fix and find & replace by @ChristineTham
- Inline syntax-marker color fix by @sospartan
- rawSourceMode, clean clipboard, web-link edit zone, and wiki-link robustness by @luca-chen198
## [0.8.0] - 2026-06-28
### Added
- `MarkdownEditorBus.findQuery` / `findResults`: query-based in-document find. The host posts a
search string (+ current index) and the engine matches against its OWN displayed text,
highlighting in display coordinates and posting the match count back. This is correct where the
displayed text differs from the source — e.g. node links rendered shorter than `[[Name|UUID]]`,
LaTeX, or images — which the legacy `findScrollToRange` (host-computed source-coordinate ranges)
highlighted at the wrong offset. Opt-in; `findScrollToRange` is unchanged for existing embedders.
- `NativeTextView.isCursorExcluded: ((CGPoint) -> Bool)?` — embedder-supplied
predicate that suppresses the edit-mode I-beam cursor when the mouse is inside
a defined exclusion zone (e.g. a formatting toolbar). When the closure returns
`true`, `mouseMoved:` skips calling `super.mouseMoved` to avoid NSTextView's
built-in I-beam cursor, setting the arrow cursor instead. Exposed through
`NativeTextViewWrapper.isCursorExcluded`.
- `NativeTextViewWrapper.onBuildContextMenu: ((NSMenu, NSRange) -> NSMenu)?`
embedder hook to build the editor's right-click menu. The engine hands over the
default `NSMenu` + the current selection; the embedder returns the menu to show
(driving the `didMarkdown*` actions through the bus). Keeps the engine UI-free.
- `==highlight==` inline markup: double-equals markers around text apply a
background color (configurable via `MarkdownEditorTheme.highlightColor`,
default `.systemOrange.withAlphaComponent(0.4)`). Content is recursively parsed so nested emphasis,
code, etc. work inside highlights.
- `MarkdownEditorBus.applyHighlightRequest` /
`selectionHighlightDidChange`: bus notification names for driving a
highlight toolbar button from host UI.
- `MarkdownEditorBus` extended with nine new notification types for
formatting toolbar integration: `applyStrikethroughRequest`,
`applyInlineCodeRequest`, `applyBlockquoteRequest`,
`applyUnorderedListRequest`, `applyOrderedListRequest`,
`applyLinkRequest`, `applyCodeBlockRequest`,
`applyHorizontalRuleRequest`, `applyImageRequest`. Embedders wire
these into `NotificationCenter` to trigger formatting from
external UI (toolbars, menus) without reaching into the editor's
view hierarchy.
- New formatting actions on the coordinator (callable directly or
via the bus above): `didMarkdownStrikethrough`, `didMarkdownInlineCode`,
`didMarkdownBlockquote`, `didMarkdownLink`, `didMarkdownCodeBlock`,
`didMarkdownHorizontalRule`, `didMarkdownImage`.
- Word-boundary detection in inline formatting: when the cursor is
placed inside an English word with no active text selection, bold,
italic, strikethrough, and inline-code actions now auto-select the
containing word before wrapping. If no word character is adjacent
to the cursor, empty markers are inserted as before. The cursor's
relative offset within the word is preserved after wrapping
(e.g. `wo|rd``**wo|rd**`).
- Headless test suite for formatting actions (`FormattingActionTests`
— 21 tests covering bold, strikethrough, inline code, blockquote,
link, code block, horizontal rule, and image insertion).
### Changed
- The engine no longer ships a built-in right-click "Format" context menu — menus
are now embedder-supplied via `onBuildContextMenu` (above). The system rich-text
"Font" submenu (Bold/Italic/Show Colors…) is stripped from the default menu, since
those font traits don't apply to Markdown.
### Fixed
- Blockquote removal no longer doubles trailing newlines when the
original line already carries one.
## [0.7.1] - 2026-06-20
### Added
- `MarkdownEditorConfiguration.heightBehavior` (`.scrolls` default / `.fitsContent`):
in `.fitsContent` the editor grows to its content height and reports it to
SwiftUI, so an enclosing `ScrollView` scrolls the page instead of a nested
internal scroller. Opt-in, off by default — no change for existing embedders. (#75)
- `BlockquoteStyle` configuration struct with `extraLineHeight` to control line
spacing inside blockquotes, following the `ListStyle.extraLineHeight` /
`ParagraphStyle.lineHeightExtraSpacing` pattern. Defaults to `0` (no extra
spacing), preserving existing rendering. (#76)
### Fixed
- Mouse-wheel / trackball scrolling no longer clamps back at the bottom past a
stale-small content-height measurement. (#71)
- Inspector clip mask and caret reveal at the document end. (#73)
- Scroll position is remembered per document across switches, and Writing Tools
results stay styled and visible after accept. (#70)
- Empty-file placeholder no longer clips to one line after a view rebuild. (#69)
### Added
- Scroll-away header: `NativeTextViewWrapper` gains `header: AnyView?`,
`headerCollapsedHeight: CGFloat`, and `headerExpanded: Bool`. The engine
hosts the supplied SwiftUI view above the document body, scrolling with
it; collapsing animates the reserved band down to `headerCollapsedHeight`
(the top row stays visible, lower rows clip away). The hosted content
refreshes on every SwiftUI update and stays fully interactive. Composes
with `readingWidth`. See the README's *Scrolling Header* section.
### Changed
- The scroll view's `documentView` is now always an engine-internal
container view (hosting the text view, the optional scroll-away header,
and the reading column's breakout overlays) rather than sometimes the
`NSTextView` itself. Embedders that reached into
`scrollView.documentView` expecting an `NSTextView` must adapt — the
document view's class was never API.
- **Breaking**: The editor's enclosing scroll view no longer applies a
hard-coded `top: 55.4` content inset. The default is now `0` on every
edge, matching the most common embedding case where the editor fills
its container exactly. Embedders that previously relied on the engine
reserving header space (e.g. for a translucent toolbar) must opt in
explicitly:
```swift
var config = MarkdownEditorConfiguration.default
config.safeAreaInsets = SafeAreaInsets(top: 55.4)
```
### Added
- `SafeAreaInsets` struct exposing `top` / `leading` / `trailing` / `bottom`
inset knobs for the editor's enclosing scroll view, configurable via
`MarkdownEditorConfiguration.safeAreaInsets`.
- `MarkdownASTStyler` now stamps `.spellingState: 0` on fenced code blocks
and inline `` `code` `` spans, completing the engine's existing
spell-check suppression convention (links, wiki-links, LaTeX, and tables
already carry the same attribute). The system spell-checker no longer
underlines tokens inside code regions even when continuous spell
checking is enabled.
### Fixed
- Undo is now kept per `documentId`, so Cmd+Z keeps working after switching
files. The single reused `NSTextView` previously wiped its undo manager on
every document switch; the editor now vends a per-document `UndoManager`
(via the new `undoManager(for:)` delegate method) whose undo/redo stack
survives switching away and back. (#77)
- A document's surviving undo stack is dropped when its text is reloaded
*changed* while it was switched away (e.g. renaming a node rewrites the
`[[label]]` in every file that links it), so Cmd+Z can no longer replay
stale ranges against the rewritten content. (#78)
- `NativeTextViewWrapper` keeps links clickable and text selectable
when `isEditable: false`; `isSelectable` is no longer coupled to
`isEditable`. (#31)
- `NativeTextViewWrapper` now applies its initial styling pass even when
the bound text starts at its final value (e.g. supplied as a SwiftUI
`@State` initializer). Previously the editor would render the raw
Markdown source until the user clicked into the document, because the
coordinator's `lastSyncedText` already matched the bound text at first
`updateNSView`. The early-return now also requires `didInitialFormatting`
to be true, which only flips after the first styling pass completes.
### Added
- Initial public API surface:
- `NativeTextViewWrapper` — SwiftUI bridge for the AppKit-backed editor
- `MarkdownEditorConfiguration` — every spacing / sizing / behavior knob
- `MarkdownEditorTheme` — color palette, defaults to system colors
- `MarkdownEditorServices` — container for the four service protocols
- Service protocols: `WikiLinkResolver`, `EmbeddedImageProvider`,
`SyntaxHighlighter`, `LatexRenderer`
- No-op default implementations: `NoOpWikiLinkResolver`,
`NoOpEmbeddedImageProvider`, `PlainTextSyntaxHighlighter`,
`NoOpLatexRenderer`
- `WikiLinkService` — bidirectional storage / display roundtrip helper
- `PasteboardImageReader` — pasteboard image inspection helpers
- Selection / replacement value types: `WikiLinkSelection`,
`InlineSelectionState`, `InlineReplacementRequest`, `CodeBlockSelection`
- `CodeBlockButton` — drop-in copy button overlay
- DocC documentation catalog with landing page and topic groups
- Triple-slash documentation comments on the full public API surface
[Unreleased]: https://github.com/nodes-app/swift-markdown-engine/compare/0.7.1...HEAD
[0.7.1]: https://github.com/nodes-app/swift-markdown-engine/compare/0.7.0...0.7.1
+92
View File
@@ -0,0 +1,92 @@
# Contributing to MarkdownEngine
Thanks for your interest. **MarkdownEngine is maintained by one person —
expect 12 weeks for review.** A pull request is the normal way in, for
fixes, documentation and new extensions alike. If a change is large or
architectural, open it as a draft PR with the design sketched in the
description — that gets you an answer faster than describing it in prose.
> **New here?** Start with [ARCHITECTURE.md](ARCHITECTURE.md) — a
> codemap that walks each directory in the order text flows through
> the engine.
## Development setup
```bash
git clone https://github.com/nodes-app/swift-markdown-engine.git
cd swift-markdown-engine
swift build
swift test
```
Open `Package.swift` in Xcode for a graphical environment. The runnable
demo is in `Demo/MarkdownEngineDemo.xcodeproj` — open and **Run** to see
your changes against a real app target.
### Local DocC preview
Temporarily add the [swift-docc-plugin](https://github.com/swiftlang/swift-docc-plugin)
to `Package.swift`, then `swift package --disable-sandbox preview-documentation
--target MarkdownEngine`. It's intentionally not a permanent dependency — the
core product stays free of optional tooling.
## Reporting bugs
Include:
- A minimal reproducer (the smallest Markdown input + code that triggers
it)
- macOS, Xcode, and Swift versions
- Expected vs. actual behavior
Screen recordings welcome.
## Pull requests
- One logical change per PR, branched from `main`
- Tests for new tokenizer / styler / service / extension behavior in
`Tests/MarkdownEngineTests/`
- DocC comments for any public-API change; update `Demo/` if relevant
- One-line entry in `CHANGELOG.md` under `[Unreleased]`
- `swift build` and `swift test` must be green; CI runs the same checks
## Design constraints
Non-negotiable for the core `MarkdownEngine` target:
- **Don't add external dependencies to the core `MarkdownEngine`
target.** App-specific behaviors plug in through the four service
protocols (`WikiLinkResolver`, `EmbeddedImageProvider`,
`SyntaxHighlighter`, `LatexRenderer`) instead. The two existing
bridge products (`MarkdownEngineCodeBlocks` → HighlighterSwift,
`MarkdownEngineLatex` → SwiftMath) are the deliberate exception so
consumers can opt in. A new bridge or a new core dependency is a bigger
call — make the case in the PR description.
- **New constructs are extensions, not core grammar.** A construct like
`==highlight==` (inline) or a `::: … :::` fenced block belongs in
`Sources/MarkdownEngine/Extensions/` as a `MarkdownExtension` — see
`HighlightExtension` / `ContainerExtension` as templates — never a new case
threaded through the parser, styler, and renderer. This keeps the core pure
markdown and each construct isolated. Image/overlay-rendered constructs
(tables, math) are the exception — they still need core work.
- **Public surface stays small.** Favor `internal`; new public symbols
need a DocC comment.
## Commit messages
Imperative subject, blank line, then a paragraph explaining *why*:
```
Tokenize escaped backticks inside fenced code blocks
The previous tokenizer treated `\`` inside ``` … ``` as a token
delimiter, which broke any code block containing escaped backtick
examples. The new behavior matches CommonMark.
```
The "what" is in the diff.
## License
By contributing, you agree that your contributions are licensed under
the [Apache 2.0 License](LICENSE).
@@ -0,0 +1,364 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 60;
objects = {
/* 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 */; };
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 */; };
1A2B3C0000000000000009BB /* MarkdownEngineLatex in Frameworks */ = {isa = PBXBuildFile; productRef = 1A2B3C0000000000000009AA /* MarkdownEngineLatex */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
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>"; };
1A2B3C0000000000000006AA /* MDE.icon */ = {isa = PBXFileReference; lastKnownFileType = folder.iconcomposer.icon; path = MDE.icon; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
1A2B3C0000000000000003BB /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
1A2B3C0000000000000007CC /* MarkdownEngine in Frameworks */,
1A2B3C0000000000000008BB /* MarkdownEngineCodeBlocks in Frameworks */,
1A2B3C0000000000000009BB /* MarkdownEngineLatex in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
1A2B3C0000000000000001BB = {
isa = PBXGroup;
children = (
1A2B3C0000000000000001DD /* MarkdownEngineDemo */,
1A2B3C0000000000000001CC /* Products */,
);
sourceTree = "<group>";
};
1A2B3C0000000000000001CC /* Products */ = {
isa = PBXGroup;
children = (
1A2B3C0000000000000003DD /* MarkdownEngineDemo.app */,
);
name = Products;
sourceTree = "<group>";
};
1A2B3C0000000000000001DD /* MarkdownEngineDemo */ = {
isa = PBXGroup;
children = (
1A2B3C0000000000000004AA /* MarkdownEngineDemoApp.swift */,
1A2B3C0000000000000005AA /* ContentView.swift */,
1A2B3C0000000000000006AA /* MDE.icon */,
);
path = MarkdownEngineDemo;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
1A2B3C0000000000000001EE /* MarkdownEngineDemo */ = {
isa = PBXNativeTarget;
buildConfigurationList = 1A2B3C0000000000000001FF /* Build configuration list for PBXNativeTarget "MarkdownEngineDemo" */;
buildPhases = (
1A2B3C0000000000000003AA /* Sources */,
1A2B3C0000000000000003BB /* Frameworks */,
1A2B3C0000000000000003CC /* Resources */,
);
buildRules = (
);
dependencies = (
);
name = MarkdownEngineDemo;
packageProductDependencies = (
1A2B3C0000000000000007BB /* MarkdownEngine */,
1A2B3C0000000000000008AA /* MarkdownEngineCodeBlocks */,
1A2B3C0000000000000009AA /* MarkdownEngineLatex */,
);
productName = MarkdownEngineDemo;
productReference = 1A2B3C0000000000000003DD /* MarkdownEngineDemo.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
1A2B3C0000000000000001AA /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 1500;
LastUpgradeCheck = 1500;
TargetAttributes = {
1A2B3C0000000000000001EE = {
CreatedOnToolsVersion = 15.0;
};
};
};
buildConfigurationList = 1A2B3C0000000000000002AA /* Build configuration list for PBXProject "MarkdownEngineDemo" */;
compatibilityVersion = "Xcode 14.0";
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = 1A2B3C0000000000000001BB;
packageReferences = (
1A2B3C0000000000000007AA /* XCLocalSwiftPackageReference ".." */,
);
productRefGroup = 1A2B3C0000000000000001CC /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
1A2B3C0000000000000001EE /* MarkdownEngineDemo */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
1A2B3C0000000000000003CC /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
1A2B3C0000000000000006BB /* MDE.icon in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
1A2B3C0000000000000003AA /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
1A2B3C0000000000000004BB /* MarkdownEngineDemoApp.swift in Sources */,
1A2B3C0000000000000005BB /* ContentView.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin XCBuildConfiguration section */
1A2B3C0000000000000002BB /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = MDE;
CODE_SIGN_IDENTITY = "-";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1;
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = "MarkdownEngine Demo";
INFOPLIST_KEY_NSHumanReadableCopyright = "";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = "com.nodes-app.MarkdownEngineDemo";
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
};
name = Debug;
};
1A2B3C0000000000000002CC /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = MDE;
CODE_SIGN_IDENTITY = "-";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1;
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = "MarkdownEngine Demo";
INFOPLIST_KEY_NSHumanReadableCopyright = "";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = "com.nodes-app.MarkdownEngineDemo";
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
};
name = Release;
};
1A2B3C0000000000000002DD /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MACOSX_DEPLOYMENT_TARGET = 14.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = macosx;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
};
name = Debug;
};
1A2B3C0000000000000002EE /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MACOSX_DEPLOYMENT_TARGET = 14.0;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
SDKROOT = macosx;
SWIFT_COMPILATION_MODE = wholemodule;
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
1A2B3C0000000000000001FF /* Build configuration list for PBXNativeTarget "MarkdownEngineDemo" */ = {
isa = XCConfigurationList;
buildConfigurations = (
1A2B3C0000000000000002BB /* Debug */,
1A2B3C0000000000000002CC /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
1A2B3C0000000000000002AA /* Build configuration list for PBXProject "MarkdownEngineDemo" */ = {
isa = XCConfigurationList;
buildConfigurations = (
1A2B3C0000000000000002DD /* Debug */,
1A2B3C0000000000000002EE /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
/* Begin XCLocalSwiftPackageReference section */
1A2B3C0000000000000007AA /* XCLocalSwiftPackageReference ".." */ = {
isa = XCLocalSwiftPackageReference;
relativePath = ..;
};
/* End XCLocalSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
1A2B3C0000000000000007BB /* MarkdownEngine */ = {
isa = XCSwiftPackageProductDependency;
productName = MarkdownEngine;
};
1A2B3C0000000000000008AA /* MarkdownEngineCodeBlocks */ = {
isa = XCSwiftPackageProductDependency;
productName = MarkdownEngineCodeBlocks;
};
1A2B3C0000000000000009AA /* MarkdownEngineLatex */ = {
isa = XCSwiftPackageProductDependency;
productName = MarkdownEngineLatex;
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = 1A2B3C0000000000000001AA /* Project object */;
}
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>
@@ -0,0 +1,78 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1500"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "1A2B3C0000000000000001EE"
BuildableName = "MarkdownEngineDemo.app"
BlueprintName = "MarkdownEngineDemo"
ReferencedContainer = "container:MarkdownEngineDemo.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES">
<Testables>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "1A2B3C0000000000000001EE"
BuildableName = "MarkdownEngineDemo.app"
BlueprintName = "MarkdownEngineDemo"
ReferencedContainer = "container:MarkdownEngineDemo.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "1A2B3C0000000000000001EE"
BuildableName = "MarkdownEngineDemo.app"
BlueprintName = "MarkdownEngineDemo"
ReferencedContainer = "container:MarkdownEngineDemo.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
@@ -0,0 +1,351 @@
//
// ContentView.swift
// MarkdownEngine
//
// Created by Nicolas von Mallinckrodt on 29.04.26.
//
import SwiftUI
import MarkdownEngine
// Optional bridge products. Each is independent drop either of these
// `#if` blocks (or remove the matching Swift Package product dependency
// from the Xcode project) and the demo still compiles. Code blocks fall
// back to plain monospace; LaTeX falls back to its raw `$$` source.
#if canImport(MarkdownEngineCodeBlocks)
import MarkdownEngineCodeBlocks
#endif
#if canImport(MarkdownEngineLatex)
import MarkdownEngineLatex
#endif
struct ContentView: View {
@State private var text: String = sampleMarkdown
// Engine modes, flipped live from the toolbar.
@State private var isReadOnly = false
@State private var showRawSource = false
@State private var useReadingColumn = false
// Base font size; all relative sizing (headings, code, math) tracks it.
@State private var fontSize: CGFloat = 16
// Scroll-away header demo.
@State private var showHeader = false
@State private var headerExpanded = true
var body: some View {
NativeTextViewWrapper(
text: $text,
configuration: configuration,
fontSize: fontSize,
isEditable: !isReadOnly,
placeholder: NSAttributedString(
string: "Empty document — start typing, markdown styles live…",
attributes: [
.font: NSFont.systemFont(ofSize: fontSize),
.foregroundColor: NSColor.secondaryLabelColor,
]
),
header: showHeader ? AnyView(demoHeader) : nil,
headerCollapsedHeight: 40,
headerExpanded: headerExpanded
)
// `readingWidth` is applied when the underlying NSView is built, so
// flipping the reading column recreates the editor via `.id`. The
// `text` binding survives; scroll position resets fine for a demo.
.id(useReadingColumn)
.toolbar {
ToolbarItemGroup {
Toggle(isOn: $isReadOnly) {
Label("Read-only", systemImage: isReadOnly ? "lock" : "lock.open")
}
.help("Read-only: the styled document stays scrollable and selectable, editing is off")
Toggle(isOn: $showRawSource) {
Label("Raw source", systemImage: "chevron.left.forwardslash.chevron.right")
}
.help("Raw markdown source: no styling, no syntax hiding")
Toggle(isOn: $useReadingColumn) {
Label("Reading column", systemImage: "arrow.right.and.line.vertical.and.arrow.left")
}
.help("Centered fixed-width reading column — wide tables still break out to full width")
ControlGroup {
Button {
fontSize = max(10, fontSize - 2)
} label: {
Label("Smaller text", systemImage: "textformat.size.smaller")
}
.disabled(fontSize <= 10)
Button {
fontSize = min(28, fontSize + 2)
} label: {
Label("Larger text", systemImage: "textformat.size.larger")
}
.disabled(fontSize >= 28)
}
.help("Base font size — headings, code, and math scale relative to it")
Menu {
Toggle("Show header", isOn: $showHeader)
Toggle("Expanded", isOn: $headerExpanded)
.disabled(!showHeader)
} label: {
Label("Header", systemImage: "rectangle.topthird.inset.filled")
}
.help("Scroll-away header: an embedder-supplied SwiftUI view hosted above the document")
}
}
}
/// Sample scroll-away header: a fixed top row (kept visible when collapsed)
/// plus detail rows that reveal/hide with the `headerExpanded` toggle.
private var demoHeader: some View {
VStack(alignment: .leading, spacing: 0) {
HStack {
Text("Scroll-away header").font(.headline)
Spacer()
}
.frame(height: 40) // == headerCollapsedHeight: the always-visible row
VStack(alignment: .leading, spacing: 6) {
Text("These rows clip away when the header collapses.")
Text("The header scrolls with the document body and stays fully interactive.")
.foregroundStyle(.secondary)
}
.font(.callout)
.padding(.bottom, 12)
}
.padding(.horizontal, 16)
}
/// The engine talks to your app through service protocols. Two of them
/// `SyntaxHighlighter` and `LatexRenderer` render the code-block and
/// LaTeX visuals. The base `MarkdownEngine` ships no-op defaults
/// (plain monospace, raw `$$`); the optional `MarkdownEngineCodeBlocks`
/// and `MarkdownEngineLatex` products ship ready-made bridges backed by
/// HighlighterSwift and SwiftMath respectively.
///
/// This demo opportunistically plugs in whichever bridges are linked,
/// so you can see exactly what each one adds.
private var configuration: MarkdownEditorConfiguration {
var config = MarkdownEditorConfiguration.default
#if canImport(MarkdownEngineCodeBlocks)
// Syntax highlighting for fenced code blocks. Auto-switches between
// `atom-one-light` and `atom-one-dark` with system appearance.
config.services.syntaxHighlighter = HighlighterSwiftBridge()
#endif
#if canImport(MarkdownEngineLatex)
// LaTeX rendering for `$inline$` and `$$block$$` math. Uses the
// Latin Modern math font and tints formulas to match the theme.
config.services.latex = SwiftMathBridge()
#endif
// Opt-in constructs beyond pure markdown. The core engine no longer
// knows `==highlight==` or `~~strikethrough~~` they are extensions
// you register. Unregistered syntax stays literal text.
config.extensions = [HighlightExtension(), StrikethroughExtension()]
// Toolbar-driven modes.
config.rawSourceMode = showRawSource
config.readingWidth = useReadingColumn ? 620 : nil
return config
}
}
/// Builds the demo markdown shown when the editor first loads.
///
/// The text is composed from a fixed header/footer plus feature sections.
/// Three of them inline formatting, block math, and code swap between
/// a full showcase and a short "feature unavailable" note depending on
/// which optional bridge products are linked.
///
/// When a bridge is missing, the fallback links to the README section
/// that explains how to enable that feature in your own app.
private var sampleMarkdown: String {
[
markdownHeader,
inlineFormattingSection,
blocksSection,
taskListSection,
extensionSection,
tableSection,
latexSection,
codeSection,
markdownFooter,
].joined(separator: "\n\n")
}
/// Blockquote + list demo: quotes keep inline styling; lists auto-continue
/// on Return, renumber, and change nesting with Tab / Shift-Tab.
private let blocksSection = """
## Blockquotes & lists
> Blockquotes keep full **inline** styling and quote markers hide like every other marker.
Lists auto-continue on Return; Tab and Shift-Tab move the nesting level:
- Unordered lists
- nest two spaces per level
- up to three levels deep
1. Ordered lists renumber as you edit
2. and auto-continue too
"""
/// Task-list demo: click a checkbox to toggle it. The glyphs are SF Symbols;
/// embedders can swap them via `TaskCheckboxStyle` (`config.taskCheckbox`).
private let taskListSection = """
## Task lists
- [x] Draw checkboxes as SF Symbols
- [ ] Click a box to toggle it
- [ ] Ship it
"""
/// Extension seam demo: `==highlight==` and `~~strikethrough~~` are NOT part
/// of the core grammar anymore they're supplied by the opt-in
/// `HighlightExtension` and `StrikethroughExtension` registered above.
private let extensionSection = """
## Extensions
The engine core parses pure markdown; extra constructs are opt-in extensions. \
This ==highlighted text== comes from `HighlightExtension`, and this \
~~struck-through text~~ from `StrikethroughExtension`. Unregistered, the exact \
same characters would stay literal markdown. Nesting works too: \
==with *italic* inside== and ~~also *nested*~~.
"""
/// Table layout demo: the first table's cells WRAP to the available width
/// (CSS auto-layout style); the second has so many columns that even the
/// longest-word minimums don't fit it stays wide and scrolls horizontally.
private let tableSection = """
## Tables
Cells wrap to the available width:
| Novel | Opening line |
|---|---|
| Der Zauberberg (1924) | "Ein einfacher junger Mensch reiste im Hochsommer von Hamburg, seiner Vaterstadt, nach Davos-Platz im Graubündischen." |
| The Master and Margarita (196667) | "At the sunset hour of one warm spring day two men were to be seen at Patriarch's Ponds." (trans. Michael Glenny) |
| The Picture of Dorian Gray (1890) | "The studio was filled with the rich odour of roses, and when the light summer wind stirred amidst the trees of the garden, there came through the open door the heavy scent of the lilac, or the more delicate perfume of the pink-flowering thorn." |
Too many columns horizontal scroll instead of crushed cells:
| Movement | Landmark novel | Narrative signature | Characteristic preoccupations | Philosophical undercurrents | Contemporaneous reception | Posthumous reputation | Author | Structural device | Central symbol | Typical setting | Enduring influence |
|---|---|---|---|---|---|---|---|---|---|---|---|
| Modernism | Der Zauberberg | Essayistic time-dilation | Sanatorium cosmopolitanism | Schopenhauer-inflected pessimism | Immediate bestseller | Cornerstone of literary modernism | Thomas Mann | Bildungsroman inversion | The mountain as timeless enclosure | Alpine sanatorium | Shaped the European novel of ideas |
| Menippean satire | The Master and Margarita | Novel-within-a-novel | Cowardice and censorship | Faustian epigraph | Suppressed, samizdat-circulated | Perennial Russian favorite | Mikhail Bulgakov | Interleaved dual narratives | The devil as satirical mirror | Soviet Moscow and biblical Jerusalem | Model for satire under censorship |
| Aestheticism | The Picture of Dorian Gray | Epigrammatic wit | Portrait-as-conscience | Paterian hedonism | Scandalized reviewers | Perpetually adapted | Oscar Wilde | Portrait as moral ledger | The aging portrait | Fin de siècle London | Touchstone for art for arts sake |
"""
private let markdownHeader = """
# MarkdownEngine
A native macOS Markdown editor built on **TextKit 2**, bridged to SwiftUI brought to you by [nodes-web.com](https://nodes-web.com).
Edit this text live. Formatting updates as you type and the toolbar flips engine modes at runtime: read-only, raw markdown source, and a centered reading column.
---
"""
/// Inline formatting demo. Drops the inline-LaTeX example sentence when
/// the LaTeX bridge isn't linked, so the reader doesn't see raw `$$`.
private var inlineFormattingSection: String {
#if canImport(MarkdownEngineLatex)
return #"""
## Inline formatting
Mix **bold**, *italic*, and ***both at once***. Reach for `inline code` when a short snippet helps. Inline math fits naturally in prose the Pythagorean identity says $a^2 + b^2 = c^2$, and Euler's identity famously claims $e^{i\pi} + 1 = 0$.
"""#
#else
return """
## Inline formatting
Mix **bold**, *italic*, and ***both at once***. Reach for `inline code` when a short snippet helps.
"""
#endif
}
/// Block LaTeX demo when the `MarkdownEngineLatex` bridge is linked;
/// otherwise a short note pointing to the README section that explains
/// how to enable LaTeX rendering.
private var latexSection: String {
#if canImport(MarkdownEngineLatex)
return #"""
## Block math
$$
\int_{-\infty}^{\infty} e^{-x^2}\,dx = \sqrt{\pi}
$$
$$
\frac{\partial}{\partial t}\Psi(\mathbf{r}, t) = -\frac{i}{\hbar}\hat{H}\,\Psi(\mathbf{r}, t)
$$
"""#
#else
return """
## LaTeX
LaTeX (`$inline$` and `$$block$$`) is parsed but not rendered without the optional `MarkdownEngineLatex` product. See [LaTeX Rendering](https://github.com/nodes-app/swift-markdown-engine#latex-rendering) in the README to wire it up.
"""
#endif
}
/// Fenced code-block demo when the `MarkdownEngineCodeBlocks` bridge is
/// linked; otherwise a plain monospace example and a link to the
/// README's Code Blocks section.
private var codeSection: String {
#if canImport(MarkdownEngineCodeBlocks)
return #"""
## Code
Swift, with syntax highlighting:
```swift
import SwiftUI
import MarkdownEngine
struct Editor: View {
@State private var text = "# Hello"
var body: some View {
NativeTextViewWrapper(text: $text)
.frame(minWidth: 640, minHeight: 480)
}
}
```
And a little JSON:
```json
{
"engine": "MarkdownEngine",
"features": ["latex", "code", "wiki-links"],
"version": 1.0
}
```
"""#
#else
return #"""
## Code
Fenced code blocks render as plain monospace without the optional `MarkdownEngineCodeBlocks` product. See [Code Blocks](https://github.com/nodes-app/swift-markdown-engine#code-blocks) in the README for syntax-highlighted output:
```swift
let greeting = "Hello, world!"
```
"""#
#endif
}
private let markdownFooter = """
---
"""
@@ -0,0 +1,10 @@
<svg width="173" height="185" viewBox="0 0 173 185" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_1869_2042)">
<path d="M84.1452 185H85.3003C97.6509 185 102.36 181.114 102.36 168.928V109.41L90.8093 115.68L142.878 145.616C146.788 147.646 149.897 148.971 152.741 148.971C157.628 148.971 161.004 145.616 165.091 139.523L165.714 138.552C168.38 134.577 169.712 130.957 169.712 127.955C169.712 123.009 166.247 119.301 160.116 115.945L107.07 85.4796V98.1072L160.027 69.0549C166.247 65.7875 169.445 62.167 169.445 57.2219C169.445 54.2195 168.113 50.7756 165.891 46.8019L165.27 45.6539C161.271 39.2076 157.628 35.9403 152.83 35.9403C149.987 35.9403 146.698 37.2649 142.79 39.2959L90.8981 69.2315L102.36 75.5011V16.0716C102.36 3.88544 97.6509 0 85.3003 0H84.1452C71.7944 0 67.0852 3.88544 67.0852 16.0716V75.5011L78.814 69.2315L26.7452 39.2959C22.8356 37.0883 19.1926 35.6754 16.1715 35.6754C11.2845 35.6754 7.28607 39.031 4.17616 45.8305L3.64304 46.8019C1.68824 50.8639 0.444272 54.3961 0.444272 57.3986C0.444272 62.0787 3.37647 65.6992 9.59631 69.1431L62.6425 98.1072V85.4796L9.50741 115.945C3.37647 119.301 0 122.922 0 127.778C0 130.78 1.42167 134.401 3.73189 138.463L4.26502 139.258C7.9969 145.704 11.5511 149.06 16.6158 149.06C19.4591 149.06 22.8356 147.646 26.6564 145.616L78.814 115.68L67.0852 109.41V168.928C67.0852 181.114 71.7944 185 84.1452 185Z" fill="#E0A347"/>
</g>
<defs>
<clipPath id="clip0_1869_2042">
<rect width="173" height="185" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

@@ -0,0 +1,10 @@
<svg width="173" height="185" viewBox="0 0 173 185" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_1869_2051)">
<path d="M84.1452 185H85.3003C97.6509 185 102.36 181.114 102.36 168.928V109.41L90.8093 115.68L142.878 145.616C146.788 147.646 149.897 148.971 152.741 148.971C157.628 148.971 161.004 145.616 165.091 139.523L165.714 138.552C168.38 134.577 169.712 130.957 169.712 127.955C169.712 123.009 166.247 119.301 160.116 115.945L107.07 85.4796V98.1072L160.027 69.0549C166.247 65.7875 169.445 62.167 169.445 57.2219C169.445 54.2195 168.113 50.7756 165.891 46.8019L165.27 45.6539C161.271 39.2076 157.628 35.9403 152.83 35.9403C149.987 35.9403 146.698 37.2649 142.79 39.2959L90.8981 69.2315L102.36 75.5011V16.0716C102.36 3.88544 97.6509 0 85.3003 0H84.1452C71.7944 0 67.0852 3.88544 67.0852 16.0716V75.5011L78.814 69.2315L26.7452 39.2959C22.8356 37.0883 19.1926 35.6754 16.1715 35.6754C11.2845 35.6754 7.28607 39.031 4.17616 45.8305L3.64304 46.8019C1.68824 50.8639 0.444272 54.3961 0.444272 57.3986C0.444272 62.0787 3.37647 65.6992 9.59631 69.1431L62.6425 98.1072V85.4796L9.50741 115.945C3.37647 119.301 0 122.922 0 127.778C0 130.78 1.42167 134.401 3.73189 138.463L4.26502 139.258C7.9969 145.704 11.5511 149.06 16.6158 149.06C19.4591 149.06 22.8356 147.646 26.6564 145.616L78.814 115.68L67.0852 109.41V168.928C67.0852 181.114 71.7944 185 84.1452 185Z" fill="#D1403F"/>
</g>
<defs>
<clipPath id="clip0_1869_2051">
<rect width="173" height="185" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

@@ -0,0 +1,10 @@
<svg width="173" height="185" viewBox="0 0 173 185" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_1869_2047)">
<path d="M84.1452 185H85.3003C97.6509 185 102.36 181.114 102.36 168.928V109.41L90.8093 115.68L142.878 145.616C146.788 147.646 149.897 148.971 152.741 148.971C157.628 148.971 161.004 145.616 165.091 139.523L165.714 138.552C168.38 134.577 169.712 130.957 169.712 127.955C169.712 123.009 166.247 119.301 160.116 115.945L107.07 85.4796V98.1072L160.027 69.0549C166.247 65.7875 169.445 62.167 169.445 57.2219C169.445 54.2195 168.113 50.7756 165.891 46.8019L165.27 45.6539C161.271 39.2076 157.628 35.9403 152.83 35.9403C149.987 35.9403 146.698 37.2649 142.79 39.2959L90.8981 69.2315L102.36 75.5011V16.0716C102.36 3.88544 97.6509 0 85.3003 0H84.1452C71.7944 0 67.0852 3.88544 67.0852 16.0716V75.5011L78.814 69.2315L26.7452 39.2959C22.8356 37.0883 19.1926 35.6754 16.1715 35.6754C11.2845 35.6754 7.28607 39.031 4.17616 45.8305L3.64304 46.8019C1.68824 50.8639 0.444272 54.3961 0.444272 57.3986C0.444272 62.0787 3.37647 65.6992 9.59631 69.1431L62.6425 98.1072V85.4796L9.50741 115.945C3.37647 119.301 0 122.922 0 127.778C0 130.78 1.42167 134.401 3.73189 138.463L4.26502 139.258C7.9969 145.704 11.5511 149.06 16.6158 149.06C19.4591 149.06 22.8356 147.646 26.6564 145.616L78.814 115.68L67.0852 109.41V168.928C67.0852 181.114 71.7944 185 84.1452 185Z" fill="#056CC1"/>
</g>
<defs>
<clipPath id="clip0_1869_2047">
<rect width="173" height="185" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

@@ -0,0 +1,62 @@
{
"fill" : "automatic",
"groups" : [
{
"blend-mode" : "normal",
"blur-material" : 0.5,
"hidden" : false,
"layers" : [
{
"fill" : "automatic",
"image-name" : "staroflife.fill 2-1.svg",
"name" : "staroflife.fill 2-1",
"position" : {
"scale" : 3,
"translation-in-points" : [
0,
90
]
}
},
{
"image-name" : "staroflife.fill 2.svg",
"name" : "staroflife.fill 2",
"position" : {
"scale" : 3,
"translation-in-points" : [
0,
0
]
}
},
{
"image-name" : "staroflife.fill 1.svg",
"name" : "staroflife.fill 1",
"position" : {
"scale" : 3,
"translation-in-points" : [
0,
-90
]
}
}
],
"lighting" : "individual",
"shadow" : {
"kind" : "layer-color",
"opacity" : 0.5
},
"specular" : false,
"translucency" : {
"enabled" : true,
"value" : 0.45
}
}
],
"supported-platforms" : {
"circles" : [
"watchOS"
],
"squares" : "shared"
}
}
@@ -0,0 +1,18 @@
//
// MarkdownEngineDemoApp.swift
// MarkdownEngine
//
// Created by Nicolas von Mallinckrodt on 29.04.26.
//
import SwiftUI
@main
struct MarkdownEngineDemoApp: App {
var body: some Scene {
WindowGroup("MarkdownEngine Demo") {
ContentView()
.frame(minWidth: 640, minHeight: 480)
}
}
}
+201
View File
@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [2026] [Luca Chen]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+51
View File
@@ -0,0 +1,51 @@
// swift-tools-version: 5.9
import PackageDescription
// MarkdownEngine a TextKit-2 backed Markdown editor view for macOS.
//
// Embedders import `MarkdownEngine` and supply their own adapters that
// conform to the engine's service protocols (`WikiLinkResolver`,
// `EmbeddedImageProvider`, `SyntaxHighlighter`, `LatexRenderer`). The engine
// itself has zero external dependencies.
//
// Users who want turnkey adapters for the two highest-friction protocols
// (code-block styling/highlighting, LaTeX rendering) can additionally
// depend on the `MarkdownEngineCodeBlocks` and/or `MarkdownEngineLatex`
// products, which ship pre-built bridges backed by HighlighterSwift and
// SwiftMath respectively. Both products are opt-in: the core
// `MarkdownEngine` library stays free of those transitive dependencies
// at link time.
let package = Package(
name: "MarkdownEngine",
platforms: [.macOS(.v14)],
products: [
.library(name: "MarkdownEngine", targets: ["MarkdownEngine"]),
.library(name: "MarkdownEngineCodeBlocks", targets: ["MarkdownEngineCodeBlocks"]),
.library(name: "MarkdownEngineLatex", targets: ["MarkdownEngineLatex"]),
],
dependencies: [
.package(url: "https://github.com/smittytone/HighlighterSwift", from: "3.0.0"),
.package(url: "https://github.com/mgriebling/SwiftMath", from: "1.7.0"),
],
targets: [
.target(name: "MarkdownEngine"),
.target(
name: "MarkdownEngineCodeBlocks",
dependencies: [
"MarkdownEngine",
.product(name: "Highlighter", package: "HighlighterSwift"),
]
),
.target(
name: "MarkdownEngineLatex",
dependencies: [
"MarkdownEngine",
.product(name: "SwiftMath", package: "SwiftMath"),
]
),
.testTarget(
name: "MarkdownEngineTests",
dependencies: ["MarkdownEngine"]
)
]
)
+350
View File
@@ -0,0 +1,350 @@
<p align="center">
<img width="128" alt="SwiftMarkdownEngine logo" src="media/logo.png" />
</p>
<h1 align="center">SwiftMarkdownEngine</h1>
<p align="center">
<a href="https://swift.org"><img src="https://img.shields.io/badge/Swift-5.9+-F05138?logo=swift&logoColor=white" alt="Swift 5.9+" /></a>
<a href="https://developer.apple.com/macos/"><img src="https://img.shields.io/badge/Platforms-macOS%2014+-lightgrey" alt="Platforms macOS 14+" /></a>
<a href="LICENSE"><img src="https://img.shields.io/badge/License-Apache%202.0-yellow.svg" alt="License: Apache 2.0" /></a>
<a href="https://github.com/nodes-app/swift-markdown-engine/actions/workflows/ci.yml"><img src="https://github.com/nodes-app/swift-markdown-engine/actions/workflows/ci.yml/badge.svg" alt="CI" /></a>
</p>
<video src="https://github.com/user-attachments/assets/b61ed622-0e9a-4e91-9de5-9cd6c53752e5"
autoplay loop muted playsinline
width="100%">
</video>
A native AppKit Markdown editor for macOS, built on TextKit 2 and bridged to SwiftUI. It is the editor inside **[Nodes](https://apps.apple.com/app/apple-store/id6745401961?pt=127809373&ct=github&mt=8)**, a macOS notes app. Live styling, wiki-link support, fenced code blocks with syntax highlighting, LaTeX rendering, embedded images, and GitHub-style task
checkboxes.
## Features
- **Live Markdown styling** — bold, italic, headings, lists, blockquotes, GFM tables, code, links, task checkboxes, horizontal rules
- **Extensions** — opt-in constructs beyond CommonMark (`==highlight==`, `~~strikethrough~~`, …); add your own via [`MarkdownExtension`](#extensions)
- **Wiki-style linking** with two-form storage / display roundtripping
(`[[Name|<id>]]``[[Name]]`)
- **Image embeds** — both `![[Name]]` (Obsidian-style, embedder supplies the
bytes) and standard Markdown `![alt](url)`
- **LaTeX** — both block (`$$ … $$`) and inline (`$…$`), embedder supplies
the renderer
- **Code blocks** with embedder-supplied syntax highlighting and overlayable
copy buttons
- **Reading column** — opt-in fixed-width centered column, wide tables
break out to the full window width (`readingWidth`)
- **Scroll-away header** — host your own SwiftUI view above the document;
it scrolls with the content and collapses to a pinned top row
- **TextKit 2** layout for accurate, modern text rendering
- **Writing Tools** integration on macOS 15.1+
- **Comfortable bottom overscroll** so the caret never pins to the viewport
edge while typing
- **Drag-select autoscroll boost** for long documents
- **Spelling & grammar** with code/LaTeX/wiki-link suppression
## Installation
```swift
dependencies: [
.package(url: "https://github.com/nodes-app/swift-markdown-engine", from: "0.1.0")
],
targets: [
.target(
name: "YourApp",
dependencies: [
.product(name: "MarkdownEngine", package: "swift-markdown-engine"),
]
)
]
```
Or in Xcode: **File → Add Package Dependencies…** and paste the repo URL.
The package ships three library products — add only what you need:
| Product | Use when |
|---|---|
| `MarkdownEngine` | You want the editor only. Zero external dependencies. |
| `MarkdownEngineCodeBlocks` | You want the full visual code-block experience — background fill, monospace font, and syntax highlighting — without writing your own bridge. Pulls in [HighlighterSwift](https://github.com/smittytone/HighlighterSwift) transitively. See [Customization → Code Blocks](#code-blocks). |
| `MarkdownEngineLatex` | You want LaTeX formula rendering without writing your own bridge. Pulls in [SwiftMath](https://github.com/mgriebling/SwiftMath) transitively. See [Customization → LaTeX Rendering](#latex-rendering). |
## Quick Start
```swift
import SwiftUI
import MarkdownEngine
struct EditorScreen: View {
@State private var text: String = "# Hello, *world*"
var body: some View {
NativeTextViewWrapper(text: $text)
}
}
```
That's it. See [Customization](#customization) below for syntax
highlighting, themes, wiki-link state, and more.
> **Displaying multiple editors?** Pass a stable, unique
> `documentId: "your-doc-id"` so undo history and pending replacements
> stay scoped to each editor instance.
## Customization
### Service Protocols
The engine talks to your app through four service protocols, each with
a no-op default so you only implement what you actually need:
| Protocol | What you supply | Ready-made bridge / suggested library |
|---|---|---|
| `WikiLinkResolver` | Resolve a `[[Name]]` to a stable opaque id | (your data model) |
| `EmbeddedImageProvider` | Look up an `NSImage` for `![[Name]]` | (your asset store) |
| `SyntaxHighlighter` | Highlight code blocks for a given language | **`HighlighterSwiftBridge`** ([recommended](#code-blocks)) — built on [HighlighterSwift](https://github.com/smittytone/HighlighterSwift) |
| `LatexRenderer` | Render a LaTeX string to an `NSImage` | **`SwiftMathBridge`** ([recommended](#latex-rendering)) — built on [SwiftMath](https://github.com/mgriebling/SwiftMath) |
Implement what you need and pass it through `MarkdownEditorServices`:
```swift
struct MyResolver: WikiLinkResolver {
func resolve(displayName: String, range: NSRange) -> WikiLinkResolution? {
myIndex[displayName].map { WikiLinkResolution(id: $0, exists: true) }
}
}
configuration.services = MarkdownEditorServices(
wikiLinks: MyResolver()
// images, syntaxHighlighter, latex omitted → no-op defaults
)
```
Each protocol and its no-op default are documented in DocC.
### Extensions
The core engine parses pure markdown. Extra constructs like `==highlight==`,
`~~strikethrough~~`, and `::: … :::` container blocks are opt-in extensions:
```swift
var config = MarkdownEditorConfiguration()
config.extensions = [HighlightExtension(), StrikethroughExtension(), ContainerExtension()]
```
Unregistered syntax stays literal text. An extension contributes an inline
form (`InlineSyntax`), a fenced block form (`BlockSyntax`), or both — plus the
attributes for its content and an HTML wrapper for rich copy. The parser owns
all geometry, marker/fence hiding, caret reveal, and incremental restyling, so
extensions behave identically to built-ins and cannot affect neighboring
constructs. Conform to `MarkdownExtension` to add your own.
### Code Blocks
**Recommended path: depend on the `MarkdownEngineCodeBlocks` product
and use the bundled `HighlighterSwiftBridge`.** Rolling your own
`SyntaxHighlighter` has subtle footguns the bridge already handles —
line-height metrics across light/dark themes, appearance-change
observation, layout-pass timing, font name extraction from the theme,
and CSS-theme-derived background colors. Use the bundle unless you
specifically need a non-HighlighterSwift library.
```swift
import MarkdownEngineCodeBlocks
var configuration = MarkdownEditorConfiguration.default
configuration.services = MarkdownEditorServices(
syntaxHighlighter: HighlighterSwiftBridge()
)
```
The bridge auto-switches between `atom-one-light` and `atom-one-dark`
with system appearance. Different theme names or a pinned single theme
are configurable via init params — see DocC.
Need a different highlighter library entirely? Implement
`SyntaxHighlighter` yourself (see [Service Protocols](#service-protocols)
above for the declaration) and reference the bundled bridge in
`Sources/MarkdownEngineCodeBlocks/` as a working example.
### LaTeX Rendering
**Recommended path: depend on the `MarkdownEngineLatex` product and use
the bundled `SwiftMathBridge`.** Hand-rolling a `LatexRenderer` has
real footguns the bridge already handles — appearance-aware text color,
zero-sized output guards (`lockFocus` crashes on 0×0 images),
window-vs-NSApp appearance distinction, single-letter padding, and an
internal cache keyed by (latex, font size, appearance, theme color).
```swift
import MarkdownEngineLatex
var configuration = MarkdownEditorConfiguration.default
configuration.services = MarkdownEditorServices(
latex: SwiftMathBridge()
)
```
The bridge uses the Latin Modern math font and tints formulas with
`MarkdownEditorTheme.latexLightModeText` / `latexDarkModeText`. Pass
`singleLetterPaddingBottom:` to override the engine's matching default.
### Theming
Every color the editor puts on screen reads from `MarkdownEditorTheme`:
```swift
var theme = MarkdownEditorTheme.default
theme.bodyText = .labelColor
theme.findMatchHighlight = NSColor(named: "MyAccent")!
var configuration = MarkdownEditorConfiguration.default
configuration.theme = theme
```
Defaults map to `NSColor` dynamic system colors, so light/dark mode
keeps working without extra code.
### Tuning
`MarkdownEditorConfiguration` exposes every spacing / sizing / behavior
knob the engine has, grouped by concern:
```swift
var configuration = MarkdownEditorConfiguration.default
configuration.codeBlock.fontSizeScale = 0.9
configuration.headings.fontMultipliers = [2.4, 1.8, 1.4, 1.1, 0.9, 0.75]
configuration.overscroll.percent = 0.4
configuration.lists.helpersEnabled = false
configuration.safeAreaInsets = SafeAreaInsets(top: 56) // headroom under a translucent toolbar
```
### Wiki-Links & Replacement State
Two optional bindings on `NativeTextViewWrapper` let you observe
wiki-link state and push inline replacements programmatically. Pass
only what you need — each is independent and defaults to a no-op:
```swift
NativeTextViewWrapper(
text: $text,
isWikiLinkActive: $isWikiLinkActive,
pendingInlineReplacement: $pendingReplacement
)
```
- `isWikiLinkActive` — the wrapper sets this to `true` while the caret
sits inside a `[[Name]]` link, so you can present a contextual UI.
- `pendingInlineReplacement` — assign a non-nil value to push a
replacement (e.g. an autocomplete result); the engine consumes it
and clears the binding.
### Height Behavior
By default the editor scrolls internally. Set `heightBehavior` to
`.fitsContent` to make it grow to fit its content and report that height to
SwiftUI, so an enclosing `ScrollView` scrolls the page instead:
```swift
ScrollView {
NativeTextViewWrapper(text: $text, configuration: .init(heightBehavior: .fitsContent))
}
```
Composes with `readingWidth` and the scrolling header, and is switchable at
runtime. `.fitsContent` lays out the whole document (no viewport
virtualization), so prefer it for small-to-medium content. See
``HeightBehavior`` in DocC for the full behavior.
### Reading Column
Give long documents a fixed-width centered column; wide GFM tables break out
to the full window width, Google-Docs-style:
```swift
configuration.readingWidth = 650
```
Text wraps at `readingWidth` and never re-wraps on resize (only the column's
position moves), keeping live resize smooth. Leave it `nil` (default) to fill
the container edge-to-edge.
### Scrolling Header
Host a SwiftUI view above the document body that scrolls away with it —
metadata, a property table, a contextual toolbar:
```swift
NativeTextViewWrapper(
text: $text,
header: AnyView(MyDocumentHeader(document: document)),
headerCollapsedHeight: 40,
headerExpanded: isHeaderExpanded
)
```
The engine hosts it in an `NSHostingView`, reserves its intrinsic height, and
keeps it fully interactive. `headerExpanded: false` collapses to
`headerCollapsedHeight` (top row stays, rows below clip away, animated). Inject
any required environment *before* wrapping in `AnyView`, and give wrapping
content an explicit height so it doesn't clip at the band's bottom. Composes
with `readingWidth`; an optional `placeholder:` shows ghost text while empty;
`header: nil` (default) adds nothing. The demo's **Header** toggle shows it.
## Demo
A runnable SwiftUI demo lives in [`Demo/`](Demo/MarkdownEngineDemo.xcodeproj).
Open it in Xcode and hit **Run** — the demo references the package via
a local path, so any engine edit rebuilds into the demo on the next run.
> If you're seeing a "missing package product" error, it's almost always
> stale package cache. Use **File → Packages → Reset Package Caches**
> once and rebuild.
## Documentation
Full API docs ship as DocC. In Xcode: **Product → Build Documentation**
(`⇧⌃⌘D`); for local CLI preview see [CONTRIBUTING.md](CONTRIBUTING.md). Once
hosted on Swift Package Index, docs will live at
`https://swiftpackageindex.com/nodes-app/swift-markdown-engine/documentation`.
## Requirements & Status
- macOS 14 or later (15.1+ for Apple Writing Tools integration)
- Swift 5.9 / Xcode 15 or later
MarkdownEngine is currently **pre-1.0**. The public API may change between
minor releases as it stabilizes. Production use is fine — pin a specific
version (`0.x.y`) in your `Package.swift`.
## Who makes it
<a href="https://apps.apple.com/app/apple-store/id6745401961?pt=127809373&ct=github&mt=8">
<img align="right" width="96" alt="Nodes" src="media/nodes-app-icon.png" />
</a>
MarkdownEngine is the editor inside **[Nodes](https://apps.apple.com/app/apple-store/id6745401961?pt=127809373&ct=github&mt=8)**,
a macOS app for writing, linking and exploring notes. This is not a side project
we open-sourced and walked away from — it is the editor our own users type in
every day, and every fix here ships in a real app first.
If it is useful to you, telling someone about it is all we would ask for.
## Contributing
Bug reports, ideas, and pull requests are welcome.
- [ARCHITECTURE.md](ARCHITECTURE.md) — codemap and pipeline guide for
contributors
- [CONTRIBUTING.md](CONTRIBUTING.md) — setup, PR process, and design
constraints
## License
MarkdownEngine is released under the Apache 2.0 License. See [LICENSE](LICENSE)
for the full text.
---
Built by a small team in Munich and Zurich. Day-to-day on [Instagram](https://www.instagram.com/nodes.app).
@@ -0,0 +1,727 @@
//
// MarkdownEditorConfiguration.swift
// MarkdownEngine
//
// Created by Luca Chen on 16.03.26.
//
// Centralized configuration for the Markdown editor engine.
//
// This struct exposes every spacing, sizing, and behavior knob that is
// shared across the engine. The defaults reproduce the historical
// Nodes-app behavior, so passing `.default` keeps existing rendering
// pixel-identical. Embedders that want a different look-and-feel can
// override individual fields without forking the engine.
//
import AppKit
import Foundation
// MARK: - Top-level Configuration
/// All tunable values for the Markdown editor engine grouped by concern.
///
/// The struct is deliberately flat-with-nested-groups: top level holds
/// orthogonal feature areas (markers, code blocks, lists, ), each group
/// owns the values that belong together. Default values are the production
/// defaults used by the Nodes app and have been chosen empirically.
public struct MarkdownEditorConfiguration: Sendable {
public var theme: MarkdownEditorTheme
public var services: MarkdownEditorServices
public var markers: MarkerStyle
public var codeBlock: CodeBlockStyle
public var inlineCode: InlineCodeStyle
public var lists: ListStyle
public var taskCheckbox: TaskCheckboxStyle
public var headings: HeadingStyle
public var imageEmbed: ImageEmbedStyle
public var blockLatex: BlockLatexStyle
public var inlineLatex: InlineLatexStyle
public var blockquote: BlockquoteStyle
public var link: LinkStyle
public var paragraph: ParagraphStyle
public var overscroll: OverscrollPolicy
public var dragSelection: DragSelectionPolicy
public var safeAreaInsets: SafeAreaInsets
public var scrollers: ScrollersPolicy
public var textInsets: TextInsets
/// Centered reading-column width; wide tables break out to full width. nil = full width (default).
public var readingWidth: CGFloat?
public var spellChecking: SpellCheckingPolicy
/// Smart quote/dash substitution while typing. Independent of `spellChecking`
/// AppKit tracks these as separate `NSTextView` flags.
public var textSubstitution: TextSubstitutionPolicy
/// Inline predictive-text completion (the ghost-text suggestion AppKit
/// shows as you type, same feature as Notes/TextEdit). Mirrors
/// `NSTextView.isAutomaticTextCompletionEnabled`.
public var textCompletion: TextCompletionPolicy
/// System Writing Tools (proofread/rewrite/summarize). Mirrors
/// `NSTextView.writingToolsBehavior` `.none` when disabled.
public var writingTools: WritingToolsPolicy
/// How the editor resolves its own height.
///
/// - `.scrolls` (default): the editor scrolls internally within whatever
/// height SwiftUI gives it. This is the historical behavior.
/// - `.fitsContent`: the editor grows to fit its content and reports that
/// height to SwiftUI, so an enclosing `ScrollView` scrolls the page
/// instead of a nested internal scroller. The editor re-reports its
/// height per keystroke as well as after async content changes (image
/// loads, font-size changes, header band resizes).
///
/// Switching at runtime is supported; the editor reconfigures immediately
/// (scroller visibility, overscroll, inflation, and intrinsic size all
/// update in the same SwiftUI update cycle).
///
/// - SeeAlso: ``HeightBehavior``
public var heightBehavior: HeightBehavior
/// Present the document as raw Markdown source: no syntax hiding, no
/// styling, no wiki-link display transform (`[[Name|UUID]]` shows verbatim).
/// Stays editable, but smart input (list continuation, auto-wrap, ) is off.
/// Runtime-switchable; a flip rebuilds immediately and drops the document's
/// undo stack (actions from the other mode would replay at stale ranges).
public var rawSourceMode: Bool
/// Opt-in constructs beyond pure markdown (e.g. `==highlight==`). Empty by
/// default: unregistered syntax stays literal text. Order defines match
/// precedence among extensions; built-in constructs always win first.
public var extensions: [any MarkdownExtension]
/// Let the caret and the I-beam take the ink of the extension span they sit
/// in, instead of `theme.bodyText` and the plain system pointer.
///
/// Off by default. It only matters for an extension that INVERTS its
/// content (dark ink on a light block), where both cursors would otherwise
/// be drawn in the block's own color and disappear inside it. An extension
/// whose `contentAttributes` set no foreground is unaffected either way
/// so this stays the embedder's explicit decision rather than something the
/// engine infers from a color it happens to see.
public var cursorFollowsSpanInk: Bool
public init(
theme: MarkdownEditorTheme = .default,
services: MarkdownEditorServices = .default,
markers: MarkerStyle = .default,
codeBlock: CodeBlockStyle = .default,
inlineCode: InlineCodeStyle = .default,
lists: ListStyle = .default,
taskCheckbox: TaskCheckboxStyle = .default,
headings: HeadingStyle = .default,
imageEmbed: ImageEmbedStyle = .default,
blockLatex: BlockLatexStyle = .default,
inlineLatex: InlineLatexStyle = .default,
blockquote: BlockquoteStyle = .default,
link: LinkStyle = .default,
paragraph: ParagraphStyle = .default,
overscroll: OverscrollPolicy = .default,
dragSelection: DragSelectionPolicy = .default,
safeAreaInsets: SafeAreaInsets = .default,
scrollers: ScrollersPolicy = .default,
textInsets: TextInsets = .default,
readingWidth: CGFloat? = nil,
spellChecking: SpellCheckingPolicy = .default,
textSubstitution: TextSubstitutionPolicy = .default,
textCompletion: TextCompletionPolicy = .default,
writingTools: WritingToolsPolicy = .default,
heightBehavior: HeightBehavior = .scrolls,
rawSourceMode: Bool = false,
extensions: [any MarkdownExtension] = [],
cursorFollowsSpanInk: Bool = false
) {
self.theme = theme
self.services = services
self.markers = markers
self.codeBlock = codeBlock
self.inlineCode = inlineCode
self.lists = lists
self.taskCheckbox = taskCheckbox
self.headings = headings
self.imageEmbed = imageEmbed
self.blockLatex = blockLatex
self.inlineLatex = inlineLatex
self.blockquote = blockquote
self.link = link
self.paragraph = paragraph
self.overscroll = overscroll
self.dragSelection = dragSelection
self.safeAreaInsets = safeAreaInsets
self.scrollers = scrollers
self.textInsets = textInsets
self.readingWidth = readingWidth
self.spellChecking = spellChecking
self.textSubstitution = textSubstitution
self.textCompletion = textCompletion
self.writingTools = writingTools
self.heightBehavior = heightBehavior
self.rawSourceMode = rawSourceMode
self.extensions = extensions
self.cursorFollowsSpanInk = cursorFollowsSpanInk
}
public static let `default` = MarkdownEditorConfiguration()
}
// MARK: - Spell checking
/// Initial state for the three "Spelling and Grammar" toggles. Only consulted
/// at `makeNSView` time; afterwards the user's context-menu choices take
/// precedence and are surfaced via ``NativeTextViewWrapper/onSpellCheckingPolicyChanged``.
public struct SpellCheckingPolicy: Sendable {
/// Mirrors `NSTextView.isContinuousSpellCheckingEnabled`.
public var continuousSpellChecking: Bool
/// Mirrors `NSTextView.isGrammarCheckingEnabled`.
public var grammarChecking: Bool
/// Mirrors `NSTextView.isAutomaticSpellingCorrectionEnabled`.
public var automaticSpellingCorrection: Bool
public init(
continuousSpellChecking: Bool = true,
grammarChecking: Bool = true,
automaticSpellingCorrection: Bool = true
) {
self.continuousSpellChecking = continuousSpellChecking
self.grammarChecking = grammarChecking
self.automaticSpellingCorrection = automaticSpellingCorrection
}
public static let `default` = SpellCheckingPolicy()
}
// MARK: - Text substitution
/// Smart quote/dash substitution while typing. Mirrors the historical
/// hardcoded `NativeTextViewWrapper` behavior (quotes on, dashes off) as the
/// default, now exposed as a config knob instead of fixed AppKit calls.
public struct TextSubstitutionPolicy: Sendable {
/// Mirrors `NSTextView.isAutomaticQuoteSubstitutionEnabled`.
public var quoteSubstitution: Bool
/// Mirrors `NSTextView.isAutomaticDashSubstitutionEnabled`.
public var dashSubstitution: Bool
public init(
quoteSubstitution: Bool = true,
dashSubstitution: Bool = false
) {
self.quoteSubstitution = quoteSubstitution
self.dashSubstitution = dashSubstitution
}
public static let `default` = TextSubstitutionPolicy()
}
// MARK: - Text completion
/// Inline predictive-text completion while typing (ghost-text suggestion,
/// accepted with Tab/ same feature as Notes/TextEdit on macOS 14+).
public struct TextCompletionPolicy: Sendable {
/// Mirrors `NSTextView.isAutomaticTextCompletionEnabled`.
public var isEnabled: Bool
public init(isEnabled: Bool = true) {
self.isEnabled = isEnabled
}
public static let `default` = TextCompletionPolicy()
}
// MARK: - Writing Tools
/// System Writing Tools (proofread / rewrite / summarize / compose), backed
/// by Apple Intelligence where available.
public struct WritingToolsPolicy: Sendable {
/// When `false`, `NSTextView.writingToolsBehavior` is set to `.none`
/// instead of `.limited` hides Writing Tools entirely for this view.
public var isEnabled: Bool
public init(isEnabled: Bool = true) {
self.isEnabled = isEnabled
}
public static let `default` = WritingToolsPolicy()
}
// MARK: - Scroll bars
/// Scroll bar visibility. Default: vertical only, autohide on.
public struct ScrollersPolicy: Sendable {
public var hasVerticalScroller: Bool
public var hasHorizontalScroller: Bool
public var autohidesScrollers: Bool
public init(
hasVerticalScroller: Bool = true,
hasHorizontalScroller: Bool = false,
autohidesScrollers: Bool = true
) {
self.hasVerticalScroller = hasVerticalScroller
self.hasHorizontalScroller = hasHorizontalScroller
self.autohidesScrollers = autohidesScrollers
}
public static let `default` = ScrollersPolicy()
/// No scrollers (use with a custom scroll overlay).
public static let hidden = ScrollersPolicy(hasVerticalScroller: false, hasHorizontalScroller: false)
/// Vertical only same as `.default`.
public static let vertical = ScrollersPolicy(hasVerticalScroller: true, hasHorizontalScroller: false)
/// Both axes (code-heavy / wide content).
public static let both = ScrollersPolicy(hasVerticalScroller: true, hasHorizontalScroller: true)
/// Vertical, no auto-hide.
public static let alwaysVisible = ScrollersPolicy(hasVerticalScroller: true, autohidesScrollers: false)
}
// MARK: - Text insets
/// Margins inside the text view (`NSTextView.textContainerInset`). Scroll bar stays at the outer edge.
public struct TextInsets: Sendable {
public var horizontal: CGFloat
public var vertical: CGFloat
public init(horizontal: CGFloat = 0, vertical: CGFloat = 0) {
self.horizontal = horizontal
self.vertical = vertical
}
public static let `default` = TextInsets()
}
// MARK: - Marker visibility
/// How Markdown syntax markers (e.g. `**`, `*`, `$`) are visualized when
/// the cursor is not inside the corresponding token.
///
/// The engine's default approach is to keep markers in the text storage but
/// shrink them to a near-zero font size (`hiddenMarkerFontSize`). This avoids
/// any range translation between displayed and stored text cursor movement,
/// find/replace, selection, and copy/paste all stay trivially correct.
/// The trade-off is a sub-pixel residue at extreme zoom levels.
public struct MarkerStyle: Sendable {
/// Font size used for "hidden" inline markers. Effectively invisible at
/// normal zoom while keeping displayed-range == stored-range.
public var hiddenMarkerFontSize: CGFloat
/// Alpha applied to inline-code's secondary marker color.
public var inlineCodeMarkerAlpha: CGFloat
/// Alpha applied to non-focused find matches when in-document search
/// highlights are visible. The focused match is drawn at full opacity.
public var findMatchHighlightAlpha: CGFloat
public init(
hiddenMarkerFontSize: CGFloat = 0.1,
inlineCodeMarkerAlpha: CGFloat = 0.5,
findMatchHighlightAlpha: CGFloat = 0.65
) {
self.hiddenMarkerFontSize = hiddenMarkerFontSize
self.inlineCodeMarkerAlpha = inlineCodeMarkerAlpha
self.findMatchHighlightAlpha = findMatchHighlightAlpha
}
public static let `default` = MarkerStyle()
}
// MARK: - Code blocks
/// Styling for fenced code blocks (```language ... ```).
public struct CodeBlockStyle: Sendable {
/// Code-block font size as a fraction of the document base font size.
public var fontSizeScale: CGFloat
/// Vertical paragraph spacing applied above and below the code block.
public var paragraphSpacing: CGFloat
/// Left/right indent (in points) so code blocks don't run into the gutter.
public var horizontalIndent: CGFloat
public init(
fontSizeScale: CGFloat = 0.85,
paragraphSpacing: CGFloat = 2.0,
horizontalIndent: CGFloat = 12.0
) {
self.fontSizeScale = fontSizeScale
self.paragraphSpacing = paragraphSpacing
self.horizontalIndent = horizontalIndent
}
public static let `default` = CodeBlockStyle()
}
// MARK: - Inline code
/// Styling for inline `` `code` `` spans.
public struct InlineCodeStyle: Sendable {
/// Inline-code reuses the code block font size scale by default.
public var fontSizeScale: CGFloat
public init(fontSizeScale: CGFloat = 0.85) {
self.fontSizeScale = fontSizeScale
}
public static let `default` = InlineCodeStyle()
}
// MARK: - Lists
/// Behavior toggles and metrics for ordered / unordered list editing.
public struct ListStyle: Sendable {
/// Master switch for list-related editing helpers (auto-continue,
/// auto-indent, marker conversion). When `false`, lists are still
/// rendered, but typing-time conveniences are skipped.
public var helpersEnabled: Bool
/// Master switch for auto-closing pairs `()`, `{}`, `[]` while typing.
public var autoClosePairsEnabled: Bool
/// Indent (in points) that one nesting level adds to the list item.
public var indentPerLevel: CGFloat
/// Maximum nesting level reachable by pressing Tab inside a list.
public var maximumNestingLevel: Int
/// Extra line height added on top of the default to give list items room.
public var extraLineHeight: CGFloat
public init(
helpersEnabled: Bool = true,
autoClosePairsEnabled: Bool = true,
indentPerLevel: CGFloat = 27.5,
maximumNestingLevel: Int = 3,
extraLineHeight: CGFloat = 2
) {
self.helpersEnabled = helpersEnabled
self.autoClosePairsEnabled = autoClosePairsEnabled
self.indentPerLevel = indentPerLevel
self.maximumNestingLevel = maximumNestingLevel
self.extraLineHeight = extraLineHeight
}
public static let `default` = ListStyle()
}
// MARK: - Task checkboxes
/// SF Symbol names used to draw task-list checkboxes (`- [ ]` / `- [x]`).
///
/// Any SF Symbol available on the deployment target can be substituted, for
/// example `"circle"` / `"checkmark.circle.fill"`. A name that doesn't
/// resolve falls back to the corresponding default symbol at draw time, so a
/// typo degrades to the stock look instead of drawing nothing. Tint colors
/// stay theme-driven (`MarkdownEditorTheme/mutedText` unchecked,
/// `MarkdownEditorTheme/bodyText` checked).
public struct TaskCheckboxStyle: Sendable {
/// SF Symbol drawn for an unchecked task item (`[ ]`).
public var uncheckedSymbolName: String
/// SF Symbol drawn for a checked task item (`[x]`).
public var checkedSymbolName: String
public init(
uncheckedSymbolName: String = "square",
checkedSymbolName: String = "checkmark.square.fill"
) {
self.uncheckedSymbolName = uncheckedSymbolName
self.checkedSymbolName = checkedSymbolName
}
public static let `default` = TaskCheckboxStyle()
}
// MARK: - Headings
/// Per-level heading metrics. Defaults follow the historical Nodes ratios,
/// which are loosely based on browser default heading sizes.
public struct HeadingStyle: Sendable {
/// Font-size multiplier per heading level (1...6).
public var fontMultipliers: [CGFloat]
/// Top spacing in `em` units per heading level (1...6).
public var topSpacingEm: [CGFloat]
public init(
fontMultipliers: [CGFloat] = [2.0, 1.5, 1.17, 1.0, 0.83, 0.67],
topSpacingEm: [CGFloat] = [0.35, 0.30, 0.25, 0.20, 0.15, 0.10]
) {
self.fontMultipliers = fontMultipliers
self.topSpacingEm = topSpacingEm
}
public func fontMultiplier(for level: Int) -> CGFloat {
let index = max(1, min(level, fontMultipliers.count)) - 1
return fontMultipliers[index]
}
public func topSpacingEm(for level: Int) -> CGFloat {
let index = max(1, min(level, topSpacingEm.count)) - 1
return topSpacingEm[index]
}
public static let `default` = HeadingStyle()
}
// MARK: - Image embeds (![[...]])
/// Sizing and spacing rules for `![[Name]]` image embeds.
public struct ImageEmbedStyle: Sendable {
/// Minimum allowed display width (points) for an embedded image.
public var minimumWidth: CGFloat
/// Fallback maximum width if no usable text container width is available.
public var fallbackMaxWidth: CGFloat
/// Sanity bound container widths above this are treated as invalid.
public var unreasonableMaxWidth: CGFloat
/// Vertical paragraph spacing above/below the image paragraph.
public var paragraphSpacing: CGFloat
/// Gap between the source line and the rendered image (visibleSource mode).
public var imageGap: CGFloat
public init(
minimumWidth: CGFloat = 50,
fallbackMaxWidth: CGFloat = 650,
unreasonableMaxWidth: CGFloat = 1_000_000,
paragraphSpacing: CGFloat = 8,
imageGap: CGFloat = 8
) {
self.minimumWidth = minimumWidth
self.fallbackMaxWidth = fallbackMaxWidth
self.unreasonableMaxWidth = unreasonableMaxWidth
self.paragraphSpacing = paragraphSpacing
self.imageGap = imageGap
}
public static let `default` = ImageEmbedStyle()
}
// MARK: - LaTeX
/// Vertical spacing for block-LaTeX `$$...$$` paragraphs.
public struct BlockLatexStyle: Sendable {
/// Top spacing for $$...$$ block paragraphs.
public var paragraphSpacingBefore: CGFloat
/// Bottom spacing for $$...$$ block paragraphs.
public var paragraphSpacing: CGFloat
/// Extra bottom padding added to single-letter formulas to avoid clipping.
public var singleLetterPaddingBottom: CGFloat
public init(
paragraphSpacingBefore: CGFloat = 16,
paragraphSpacing: CGFloat = 20,
singleLetterPaddingBottom: CGFloat = 1.0
) {
self.paragraphSpacingBefore = paragraphSpacingBefore
self.paragraphSpacing = paragraphSpacing
self.singleLetterPaddingBottom = singleLetterPaddingBottom
}
public static let `default` = BlockLatexStyle()
}
/// Reserved for future inline-LaTeX (`$...$`) tuning. Currently has no
/// effect; inline LaTeX inherits font size from the surrounding context.
public struct InlineLatexStyle: Sendable {
/// Reserved for future inline-LaTeX tuning currently the engine inherits
/// font size from the surrounding heading context.
public var placeholder: Void
public init() { self.placeholder = () }
public static let `default` = InlineLatexStyle()
}
// MARK: - Blockquote
/// Extra line height added to blockquote lines.
///
/// By default blockquote lines use the font's natural line height with no
/// extra spacing. Set `extraLineHeight` to add breathing room, matching
/// the pattern used by `ListStyle.extraLineHeight` and
/// `ParagraphStyle.lineHeightExtraSpacing`.
public struct BlockquoteStyle: Sendable {
/// Extra height (points) added to the default line height for blockquote lines.
public var extraLineHeight: CGFloat
public init(extraLineHeight: CGFloat = 0) {
self.extraLineHeight = extraLineHeight
}
public static let `default` = BlockquoteStyle()
}
// MARK: - Links
/// Foreground alpha values applied to link content in different states.
public struct LinkStyle: Sendable {
/// Foreground alpha for the visible label of an active markdown link.
public var activeLinkAlpha: CGFloat
/// Foreground alpha applied to "incomplete" link content (e.g. `[text]`
/// without a target).
public var incompleteLinkAlpha: CGFloat
public init(activeLinkAlpha: CGFloat = 0.55, incompleteLinkAlpha: CGFloat = 0.7) {
self.activeLinkAlpha = activeLinkAlpha
self.incompleteLinkAlpha = incompleteLinkAlpha
}
public static let `default` = LinkStyle()
}
// MARK: - Paragraphs
/// Default paragraph spacing and line height applied to body text.
public struct ParagraphStyle: Sendable {
/// Extra paragraph spacing as a fraction of the document's default line height.
public var spacingFactor: CGFloat
/// Extra height (points) added to the default paragraph line height.
public var lineHeightExtraSpacing: CGFloat
public init(spacingFactor: CGFloat = 0.3, lineHeightExtraSpacing: CGFloat = 2) {
self.spacingFactor = spacingFactor
self.lineHeightExtraSpacing = lineHeightExtraSpacing
}
public static let `default` = ParagraphStyle()
}
// MARK: - Bottom overscroll
/// Controls the empty space below the last line so that typing at the bottom
/// of a long document remains comfortable instead of pinning to the viewport
/// bottom edge.
public struct OverscrollPolicy: Sendable {
/// Desired overscroll as a fraction of the visible viewport height.
public var percent: CGFloat
/// Hard upper bound for the overscroll in points.
public var maxPoints: CGFloat
/// Hard lower bound for the overscroll in points.
public var minPoints: CGFloat
/// Fraction of the viewport above which overscroll starts ramping up.
public var activationStartFraction: CGFloat
/// Fraction of the viewport over which overscroll fully ramps in.
public var activationRangeFraction: CGFloat
public init(
percent: CGFloat = 0.5,
maxPoints: CGFloat = 450,
minPoints: CGFloat = 40,
activationStartFraction: CGFloat = 0.15,
activationRangeFraction: CGFloat = 0.85
) {
self.percent = percent
self.maxPoints = maxPoints
self.minPoints = minPoints
self.activationStartFraction = activationStartFraction
self.activationRangeFraction = activationRangeFraction
}
public static let `default` = OverscrollPolicy()
}
// MARK: - Drag selection
/// Tuning for the auto-scroll boost that engages while the user drags a
/// selection past the visible viewport edges.
public struct DragSelectionPolicy: Sendable {
/// Movement threshold (points) before the auto-scroll boost engages.
public var movementThreshold: CGFloat
/// Distance from the window edge that triggers the boost.
public var edgeTriggerDistance: CGFloat
/// Pixels per tick scrolled while the boost is active.
public var scrollStepPerTick: CGFloat
/// Boost timer frequency (ticks per second).
public var ticksPerSecond: Double
public init(
movementThreshold: CGFloat = 5.0,
edgeTriggerDistance: CGFloat = 5.0,
scrollStepPerTick: CGFloat = 12.0,
ticksPerSecond: Double = 60.0
) {
self.movementThreshold = movementThreshold
self.edgeTriggerDistance = edgeTriggerDistance
self.scrollStepPerTick = scrollStepPerTick
self.ticksPerSecond = ticksPerSecond
}
public static let `default` = DragSelectionPolicy()
}
// MARK: - Safe-area insets
/// Reserves space on the scroll view for system overlays (e.g. a translucent toolbar to scroll underneath). Maps to `NSScrollView.contentInsets`; scroll bar follows the inset.
public struct SafeAreaInsets: Sendable {
public var top: CGFloat
public var leading: CGFloat
public var trailing: CGFloat
public var bottom: CGFloat
public init(
top: CGFloat = 0,
leading: CGFloat = 0,
trailing: CGFloat = 0,
bottom: CGFloat = 0
) {
self.top = top
self.leading = leading
self.trailing = trailing
self.bottom = bottom
}
public static let `default` = SafeAreaInsets()
}
// MARK: - Height behavior
extension MarkdownEditorConfiguration {
/// How the editor resolves its own height.
///
/// ## Usage
///
/// ```swift
/// // Inline editor inside a page scroll view:
/// ScrollView {
/// NativeTextViewWrapper(
/// text: $text,
/// configuration: .init(heightBehavior: .fitsContent)
/// )
/// }
/// ```
///
/// ## Behavior
///
/// In `.fitsContent` mode:
/// - The editor reports `headerHeight + text content height` to SwiftUI.
/// - Typing grows/shrinks the block per keystroke; SwiftUI re-lays-out.
/// - An empty document shows at least one body line of height.
/// - Scroll-wheel events pass through to the enclosing scroll view.
/// - Caret visibility propagates to the enclosing (page-level) scroll
/// view so editing at the bottom of a tall block keeps the caret
/// on-screen.
/// - Async content changes (image/LaTeX finishing layout, font-size
/// change) re-report size via `invalidateIntrinsicContentSize`.
/// - Switching between `.scrolls` and `.fitsContent` at runtime is
/// supported; the editor reconfigures immediately.
///
/// ## Composition
///
/// - **Reading column** (`readingWidth`): the centered fixed-width column
/// is preserved; height grows to the column's content height.
/// - **Scroll-away header**: a static header's band is included in the
/// reported height. The collapse-on-scroll animation is driven by the
/// inner scroll offset, which is always zero in `.fitsContent`, so the
/// collapse never triggers. Combining a collapsing header with
/// `.fitsContent` is allowed but the collapse behavior is not meaningful.
///
/// ## Trade-offs
///
/// `.fitsContent` forces full-document layout so the total height is known.
/// For small-to-medium documents this is fine; for very large documents it
/// forgoes TextKit-2 viewport virtualization.
public enum HeightBehavior: Sendable {
/// The editor scrolls internally within the height SwiftUI gives it.
/// This is the historical behavior and the default.
case scrolls
/// The editor grows to fit its content and reports that height back to
/// SwiftUI, so an enclosing scroll view / page scrolls instead of a
/// nested scroll view. Internal scrolling and bottom-overscroll slack
/// are disabled in this mode.
case fitsContent
/// Whether the vertical scroller should be shown for this height
/// behavior and scroller policy combination.
///
/// In `.fitsContent` the editor never scrolls internally, so the
/// vertical scroller is always hidden regardless of the policy.
/// In `.scrolls` the policy's `hasVerticalScroller` is respected.
public func wantsVerticalScroller(for scrollers: ScrollersPolicy) -> Bool {
switch self {
case .fitsContent: return false
case .scrolls: return scrollers.hasVerticalScroller
}
}
}
}
@@ -0,0 +1,117 @@
//
// MarkdownEditorTheme.swift
// MarkdownEngine
//
// Created by Luca Chen on 16.03.26.
//
// Color palette for the Markdown editor engine.
//
// All user-visible colors used by the engine are routed through this
// struct. Defaults map to system colors so the editor adapts to light/
// dark mode automatically. Embedders that want a custom palette (for
// example, a sepia or high-contrast preset) can replace any subset of
// the colors without touching engine source files.
//
import AppKit
import Foundation
// MARK: - Theme
/// Color palette consumed by the Markdown editor engine.
///
/// Every color the engine puts on screen is read from this struct, so a
/// single override is enough to retheme the entire editor. The defaults
/// reproduce a system-native macOS look using `NSColor` dynamic system
/// colors, so light/dark-mode switching keeps working without extra code.
public struct MarkdownEditorTheme: Sendable {
// MARK: Text colors
/// Foreground color for plain body text and the typing caret.
public var bodyText: NSColor
/// Foreground color for de-emphasized text and most syntax markers.
/// Defaults to `secondaryLabelColor` so it tracks the system style.
public var mutedText: NSColor
/// Foreground color for content the engine wants to deemphasize further
/// than `mutedText` for example, broken wiki-links.
public var disabledText: NSColor
/// Foreground color for heading marker glyphs (`#`, `##`, ).
public var headingMarker: NSColor
// MARK: Links
/// Foreground color for hyperlinks that resolve to an URL.
public var link: NSColor
/// Foreground color for incomplete `[text]` patterns (no URL yet).
public var incompleteLink: NSColor
// MARK: Find / search highlights
/// Background color used to highlight all matches when the user is
/// running an in-document search.
///
/// The default is `.systemYellow` so embedders that don't customize
/// this still get a sensible result. Apps with their own brand color
/// (for example, the Nodes app uses its custom yellow) should override
/// this to match their palette.
public var findMatchHighlight: NSColor
/// Background color used to highlight the currently-focused match
/// during in-document search. Typically a stronger version of
/// ``findMatchHighlight``.
public var findCurrentMatchHighlight: NSColor
// MARK: LaTeX rendering
/// Foreground color used when rendering LaTeX formulas in light mode.
public var latexLightModeText: NSColor
/// Foreground color used when rendering LaTeX formulas in dark mode.
public var latexDarkModeText: NSColor
// MARK: Strikethrough / decoration
/// Stroke color used for strikethrough decorations
/// (e.g. completed task list items, horizontal rules).
public var strikethroughColor: NSColor
// MARK: Highlight
/// Background color used for `==highlight==` inline markup.
public var highlightColor: NSColor
// MARK: Init
public init(
bodyText: NSColor = .labelColor,
mutedText: NSColor = .secondaryLabelColor,
disabledText: NSColor = .tertiaryLabelColor,
headingMarker: NSColor = .gray,
link: NSColor = .linkColor,
incompleteLink: NSColor = .systemBlue,
findMatchHighlight: NSColor = .systemYellow,
findCurrentMatchHighlight: NSColor = .systemYellow,
latexLightModeText: NSColor = .black,
latexDarkModeText: NSColor = .white,
strikethroughColor: NSColor = .labelColor,
highlightColor: NSColor = .systemOrange.withAlphaComponent(0.4)
) {
self.bodyText = bodyText
self.mutedText = mutedText
self.disabledText = disabledText
self.headingMarker = headingMarker
self.link = link
self.incompleteLink = incompleteLink
self.findMatchHighlight = findMatchHighlight
self.findCurrentMatchHighlight = findCurrentMatchHighlight
self.latexLightModeText = latexLightModeText
self.latexDarkModeText = latexDarkModeText
self.strikethroughColor = strikethroughColor
self.highlightColor = highlightColor
}
/// System-native palette built from `NSColor` dynamic system colors.
///
/// Use this if you want the engine to look like a stock macOS
/// `NSTextView`. It's also the default when no theme is supplied.
public static let `default` = MarkdownEditorTheme()
}
@@ -0,0 +1,135 @@
//
// PerfTrace.swift
// MarkdownEngine
//
// Created by Luca Chen on 07.07.26.
//
// TEMP diagnostics (typing performance). Prints one compact line per keystroke
// with a per-phase breakdown plus the current document length, so we can see
// which costs grow with file size instead of staying constant. The whole point:
// type in a short file, then a long one, and compare `total` for the same edit.
//
// Toggle: set the env var MD_PERF=0 in the run scheme to silence.
// Debug-only the whole thing compiles out in Release.
// Remove before shipping (this file + the `PerfTrace.` call sites).
//
import Foundation
enum PerfTrace {
#if DEBUG
static var enabled = ProcessInfo.processInfo.environment["MD_PERF"] != "0"
/// Opt-in for the sampled full-rebuild verifier asserts (wiki splice,
/// backtick census, parse buffer). They run 3× O(doc) work synchronously
/// on every 64th keystroke periodic spikes that pollute the PERF
/// numbers so they stay off unless explicitly requested.
static let verifyEnabled = ProcessInfo.processInfo.environment["MD_PERF_VERIFY"] == "1"
#else
static let enabled = false
static let verifyEnabled = false
#endif
// All call sites run on the main thread (the coordinator + text view are
// main-actor), so plain static state is safe under the package's Swift 5 mode.
private static var active = false
private static var frameStart: UInt64 = 0
private static var docLength = 0
private static var phases: [(String, Double)] = []
private static var notes: [String] = []
/// Summed costs for code that runs MANY times per frame or from inside
/// AppKit callbacks (caret reveal, spell-checker callbacks) printed as
/// `+label=(×n)` after the sequential phases.
private static var accumulated: [(String, Double, Int)] = []
private static func nowMs() -> Double {
Double(DispatchTime.now().uptimeNanoseconds) / 1_000_000
}
/// Open a per-keystroke frame. Every `measure`/`note` until `end()` attaches to it.
/// A frame already opened this keystroke is CONTINUED, not reset:
/// shouldChangeTextIn opens the frame (so the pre-edit parse and the
/// smart-input interceptors are counted they used to run before the
/// frame and were invisible), the mid-edit selection change and
/// textDidChange attach to it. A frame left open by an edit that never
/// reached textDidChange is considered stale after 1s and reset.
static func begin(docLength len: Int) {
guard enabled else { return }
let now = DispatchTime.now().uptimeNanoseconds
docLength = len
if active, Double(now - frameStart) / 1_000_000 < 1_000 { return }
active = true
phases.removeAll(keepingCapacity: true)
notes.removeAll(keepingCapacity: true)
accumulated.removeAll(keepingCapacity: true)
frameStart = now
}
/// Like `measure`, but SUMS repeated calls under one label instead of
/// appending a phase per call for work triggered from inside AppKit
/// (caret reveal, spell-checker callbacks) that can fire several times
/// per keystroke and would otherwise stay invisible in the frame.
@discardableResult
static func accumulate<T>(_ label: String, _ body: () -> T) -> T {
guard enabled, active else { return body() }
let t0 = nowMs()
let result = body()
let dt = nowMs() - t0
if let i = accumulated.firstIndex(where: { $0.0 == label }) {
accumulated[i].1 += dt
accumulated[i].2 += 1
} else {
accumulated.append((label, dt, 1))
}
return result
}
/// Time one sequential top-level phase of the current frame.
@discardableResult
static func measure<T>(_ label: String, _ body: () -> T) -> T {
guard enabled, active else { return body() }
let t0 = nowMs()
let result = body()
phases.append((label, nowMs() - t0))
return result
}
/// Attach a free-form detail line (e.g. how many tables were re-rendered).
/// The closure only runs when tracing is active, so it costs nothing when off.
static func note(_ make: () -> String) {
guard enabled, active else { return }
notes.append(make())
}
/// Record a named timestamp (offset from frame start) inline in the
/// breakdown, printed as `@label=12.34`. The gaps BETWEEN checkpoints and
/// the measured spans locate work the spans don't cover (AppKit edit
/// application, layout, notification dispatch between our callbacks).
static func checkpoint(_ label: String) {
guard enabled, active else { return }
phases.append(("@" + label, Double(DispatchTime.now().uptimeNanoseconds - frameStart) / 1_000_000))
}
/// Close the frame and print total + per-phase breakdown + notes.
/// `other` = total Σ(phases + accumulated): time inside the frame that
/// no span covers (AppKit edit processing, layout, unmeasured code).
static func end() {
guard enabled, active else { return }
active = false
let total = Double(DispatchTime.now().uptimeNanoseconds - frameStart) / 1_000_000
var breakdown = phases.map { String(format: "%@=%.2f", $0.0, $0.1) }.joined(separator: " ")
if !accumulated.isEmpty {
breakdown += " " + accumulated.map { String(format: "+%@=%.2f(×%d)", $0.0, $0.1, $0.2) }.joined(separator: " ")
}
let covered = phases.filter { !$0.0.hasPrefix("@") }.reduce(0) { $0 + $1.1 }
+ accumulated.reduce(0) { $0 + $1.1 }
print(String(format: "⌨️ PERF doc=%dch total=%.2fms | %@ other=%.2f", docLength, total, breakdown, total - covered))
for note in notes { print(" └─ \(note)") }
}
/// Standalone timing print for a cost that runs *outside* the keystroke frame
/// (e.g. the async wide-table overlay reconcile fired after the edit settles).
static func stamp(_ label: String, _ ms: Double, _ detail: @autoclosure () -> String = "") {
guard enabled else { return }
print(String(format: "⏱️ PERF %@ %.2fms %@", label, ms, detail()))
}
}
@@ -0,0 +1,63 @@
//
// ContainerExtension.swift
// MarkdownEngine
//
// Created by Luca Chen on 15.07.26.
//
// `:::` fenced container as the first block extension. Not registered by
// default the core engine parses pure markdown; embedders opt in via:
//
// configuration.extensions = [ContainerExtension()]
//
// Syntax:
//
// ::: note
// Body text with **inline** markdown.
// :::
//
// The fence lines hide while the caret is outside the block and reveal
// muted while editing (mirroring code fences); the body keeps full inline
// styling plus the container background. An unclosed container runs to the end
// of the document (unlike an unclosed ``` fence, which stays literal text
// until its closing fence exists).
//
import AppKit
import Foundation
public struct ContainerExtension: MarkdownExtension {
/// Well-known id.
public static let identifier = "container"
/// Background tint applied to the container block.
public var backgroundColor: NSColor
/// The default resolves per appearance: `withAlphaComponent` on a dynamic
/// system color FREEZES it (same gotcha the table renderer documents), so
/// the tint is rebuilt inside a dynamic provider and keeps tracking
/// light/dark mode.
public init(backgroundColor: NSColor = NSColor(name: nil) { appearance in
var resolved = NSColor.systemBlue
appearance.performAsCurrentDrawingAppearance {
resolved = NSColor.systemBlue.usingColorSpace(.sRGB) ?? .systemBlue
}
return resolved.withAlphaComponent(0.12)
}) {
self.backgroundColor = backgroundColor
}
public var id: String { Self.identifier }
public var block: BlockSyntax? {
BlockSyntax(fence: ":::")
}
public func contentAttributes(theme: MarkdownEditorTheme) -> [NSAttributedString.Key: Any] {
[.backgroundColor: backgroundColor]
}
public func html(childrenHTML: String) -> String {
"<blockquote>\(childrenHTML)</blockquote>"
}
}
@@ -0,0 +1,46 @@
//
// HighlightExtension.swift
// MarkdownEngine
//
// Created by Luca Chen on 15.07.26.
//
// `==text==` highlight (Obsidian/CriticMarkup flavor) as the first inline
// span extension. Not registered by default the core engine parses pure
// markdown; embedders opt in via:
//
// configuration.extensions = [HighlightExtension()]
//
// Behavior is identical to the formerly built-in construct: content gets the
// theme's highlight background, content is re-parsed (emphasis etc. nest),
// markers mute while the caret is inside and shrink away otherwise, and the
// clean-copy path emits `<mark>`.
//
import AppKit
import Foundation
public struct HighlightExtension: MarkdownExtension {
/// Well-known id, referenced by the engine's formatting actions
/// (context menu / `applyHighlightRequest` toggle).
public static let identifier = "highlight"
public init() {}
public var id: String { Self.identifier }
public var inline: InlineSyntax? {
InlineSyntax(open: "==", close: "==")
}
/// `.markdownBlockBackground`, not `.backgroundColor`: the fill covers the
/// whole line box, so a highlight that wraps over several lines reads as
/// one block instead of a band per line (see the key's own note).
public func contentAttributes(theme: MarkdownEditorTheme) -> [NSAttributedString.Key: Any] {
[.markdownBlockBackground: theme.highlightColor]
}
public func html(childrenHTML: String) -> String {
"<mark>\(childrenHTML)</mark>"
}
}
@@ -0,0 +1,237 @@
//
// MarkdownExtension.swift
// MarkdownEngine
//
// Created by Luca Chen on 15.07.26.
//
// The extension seam: a construct beyond pure markdown an inline span
// (`==text==`, `%%text%%`, ) and/or a fenced block (`::: :::`) can be
// supplied by an extension instead of being hard-coded into the parser. The core stays pure
// markdown; extensions are opt-in per editor instance via
// `MarkdownEditorConfiguration.extensions`.
//
// Isolation contract: an extension supplies SYNTAX (the delimiters) and
// ATTRIBUTES (how the content looks). It never emits ranges the parser
// derives content/marker ranges itself, so a buggy extension can restyle its
// own span at worst, never a neighbor. Marker mute/shrink, caret reveal,
// incremental restyle, and copy behavior are handled generically by the
// engine, identical for every extension.
//
import AppKit
import Foundation
// MARK: - Syntax rule
/// The syntax of a delimited span, mirroring the semantics of the engine's
/// built-in span scanners:
///
/// * The span opens where `open` matches and closes at the FIRST exact `close`
/// match on the same line.
/// * A lone occurrence of `close`'s first character inside the content aborts
/// the match (the candidate stays literal) `==a=b==` is not a span.
/// * A newline before the close aborts the match (spans are single-line).
public struct InlineSyntax: Sendable, Equatable {
/// Opening delimiter, e.g. `"=="`.
public var open: String
/// Closing delimiter, e.g. `"=="`.
public var close: String
/// Whether the content is re-parsed as markdown (container, like
/// `==bold **inside**==`) or kept opaque (leaf, like a comment).
///
/// Note: an opaque span's content is still VISIBLE text carrying the
/// extension's `contentAttributes` the engine does not yet offer a
/// caret-aware hide/reveal affordance for content (markers shrink
/// generically, content does not). A comment-style extension that wants
/// to fully hide its content needs that future affordance.
public var parsesContent: Bool
/// Reject an empty span (`====`). Default `true`.
public var requiresNonEmptyContent: Bool
/// Reject when the character before `open` equals `open`'s first character
/// (the span must not extend a longer delimiter run). Default `true`,
/// matching `~~`/`==` built-in behavior.
public var rejectsOpenerRun: Bool
/// Reject when the character after `close` equals `close`'s last character.
/// `~~` uses this (strict GFM-ish run handling); `==` does not. Default `false`.
public var rejectsCloserRun: Bool
public init(
open: String,
close: String,
parsesContent: Bool = true,
requiresNonEmptyContent: Bool = true,
rejectsOpenerRun: Bool = true,
rejectsCloserRun: Bool = false
) {
self.open = open
self.close = close
self.parsesContent = parsesContent
self.requiresNonEmptyContent = requiresNonEmptyContent
self.rejectsOpenerRun = rejectsOpenerRun
self.rejectsCloserRun = rejectsCloserRun
}
}
// MARK: - Block syntax rule
/// The syntax of a fenced block, mirroring the engine's built-in fence
/// semantics (``` code fences):
///
/// * A line starting with `fence` at column 0 OPENS the block; the rest of
/// that line is the info string (e.g. `::: warning`).
/// * The next line starting with `fence` at column 0 CLOSES it.
/// * An unclosed block runs to the end of the document.
///
/// Built-in constructs always classify first a fence that collides with a
/// built-in line form (```, `$$`, `#`, `>`, list markers, `||`) never fires.
public struct BlockSyntax: Sendable, Equatable {
/// Fence prefix that opens and closes the block, e.g. `":::"`.
public var fence: String
public init(fence: String) {
self.fence = fence
}
}
// MARK: - Extension protocol
/// An opt-in construct beyond pure markdown. Register instances via
/// `MarkdownEditorConfiguration.extensions`; an unregistered construct's
/// syntax stays literal text.
///
/// An extension contributes one or both syntax forms:
/// * ``inline`` a delimited span on a single line (`==text==`).
/// * ``block`` a fenced multi-line block (`:::` `:::`).
///
/// It supplies only SYNTAX (delimiters) and ATTRIBUTES (how its content
/// looks). It never emits ranges the parser derives all geometry so a
/// misbehaving extension can at worst restyle its own construct, never a
/// neighbor. Marker mute/hide, caret reveal, incremental restyle, table
/// cells, and rich copy are handled generically by the engine, identical
/// for every extension.
public protocol MarkdownExtension: Sendable {
/// Stable identifier, unique per extension (e.g. `"highlight"`). Used for
/// dispatch and cache keying never shown to users.
var id: String { get }
/// Inline span form; `nil` when the extension has none (default).
var inline: InlineSyntax? { get }
/// Fenced block form; `nil` when the extension has none (default).
var block: BlockSyntax? { get }
/// Attributes applied to the construct's CONTENT range (between the
/// markers/fences). Called during styling; must be cheap and synchronous.
func contentAttributes(theme: MarkdownEditorTheme) -> [NSAttributedString.Key: Any]
/// Wrap the rendered inner HTML for the clean-copy path
/// (`childrenHTML` is already escaped / recursively rendered).
func html(childrenHTML: String) -> String
}
public extension MarkdownExtension {
// NOTE: because these have nil defaults, a conformance that misspells the
// property name (`var inlin: `) compiles fine and silently yields an
// inert extension. If your construct never fires, check these two names
// first.
var inline: InlineSyntax? { nil }
var block: BlockSyntax? { nil }
}
// MARK: - Parser-facing registry (internal)
/// Precompiled, purely syntactic view of the registered extensions the only
/// thing the parser sees. Built once per parse entry from the configuration.
struct ExtensionRegistry {
struct Entry {
let id: String
let open: [unichar]
let close: [unichar]
let syntax: InlineSyntax
}
struct BlockEntry {
let id: String
let fence: String
let fenceChars: [unichar]
}
/// Inline span rules, in registration order.
let entries: [Entry]
/// Fenced block rules, in registration order.
let blockEntries: [BlockEntry]
/// Stable fingerprint for cache keying ("" when empty). Two registries with
/// the same fingerprint produce identical parses for identical text.
let fingerprint: String
static let empty = ExtensionRegistry(entries: [], blockEntries: [], fingerprint: "")
private init(entries: [Entry], blockEntries: [BlockEntry], fingerprint: String) {
self.entries = entries
self.blockEntries = blockEntries
self.fingerprint = fingerprint
}
init(extensions: [any MarkdownExtension]) {
guard !extensions.isEmpty else {
self = .empty
return
}
self.entries = extensions.compactMap { ext in
guard let syntax = ext.inline else { return nil }
return Entry(
id: ext.id,
open: Array(syntax.open.utf16),
close: Array(syntax.close.utf16),
syntax: syntax
)
}
self.blockEntries = extensions.compactMap { ext in
guard let block = ext.block, !block.fence.isEmpty else { return nil }
return BlockEntry(id: ext.id, fence: block.fence, fenceChars: Array(block.fence.utf16))
}
// Every syntax field participates: registries that differ in ANY flag
// must never share cached parse results. Free-text fields (id, open,
// close, fence) are length-prefixed so the concatenation is injective
// an id containing the separator characters cannot alias another
// registry.
func framed(_ str: String) -> String { "\(str.utf16.count).\(str)" }
self.fingerprint = extensions
.map { ext in
var parts = [framed(ext.id)]
if let s = ext.inline {
parts += ["i", framed(s.open), framed(s.close),
"\(s.parsesContent)", "\(s.requiresNonEmptyContent)",
"\(s.rejectsOpenerRun)", "\(s.rejectsCloserRun)"]
}
if let b = ext.block {
parts += ["b", framed(b.fence)]
}
return parts.joined(separator: ",")
}
.joined(separator: "|")
}
var isEmpty: Bool { entries.isEmpty && blockEntries.isEmpty }
/// The first registered block rule whose fence opens `line` (column 0),
/// or nil. Registration order is precedence, matching the inline rules.
func blockEntry(opening line: String) -> BlockEntry? {
blockEntries.first { line.hasPrefix($0.fence) }
}
/// The block rule with the given extension id, or nil.
func blockEntry(for id: String) -> BlockEntry? {
blockEntries.first { $0.id == id }
}
}
extension MarkdownEditorConfiguration {
/// The parser-facing registry derived from `extensions`.
var extensionRegistry: ExtensionRegistry {
ExtensionRegistry(extensions: extensions)
}
/// Styler-facing lookup: extension behavior by id.
var extensionsByID: [String: any MarkdownExtension] {
var out: [String: any MarkdownExtension] = [:]
for ext in extensions { out[ext.id] = ext }
return out
}
}
@@ -0,0 +1,45 @@
//
// StrikethroughExtension.swift
// MarkdownEngine
//
// Created by Luca Chen on 15.07.26.
//
// `~~text~~` strikethrough (GFM flavor) as an inline span extension. Not
// registered by default the core engine parses pure markdown; embedders
// opt in via:
//
// configuration.extensions = [StrikethroughExtension()]
//
// Matches the formerly built-in semantics exactly, including the stricter
// GFM-ish run handling: `~~a~~~` stays literal (the closer must not extend
// into a longer `~` run), unlike highlight's tolerant `==abc===`.
//
import AppKit
import Foundation
public struct StrikethroughExtension: MarkdownExtension {
/// Well-known id, referenced by the engine's formatting actions
/// (context menu / `applyStrikethroughRequest` toggle).
public static let identifier = "strikethrough"
public init() {}
public var id: String { Self.identifier }
public var inline: InlineSyntax? {
InlineSyntax(open: "~~", close: "~~", rejectsCloserRun: true)
}
public func contentAttributes(theme: MarkdownEditorTheme) -> [NSAttributedString.Key: Any] {
[
.strikethroughStyle: NSUnderlineStyle.single.rawValue,
.strikethroughColor: theme.strikethroughColor,
]
}
public func html(childrenHTML: String) -> String {
"<del>\(childrenHTML)</del>"
}
}
@@ -0,0 +1,130 @@
//
// MarkdownInputHandler.swift
// MarkdownEngine
//
// Created by Luca Chen on 18.02.26.
//
// Handles Markdown typing shortcuts, like continuing lists and keeping block
// LaTeX on its own line while you type.
import AppKit
enum MarkdownInputHandler {
/// `codeTokens` (codeBlock + inlineCode, from the keystroke's existing
/// parse) answers "is the caret in code?" without the O(doc) document
/// scan the handler otherwise runs on every space/Enter/Tab.
static func handleListInsertion(textView: NSTextView, affectedCharRange: NSRange, replacementString: String?, codeTokens: [MarkdownToken]? = nil) -> Bool {
let isInsideCodeBlock = codeTokens.map {
MarkdownDetection.isInsideCodeBlock(location: affectedCharRange.location, codeTokens: $0)
}
return MarkdownLists.handleInsertion(textView: textView, affectedCharRange: affectedCharRange,
replacementString: replacementString, isInsideCodeBlock: isInsideCodeBlock)
}
// MARK: - Block LaTeX Auto-Wrap
private static func insertTextProgrammatically(_ textView: NSTextView, text: String, at range: NSRange, cursorAfter: Int) {
if let coord = textView.delegate as? NativeTextViewWrapper.Coordinator {
coord.isProgrammaticEdit = true
// Replaces a suppressed keystroke that never applied reset its
// pending count so this edit registers as the cycle's single
// tracked edit and textDidChange keeps the trusted fast paths.
coord.pendingEditCount = 0
}
textView.insertText(text, replacementRange: range)
if let coord = textView.delegate as? NativeTextViewWrapper.Coordinator {
coord.isProgrammaticEdit = false
}
textView.setSelectedRange(NSRange(location: cursorAfter, length: 0))
}
/// Keeps block LaTeX ($$...$$) on its own line by inserting newlines; returns true if handled.
static func handleBlockLatexAutoWrap(
textView: NSTextView,
affectedCharRange: NSRange,
replacementString: String?,
blockLatexTokens: [MarkdownToken]? = nil
) -> Bool {
let resolvedTokens: [MarkdownToken]
if let blockLatexTokens {
resolvedTokens = blockLatexTokens
} else {
resolvedTokens = MarkdownTokenizer.parseTokensViaAST(
in: textView.string,
registry: (textView as? NativeTextView)?.configuration.extensionRegistry ?? .empty
).filter { $0.kind == .blockLatex }
}
return handleBlockAutoWrap(textView: textView, affectedCharRange: affectedCharRange,
replacementString: replacementString, tokens: resolvedTokens)
}
/// Ensures image embeds (![[...]]) stay on their own line by automatically inserting newlines.
static func handleImageEmbedAutoWrap(
textView: NSTextView,
affectedCharRange: NSRange,
replacementString: String?,
imageEmbedTokens: [MarkdownToken]? = nil
) -> Bool {
let resolvedTokens: [MarkdownToken]
if let imageEmbedTokens {
resolvedTokens = imageEmbedTokens
} else {
resolvedTokens = MarkdownTokenizer.parseTokensViaAST(
in: textView.string,
registry: (textView as? NativeTextView)?.configuration.extensionRegistry ?? .empty
).filter { $0.kind == .imageEmbed }
}
return handleBlockAutoWrap(textView: textView, affectedCharRange: affectedCharRange,
replacementString: replacementString, tokens: resolvedTokens)
}
/// Shared auto-wrap logic: ensures a block-level token stays on its own line.
private static func handleBlockAutoWrap(
textView: NSTextView,
affectedCharRange: NSRange,
replacementString: String?,
tokens: [MarkdownToken]
) -> Bool {
guard let replacement = replacementString,
!replacement.isEmpty,
replacement != "\n" else { return false }
let text = textView.string as NSString
let newlineChar = UInt16(("\n" as Character).asciiValue!)
for token in tokens {
let tokenEnd = NSMaxRange(token.range)
// Typing right after closing marker
if affectedCharRange.location == tokenEnd {
if tokenEnd < text.length && text.character(at: tokenEnd) == newlineChar {
insertTextProgrammatically(textView, text: replacement,
at: NSRange(location: tokenEnd + 1, length: 0),
cursorAfter: tokenEnd + 1 + replacement.utf16.count)
} else {
insertTextProgrammatically(textView, text: "\n" + replacement,
at: affectedCharRange,
cursorAfter: affectedCharRange.location + 1 + replacement.utf16.count)
}
return true
}
// Typing right before opening marker
if affectedCharRange.location == token.range.location {
if token.range.location > 0 && text.character(at: token.range.location - 1) == newlineChar {
insertTextProgrammatically(textView, text: replacement,
at: NSRange(location: token.range.location - 1, length: 0),
cursorAfter: token.range.location - 1 + replacement.utf16.count)
} else {
insertTextProgrammatically(textView, text: replacement + "\n",
at: affectedCharRange,
cursorAfter: affectedCharRange.location + replacement.utf16.count)
}
return true
}
}
return false
}
}
@@ -0,0 +1,327 @@
//
// MarkdownListHandler.swift
// MarkdownEngine
//
// Created by Luca Chen on 18.02.26.
//
// Makes list editing feel natural by continuing items, handling indentation,
// and applying spacing/alignment that keeps lists easy to read.
import AppKit
struct MarkdownLists {
static func performEdit(_ textView: NSTextView, replace range: NSRange, with string: String) {
let ns = textView.string as NSString
let loc = min(range.location, ns.length)
let maxLen = ns.length - loc
let len = min(range.length, max(0, maxLen))
let safeRange = NSRange(location: loc, length: len)
if let coord = textView.delegate as? NativeTextViewWrapper.Coordinator {
coord.isProgrammaticEdit = true
// This edit REPLACES a suppressed keystroke that never applied.
// Dropping its pending count lets the shouldChangeText below
// re-register as the cycle's single tracked edit, so textDidChange
// keeps the trusted fast paths (the descriptor is refreshed for
// every proposed edit and describes THIS transition exactly).
coord.pendingEditCount = 0
}
defer {
if let coord = textView.delegate as? NativeTextViewWrapper.Coordinator { coord.isProgrammaticEdit = false }
}
guard textView.shouldChangeText(in: safeRange, replacementString: string) else { return }
textView.textStorage?.replaceCharacters(in: safeRange, with: string)
textView.didChangeText()
}
// Markers: `-`/`*`/`+` (raw Markdown) + legacy `` (rendered, never typed).
static let listRegex = try! NSRegularExpression(
pattern: #"^\s*((?:(\d+)\.|[-•*+])(?:\s+\[[ xX]\])?\s+)"#
)
/// Blockquote line: 3 indent + `>` marker run; group 1 = whitespace, group 2 = markers.
// Trailing `[ \t]*` so the prefix length covers the space(s) the continuation
// inserts (`markers + " "`) otherwise exiting an empty quote leaves a stray
// space (greedy like listRegex's `\s+`).
static let blockquoteRegex = try! NSRegularExpression(
pattern: #"^( {0,3})(>+(?:[ \t]+>+)*)[ \t]*"#
)
static let dashNoSpaceRegex = try! NSRegularExpression(pattern: #"^\s*-(?!\s)"#)
static let leadingWhitespaceRegex = try! NSRegularExpression(pattern: #"^\s*"#)
static func indentLevel(from leadingWhitespace: String) -> Int {
let tabCount = leadingWhitespace.filter { $0 == "\t" }.count
let spaceCount = leadingWhitespace.filter { $0 == " " }.count
return tabCount + (spaceCount / 2)
}
/// Remove the current line's leading marker and put the caret at line start (exit empty block on Enter).
private static func removeLinePrefixAndExit(
textView: NSTextView,
currentLineRange: NSRange,
prefixLength: Int
) -> Bool {
let lineEnd = currentLineRange.location + currentLineRange.length
let hasNewline = currentLineRange.length > 0
&& (textView.string as NSString)
.substring(with: NSRange(location: lineEnd - 1, length: 1)) == "\n"
let maxBodyLen = hasNewline ? currentLineRange.length - 1 : currentLineRange.length
let removalLength = min(prefixLength, maxBodyLen)
let removalRange = NSRange(location: currentLineRange.location, length: removalLength)
performEdit(textView, replace: removalRange, with: "")
textView.setSelectedRange(NSRange(location: currentLineRange.location, length: 0))
return false
}
/// Mirror Enter-key quote continuation for multi-line pastes: when `location`
/// sits on a blockquote line, prefix every line after the first with that
/// line's `>` marker run so the whole paste stays inside the quote. Returns
/// `pasted` unchanged when it has no newline or the caret isn't in a quote.
static func blockquoteContinuedPaste(_ pasted: String, at location: Int, in document: String) -> String {
guard pasted.contains("\n") else { return pasted }
let ns = document as NSString
guard location >= 0, location <= ns.length else { return pasted }
let lineRange = ns.lineRange(for: NSRange(location: location, length: 0))
let nsLine = ns.substring(with: lineRange) as NSString
guard let match = blockquoteRegex.firstMatch(
in: nsLine as String,
range: NSRange(location: 0, length: nsLine.length)
) else { return pasted }
let ws = nsLine.substring(with: match.range(at: 1))
let markers = nsLine.substring(with: match.range(at: 2))
let prefix = ws + markers + " "
return pasted.replacingOccurrences(of: "\n", with: "\n" + prefix)
}
// MARK: - Input Handling
/// `isInsideCodeBlock` is the caller's pre-parsed answer for
/// `affectedCharRange.location` (the coordinator derives it from the
/// keystroke's existing parse). `nil` direct callers without a parse
/// falls back to deriving it here, which walks the whole document.
static func handleInsertion(textView: NSTextView, affectedCharRange: NSRange, replacementString: String?, isInsideCodeBlock: Bool? = nil) -> Bool {
guard let replacementString = replacementString else { return true }
// Fast path: plain characters never trigger list/pair/arrow handling.
if replacementString.count == 1,
let ch = replacementString.first,
ch != ">" && ch != "[" && ch != "(" && ch != "{" &&
ch != "\t" && ch != " " && ch != "\n" {
return true
}
let activeConfig = (textView as? NativeTextView)?.configuration ?? .default
let listsEnabled = activeConfig.lists.helpersEnabled
let autoClosePairsEnabled = activeConfig.lists.autoClosePairsEnabled
func insertAutoPair(open openChar: String, close closeChar: String) -> Bool {
let insertionLocation = affectedCharRange.location
MarkdownLists.performEdit(textView, replace: affectedCharRange, with: "\(openChar)\(closeChar)")
textView.setSelectedRange(NSRange(location: insertionLocation + openChar.count, length: 0))
return false
}
let isInCodeBlock = isInsideCodeBlock ?? (
textView.string.contains("`")
? MarkdownDetection.isInsideCodeBlock(location: affectedCharRange.location, in: textView.string)
: false
)
if replacementString == ">" && affectedCharRange.length == 0 && !isInCodeBlock {
let insertionLocation = affectedCharRange.location
guard insertionLocation > 0 else { return true }
let nsText = textView.string as NSString
let previousCharRange = NSRange(location: insertionLocation - 1, length: 1)
let previousChar = nsText.substring(with: previousCharRange)
if previousChar == "-" {
MarkdownLists.performEdit(textView, replace: previousCharRange, with: "")
textView.setSelectedRange(NSRange(location: insertionLocation, length: 0))
return false
}
}
// Autocomplete Obsidian-style node brackets and single square brackets
if replacementString == "[" {
let nsText = textView.string as NSString
let insertionLocation = affectedCharRange.location
if insertionLocation > 0 {
let prevChar = nsText.substring(with: NSRange(location: insertionLocation - 1, length: 1))
if prevChar == "[" {
let hasAutoCloseBracket = insertionLocation < nsText.length
&& nsText.substring(with: NSRange(location: insertionLocation, length: 1)) == "]"
if hasAutoCloseBracket {
// Collapse auto-paired "[]" into "[[]]" without changing surrounding text.
MarkdownLists.performEdit(
textView,
replace: NSRange(location: insertionLocation - 1, length: 2),
with: "[[]]"
)
} else {
// If the char to the right is not "]" (e.g. newline), do not delete it.
MarkdownLists.performEdit(textView, replace: affectedCharRange, with: "[]]")
}
textView.setSelectedRange(NSRange(location: insertionLocation + 1, length: 0))
return false
}
}
guard autoClosePairsEnabled else { return true }
return insertAutoPair(open: "[", close: "]")
}
// Autocomplete parentheses / braces
if replacementString == "(" || replacementString == "{" {
guard autoClosePairsEnabled else { return true }
let closeChar = (replacementString == "(") ? ")" : "}"
return insertAutoPair(open: replacementString, close: closeChar)
}
// TAB: indent list items (skip in code blocks)
if replacementString == "\t" && !isInCodeBlock {
guard listsEnabled else { return true }
let nsText = textView.string as NSString
let insertionLocation = affectedCharRange.location
let safeLocTAB = min(affectedCharRange.location, nsText.length)
let currentLineRange = nsText.lineRange(for: NSRange(location: safeLocTAB, length: 0))
let currentLine = nsText.substring(with: currentLineRange)
if MarkdownLists.listRegex.firstMatch(in: currentLine, range: NSRange(location: 0, length: currentLine.utf16.count)) != nil {
if let wsMatch = MarkdownLists.leadingWhitespaceRegex.firstMatch(in: currentLine, range: NSRange(location: 0, length: currentLine.utf16.count)) {
let ws = (currentLine as NSString).substring(with: wsMatch.range)
let level = MarkdownLists.indentLevel(from: ws)
if level >= MarkdownEditorConfiguration.default.lists.maximumNestingLevel {
return false
}
}
MarkdownLists.performEdit(textView, replace: NSRange(location: currentLineRange.location, length: 0), with: "\t")
textView.setSelectedRange(NSRange(location: insertionLocation + 1, length: 0))
return false
}
if MarkdownLists.dashNoSpaceRegex.firstMatch(in: currentLine, range: NSRange(location: 0, length: currentLine.utf16.count)) != nil {
if let wsMatch = MarkdownLists.leadingWhitespaceRegex.firstMatch(in: currentLine, range: NSRange(location: 0, length: currentLine.utf16.count)) {
let ws = (currentLine as NSString).substring(with: wsMatch.range)
let level = MarkdownLists.indentLevel(from: ws)
if level >= MarkdownEditorConfiguration.default.lists.maximumNestingLevel { return false }
}
MarkdownLists.performEdit(textView, replace: NSRange(location: currentLineRange.location, length: 0), with: "\t")
textView.setSelectedRange(NSRange(location: insertionLocation + 1, length: 0))
return false
}
return true
}
// ENTER: list continuation/outdent
if replacementString == "\n" {
let nsText = textView.string as NSString
let safeLocENTER = min(affectedCharRange.location, nsText.length)
let currentLineRange = nsText.lineRange(for: NSRange(location: safeLocENTER, length: 0))
let currentLine = nsText.substring(with: currentLineRange).trimmingCharacters(in: .whitespacesAndNewlines)
// Horizontal rules render via the styler; source stays literal `---` so files round-trip.
if currentLine.range(of: "^```\\w*$", options: .regularExpression) != nil {
// Non-overlapping ``` count before the line (what
// components(separatedBy:).count-1 computed, without
// materializing an O(doc) substring array).
var openingCount = 0
var searchLocation = 0
while searchLocation < currentLineRange.location {
let found = nsText.range(of: "```", options: [],
range: NSRange(location: searchLocation,
length: currentLineRange.location - searchLocation))
if found.location == NSNotFound { break }
openingCount += 1
searchLocation = NSMaxRange(found)
}
let afterLineStart = currentLineRange.location + currentLineRange.length
let hasClosingAfter: Bool = {
guard afterLineStart < nsText.length else { return false }
let after = NSRange(location: afterLineStart, length: nsText.length - afterLineStart)
return nsText.range(of: "```", options: [], range: after).location != NSNotFound
}()
let lineEnd = currentLineRange.location + max(0, currentLineRange.length - 1)
let cursorAtLineEnd = affectedCharRange.location >= lineEnd
if openingCount.isMultiple(of: 2) && cursorAtLineEnd && !hasClosingAfter {
let insertionLocation = affectedCharRange.location
let completion = "\n\n```"
MarkdownLists.performEdit(textView, replace: affectedCharRange, with: completion)
textView.setSelectedRange(NSRange(location: insertionLocation + 1, length: 0))
return false
}
}
// Skip list / blockquote continuation in code blocks.
guard listsEnabled && !isInCodeBlock else { return true }
// Blockquote continuation: `> foo` `\n> `, `>>>` stays `>>>`, empty marker exit.
let quoteLine = nsText.substring(with: currentLineRange)
if let quoteMatch = MarkdownLists.blockquoteRegex.firstMatch(
in: quoteLine,
range: NSRange(location: 0, length: quoteLine.utf16.count)
) {
let ws = (quoteLine as NSString).substring(with: quoteMatch.range(at: 1))
let markers = (quoteLine as NSString).substring(with: quoteMatch.range(at: 2))
let prefixLength = quoteMatch.range.length
let contentStart = quoteMatch.range.location + prefixLength
let contentLength = quoteLine.utf16.count - contentStart
let contentText = (quoteLine as NSString)
.substring(with: NSRange(location: contentStart, length: contentLength))
.trimmingCharacters(in: .whitespacesAndNewlines)
if contentText.isEmpty {
return removeLinePrefixAndExit(
textView: textView,
currentLineRange: currentLineRange,
prefixLength: prefixLength
)
}
MarkdownLists.performEdit(textView, replace: affectedCharRange, with: "\n" + ws + markers + " ")
return false
}
let listLine = nsText.substring(with: currentLineRange)
if let match = MarkdownLists.listRegex.firstMatch(in: listLine, range: NSRange(location: 0, length: listLine.utf16.count)) {
let contentStart = match.range.location + match.range.length
let contentLength = listLine.utf16.count - contentStart
let contentRangeLocal = NSRange(location: contentStart, length: contentLength)
let contentText = (listLine as NSString).substring(with: contentRangeLocal).trimmingCharacters(in: .whitespacesAndNewlines)
if contentText.isEmpty {
return removeLinePrefixAndExit(
textView: textView,
currentLineRange: currentLineRange,
prefixLength: match.range.location + match.range.length
)
}
let leadingWhitespace: String
if let wsMatch = MarkdownLists.leadingWhitespaceRegex.firstMatch(in: listLine, range: NSRange(location: 0, length: listLine.utf16.count)) {
leadingWhitespace = (listLine as NSString).substring(with: wsMatch.range)
} else {
leadingWhitespace = ""
}
let markerRaw = (listLine as NSString).substring(with: match.range(at: 1))
let marker = markerRaw.trimmingCharacters(in: .whitespaces)
let hasCheckbox = marker.range(of: #"\[[ xX]\]"#, options: .regularExpression) != nil
let newListItem: String
if match.range(at: 2).location != NSNotFound,
let number = Int((listLine as NSString).substring(with: match.range(at: 2))) {
if hasCheckbox {
newListItem = "\n" + leadingWhitespace + "\(number + 1). [ ] "
} else {
newListItem = "\n" + leadingWhitespace + "\(number + 1). "
}
} else {
// Continue with the user's marker char (legacy `` `-`), keeping leading whitespace.
let bulletChar = (marker.first == "") ? "-" : String(marker.prefix(1))
if hasCheckbox {
newListItem = "\n" + leadingWhitespace + bulletChar + " [ ] "
} else {
newListItem = "\n" + leadingWhitespace + bulletChar + " "
}
}
MarkdownLists.performEdit(textView, replace: affectedCharRange, with: newListItem)
return false
}
}
return true
}
}
@@ -0,0 +1,113 @@
# ``MarkdownEngine``
A TextKit 2-backed Markdown editor view for macOS, bridged to SwiftUI.
## Overview
MarkdownEngine provides a native AppKit Markdown editor with live styling,
wiki-style ``[[Name]]`` linking, fenced code blocks with syntax highlighting,
LaTeX rendering, embedded images, and GitHub-style task checkboxes.
The engine itself has **zero external dependencies**. Everything app-specific
is injected through small service protocols, so embedders stay in control of
where wiki-links resolve, where embedded images live, how code is highlighted,
and how LaTeX is rendered.
### Quick Start
```swift
import SwiftUI
import MarkdownEngine
struct EditorScreen: View {
@State private var text: String = "# Hello, *world*"
@State private var isLinkActive: Bool = false
@State private var pendingReplacement: InlineReplacementRequest?
var body: some View {
NativeTextViewWrapper(
text: $text,
isWikiLinkActive: $isLinkActive,
pendingInlineReplacement: $pendingReplacement,
configuration: .default,
fontName: "SF Pro",
documentId: "doc-1"
)
}
}
```
The default ``MarkdownEditorConfiguration`` ships with no-op service
implementations, so the editor renders plain Markdown out of the box. Add
real services as you need them.
### Customizing Appearance
```swift
var theme = MarkdownEditorTheme.default
theme.bodyText = .labelColor
theme.headingMarker = .secondaryLabelColor
var configuration = MarkdownEditorConfiguration.default
configuration.theme = theme
```
### Wiring Up Services
```swift
let services = MarkdownEditorServices(
wikiLinks: MyWikiLinkResolver(),
images: MyImageProvider(),
syntaxHighlighter: MySyntaxHighlighter(),
latex: MyLatexRenderer()
)
var configuration = MarkdownEditorConfiguration.default
configuration.services = services
```
## Topics
### Editor View
- ``NativeTextViewWrapper``
### Configuration
- ``MarkdownEditorConfiguration``
- ``MarkdownEditorTheme``
### Service Protocols
- ``WikiLinkResolver``
- ``EmbeddedImageProvider``
- ``SyntaxHighlighter``
- ``LatexRenderer``
### Services Container
- ``MarkdownEditorServices``
- ``MarkdownEditorBus``
### Default No-Op Implementations
- ``NoOpWikiLinkResolver``
- ``NoOpEmbeddedImageProvider``
- ``PlainTextSyntaxHighlighter``
- ``NoOpLatexRenderer``
### Selection & Replacement
- ``InlineSelectionState``
- ``InlineSelectionKind``
- ``WikiLinkSelection``
- ``InlineReplacementRequest``
- ``CodeBlockSelection``
### Wiki-Link Roundtripping
- ``WikiLinkService``
### Pasteboard Helpers
- ``PasteboardImageReader``
@@ -0,0 +1,269 @@
//
// BlockLevelTokenizer.swift
// MarkdownEngine
//
// Builds the block-level MarkdownTokens (heading, blockquote, fenced code,
// table, block LaTeX) directly from already-classified block substrings
// replacing the legacy `parseTokens` regexes. Inline tokens come from the AST
// (`InlineParser` `InlineASTAdapter`); this only covers block-level kinds.
//
// Token shapes are reproduced 1:1 from the old regex tokenizer so every
// downstream consumer (ContextMenu, code/LaTeX detection, the NSImage render
// passes) sees identical tokens. A parity check pins that during the swap.
//
import Foundation
enum BlockLevelTokenizer {
private static let backtick: unichar = 0x60
private static let dollar: unichar = 0x24
private static let hash: unichar = 0x23
private static let pipe: unichar = 0x7C
private static let gt: unichar = 0x3E
private static let dash: unichar = 0x2D
private static let colon: unichar = 0x3A
private static let space: unichar = 0x20
private static let tab: unichar = 0x09
private static let lf: unichar = 0x0A
private static let cr: unichar = 0x0D
private static func isWS(_ c: unichar) -> Bool { c == space || c == tab }
/// Content end (excludes trailing CR/LF) and next-line start for the line at `start`.
private static func line(in s: NSString, from start: Int) -> (contentEnd: Int, nextStart: Int) {
let len = s.length
var i = start
while i < len, s.character(at: i) != lf, s.character(at: i) != cr { i += 1 }
let contentEnd = i
if i < len, s.character(at: i) == cr { i += 1 }
if i < len, s.character(at: i) == lf { i += 1 }
return (contentEnd, i)
}
/// Block-level tokens for one block substring, dispatched by its kind.
static func tokens(for kind: BlockKind, in sub: NSString, registry: ExtensionRegistry = .empty) -> [MarkdownToken] {
switch kind {
case .fencedCode: return codeBlock(in: sub)
case .heading: return heading(in: sub)
case .blockquote: return blockquote(in: sub)
case .table: return table(in: sub)
case .blockLatex: return blockLatex(in: sub)
case .ext(let id): return extensionBlock(in: sub, id: id,
fence: registry.blockEntry(for: id)?.fence ?? "")
case .paragraph, .list, .thematicBreak, .blank:
// Safety-net table scan; tables/block LaTeX are their own blocks now, inline `$$$$` stays plain.
return table(in: sub)
}
}
// MARK: - Extension fenced block (open fence line closing fence line / EOF)
private static func extensionBlock(in s: NSString, id: String, fence: String) -> [MarkdownToken] {
let len = s.length
guard len > 0 else { return [] }
let afterOpenLine = line(in: s, from: 0).nextStart
// Closing fence: the LAST line, when it starts with the fence (the
// block parser guarantees no interior fence line).
var closeStart = -1
var closeEnd = -1
if afterOpenLine < len, !fence.isEmpty {
var lineStart = afterOpenLine
while lineStart < len {
let (contentEnd, next) = line(in: s, from: lineStart)
if next >= len {
let text = s.substring(with: NSRange(location: lineStart, length: contentEnd - lineStart))
if text.hasPrefix(fence) { closeStart = lineStart; closeEnd = contentEnd }
break
}
if next <= lineStart { break }
lineStart = next
}
}
let contentEnd = closeStart >= 0 ? closeStart : len
var markers = [NSRange(location: 0, length: afterOpenLine)]
if closeStart >= 0 { markers.append(NSRange(location: closeStart, length: closeEnd - closeStart)) }
return [MarkdownToken(
kind: .extensionBlock(id),
range: NSRange(location: 0, length: len),
contentRange: NSRange(location: afterOpenLine, length: max(0, contentEnd - afterOpenLine)),
markerRanges: markers)]
}
// MARK: - Heading (legacy `^\s*(#{1,6}) +(.*)$`)
private static func heading(in s: NSString) -> [MarkdownToken] {
let len = s.length
var i = 0
while i < len, isWS(s.character(at: i)) { i += 1 }
let hashStart = i
while i < len, s.character(at: i) == hash { i += 1 }
let hashEnd = i
guard hashEnd > hashStart, hashEnd - hashStart <= 6,
hashEnd < len, s.character(at: hashEnd) == space else { return [] }
var contentStart = hashEnd
while contentStart < len, s.character(at: contentStart) == space { contentStart += 1 }
let lineEnd = line(in: s, from: 0).contentEnd
let tokenRange = NSRange(location: hashStart, length: lineEnd - hashStart)
let markers = [NSRange(location: hashStart, length: hashEnd - hashStart),
NSRange(location: hashEnd, length: 1)]
let content = NSRange(location: contentStart, length: max(0, lineEnd - contentStart))
return [MarkdownToken(kind: .heading, range: tokenRange, contentRange: content, markerRanges: markers)]
}
// MARK: - Blockquote (legacy `^[ \t]{0,3}((?:>[ \t]?)+)(.*)$`, one token per line)
private static func blockquote(in s: NSString) -> [MarkdownToken] {
let len = s.length
var tokens: [MarkdownToken] = []
var lineStart = 0
while lineStart < len {
let (contentEnd, nextStart) = line(in: s, from: lineStart)
var i = lineStart
var indent = 0
while i < contentEnd, indent < 3, isWS(s.character(at: i)) { i += 1; indent += 1 }
let markerStart = i
if i < contentEnd, s.character(at: i) == gt {
while i < contentEnd, s.character(at: i) == gt {
i += 1
if i < contentEnd, isWS(s.character(at: i)) { i += 1 }
}
let markerEnd = i
tokens.append(MarkdownToken(
kind: .blockquote,
range: NSRange(location: lineStart, length: contentEnd - lineStart),
contentRange: NSRange(location: markerEnd, length: contentEnd - markerEnd),
markerRanges: [NSRange(location: markerStart, length: markerEnd - markerStart)]))
}
if nextStart <= lineStart { break }
lineStart = nextStart
}
return tokens
}
// MARK: - Fenced code (legacy ```lang\n\n```)
private static func codeBlock(in s: NSString) -> [MarkdownToken] {
let len = s.length
guard len >= 3 else { return [] }
let afterOpenLine = line(in: s, from: 0).nextStart
var lineStart = afterOpenLine
var closingStart = -1
while lineStart < len {
if lineStart + 3 <= len,
s.character(at: lineStart) == backtick,
s.character(at: lineStart + 1) == backtick,
s.character(at: lineStart + 2) == backtick {
closingStart = lineStart
break
}
let next = line(in: s, from: lineStart).nextStart
if next <= lineStart { break }
lineStart = next
}
guard closingStart >= 0 else { return [] } // no closing fence legacy didn't match
return [MarkdownToken(
kind: .codeBlock,
range: NSRange(location: 0, length: closingStart + 3),
contentRange: NSRange(location: afterOpenLine, length: closingStart - afterOpenLine),
markerRanges: [NSRange(location: 0, length: afterOpenLine),
NSRange(location: closingStart, length: 3)])]
}
// MARK: - Table (legacy header `||` + separator `|--|` + data rows)
private static func table(in s: NSString) -> [MarkdownToken] {
let len = s.length
var tokens: [MarkdownToken] = []
var lineStart = 0
while lineStart < len {
let (contentEnd, nextStart) = line(in: s, from: lineStart)
if isTableRow(s, lineStart, contentEnd), nextStart < len {
let (sepEnd, afterSep) = line(in: s, from: nextStart)
if isTableSeparator(s, nextStart, sepEnd) {
var rowEnd = sepEnd
var cursor = afterSep
while cursor < len {
let (cEnd, cNext) = line(in: s, from: cursor)
guard isTableRow(s, cursor, cEnd) else { break }
rowEnd = cEnd
if cNext <= cursor { cursor = cEnd; break }
cursor = cNext
}
tokens.append(MarkdownToken(
kind: .table,
range: NSRange(location: lineStart, length: rowEnd - lineStart),
contentRange: NSRange(location: lineStart, length: rowEnd - lineStart),
markerRanges: []))
lineStart = cursor
continue
}
}
if nextStart <= lineStart { break }
lineStart = nextStart
}
return tokens
}
/// `^[ \t]*\|.+\|[ \t]*$` a pipe, 1 char, a pipe (trailing ws allowed).
private static func isTableRow(_ s: NSString, _ start: Int, _ end: Int) -> Bool {
var i = start
while i < end, isWS(s.character(at: i)) { i += 1 }
guard i < end, s.character(at: i) == pipe else { return false }
var j = end
while j > i, isWS(s.character(at: j - 1)) { j -= 1 }
guard j - 1 > i, s.character(at: j - 1) == pipe else { return false }
return (j - 1) - (i + 1) >= 1
}
/// `^[ \t]*\|[- \t:|]+\|[ \t]*$` outer pipes, inner only `- : | space tab`.
private static func isTableSeparator(_ s: NSString, _ start: Int, _ end: Int) -> Bool {
var i = start
while i < end, isWS(s.character(at: i)) { i += 1 }
guard i < end, s.character(at: i) == pipe else { return false }
var j = end
while j > i, isWS(s.character(at: j - 1)) { j -= 1 }
guard j - 1 > i, s.character(at: j - 1) == pipe else { return false }
var k = i + 1
var count = 0
while k < j - 1 {
let c = s.character(at: k)
guard c == dash || c == space || c == tab || c == colon || c == pipe else { return false }
count += 1; k += 1
}
return count >= 1
}
// MARK: - Block LaTeX (legacy `(?s)(?<!\$)\$\$(.+?)\$\$`)
private static func blockLatex(in s: NSString) -> [MarkdownToken] {
let len = s.length
var tokens: [MarkdownToken] = []
var i = 0
while i + 1 < len {
if s.character(at: i) == dollar, s.character(at: i + 1) == dollar {
if i > 0, s.character(at: i - 1) == dollar { i += 1; continue } // (?<!\$)
var j = i + 2
var closeAt = -1
while j + 1 < len {
if s.character(at: j) == dollar, s.character(at: j + 1) == dollar, j > i + 2 {
closeAt = j; break
}
j += 1
}
if closeAt >= 0 {
tokens.append(MarkdownToken(
kind: .blockLatex,
range: NSRange(location: i, length: (closeAt + 2) - i),
contentRange: NSRange(location: i + 2, length: closeAt - (i + 2)),
markerRanges: [NSRange(location: i, length: 2),
NSRange(location: closeAt, length: 2)]))
i = closeAt + 2
continue
}
}
i += 1
}
return tokens
}
}
@@ -0,0 +1,472 @@
//
// BlockParser.swift
// MarkdownEngine
//
// Phase 1 of the regexAST refactor: the block-structure pass. Splits the
// document into a flat, gap-free (tiling) sequence of blocks following the
// CommonMark two-phase model block structure first, inline content later.
// Inline parsing happens per inline-bearing block in a separate step.
//
// Ranges are absolute UTF-16 NSRanges into the source (the editor is
// NSTextView / TextKit-2 based, so UTF-16 offsets are the native currency).
// Storing relative widths (green-tree style, for cheap incremental reparse)
// is a deliberate Phase 3 concern and intentionally deferred here.
//
// Line classification mirrors the recognition the current regex tokenizer /
// styler perform, so block ranges line up with today's tokens:
// heading headingRegex `^\s*#{1,6} +`
// thematic break styler HR pattern `^\s*(-{3,}|\*{3,}|_{3,})\s*$`
// fenced code codeBlockRegex opening/closing ``` line
// blockquote blockquoteRegex `^[ \t]{0,3}(>)`
//
import Foundation
/// The block-level classification of a run of lines.
enum BlockKind: Equatable {
case paragraph // inline-bearing
case heading // single ATX line (`# `), inline-bearing content
case blockquote // consecutive `>` lines, inline-bearing per line
case list // consecutive list-item lines (`-`/`*`/`+` or `1.`/`1)`)
case fencedCode // `````` opaque (no inline parsing inside)
case blockLatex // $$$$ opaque
case table // GFM table opaque (rendered as a unit)
case thematicBreak // `---` / `***` / `___` produces no token today
case blank // blank / whitespace-only line(s) separator
case ext(String) // extension-supplied fenced block (id), inline-bearing content
}
/// One block; `range` is the absolute UTF-16 span of its lines, tiling with no gaps.
struct Block: Equatable {
let kind: BlockKind
let range: NSRange
}
/// A resolved contiguous change between two buffer states, in UTF-16 units.
/// `changeStart ..< changeEndOld` in the old buffer was replaced by
/// `changeStart ..< changeEndNew` in the new one. The region may be wider
/// than the minimal diff splice logic only requires containment.
struct BufferDiff {
let changeStart: Int
let changeEndOld: Int
let changeEndNew: Int
let delta: Int
}
enum BlockParser {
private static let cacheLock = NSLock()
private static var cachedChars: [unichar]? // UTF-16 buffer of the last parse
private static var cachedBlocks: [Block]?
/// Registry fingerprint the memo was computed under extension fences
/// change the block structure of identical text.
private static var cachedFingerprint: String = ""
/// Splits `text` into gap-free tiling blocks; memoizes the last parse so both per-keystroke callers share one line-scan.
/// Pass `utf16Chars` when the caller already extracted the buffer (must match `text`).
static func parse(_ text: String, utf16Chars: [unichar]? = nil, registry: ExtensionRegistry = .empty) -> [Block] {
let textNS = text as NSString
let newLen = textNS.length
let newChars: [unichar]
if let utf16Chars, utf16Chars.count == newLen {
newChars = utf16Chars
} else {
var buffer = [unichar](repeating: 0, count: newLen)
if newLen > 0 { textNS.getCharacters(&buffer, range: NSRange(location: 0, length: newLen)) }
newChars = buffer
}
cacheLock.lock()
let prevChars = cachedFingerprint == registry.fingerprint ? cachedChars : nil
let prevBlocks = cachedFingerprint == registry.fingerprint ? cachedBlocks : nil
cacheLock.unlock()
if let prevChars, let prevBlocks {
// Identical text memcmp hit (the scan below would walk O(doc)).
if equalBuffers(prevChars, newChars) { return prevBlocks }
if let diff = scanDiff(old: prevChars, new: newChars),
let (incr, _) = incrementalParse(oldChars: prevChars, oldBlocks: prevBlocks, newChars: newChars, newNS: textNS, diff: diff, registry: registry) {
cacheLock.lock(); cachedChars = newChars; cachedBlocks = incr; cachedFingerprint = registry.fingerprint; cacheLock.unlock()
return incr
}
}
let blocks = computeBlocks(text, registry: registry)
cacheLock.lock(); cachedChars = newChars; cachedBlocks = blocks; cachedFingerprint = registry.fingerprint; cacheLock.unlock()
return blocks
}
/// Adopt an externally computed parse (DocumentParseState publishes its
/// per-keystroke result) so static-path callers the restyle's
/// DocumentAST.parse above all take the memcmp hit instead of
/// re-splicing against a one-keystroke-stale cache.
static func seedCache(chars: [unichar], blocks: [Block], fingerprint: String = "") {
cacheLock.lock(); cachedChars = chars; cachedBlocks = blocks; cachedFingerprint = fingerprint; cacheLock.unlock()
}
private static func equalBuffers(_ a: [unichar], _ b: [unichar]) -> Bool {
guard a.count == b.count else { return false }
if a.isEmpty { return true }
return a.withUnsafeBytes { ap in
b.withUnsafeBytes { bp in memcmp(ap.baseAddress!, bp.baseAddress!, ap.count) == 0 }
}
}
/// Common prefix/suffix scan; nil when the buffers are identical.
static func scanDiff(old: [unichar], new: [unichar]) -> BufferDiff? {
let oldLen = old.count, newLen = new.count
var p = 0
let maxPre = min(oldLen, newLen)
while p < maxPre, old[p] == new[p] { p += 1 }
if p == oldLen, oldLen == newLen { return nil }
var s = 0
let maxSuf = maxPre - p
while s < maxSuf, old[oldLen - 1 - s] == new[newLen - 1 - s] { s += 1 }
return BufferDiff(changeStart: p, changeEndOld: oldLen - s, changeEndNew: newLen - s, delta: newLen - oldLen)
}
/// Does any LINE touched by `[lo, hi)` contain a `$$` or ``` that can ripple?
/// Line-expanded, not just ±3 around the edit: block delimiters are
/// line-classified with a TRIMMED prefix (`isBlockLatexOpen`), so editing
/// the leading whitespace of an indented `$$` opener flips the pairing
/// from arbitrarily far away from the literal `$$`. The boundary walk is
/// capped; hitting the cap reports a delimiter (conservative full parse).
static func hasBlockDelimiter(_ buf: [unichar], _ lo: Int, _ hi: Int, fences: [[unichar]] = []) -> Bool {
let cap = 4096
var start = max(0, lo - 3)
var steps = 0
while start > 0, buf[start - 1] != 0x0A, buf[start - 1] != 0x0D {
start -= 1
steps += 1
if steps > cap { return true }
}
var end = min(buf.count, hi + 3)
steps = 0
while end < buf.count, buf[end] != 0x0A, buf[end] != 0x0D {
end += 1
steps += 1
if steps > cap { return true }
}
var i = start
while i < end {
if buf[i] == 0x24 { // $
if i + 1 < end, buf[i + 1] == 0x24 { return true } // $$
} else if buf[i] == 0x60, i + 2 < end, buf[i + 1] == 0x60, buf[i + 2] == 0x60 {
return true // ```
}
// Extension fences pair with a distant partner exactly like ```
// an edit touching one must force the full reparse too.
for fence in fences where !fence.isEmpty && buf[i] == fence[0] {
if i + fence.count <= end {
var match = true
for (k, u) in fence.enumerated() where buf[i + k] != u { match = false; break }
if match { return true }
}
}
i += 1
}
return false
}
/// Splice-parse against a precomputed change region (descriptor- or scan-derived):
/// reparse the affected block window, splice between untouched prefix/suffix; nil to fall back to full.
static func incrementalParse(oldChars o: [unichar], oldBlocks: [Block], newChars n: [unichar], newNS: NSString, diff: BufferDiff, registry: ExtensionRegistry = .empty) -> (blocks: [Block], window: Int)? {
guard !oldBlocks.isEmpty else { return nil }
let oldLen = o.count, newLen = n.count
guard oldLen > 0, newLen > 0 else { return nil }
let delta = diff.delta
let changeStart = diff.changeStart
let changeEnd = diff.changeEndOld // [changeStart, changeEnd) in old
guard changeStart >= 0, changeEnd <= oldLen, diff.changeEndNew <= newLen,
changeStart <= changeEnd, changeStart <= diff.changeEndNew else { return nil }
// A fence/block-LaTeX/extension delimiter in the edit can pair with a distant partner full reparse.
let fences = registry.blockEntries.map(\.fenceChars)
if hasBlockDelimiter(o, changeStart, changeEnd, fences: fences)
|| hasBlockDelimiter(n, changeStart, diff.changeEndNew, fences: fences) {
return nil
}
// 2. Affected old-block window (±1 block margin for merges/splits).
// Blocks tile the document in order binary search instead of the
// linear walks that cost O(#blocks) per keystroke in large documents.
var lo = 0, hi = oldBlocks.count - 1
while lo < hi { // last block starting <= changeStart
let m = (lo + hi + 1) / 2
if oldBlocks[m].range.location <= changeStart { lo = m } else { hi = m - 1 }
}
let firstIdx = lo
lo = 0; hi = oldBlocks.count - 1
while lo < hi { // first block ending >= changeEnd
let m = (lo + hi) / 2
if NSMaxRange(oldBlocks[m].range) >= changeEnd { hi = m } else { lo = m + 1 }
}
let lastIdx = lo
let winFirst = max(0, min(firstIdx, lastIdx) - 1)
let winLast = min(oldBlocks.count - 1, max(firstIdx, lastIdx) + 1)
// 3. Opaque multi-line blocks (fences / block LaTeX) in the window are
// fine for INTERIOR edits: the window contains each block wholly, the
// ±3 delimiter guard above already bailed on any edit that creates,
// destroys, or touches a ``` / $$ pairing, and an edit that UN-closes
// a block (trailing chars on its closer line) makes the reparsed block
// reach the window end caught by the trailing guard below. Typing
// inside a code block used to fall back to a full O(doc) reparse on
// every keystroke because of an unconditional bail here.
// 4. Window new-text range (window start is before the edit unchanged).
let winStart = oldBlocks[winFirst].range.location
let winEndNew = NSMaxRange(oldBlocks[winLast].range) + delta
guard winStart >= 0, winEndNew >= winStart, winEndNew <= newLen else { return nil }
// 5. Reparse just the window substring, shift to absolute new coords.
let windowText = newNS.substring(with: NSRange(location: winStart, length: winEndNew - winStart))
let reparsed = computeBlocks(windowText, registry: registry).map { $0.shifted(by: winStart) }
// A trailing fence/latex/extension block reaching the window end might continue past it.
if let last = reparsed.last, NSMaxRange(last.range) >= winEndNew {
switch last.kind {
case .fencedCode, .blockLatex, .ext: return nil
case .paragraph:
// The edit may have dissolved the separator that used to end
// this paragraph (backspace-joining two paragraphs): if the
// suffix ALSO starts with a paragraph, the two would need to
// MERGE a full parse never yields adjacent paragraphs. The
// splice can't merge across the cut, so fall back.
if winLast + 1 < oldBlocks.count, oldBlocks[winLast + 1].kind == .paragraph {
return nil
}
default: break
}
}
// 6. Splice: prefix (unchanged) + reparsed window + suffix (shifted).
var result: [Block] = []
result.append(contentsOf: oldBlocks[0..<winFirst])
result.append(contentsOf: reparsed)
if winLast + 1 < oldBlocks.count {
result.append(contentsOf: oldBlocks[(winLast + 1)...].map { $0.shifted(by: delta) })
}
// 7. Validate gap-free tiling of [0, newLen); else full reparse.
var cursor = 0
for b in result {
if b.range.location != cursor { return nil }
cursor = NSMaxRange(b.range)
}
guard cursor == newLen else { return nil }
return (result, reparsed.count)
}
static func computeBlocks(_ text: String, registry: ExtensionRegistry = .empty) -> [Block] {
let nsText = text as NSString
let length = nsText.length
guard length > 0 else { return [] }
// 1. Slice into physical lines (each includes its trailing newline).
var lines: [NSRange] = []
var cursor = 0
while cursor < length {
let r = nsText.lineRange(for: NSRange(location: cursor, length: 0))
lines.append(r)
cursor = NSMaxRange(r)
}
func lineText(_ i: Int) -> String { nsText.substring(with: lines[i]) }
/// Line index of the fence closing a code block opened at `start`; nil when unclosed.
func fenceCloseIndex(from start: Int) -> Int? {
var scan = start + 1
while scan < lines.count {
if isFence(lineText(scan)) { return scan }
scan += 1
}
return nil
}
/// Line index of the `$$` closing a block-LaTeX run opened at `start`; nil if none.
func blockLatexCloseIndex(from start: Int) -> Int? {
let open = lineText(start).trimmingCharacters(in: .whitespacesAndNewlines)
if open.dropFirst(2).contains("$$") { return start }
var j = start + 1
while j < lines.count { if lineText(j).contains("$$") { return j }; j += 1 }
return nil
}
// 2. Classify + group.
var blocks: [Block] = []
var i = 0
while i < lines.count {
let line = lineText(i)
if isBlank(line) {
var end = i
while end + 1 < lines.count, isBlank(lineText(end + 1)) { end += 1 }
blocks.append(Block(kind: .blank, range: union(lines[i...end])))
i = end + 1
} else if isFence(line), let end = fenceCloseIndex(from: i) {
blocks.append(Block(kind: .fencedCode, range: union(lines[i...end])))
i = end + 1
} else if isThematicBreak(line) {
blocks.append(Block(kind: .thematicBreak, range: lines[i]))
i += 1
} else if isHeading(line) {
blocks.append(Block(kind: .heading, range: lines[i]))
i += 1
} else if isBlockquote(line) {
var end = i
while end + 1 < lines.count, isBlockquote(lineText(end + 1)) { end += 1 }
blocks.append(Block(kind: .blockquote, range: union(lines[i...end])))
i = end + 1
} else if isListItem(line) {
// Consecutive list-item lines form one list block; per-item detail is parsed in DocumentAST.
var end = i
while end + 1 < lines.count, isListItem(lineText(end + 1)) { end += 1 }
blocks.append(Block(kind: .list, range: union(lines[i...end])))
i = end + 1
} else if isTableRow(line), i + 1 < lines.count, isTableSeparator(lineText(i + 1)) {
// GFM table: a `||` header, a `|--|` separator, then data rows.
var end = i + 1
while end + 1 < lines.count, isTableRow(lineText(end + 1)) { end += 1 }
blocks.append(Block(kind: .table, range: union(lines[i...end])))
i = end + 1
} else if isBlockLatexOpen(line), let end = blockLatexCloseIndex(from: i) {
// Block LaTeX `$$$$` a single line or a `$$`-delimited run.
blocks.append(Block(kind: .blockLatex, range: union(lines[i...end])))
i = end + 1
} else if let entry = registry.blockEntry(opening: line) {
// Extension fenced block: consume through the closing fence
// line (or to EOF if none) mirrors ``` semantics. Built-ins
// classify first, so a fence colliding with a built-in line
// form never reaches here.
var end = lines.count - 1
var scan = i + 1
while scan < lines.count {
if lineText(scan).hasPrefix(entry.fence) { end = scan; break }
scan += 1
}
blocks.append(Block(kind: .ext(entry.id), range: union(lines[i...end])))
i = end + 1
} else {
// Paragraph: merge consecutive plain (non-blank, non-special) lines.
var end = i
while end + 1 < lines.count {
let next = lineText(end + 1)
if isBlank(next) || isThematicBreak(next)
|| isHeading(next) || isBlockquote(next) || isListItem(next) { break }
// A table (row + separator), a CLOSED code fence, a
// block-LaTeX run, or an extension fence interrupts it
// an unclosed opener stays part of the paragraph.
if isFence(next), fenceCloseIndex(from: end + 1) != nil { break }
if isTableRow(next), end + 2 < lines.count, isTableSeparator(lineText(end + 2)) { break }
if isBlockLatexOpen(next), blockLatexCloseIndex(from: end + 1) != nil { break }
if registry.blockEntry(opening: next) != nil { break }
end += 1
}
blocks.append(Block(kind: .paragraph, range: union(lines[i...end])))
i = end + 1
}
}
return blocks
}
// MARK: - Line classification
private static func isBlank(_ line: String) -> Bool {
line.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
/// An opening or closing fence line: starts with three backticks.
private static func isFence(_ line: String) -> Bool {
line.hasPrefix("```")
}
/// `^\s*(-{3,}|\*{3,}|_{3,})\s*$` a solid run of 3+ of one of `- * _`.
private static func isThematicBreak(_ line: String) -> Bool {
let t = line.trimmingCharacters(in: .whitespacesAndNewlines)
guard t.count >= 3, let first = t.first,
first == "-" || first == "*" || first == "_" else { return false }
return t.allSatisfy { $0 == first }
}
/// `^\s*#{1,6} +` 16 hashes after optional indent, then at least one space.
private static func isHeading(_ line: String) -> Bool {
var rest = Substring(line).drop { $0 == " " || $0 == "\t" }
var hashes = 0
while let c = rest.first, c == "#" { hashes += 1; rest = rest.dropFirst() }
guard (1...6).contains(hashes) else { return false }
return rest.first == " "
}
/// `^[ \t]{0,3}>` up to 3 leading spaces/tabs, then a `>`.
private static func isBlockquote(_ line: String) -> Bool {
var rest = Substring(line)
var indent = 0
while indent < 3, let c = rest.first, c == " " || c == "\t" {
rest = rest.dropFirst(); indent += 1
}
return rest.first == ">"
}
/// A list-item line: optional indent, a bullet (`-`/`*`/`+`) or ordered marker (`1.`/`1)`), then a space/tab.
static func isListItem(_ line: String) -> Bool {
var rest = Substring(line).drop { $0 == " " || $0 == "\t" }
guard let first = rest.first else { return false }
if first == "-" || first == "*" || first == "+" {
rest = rest.dropFirst()
} else if first.isNumber {
var digits = 0
while let c = rest.first, c.isNumber, digits < 9 { rest = rest.dropFirst(); digits += 1 }
guard let d = rest.first, d == "." || d == ")" else { return false }
rest = rest.dropFirst()
} else {
return false
}
// A space/tab must follow the marker a bare `-`/`*`/`1.` stays literal (pre-AST bullet behavior).
guard let after = rest.first else { return false }
return after == " " || after == "\t"
}
/// A GFM table row: `^[ \t]*\|.+\|[ \t]*$` outer pipes, content between.
private static func isTableRow(_ line: String) -> Bool {
let t = line.trimmingCharacters(in: .whitespacesAndNewlines)
return t.count >= 3 && t.hasPrefix("|") && t.hasSuffix("|")
}
/// A GFM table separator: `^[ \t]*\|[- \t:|]+\|[ \t]*$` only `- : |` + ws inside.
private static func isTableSeparator(_ line: String) -> Bool {
let t = line.trimmingCharacters(in: .whitespacesAndNewlines)
guard t.count >= 3, t.hasPrefix("|"), t.hasSuffix("|") else { return false }
let middle = t.dropFirst().dropLast()
return !middle.isEmpty && middle.allSatisfy {
$0 == "-" || $0 == ":" || $0 == "|" || $0 == " " || $0 == "\t"
}
}
/// A block-LaTeX opener: a line whose content starts with `$$`.
private static func isBlockLatexOpen(_ line: String) -> Bool {
line.trimmingCharacters(in: .whitespacesAndNewlines).hasPrefix("$$")
}
private static func union(_ ranges: ArraySlice<NSRange>) -> NSRange {
let lo = ranges.first!.location
let hi = NSMaxRange(ranges.last!)
return NSRange(location: lo, length: hi - lo)
}
}
private extension Block {
/// A copy with the range moved by `d` UTF-16 units.
func shifted(by d: Int) -> Block {
Block(kind: kind, range: NSRange(location: range.location + d, length: range.length))
}
}
@@ -0,0 +1,210 @@
//
// BlockScopedTokenizer.swift
// MarkdownEngine
//
// The live tokenization pipeline. For each block from `BlockParser`:
// block-level tokens (heading, blockquote, table, block LaTeX, code) come from
// `BlockLevelTokenizer` (hand scanners, no regex), while ALL inline tokens come
// from the AST (`InlineParser` `InlineASTAdapter`). Results are offset back
// into document coordinates. Fenced-code blocks emit only their code-block
// token (no inline markup inside).
//
import Foundation
extension MarkdownTokenizer {
/// Per-block memo (substring block-relative tokens): only the edited block re-parses, O(change). FIFO-capped, locked.
private static let blockTokenLock = NSLock()
private static var blockTokenCache: [String: [MarkdownToken]] = [:]
private static var blockTokenOrder: [String] = []
private static let blockTokenCacheCap = 4096
// Document-level token memo: re-tokenize only the touched blocks; the rest shift by the delta.
private static let tokensLock = NSLock()
private static var cachedTokenChars: [unichar]?
private static var cachedTokens: [MarkdownToken]?
/// Registry fingerprint the memo was computed under a different set of
/// registered extensions yields different tokens for identical text.
private static var cachedTokenFingerprint: String = ""
/// The live tokenizer: block-level tokens + inline AST tokens; fenced code emits only its code-block token.
static func parseTokensViaAST(in text: String, registry: ExtensionRegistry = .empty) -> [MarkdownToken] {
let t0 = DispatchTime.now().uptimeNanoseconds
defer {
PerfTrace.note { "🗜️ tokenizer.static \(String(format: "%.2f", Double(DispatchTime.now().uptimeNanoseconds - t0) / 1_000_000))ms" }
}
let ns = text as NSString
let newLen = ns.length
var newChars = [unichar](repeating: 0, count: newLen)
if newLen > 0 { ns.getCharacters(&newChars, range: NSRange(location: 0, length: newLen)) }
let blocks = BlockParser.parse(text, utf16Chars: newChars, registry: registry)
tokensLock.lock()
let prevChars = cachedTokenFingerprint == registry.fingerprint ? cachedTokenChars : nil
let prevTokens = cachedTokenFingerprint == registry.fingerprint ? cachedTokens : nil
tokensLock.unlock()
let result: [MarkdownToken]
if let prevChars, let prevTokens {
if let diff = BlockParser.scanDiff(old: prevChars, new: newChars) {
result = incrementalTokens(oldChars: prevChars, prevTokens: prevTokens, newChars: newChars, blocks: blocks, ns: ns, diff: diff, registry: registry)?.tokens
?? fullTokens(blocks: blocks, ns: ns, registry: registry)
} else {
result = prevTokens // identical text
}
} else {
result = fullTokens(blocks: blocks, ns: ns, registry: registry)
}
tokensLock.lock()
cachedTokenChars = newChars; cachedTokens = result; cachedTokenFingerprint = registry.fingerprint
tokensLock.unlock()
return result
}
/// Adopt an externally computed parse (DocumentParseState publishes its
/// per-keystroke result) so static-path callers hit instead of re-splicing
/// against a one-keystroke-stale cache.
static func seedCache(chars: [unichar], tokens: [MarkdownToken], fingerprint: String = "") {
tokensLock.lock()
cachedTokenChars = chars; cachedTokens = tokens; cachedTokenFingerprint = fingerprint
tokensLock.unlock()
}
static func fullTokens(blocks: [Block], ns: NSString, registry: ExtensionRegistry = .empty) -> [MarkdownToken] {
var result: [MarkdownToken] = []
for block in blocks {
let delta = block.range.location
let relTokens = cachedBlockTokens(kind: block.kind, sub: ns.substring(with: block.range), registry: registry)
result.append(contentsOf: relTokens.map { $0.shifted(by: delta) })
}
return result
}
/// Reuse prefix/suffix tokens (suffix shifted) and re-tokenize only touched blocks,
/// against a precomputed change region; nil to fall back to full.
static func incrementalTokens(oldChars o: [unichar], prevTokens: [MarkdownToken], newChars n: [unichar], blocks: [Block], ns: NSString, diff: BufferDiff, registry: ExtensionRegistry = .empty) -> (tokens: [MarkdownToken], retok: Int)? {
let oldLen = o.count, newLen = n.count
guard oldLen > 0, newLen > 0, !blocks.isEmpty else { return nil }
let delta = diff.delta
let changeStart = diff.changeStart, changeEndNew = diff.changeEndNew
guard changeStart >= 0, diff.changeEndOld <= oldLen, changeEndNew <= newLen,
changeStart <= diff.changeEndOld, changeStart <= changeEndNew else { return nil }
// A fence/block-LaTeX/extension delimiter can pair with a distant partner and ripple far full tokenization.
let fences = registry.blockEntries.map(\.fenceChars)
if BlockParser.hasBlockDelimiter(o, changeStart, diff.changeEndOld, fences: fences)
|| BlockParser.hasBlockDelimiter(n, changeStart, changeEndNew, fences: fences) { return nil }
// New blocks touching the changed char range [changeStart, changeEndNew].
// Blocks tile in order the touching set is one contiguous run;
// binary search replaces the O(#blocks) full scan per keystroke.
var lo = 0, hi = blocks.count - 1
while lo < hi { // first block ending >= changeStart
let m = (lo + hi) / 2
if NSMaxRange(blocks[m].range) >= changeStart { hi = m } else { lo = m + 1 }
}
let first = lo
lo = 0; hi = blocks.count - 1
while lo < hi { // last block starting <= changeEndNew
let m = (lo + hi + 1) / 2
if blocks[m].range.location <= changeEndNew { lo = m } else { hi = m - 1 }
}
let last = lo
// Validate the run actually touches (mirrors the old filter exactly).
if first > last || blocks[first].range.location > changeEndNew || NSMaxRange(blocks[last].range) < changeStart {
return delta == 0 ? (prevTokens, 0) : nil
}
lo = first
hi = last
// Widen the window until no previous token straddles either cut (a block's extent can change in place).
var expanded = true
while expanded {
expanded = false
for t in prevTokens {
let cutStart = blocks[lo].range.location
if t.range.location < cutStart, NSMaxRange(t.range) > cutStart {
while lo > 0, blocks[lo].range.location > t.range.location { lo -= 1; expanded = true }
}
let cutEndOld = NSMaxRange(blocks[hi].range) - delta
if t.range.location < cutEndOld, NSMaxRange(t.range) > cutEndOld {
while hi < blocks.count - 1, NSMaxRange(blocks[hi].range) - delta < NSMaxRange(t.range) { hi += 1; expanded = true }
}
}
}
let regionStart = blocks[lo].range.location
let regionEndOld = NSMaxRange(blocks[hi].range) - delta
var result: [MarkdownToken] = []
for t in prevTokens where NSMaxRange(t.range) <= regionStart { result.append(t) } // prefix, unchanged
for i in lo...hi { // changed window, retokenized
let off = blocks[i].range.location
let rel = cachedBlockTokens(kind: blocks[i].kind, sub: ns.substring(with: blocks[i].range), registry: registry)
result.append(contentsOf: rel.map { $0.shifted(by: off) })
}
for t in prevTokens where t.range.location >= regionEndOld { result.append(t.shifted(by: delta)) } // suffix, shifted
return (result, hi - lo + 1)
}
/// Cached block-relative tokens for `sub` (computed on miss); a pure memo over
/// the token logic. The key carries the registry fingerprint the same text
/// tokenizes differently under a different extension set.
static func cachedBlockTokens(kind: BlockKind, sub: String, registry: ExtensionRegistry = .empty) -> [MarkdownToken] {
let key = registry.fingerprint.isEmpty ? sub : registry.fingerprint + "\u{1F}" + sub
blockTokenLock.lock()
if let cached = blockTokenCache[key] {
blockTokenLock.unlock()
return cached
}
blockTokenLock.unlock()
let blockLevel = BlockLevelTokenizer.tokens(for: kind, in: sub as NSString, registry: registry)
// Fenced code is opaque no inline markup inside it. Extension blocks
// parse inlines over their CONTENT only (the fence lines are syntax
// a `$x$` in the info string must not become a latex token).
let inline: [MarkdownToken]
if kind == .fencedCode {
inline = []
} else if case .ext = kind, let block = blockLevel.first {
let ns = sub as NSString
let content = block.contentRange
inline = content.length > 0
? InlineASTAdapter.tokens(from: InlineParser.parse(ns, range: content, registry: registry))
: []
} else {
inline = InlineASTAdapter.tokens(from: InlineParser.parse(sub, registry: registry))
}
let computed = blockLevel + inline
blockTokenLock.lock()
if blockTokenCache[key] == nil {
blockTokenCache[key] = computed
blockTokenOrder.append(key)
if blockTokenOrder.count > blockTokenCacheCap {
blockTokenCache[blockTokenOrder.removeFirst()] = nil
}
}
blockTokenLock.unlock()
return computed
}
}
private extension MarkdownToken {
/// Returns a copy with every range moved forward by `delta` UTF-16 units.
func shifted(by delta: Int) -> MarkdownToken {
func move(_ r: NSRange) -> NSRange {
NSRange(location: r.location + delta, length: r.length)
}
return MarkdownToken(
kind: kind,
range: move(range),
contentRange: move(contentRange),
markerRanges: markerRanges.map(move)
)
}
}
@@ -0,0 +1,157 @@
//
// DocumentParseState.swift
// MarkdownEngine
//
// Created by Luca Chen on 11.07.26.
//
// Per-editor incremental parse state: one UTF-16 buffer, its block list, and
// its token list evolve together under a single edit descriptor. A keystroke
// then pays one O(edit) buffer splice and a block-window re-tokenize instead
// of a full-document re-extraction plus two independent O(doc) prefix/suffix
// diff scans (BlockParser and the tokenizer each ran their own).
//
import Foundation
/// A contiguous edit in NEW-text coordinates plus the length delta, as
/// delivered by shouldChangeTextIn/textDidChange. The described region may be
/// wider than the minimal diff splice logic only requires containment.
struct ParseEditDescriptor {
let editedRange: NSRange // post-edit coords: location + replacement length
let delta: Int
}
final class DocumentParseState {
private let lock = NSLock()
private var chars: [unichar] = []
private var blocks: [Block] = []
private var tokens: [MarkdownToken] = []
private var valid = false
/// Registry fingerprint the stored tokens were computed under; a change
/// (extension registered/unregistered at runtime) invalidates the splice
/// base old tokens must not be reused under a new grammar.
private var fingerprint = ""
#if DEBUG
private var verifyCounter: UInt = 0
#endif
/// The block list matching the most recent `tokens(for:edit:)` call
/// handed to the restyle so DocumentAST.parse skips the block parser.
var currentBlocks: [Block] {
lock.lock(); defer { lock.unlock() }
return blocks
}
/// Drop all state (document switch / full rebuild) the next parse
/// re-extracts and re-parses from scratch.
func invalidate() {
lock.lock()
valid = false
chars = []; blocks = []; tokens = []
lock.unlock()
}
/// Tokens for `text`. With a trustworthy `edit` the update is
/// O(edit + touched blocks + suffix shift); without one, a single shared
/// O(doc) diff scan replaces the two independent scans of the static path.
func tokens(for text: String, edit: ParseEditDescriptor?, registry: ExtensionRegistry = .empty) -> [MarkdownToken] {
let ns = text as NSString
let newLen = ns.length
let tStart = DispatchTime.now().uptimeNanoseconds
lock.lock()
let prevChars = chars
let prevBlocks = blocks
let prevTokens = tokens
let wasValid = valid && fingerprint == registry.fingerprint
lock.unlock()
// 1. New buffer + change region spliced O(edit) when the descriptor
// passes every sanity check, extracted O(doc) otherwise.
var newChars: [unichar]
var diff: BufferDiff?
if wasValid, let edit,
edit.delta != Int.min,
edit.editedRange.location != NSNotFound,
edit.editedRange.location >= 0, edit.editedRange.length >= 0,
NSMaxRange(edit.editedRange) <= newLen,
edit.editedRange.length - edit.delta >= 0,
prevChars.count == newLen - edit.delta {
let changeStart = edit.editedRange.location
let changeEndNew = NSMaxRange(edit.editedRange)
let changeEndOld = changeEndNew - edit.delta
var replacement = [unichar](repeating: 0, count: edit.editedRange.length)
if edit.editedRange.length > 0 { ns.getCharacters(&replacement, range: edit.editedRange) }
newChars = prevChars
newChars.replaceSubrange(changeStart..<changeEndOld, with: replacement)
diff = BufferDiff(changeStart: changeStart, changeEndOld: changeEndOld,
changeEndNew: changeEndNew, delta: edit.delta)
#if DEBUG
// Sampled safety net: the spliced buffer must equal the storage.
// Opt-in (MD_PERF_VERIFY=1) the fresh O(doc) extraction spikes
// every 64th keystroke and pollutes the PERF numbers.
verifyCounter &+= 1
if PerfTrace.verifyEnabled, verifyCounter % 64 == 0 {
var fresh = [unichar](repeating: 0, count: newLen)
if newLen > 0 { ns.getCharacters(&fresh, range: NSRange(location: 0, length: newLen)) }
assert(fresh == newChars, "spliced parse buffer diverged from the text storage")
}
#endif
} else {
var buffer = [unichar](repeating: 0, count: newLen)
if newLen > 0 { ns.getCharacters(&buffer, range: NSRange(location: 0, length: newLen)) }
newChars = buffer
if wasValid {
diff = BlockParser.scanDiff(old: prevChars, new: newChars)
if diff == nil, prevChars.count == newLen {
return prevTokens // identical text
}
}
}
let tBuffer = DispatchTime.now().uptimeNanoseconds
// 2. Blocks: window splice on the shared diff, full reparse fallback.
var newBlocks: [Block]?
if wasValid, let diff {
newBlocks = BlockParser.incrementalParse(
oldChars: prevChars, oldBlocks: prevBlocks,
newChars: newChars, newNS: ns, diff: diff, registry: registry
)?.blocks
}
let resolvedBlocks = newBlocks ?? BlockParser.computeBlocks(text, registry: registry)
let tBlocks = DispatchTime.now().uptimeNanoseconds
// 3. Tokens: prefix/suffix reuse on the same diff, full fallback.
var newTokens: [MarkdownToken]?
if wasValid, let diff, newBlocks != nil {
newTokens = MarkdownTokenizer.incrementalTokens(
oldChars: prevChars, prevTokens: prevTokens,
newChars: newChars, blocks: resolvedBlocks, ns: ns, diff: diff, registry: registry
)?.tokens
}
let resolvedTokens = newTokens ?? MarkdownTokenizer.fullTokens(blocks: resolvedBlocks, ns: ns, registry: registry)
let tTokens = DispatchTime.now().uptimeNanoseconds
PerfTrace.note {
let ms = { (a: UInt64, b: UInt64) in String(format: "%.2f", Double(b - a) / 1_000_000) }
let blockMode = newBlocks != nil ? "splice" : "FULL"
let tokenMode = newTokens != nil ? "incremental" : "FULL"
return "parseState split: buffer=\(ms(tStart, tBuffer))ms blocks(\(blockMode))=\(ms(tBuffer, tBlocks))ms tokens(\(tokenMode))=\(ms(tBlocks, tTokens))ms #blocks=\(resolvedBlocks.count) #tokens=\(resolvedTokens.count)"
}
lock.lock()
chars = newChars
blocks = resolvedBlocks
tokens = resolvedTokens
valid = true
fingerprint = registry.fingerprint
lock.unlock()
// Publish to the static memos so their callers (restyle's
// DocumentAST.parse, smart-input helpers) take the memcmp hit instead
// of splicing against a one-keystroke-stale cache every time.
BlockParser.seedCache(chars: newChars, blocks: resolvedBlocks, fingerprint: registry.fingerprint)
MarkdownTokenizer.seedCache(chars: newChars, tokens: resolvedTokens, fingerprint: registry.fingerprint)
return resolvedTokens
}
}
@@ -0,0 +1,69 @@
//
// InlineASTAdapter.swift
// MarkdownEngine
//
// Phase 2.5 bridge: flattens an inline AST (`[InlineNode]`) into the legacy
// `[MarkdownToken]` shape the existing styler consumes. Walking the tree and
// emitting one token per markup node (recursing into children) reproduces the
// legacy tokenizer's flat, overlapping token set so the new AST parser can
// feed the unchanged styler. `.text` nodes carry no token.
//
import Foundation
enum InlineASTAdapter {
static func tokens(from nodes: [InlineNode]) -> [MarkdownToken] {
var result: [MarkdownToken] = []
for node in nodes { append(node, to: &result) }
return result
}
private static func append(_ node: InlineNode, to result: inout [MarkdownToken]) {
switch node {
case .text:
break
case .code(let range, let content):
let open = NSRange(location: range.location, length: content.location - range.location)
let close = NSRange(location: NSMaxRange(content), length: NSMaxRange(range) - NSMaxRange(content))
result.append(MarkdownToken(kind: .inlineCode, range: range, contentRange: content, markerRanges: [open, close]))
case .emphasis(let kind, let range, let markers, let children):
let tokenKind: MarkdownTokenKind = kind == .italic ? .italic : (kind == .bold ? .bold : .boldItalic)
result.append(MarkdownToken(kind: tokenKind, range: range,
contentRange: between(markers), markerRanges: markers))
children.forEach { append($0, to: &result) }
case .link(let range, let textRange, _, let markers, let children):
result.append(MarkdownToken(kind: .link, range: range, contentRange: textRange, markerRanges: markers))
children.forEach { append($0, to: &result) }
case .image(let range, let alt, _, let markers):
result.append(MarkdownToken(kind: .imageLink, range: range, contentRange: alt, markerRanges: markers))
case .wikiLink(let range, let name, _, let markers):
result.append(MarkdownToken(kind: .wikiLink, range: range, contentRange: name, markerRanges: markers))
case .imageEmbed(let range, let target, let markers):
result.append(MarkdownToken(kind: .imageEmbed, range: range, contentRange: target, markerRanges: markers))
case .ext(let node):
result.append(MarkdownToken(kind: .extensionSpan(node.extensionID), range: node.range,
contentRange: node.contentRange, markerRanges: node.markers))
node.children.forEach { append($0, to: &result) }
case .inlineLatex(let range, let content, let markers):
result.append(MarkdownToken(kind: .inlineLatex, range: range, contentRange: content, markerRanges: markers))
case .escape(let range, let character, let marker):
result.append(MarkdownToken(kind: .backslashEscape, range: range, contentRange: character, markerRanges: [marker]))
}
}
/// Content range between a `[open, close]` marker pair.
private static func between(_ markers: [NSRange]) -> NSRange {
let start = NSMaxRange(markers[0])
return NSRange(location: start, length: markers[1].location - start)
}
}
@@ -0,0 +1,792 @@
//
// InlineParser.swift
// MarkdownEngine
//
// Phase 2 of the regexAST refactor: the inline-structure pass. Given the
// text of a single inline-bearing block, it produces an inline AST node tree
// with correct CommonMark precedence replacing the per-construct regex soup
// that the current tokenizer uses inside each block.
//
// Built construct by construct, test-first. Ranges in the returned tree are
// relative to the parsed string; callers offset to document coordinates.
//
// Pipeline (each pass claims spans only in regions not already claimed, so
// there are never partial overlaps and buildTree is a clean containment tree):
// 1. scanCodeSpans highest precedence, opaque interior.
// 2. scanEscapes `\x` becomes a claimed span, so the escaped char is
// automatically inert for every pass below.
// 3. scanLinkFamily ![[]], [[]], ![](), [](), $$ (+ registered
// extension spans) in precedence order. URLs allow
// balanced parens. A candidate overlapping a claimed
// span is rejected (kept literal), except for opaque
// spans wholly nested inside a Markdown link's label.
// This keeps `[x](y)` inert inside code while allowing
// valid labels such as [`x`](y).
// 4. resolveEmphasis `*`/`_` delimiter runs over text outside every
// claimed span; may wrap claimed spans.
// 5. buildTree containment tree. Emphasis nests already-collected
// spans; link/extension-span content is re-parsed
// recursively; code/image/wiki/embed/latex/escape are
// opaque leaves.
//
// Claimed spans are therefore either disjoint or properly NESTED a link
// label may hold one, nothing else may. That is load-bearing for cost as well
// as correctness: it's what lets `ClaimedIndex` answer "is this claimed?" with
// a cursor and `buildTree` derive containment from a sort. A pass that claimed
// a PARTIALLY overlapping span would break both, so keep claiming whole or not
// at all.
//
import Foundation
enum EmphasisKind: Equatable { case italic, bold, boldItalic }
/// A node in the inline AST.
indirect enum InlineNode: Equatable {
case text(NSRange)
/// `` `code` `` opaque; `range` covers the backticks, `content` strips single-space padding.
case code(range: NSRange, content: NSRange)
/// `*`/`_` emphasis. `markers` is `[openMarker, closeMarker]`.
case emphasis(EmphasisKind, range: NSRange, markers: [NSRange], children: [InlineNode])
/// `[text](url)`. `markers` is `[ "[", "]", "(", ")" ]`; text is recursively parsed.
case link(range: NSRange, textRange: NSRange, url: NSRange, markers: [NSRange], children: [InlineNode])
/// `![alt](url)`. `markers` is `[ "![", "]", "(", ")" ]`. Alt is opaque.
case image(range: NSRange, alt: NSRange, url: NSRange, markers: [NSRange])
/// `[[Name|id]]`. `markers` is `[ "[[", "]]" ]`; `id` is nil when no `|`.
case wikiLink(range: NSRange, name: NSRange, id: NSRange?, markers: [NSRange])
/// `![[target]]`. `markers` is `[ "![[", "]]" ]`.
case imageEmbed(range: NSRange, target: NSRange, markers: [NSRange])
/// `$math$` opaque. `markers` is `[ "$", "$" ]`.
case inlineLatex(range: NSRange, content: NSRange, markers: [NSRange])
/// Backslash escape `\x`; `marker` is the `\`, `character` the now-literal punctuation.
case escape(range: NSRange, character: NSRange, marker: NSRange)
/// A span contributed by a registered `MarkdownExtension`
/// (e.g. `==highlight==`). Pure data behavior lives in the extension,
/// looked up by `extensionID` at styling/render time.
case ext(ExtensionInlineNode)
}
/// An extension-contributed inline span. `children` is empty for opaque
/// (non-`parsesContent`) spans.
struct ExtensionInlineNode: Equatable {
let extensionID: String
let range: NSRange
let contentRange: NSRange
let markers: [NSRange] // [open, close]
let children: [InlineNode]
}
enum InlineParser {
private static let backtick: unichar = 0x60
private static let asterisk: unichar = 0x2A
private static let underscore: unichar = 0x5F
private static let newline: unichar = 0x0A
private static let bang: unichar = 0x21
private static let lbracket: unichar = 0x5B
private static let rbracket: unichar = 0x5D
private static let lparen: unichar = 0x28
private static let rparen: unichar = 0x29
private static let pipe: unichar = 0x7C
private static let backslash: unichar = 0x5C
private static let dollar: unichar = 0x24
// MARK: - Entry point
static func parse(_ text: String, registry: ExtensionRegistry = .empty) -> [InlineNode] {
let ns = text as NSString
let len = ns.length
guard len > 0 else { return [] }
var claimed = scanCodeSpans(ns, len: len)
claimed += scanEscapes(ns, len: len, claimed: ClaimedIndex(claimed))
claimed += scanLinkFamily(ns, len: len, claimed: ClaimedIndex(claimed), registry: registry)
let emphasis = resolveEmphasis(ns, len: len, claimed: ClaimedIndex(claimed))
return buildTree(region: NSRange(location: 0, length: len), spans: claimed + emphasis, ns: ns, registry: registry)
}
/// Parse the inline content of `range` within `ns`, returning nodes in absolute document coordinates.
static func parse(_ ns: NSString, range: NSRange, registry: ExtensionRegistry = .empty) -> [InlineNode] {
offsetNodes(parse(ns.substring(with: range), registry: registry), by: range.location)
}
// MARK: - Span model
private enum Span {
case code(range: NSRange, content: NSRange)
case emphasis(kind: EmphasisKind, range: NSRange, open: NSRange, close: NSRange)
case link(range: NSRange, textRange: NSRange, url: NSRange, markers: [NSRange])
case image(range: NSRange, alt: NSRange, url: NSRange, markers: [NSRange])
case wikiLink(range: NSRange, name: NSRange, id: NSRange?, markers: [NSRange])
case imageEmbed(range: NSRange, target: NSRange, markers: [NSRange])
case inlineLatex(range: NSRange, content: NSRange, markers: [NSRange])
case escape(range: NSRange, character: NSRange, marker: NSRange)
case ext(id: String, range: NSRange, contentRange: NSRange, markers: [NSRange], parsesContent: Bool)
var fullRange: NSRange {
switch self {
case .code(let r, _), .emphasis(_, let r, _, _), .link(let r, _, _, _),
.image(let r, _, _, _), .wikiLink(let r, _, _, _), .imageEmbed(let r, _, _),
.inlineLatex(let r, _, _), .escape(let r, _, _),
.ext(_, let r, _, _, _):
return r
}
}
}
/// The already-claimed ranges, in a form the later passes can consult in
/// amortised constant time.
///
/// Every pass that asks "is this claimed?" walks the string left to right
/// and never looks back, and claimed ranges never PARTIALLY overlap (each
/// pass only claims inside regions no earlier pass took). So a cursor over
/// the sorted ranges answers without rescanning: the answer for index `i`
/// only ever involves the first range that ends after `i`.
///
/// A nested range (a code span inside a link label) sorts after its
/// container, which already covers it, so `contains` stays correct without
/// looking past the cursor. `overlapping` is the one query that must, and
/// it peeks rather than advances.
///
/// Sortedness is established here rather than assumed of callers, so no
/// call site carries an ordering obligation.
private struct ClaimedIndex {
private let ranges: [NSRange]
private var cursor = 0
init(_ spans: [Span]) {
ranges = spans.map(\.fullRange).sorted { $0.location < $1.location }
}
/// Discard ranges that end at or before `idx`. `idx` must not move backwards.
private mutating func advance(to idx: Int) {
while cursor < ranges.count, NSMaxRange(ranges[cursor]) <= idx { cursor += 1 }
}
mutating func contains(_ idx: Int) -> Bool {
advance(to: idx)
return cursor < ranges.count && NSLocationInRange(idx, ranges[cursor])
}
mutating func overlaps(_ range: NSRange) -> Bool {
advance(to: range.location)
return cursor < ranges.count && ranges[cursor].location < NSMaxRange(range)
}
/// Every claimed range overlapping `range`. Peeks forward from the
/// cursor without consuming, so the caller's left-to-right walk is
/// unaffected.
mutating func overlapping(_ range: NSRange) -> [NSRange] {
advance(to: range.location)
var out: [NSRange] = []
var k = cursor
while k < ranges.count, ranges[k].location < NSMaxRange(range) {
if NSIntersectionRange(ranges[k], range).length > 0 { out.append(ranges[k]) }
k += 1
}
return out
}
}
// MARK: - 1. Code spans
private static func scanCodeSpans(_ ns: NSString, len: Int) -> [Span] {
var spans: [Span] = []
var i = 0
while i < len {
guard ns.character(at: i) == backtick, !isEscaped(i, ns) else { i += 1; continue }
let runStart = i
var j = i
while j < len, ns.character(at: j) == backtick { j += 1 }
let runLen = j - runStart
guard let close = closingBacktickRun(in: ns, from: j, length: len, runLen: runLen) else {
i = j; continue
}
let codeRange = NSRange(location: runStart, length: (close + runLen) - runStart)
let rawContent = NSRange(location: j, length: close - j)
spans.append(.code(range: codeRange, content: strippedCodeContent(rawContent, in: ns)))
i = close + runLen
}
return spans
}
private static func closingBacktickRun(in ns: NSString, from: Int, length len: Int, runLen: Int) -> Int? {
var k = from
while k < len {
guard ns.character(at: k) == backtick, !isEscaped(k, ns) else { k += 1; continue }
let start = k
while k < len, ns.character(at: k) == backtick { k += 1 }
if k - start == runLen { return start }
}
return nil
}
private static func strippedCodeContent(_ raw: NSRange, in ns: NSString) -> NSRange {
let space: unichar = 0x20
guard raw.length >= 2,
ns.character(at: raw.location) == space,
ns.character(at: NSMaxRange(raw) - 1) == space else { return raw }
var allSpaces = true
for k in raw.location..<NSMaxRange(raw) where ns.character(at: k) != space {
allSpaces = false; break
}
guard !allSpaces else { return raw }
return NSRange(location: raw.location + 1, length: raw.length - 2)
}
// MARK: - 2. Backslash escapes (claimed escaped chars are inert everywhere)
private static func scanEscapes(_ ns: NSString, len: Int, claimed: ClaimedIndex) -> [Span] {
var claimed = claimed
var spans: [Span] = []
var i = 0
while i < len - 1 {
if ns.character(at: i) == backslash, !claimed.contains(i), isAsciiPunctuationChar(ns.character(at: i + 1)) {
spans.append(.escape(
range: NSRange(location: i, length: 2),
character: NSRange(location: i + 1, length: 1),
marker: NSRange(location: i, length: 1)
))
i += 2 // the escaped char can't itself start a new escape (even/odd `\\`)
} else {
i += 1
}
}
return spans
}
// MARK: - 3. Link family / inline LaTeX / extension spans
private static func scanLinkFamily(_ ns: NSString, len: Int, claimed: ClaimedIndex, registry: ExtensionRegistry) -> [Span] {
var claimed = claimed
// A candidate overlapping a claimed span is rejected, except for spans
// wholly nested inside a Markdown link's label (#118). Only that case
// needs the full overlap list; everything else short-circuits on the
// first one.
func hasDisallowedClaimedOverlap(_ span: Span) -> Bool {
guard case .link(_, let textRange, _, _) = span else {
return claimed.overlaps(span.fullRange)
}
return claimed.overlapping(span.fullRange).contains { !rangeContains(textRange, $0) }
}
var spans: [Span] = []
var i = 0
while i < len {
if claimed.contains(i) { i += 1; continue }
if let span = matchClaimedSpan(ns, len, at: i, registry: registry),
!hasDisallowedClaimedOverlap(span) {
spans.append(span)
i = NSMaxRange(span.fullRange)
} else {
i += 1
}
}
return spans
}
private static func matchClaimedSpan(_ ns: NSString, _ len: Int, at i: Int, registry: ExtensionRegistry) -> Span? {
if let span = matchBuiltIn(ns, len, at: i) { return span }
// Extensions match after every built-in, in registration order. A
// built-in trigger that matched-and-FAILED (e.g. `$50$` rejected by
// the math heuristic) falls through here, so an extension sharing a
// built-in's first character is still reachable.
let c = ns.character(at: i)
for entry in registry.entries where entry.open.first == c {
if let span = matchExtensionSpan(ns, len, start: i, entry: entry) { return span }
}
return nil
}
/// The built-in constructs, tried exclusively in fixed precedence order
/// the first branch whose trigger matches decides (nil = stays literal
/// for built-ins), exactly the pre-extension behavior.
private static func matchBuiltIn(_ ns: NSString, _ len: Int, at i: Int) -> Span? {
let c = ns.character(at: i)
let c1 = peek(ns, i + 1, len)
let c2 = peek(ns, i + 2, len)
if c == bang, c1 == lbracket, c2 == lbracket { return matchImageEmbed(ns, len, start: i) }
if c == lbracket, c1 == lbracket { return matchWikiLink(ns, len, start: i) }
if c == bang, c1 == lbracket { return matchImage(ns, len, start: i) }
if c == lbracket { return matchLink(ns, len, start: i) }
if c == dollar, c1 != dollar { return matchInlineLatex(ns, len, start: i) }
return nil
}
/// Generic scanner for extension-contributed delimited spans. Mirrors the
/// built-in `~~`/`==` semantics: the span opens at an exact `open` match,
/// closes at the FIRST exact `close` match on the same line, and a lone
/// occurrence of `close`'s first character inside the content aborts the
/// candidate (it stays literal).
private static func matchExtensionSpan(_ ns: NSString, _ len: Int, start i: Int, entry: ExtensionRegistry.Entry) -> Span? {
let open = entry.open, close = entry.close
guard !open.isEmpty, !close.isEmpty else { return nil }
guard matches(ns, len, at: i, chars: open) else { return nil }
if entry.syntax.rejectsOpenerRun, i > 0, ns.character(at: i - 1) == open[0] { return nil }
let contentStart = i + open.count
let closeFirst = close[0]
var k = contentStart
while k < len {
let ch = ns.character(at: k)
if ch == newline { return nil }
if ch == closeFirst {
guard matches(ns, len, at: k, chars: close) else { return nil }
if entry.syntax.requiresNonEmptyContent, k == contentStart { return nil }
if entry.syntax.rejectsCloserRun,
let after = peek(ns, k + close.count, len), after == close[close.count - 1] { return nil }
return .ext(
id: entry.id,
range: NSRange(location: i, length: (k + close.count) - i),
contentRange: NSRange(location: contentStart, length: k - contentStart),
markers: [NSRange(location: i, length: open.count), NSRange(location: k, length: close.count)],
parsesContent: entry.syntax.parsesContent
)
}
k += 1
}
return nil
}
/// Exact UTF-16 sequence match at `i`.
private static func matches(_ ns: NSString, _ len: Int, at i: Int, chars: [unichar]) -> Bool {
guard i + chars.count <= len else { return false }
for (offset, u) in chars.enumerated() where ns.character(at: i + offset) != u { return false }
return true
}
private static func peek(_ ns: NSString, _ idx: Int, _ len: Int) -> unichar? {
(idx >= 0 && idx < len) ? ns.character(at: idx) : nil
}
/// `![[ target ]]`
private static func matchImageEmbed(_ ns: NSString, _ len: Int, start i: Int) -> Span? {
let contentStart = i + 3
guard let close = closeDoubleBracket(ns, len, from: contentStart) else { return nil }
return .imageEmbed(
range: NSRange(location: i, length: (close + 2) - i),
target: NSRange(location: contentStart, length: close - contentStart),
markers: [NSRange(location: i, length: 3), NSRange(location: close, length: 2)]
)
}
/// `[[ name (| id)? ]]`
private static func matchWikiLink(_ ns: NSString, _ len: Int, start i: Int) -> Span? {
let contentStart = i + 2
var k = contentStart
var pipeIdx = -1
while k < len {
let ch = ns.character(at: k)
if ch == newline { return nil }
if ch == pipe, pipeIdx == -1 { pipeIdx = k }
if ch == rbracket {
guard peek(ns, k + 1, len) == rbracket else { return nil }
let range = NSRange(location: i, length: (k + 2) - i)
let markers = [NSRange(location: i, length: 2), NSRange(location: k, length: 2)]
if pipeIdx >= 0 {
return .wikiLink(range: range,
name: NSRange(location: contentStart, length: pipeIdx - contentStart),
id: NSRange(location: pipeIdx + 1, length: k - (pipeIdx + 1)),
markers: markers)
}
return .wikiLink(range: range,
name: NSRange(location: contentStart, length: k - contentStart),
id: nil, markers: markers)
}
k += 1
}
return nil
}
/// `![ alt ]( url )`
private static func matchImage(_ ns: NSString, _ len: Int, start i: Int) -> Span? {
let altStart = i + 2
guard let closeBracket = findChar(ns, len, from: altStart, char: rbracket),
peek(ns, closeBracket + 1, len) == lparen,
let closeParen = balancedParen(ns, len, from: closeBracket + 2) else { return nil }
let urlStart = closeBracket + 2
guard closeParen > urlStart else { return nil }
return .image(
range: NSRange(location: i, length: (closeParen + 1) - i),
alt: NSRange(location: altStart, length: closeBracket - altStart),
url: NSRange(location: urlStart, length: closeParen - urlStart),
markers: [
NSRange(location: i, length: 2),
NSRange(location: closeBracket, length: 1),
NSRange(location: closeBracket + 1, length: 1),
NSRange(location: closeParen, length: 1),
]
)
}
/// `[ text ]( url )`
private static func matchLink(_ ns: NSString, _ len: Int, start i: Int) -> Span? {
let textStart = i + 1
guard let closeBracket = findChar(ns, len, from: textStart, char: rbracket),
closeBracket > textStart,
peek(ns, closeBracket + 1, len) == lparen,
let closeParen = balancedParen(ns, len, from: closeBracket + 2) else { return nil }
let urlStart = closeBracket + 2
guard closeParen > urlStart else { return nil }
return .link(
range: NSRange(location: i, length: (closeParen + 1) - i),
textRange: NSRange(location: textStart, length: closeBracket - textStart),
url: NSRange(location: urlStart, length: closeParen - urlStart),
markers: [
NSRange(location: i, length: 1),
NSRange(location: closeBracket, length: 1),
NSRange(location: closeBracket + 1, length: 1),
NSRange(location: closeParen, length: 1),
]
)
}
/// `$ math $` single dollars, content has no `$`, passes the math heuristic.
private static func matchInlineLatex(_ ns: NSString, _ len: Int, start i: Int) -> Span? {
if i > 0, ns.character(at: i - 1) == dollar { return nil }
let contentStart = i + 1
var k = contentStart
while k < len {
let ch = ns.character(at: k)
if ch == newline { return nil }
if ch == dollar {
guard k > contentStart, peek(ns, k + 1, len) != dollar else { return nil }
let content = NSRange(location: contentStart, length: k - contentStart)
guard isInlineMathContent(ns.substring(with: content)) else { return nil }
return .inlineLatex(
range: NSRange(location: i, length: (k + 1) - i),
content: content,
markers: [NSRange(location: i, length: 1), NSRange(location: k, length: 1)]
)
}
k += 1
}
return nil
}
private static func closeDoubleBracket(_ ns: NSString, _ len: Int, from: Int) -> Int? {
var k = from
while k < len {
let ch = ns.character(at: k)
if ch == newline { return nil }
if ch == rbracket { return peek(ns, k + 1, len) == rbracket ? k : nil }
k += 1
}
return nil
}
private static func findChar(_ ns: NSString, _ len: Int, from: Int, char: unichar) -> Int? {
var k = from
while k < len {
let ch = ns.character(at: k)
if ch == char { return k }
if ch == newline { return nil }
k += 1
}
return nil
}
private static func balancedParen(_ ns: NSString, _ len: Int, from: Int) -> Int? {
var depth = 1
var k = from
while k < len {
let ch = ns.character(at: k)
if ch == newline { return nil }
if ch == lparen { depth += 1 }
else if ch == rparen { depth -= 1; if depth == 0 { return k } }
k += 1
}
return nil
}
/// Rejects currency-looking and trivially short non-mathy `$$` so prose isn't misread as math.
private static func isInlineMathContent(_ content: String) -> Bool {
let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return false }
if isCurrencyLike(trimmed) { return false }
let mathyMatches = mathyCharCount(trimmed)
if mathyMatches == 0 {
return trimmed.count <= 3 && isAllAsciiLetters(trimmed)
}
let tokenCount = trimmed.split(whereSeparator: { $0.isWhitespace }).count
if mathyMatches >= 3 { return tokenCount <= 120 }
if mathyMatches == 2 { return tokenCount <= 40 }
return tokenCount <= 6
}
/// A plain signed/thousands-grouped/decimal number (`50`, `1,000.50`, `-5`), regex-free, so currency isn't math.
private static func isCurrencyLike(_ s: String) -> Bool {
let u = Array(s.utf16)
let n = u.count
func digit(_ x: Int) -> Bool { x >= 0 && x < n && u[x] >= 0x30 && u[x] <= 0x39 }
var i = 0
if i < n, u[i] == 0x2B || u[i] == 0x2D { i += 1 } // + / -
guard digit(i) else { return false }
var sawDigit = false
while i < n {
if digit(i) { sawDigit = true; i += 1 }
else if u[i] == 0x2C, digit(i + 1), digit(i + 2), digit(i + 3), !digit(i + 4) {
i += 4 // a strict `,DDD` thousands group
} else { break }
}
guard sawDigit else { return false }
if i < n, u[i] == 0x2E { // optional `.DDD+`
i += 1
guard digit(i) else { return false }
while digit(i) { i += 1 }
}
return i == n
}
/// Count of "mathy" characters `\ ^ _ { } = + - * / < >`.
private static func mathyCharCount(_ s: String) -> Int {
let mathy: Set<unichar> = [0x5C, 0x5E, 0x5F, 0x7B, 0x7D, 0x3D, 0x2B, 0x2D, 0x2A, 0x2F, 0x3C, 0x3E]
var count = 0
for u in s.utf16 where mathy.contains(u) { count += 1 }
return count
}
/// True when `s` is one or more ASCII letters only.
private static func isAllAsciiLetters(_ s: String) -> Bool {
let u = Array(s.utf16)
guard !u.isEmpty else { return false }
for x in u where !((x >= 0x41 && x <= 0x5A) || (x >= 0x61 && x <= 0x7A)) { return false }
return true
}
// MARK: - 4. Emphasis (delimiter runs)
private struct DelimRun {
let char: unichar
let originalLength: Int
var leftEdge: Int
var rightEdge: Int
let canOpen: Bool
let canClose: Bool
let lineIdx: Int
var remaining: Int { rightEdge - leftEdge }
}
private static func resolveEmphasis(_ ns: NSString, len: Int, claimed: ClaimedIndex) -> [Span] {
var runs = collectDelimiterRuns(ns, len: len, claimed: claimed)
guard !runs.isEmpty else { return [] }
var stack: [Int] = []
var spans: [Span] = []
for idx in runs.indices {
if runs[idx].canClose {
closeAgainstStack(closerIdx: idx, runs: &runs, stack: &stack, spans: &spans)
}
if runs[idx].canOpen && runs[idx].remaining > 0 {
stack.append(idx)
}
}
return spans
}
private static func collectDelimiterRuns(_ ns: NSString, len: Int, claimed: ClaimedIndex) -> [DelimRun] {
var claimed = claimed
var runs: [DelimRun] = []
var lineIdx = 0
var i = 0
while i < len {
let c = ns.character(at: i)
if c == newline { lineIdx += 1; i += 1; continue }
guard c == asterisk || c == underscore, !claimed.contains(i) else { i += 1; continue }
var j = i
while j < len, ns.character(at: j) == c { j += 1 }
let before = i - 1, after = j
let beforeWs = isWhitespaceOrBoundary(before, ns, len)
let beforePunct = isAsciiPunctuation(before, ns, len)
let afterWs = isWhitespaceOrBoundary(after, ns, len)
let afterPunct = isAsciiPunctuation(after, ns, len)
let leftFlanking = !afterWs && (!afterPunct || beforeWs || beforePunct)
let rightFlanking = !beforeWs && (!beforePunct || afterWs || afterPunct)
let canOpen: Bool, canClose: Bool
if c == underscore {
canOpen = leftFlanking && (!rightFlanking || beforePunct)
canClose = rightFlanking && (!leftFlanking || afterPunct)
} else {
canOpen = leftFlanking
canClose = rightFlanking
}
runs.append(DelimRun(
char: c, originalLength: j - i, leftEdge: i, rightEdge: j,
canOpen: canOpen, canClose: canClose, lineIdx: lineIdx
))
i = j
}
return runs
}
private static func closeAgainstStack(
closerIdx: Int, runs: inout [DelimRun], stack: inout [Int], spans: inout [Span]
) {
var sp = stack.count - 1
while sp >= 0, runs[closerIdx].remaining > 0 {
let openerIdx = stack[sp]
if runs[openerIdx].char != runs[closerIdx].char { sp -= 1; continue }
if runs[openerIdx].lineIdx != runs[closerIdx].lineIdx {
stack.remove(at: sp); sp -= 1; continue
}
let avail = min(runs[openerIdx].remaining, runs[closerIdx].remaining)
if avail == 0 { stack.remove(at: sp); sp -= 1; continue }
let openerBoth = runs[openerIdx].canOpen && runs[openerIdx].canClose
let closerBoth = runs[closerIdx].canOpen && runs[closerIdx].canClose
if openerBoth || closerBoth {
let sum = runs[openerIdx].originalLength + runs[closerIdx].originalLength
let bothMod3 = runs[openerIdx].originalLength % 3 == 0 && runs[closerIdx].originalLength % 3 == 0
if sum % 3 == 0 && !bothMod3 { sp -= 1; continue }
}
let matchLen = avail >= 3 ? 3 : (avail >= 2 ? 2 : 1)
let openerMarkerStart = runs[openerIdx].rightEdge - matchLen
let closerMarkerStart = runs[closerIdx].leftEdge
let kind: EmphasisKind = matchLen == 3 ? .boldItalic : (matchLen == 2 ? .bold : .italic)
spans.append(.emphasis(
kind: kind,
range: NSRange(location: openerMarkerStart, length: (closerMarkerStart + matchLen) - openerMarkerStart),
open: NSRange(location: openerMarkerStart, length: matchLen),
close: NSRange(location: closerMarkerStart, length: matchLen)
))
runs[openerIdx].rightEdge -= matchLen
runs[closerIdx].leftEdge += matchLen
if runs[openerIdx].remaining == 0 { stack.remove(at: sp) }
sp -= 1
}
}
// MARK: - 5. Containment tree
private static func buildTree(region: NSRange, spans: [Span], ns: NSString, registry: ExtensionRegistry) -> [InlineNode] {
// Spans are non-overlapping or properly nested (each pass claims only
// inside regions no earlier pass took), so ordering by start ascending
// and length descending puts every span immediately after the one that
// contains it. Containment then falls out of a single ordered walk,
// instead of testing each span against every other span.
let ordered = spans
.filter { rangeContains(region, $0.fullRange) }
.sorted { a, b in
let (x, y) = (a.fullRange, b.fullRange)
return x.location == y.location ? x.length > y.length : x.location < y.location
}
var cursor = 0
return buildTree(region: region, ordered: ordered, cursor: &cursor, ns: ns, registry: registry)
}
/// Consumes spans from `cursor` for as long as they fall inside `region`,
/// leaving `cursor` on the first span that doesn't.
private static func buildTree(
region: NSRange, ordered: [Span], cursor: inout Int, ns: NSString, registry: ExtensionRegistry
) -> [InlineNode] {
var result: [InlineNode] = []
var textStart = region.location
while cursor < ordered.count {
let span = ordered[cursor]
let fr = span.fullRange
guard rangeContains(region, fr) else { break }
cursor += 1
if fr.location > textStart {
result.append(.text(NSRange(location: textStart, length: fr.location - textStart)))
}
switch span {
case .code(let range, let content):
result.append(.code(range: range, content: content))
case .emphasis(let kind, let range, let open, let close):
let content = NSRange(location: NSMaxRange(open), length: close.location - NSMaxRange(open))
result.append(.emphasis(kind, range: range, markers: [open, close],
children: buildTree(region: content, ordered: ordered,
cursor: &cursor, ns: ns, registry: registry)))
case .link(let range, let textRange, let url, let markers):
result.append(.link(range: range, textRange: textRange, url: url, markers: markers,
children: reparse(textRange, ns: ns, registry: registry)))
case .image(let range, let alt, let url, let markers):
result.append(.image(range: range, alt: alt, url: url, markers: markers))
case .wikiLink(let range, let name, let id, let markers):
result.append(.wikiLink(range: range, name: name, id: id, markers: markers))
case .imageEmbed(let range, let target, let markers):
result.append(.imageEmbed(range: range, target: target, markers: markers))
case .inlineLatex(let range, let content, let markers):
result.append(.inlineLatex(range: range, content: content, markers: markers))
case .escape(let range, let character, let marker):
result.append(.escape(range: range, character: character, marker: marker))
case .ext(let id, let range, let contentRange, let markers, let parsesContent):
result.append(.ext(ExtensionInlineNode(
extensionID: id, range: range, contentRange: contentRange, markers: markers,
children: parsesContent ? reparse(contentRange, ns: ns, registry: registry) : []
)))
}
// Every span but emphasis is opaque, so nothing should remain
// inside one. Skipping keeps the walk well-formed if that ever
// changes, rather than emitting a node past the cursor.
while cursor < ordered.count, rangeContains(fr, ordered[cursor].fullRange) { cursor += 1 }
textStart = NSMaxRange(fr)
}
if textStart < NSMaxRange(region) {
result.append(.text(NSRange(location: textStart, length: NSMaxRange(region) - textStart)))
}
return result
}
/// Recursively parse a sub-range's content, offset back to absolute coordinates.
private static func reparse(_ range: NSRange, ns: NSString, registry: ExtensionRegistry) -> [InlineNode] {
offsetNodes(parse(ns.substring(with: range), registry: registry), by: range.location)
}
// MARK: - Helpers
private static func offsetNodes(_ nodes: [InlineNode], by delta: Int) -> [InlineNode] {
nodes.map { offset($0, by: delta) }
}
private static func offset(_ node: InlineNode, by d: Int) -> InlineNode {
func s(_ r: NSRange) -> NSRange { NSRange(location: r.location + d, length: r.length) }
switch node {
case .text(let r): return .text(s(r))
case .code(let r, let c): return .code(range: s(r), content: s(c))
case .emphasis(let k, let r, let m, let ch): return .emphasis(k, range: s(r), markers: m.map(s), children: offsetNodes(ch, by: d))
case .link(let r, let tr, let u, let m, let ch): return .link(range: s(r), textRange: s(tr), url: s(u), markers: m.map(s), children: offsetNodes(ch, by: d))
case .image(let r, let a, let u, let m): return .image(range: s(r), alt: s(a), url: s(u), markers: m.map(s))
case .wikiLink(let r, let n, let id, let m): return .wikiLink(range: s(r), name: s(n), id: id.map(s), markers: m.map(s))
case .imageEmbed(let r, let t, let m): return .imageEmbed(range: s(r), target: s(t), markers: m.map(s))
case .inlineLatex(let r, let c, let m): return .inlineLatex(range: s(r), content: s(c), markers: m.map(s))
case .escape(let r, let c, let m): return .escape(range: s(r), character: s(c), marker: s(m))
case .ext(let n): return .ext(ExtensionInlineNode(
extensionID: n.extensionID, range: s(n.range), contentRange: s(n.contentRange),
markers: n.markers.map(s), children: offsetNodes(n.children, by: d)))
}
}
private static func rangeContains(_ outer: NSRange, _ inner: NSRange) -> Bool {
inner.location >= outer.location && NSMaxRange(inner) <= NSMaxRange(outer)
}
private static func isWhitespaceOrBoundary(_ idx: Int, _ ns: NSString, _ len: Int) -> Bool {
guard idx >= 0, idx < len else { return true }
let c = ns.character(at: idx)
return c == 0x20 || c == 0x09 || c == 0x0A || c == 0x0D
}
private static func isAsciiPunctuation(_ idx: Int, _ ns: NSString, _ len: Int) -> Bool {
guard idx >= 0, idx < len else { return false }
return isAsciiPunctuationChar(ns.character(at: idx))
}
private static func isAsciiPunctuationChar(_ c: unichar) -> Bool {
(c >= 0x21 && c <= 0x2F) || (c >= 0x3A && c <= 0x40)
|| (c >= 0x5B && c <= 0x60) || (c >= 0x7B && c <= 0x7E)
}
/// A character is backslash-escaped when preceded by an odd run of `\`.
private static func isEscaped(_ idx: Int, _ ns: NSString) -> Bool {
var count = 0
var k = idx - 1
while k >= 0, ns.character(at: k) == backslash { count += 1; k -= 1 }
return count % 2 == 1
}
}
@@ -0,0 +1,247 @@
//
// MarkdownAST.swift
// MarkdownEngine
//
// Phase 2.5 foundation: the semantic document AST. Combines the block-structure
// pass (BlockParser) with the inline pass (InlineParser) into one tree of
// `BlockNode`s, each inline-bearing block carrying its parsed inline children
// in absolute document coordinates. The AST-native styler (next increments)
// walks this tree instead of consuming flat tokens.
//
import Foundation
/// One list-item line: marker run, optional GFM checkbox, and indent column count.
struct ListItem: Equatable {
let range: NSRange // the item's full line (incl. trailing newline)
let marker: NSRange
let ordered: Bool
let number: Int? // ordered start value, e.g. `5.` 5
let checkbox: NSRange?
let checked: Bool
let indent: Int
let contentRange: NSRange // text after the marker (and checkbox)
let inlines: [InlineNode]
}
/// An extension-supplied fenced block. `closeFence` is nil when the block is
/// unclosed (it then runs to the end of the document).
struct ExtensionBlockNode: Equatable {
let extensionID: String
let range: NSRange
let openFence: NSRange // opening fence line incl. its newline
let closeFence: NSRange? // closing fence line, nil when unclosed
let contentRange: NSRange // lines between the fences
let inlines: [InlineNode]
}
/// A top-level block in the document AST.
indirect enum BlockNode: Equatable {
case paragraph(range: NSRange, inlines: [InlineNode])
case heading(level: Int, range: NSRange, markers: [NSRange], inlines: [InlineNode])
case blockquote(range: NSRange, inlines: [InlineNode])
case list(range: NSRange, items: [ListItem])
case codeBlock(range: NSRange)
case blockLatex(range: NSRange)
case table(range: NSRange)
case thematicBreak(range: NSRange)
case blank(range: NSRange)
case ext(ExtensionBlockNode)
var range: NSRange {
switch self {
case .paragraph(let r, _), .heading(_, let r, _, _), .blockquote(let r, _),
.list(let r, _), .codeBlock(let r), .blockLatex(let r), .table(let r),
.thematicBreak(let r), .blank(let r):
return r
case .ext(let node):
return node.range
}
}
}
enum DocumentAST {
private static let hash: unichar = 0x23
private static let space: unichar = 0x20
private static let tab: unichar = 0x09
/// Build the document AST; `scopedRanges` parses inlines only for intersecting blocks.
/// `precomputedBlocks` (the keystroke's own parse state, handed down by the
/// restyle) skips BlockParser.parse whose cache "hit" still re-extracts
/// and memcmps the full document buffer entirely.
static func parse(_ text: String, scopedRanges: [NSRange]? = nil, precomputedBlocks: [Block]? = nil,
registry: ExtensionRegistry = .empty) -> [BlockNode] {
let ns = text as NSString
let blocks = precomputedBlocks ?? BlockParser.parse(text, registry: registry)
// Scoped mode: skip building BlockNodes for blocks outside the edit.
// Blocks tile the document in order, so one sweep over sorted candidate
// ranges replaces scanning every candidate per block (which went
// quadratic in formula-rich documents with dozens of candidates).
let relevant: [Block]
if let scopedRanges {
let sorted = scopedRanges
.filter { $0.location != NSNotFound && $0.length > 0 }
.sorted { $0.location < $1.location }
var out: [Block] = []
var ci = 0
for block in blocks {
while ci < sorted.count, NSMaxRange(sorted[ci]) <= block.range.location { ci += 1 }
guard ci < sorted.count else { break }
if sorted[ci].location < NSMaxRange(block.range) { out.append(block) }
}
relevant = out
} else {
relevant = blocks
}
return relevant.map { node(for: $0, ns: ns, scopedRanges: scopedRanges, registry: registry) }
}
private static func inScope(_ range: NSRange, _ scopedRanges: [NSRange]?) -> Bool {
guard let scopedRanges else { return true }
return scopedRanges.contains { NSIntersectionRange($0, range).length > 0 }
}
private static func node(for block: Block, ns: NSString, scopedRanges: [NSRange]?, registry: ExtensionRegistry) -> BlockNode {
let scoped = inScope(block.range, scopedRanges)
switch block.kind {
case .paragraph:
return .paragraph(range: block.range, inlines: scoped ? InlineParser.parse(ns, range: block.range, registry: registry) : [])
case .heading:
return heading(block.range, ns, scoped: scoped, registry: registry)
case .blockquote:
return .blockquote(range: block.range, inlines: scoped ? InlineParser.parse(ns, range: block.range, registry: registry) : [])
case .list:
return list(block.range, ns, scoped: scoped, registry: registry)
case .fencedCode:
return .codeBlock(range: block.range)
case .blockLatex:
return .blockLatex(range: block.range)
case .table:
return .table(range: block.range)
case .thematicBreak:
return .thematicBreak(range: block.range)
case .blank:
return .blank(range: block.range)
case .ext(let id):
return extensionBlock(id: id, range: block.range, ns, scoped: scoped, registry: registry)
}
}
/// Split an extension fenced block into open fence line, optional closing
/// fence line, and the content between; inlines parse over the content.
private static func extensionBlock(id: String, range: NSRange, _ ns: NSString,
scoped: Bool, registry: ExtensionRegistry) -> BlockNode {
let fence = registry.blockEntry(for: id)?.fence ?? ""
let end = NSMaxRange(range)
// Opening fence line including its terminator via lineRange, the
// same primitive the block parser tiles with, so the fence/content
// split agrees on EVERY line terminator (\n, \r\n, U+2028, ).
let openLine = ns.lineRange(for: NSRange(location: range.location, length: 0))
let openEnd = min(NSMaxRange(openLine), end)
let openFence = NSRange(location: range.location, length: openEnd - range.location)
// Closing fence: the block's last line, when it starts with the fence
// and is not the opening line itself.
var closeFence: NSRange?
if openEnd < end {
let lastLine = ns.lineRange(for: NSRange(location: end - 1, length: 0))
if lastLine.location >= openEnd,
!fence.isEmpty,
ns.substring(with: lastLine).hasPrefix(fence) {
closeFence = lastLine
}
}
let contentEnd = closeFence?.location ?? end
let contentRange = NSRange(location: openEnd, length: max(0, contentEnd - openEnd))
return .ext(ExtensionBlockNode(
extensionID: id,
range: range,
openFence: openFence,
closeFence: closeFence,
contentRange: contentRange,
inlines: scoped && contentRange.length > 0
? InlineParser.parse(ns, range: contentRange, registry: registry) : []
))
}
/// ATX heading: optional indent, `#`×level, space(s), then inline content.
private static func heading(_ range: NSRange, _ ns: NSString, scoped: Bool = true, registry: ExtensionRegistry = .empty) -> BlockNode {
let end = NSMaxRange(range)
var i = range.location
while i < end, ns.character(at: i) == space || ns.character(at: i) == tab { i += 1 }
let hashStart = i
var level = 0
while i < end, ns.character(at: i) == hash { level += 1; i += 1 }
var contentStart = i
while contentStart < end, ns.character(at: contentStart) == space { contentStart += 1 }
// Markers span `#`(s) plus trailing space(s) so the whole syntax collapses on shrink.
let markers = [NSRange(location: hashStart, length: contentStart - hashStart)]
var contentEnd = end
while contentEnd > contentStart, isLineBreak(ns.character(at: contentEnd - 1)) { contentEnd -= 1 }
let contentRange = NSRange(location: contentStart, length: contentEnd - contentStart)
return .heading(level: level, range: range, markers: markers,
inlines: scoped ? InlineParser.parse(ns, range: contentRange, registry: registry) : [])
}
/// Split a list block into one `ListItem` per physical line.
private static func list(_ range: NSRange, _ ns: NSString, scoped: Bool = true, registry: ExtensionRegistry = .empty) -> BlockNode {
var items: [ListItem] = []
var cursor = range.location
let end = NSMaxRange(range)
while cursor < end {
let line = ns.lineRange(for: NSRange(location: cursor, length: 0))
items.append(listItem(line, ns, scoped: scoped, registry: registry))
cursor = NSMaxRange(line)
}
return .list(range: range, items: items)
}
/// Parse one list-item line: indent, marker, optional task checkbox, inline content.
private static func listItem(_ lineRange: NSRange, _ ns: NSString, scoped: Bool = true, registry: ExtensionRegistry = .empty) -> ListItem {
let end = NSMaxRange(lineRange)
var i = lineRange.location
var indent = 0
while i < end, ns.character(at: i) == space || ns.character(at: i) == tab { i += 1; indent += 1 }
let markerStart = i
var ordered = false
var number: Int?
let c = i < end ? ns.character(at: i) : 0
if c == 0x2D || c == 0x2A || c == 0x2B { // - * +
i += 1
} else { // N. / N)
var value = 0
var digits = 0
while i < end, ns.character(at: i) >= 0x30, ns.character(at: i) <= 0x39, digits < 9 {
value = value * 10 + Int(ns.character(at: i) - 0x30); i += 1; digits += 1
}
ordered = true
number = value
if i < end { i += 1 } // the `.` or `)`
}
let marker = NSRange(location: markerStart, length: i - markerStart)
if i < end, ns.character(at: i) == space || ns.character(at: i) == tab { i += 1 }
var checkbox: NSRange?
var checked = false
if i + 2 < end, ns.character(at: i) == 0x5B, ns.character(at: i + 2) == 0x5D { // [ x ]
let mid = ns.character(at: i + 1)
if mid == space || mid == 0x78 || mid == 0x58 { // space / x / X
checkbox = NSRange(location: i, length: 3)
checked = (mid == 0x78 || mid == 0x58)
i += 3
if i < end, ns.character(at: i) == space || ns.character(at: i) == tab { i += 1 }
}
}
var contentEnd = end
while contentEnd > i, isLineBreak(ns.character(at: contentEnd - 1)) { contentEnd -= 1 }
let content = NSRange(location: i, length: max(0, contentEnd - i))
return ListItem(range: lineRange, marker: marker, ordered: ordered, number: number,
checkbox: checkbox, checked: checked, indent: indent,
contentRange: content, inlines: scoped ? InlineParser.parse(ns, range: content, registry: registry) : [])
}
private static func isLineBreak(_ c: unichar) -> Bool { c == 0x0A || c == 0x0D }
}
@@ -0,0 +1,170 @@
//
// MarkdownDetection.swift
// MarkdownEngine
//
// Created by Luca Chen on 18.02.26.
//
// Helper checks for questions like "is the cursor inside code or LaTeX?"
// and "which Markdown part is currently active?".
import Foundation
enum MarkdownDetection {
// MARK: - Active Token Indices
static func computeActiveTokenIndices(
selectionRange: NSRange,
tokens: [MarkdownToken],
in text: NSString,
suppressed: Bool = false
) -> Set<Int> {
// Read-only mode (no caret) hides all tokens regardless of any trailing selection.
if suppressed { return [] }
var indices: Set<Int> = []
let caretLocation = selectionRange.location
for (index, token) in tokens.enumerated() {
let start = token.range.location
let end = NSMaxRange(token.range)
if selectionRange.length > 0 && (token.kind == .inlineLatex || token.kind == .blockLatex) && NSIntersectionRange(selectionRange, token.range).length > 0 {
indices.insert(index)
continue
}
if caretLocation >= start && caretLocation < end {
indices.insert(index)
continue
}
if caretLocation == end {
let lastIndex = end - 1
if lastIndex >= start && lastIndex < text.length {
let lastChar = text.substring(with: NSRange(location: lastIndex, length: 1))
if lastChar != "\n" {
indices.insert(index)
}
}
}
}
// When a container token (e.g. a table) is active, every inline token inside it becomes active too.
let activeContainers: [MarkdownToken] = indices.compactMap { idx in
let token = tokens[idx]
return token.kind == .table ? token : nil
}
if !activeContainers.isEmpty {
for (i, token) in tokens.enumerated() where !indices.contains(i) {
let tStart = token.range.location
let tEnd = NSMaxRange(token.range)
if activeContainers.contains(where: {
tStart >= $0.range.location && tEnd <= NSMaxRange($0.range)
}) {
indices.insert(i)
}
}
}
return indices
}
// MARK: - Code Block Detection
/// Slow: parses tokens each call. Pass the editor's registry so the parse
/// matches the styled document's grammar (an extension span can pre-claim
/// text a built-in would otherwise recognize).
static func isInsideCodeBlock(range: NSRange, in text: String, registry: ExtensionRegistry = .empty) -> Bool {
let codeTokens = MarkdownTokenizer.parseTokensViaAST(in: text, registry: registry).filter { $0.kind == .codeBlock || $0.kind == .inlineCode }
return isInsideCodeBlock(range: range, codeTokens: codeTokens)
}
static func isInsideCodeBlock(location: Int, in text: String, registry: ExtensionRegistry = .empty) -> Bool {
isInsideCodeBlock(range: NSRange(location: location, length: 0), in: text, registry: registry)
}
/// Fast: uses pre-parsed tokens
static func isInsideCodeBlock(range: NSRange, codeTokens: [MarkdownToken]) -> Bool {
guard !codeTokens.isEmpty else { return false }
for token in codeTokens {
let start = token.range.location
let end = start + token.range.length
if range.length == 0 {
if range.location >= start && range.location <= end { return true }
} else {
if range.location < end && range.location + range.length > start { return true }
}
}
return false
}
static func isInsideCodeBlock(location: Int, codeTokens: [MarkdownToken]) -> Bool {
isInsideCodeBlock(range: NSRange(location: location, length: 0), codeTokens: codeTokens)
}
/// Count of non-overlapping ``` occurrences, scanning left to right
/// exactly `components(separatedBy: "```").count - 1`, but as one UTF-16
/// pass with no substring-array allocation.
static func tripleBacktickCount(in text: NSString) -> Int {
let length = text.length
guard length >= 3 else { return 0 }
var buffer = [unichar](repeating: 0, count: length)
text.getCharacters(&buffer, range: NSRange(location: 0, length: length))
var count = 0
var i = 0
while i + 2 < length { // i can reach length - 3
if buffer[i] == 0x60, buffer[i + 1] == 0x60, buffer[i + 2] == 0x60 {
count += 1
i += 3
} else {
i += 1
}
}
return count
}
/// The ``` count contributed by the backtick runs that intersect `range`.
/// The window expands through adjacent backticks on both sides, so every
/// run inside it is a MAXIMAL run of the whole text and the greedy global
/// count is exactly Σ floor(runLen/3) over maximal runs, which makes these
/// window counts composable: full = fullBefore windowBefore + windowAfter.
static func backtickWindowCount(in text: NSString, around range: NSRange) -> Int {
let length = text.length
guard range.location >= 0, NSMaxRange(range) <= length else { return 0 }
var lo = range.location
while lo > 0, text.character(at: lo - 1) == 0x60 { lo -= 1 }
var hi = NSMaxRange(range)
while hi < length, text.character(at: hi) == 0x60 { hi += 1 }
var count = 0
var run = 0
var i = lo
while i < hi {
if text.character(at: i) == 0x60 {
run += 1
} else {
count += run / 3
run = 0
}
i += 1
}
return count + run / 3
}
// MARK: - LaTeX Detection
/// Slow: parses tokens each call. The registry matters here: a registered
/// extension (e.g. `==$==$`) can claim characters that would otherwise
/// pair into a phantom `$$`, so parsing with `.empty` diverges from the
/// styled document.
static func isInsideLatex(location: Int, in text: String, registry: ExtensionRegistry = .empty) -> Bool {
let tokens = MarkdownTokenizer.parseTokensViaAST(in: text, registry: registry)
let latexTokens = tokens.filter { $0.kind == .inlineLatex || $0.kind == .blockLatex }
return isInsideLatex(location: location, latexTokens: latexTokens)
}
static func isInsideLatex(location: Int, latexTokens: [MarkdownToken]) -> Bool {
guard !latexTokens.isEmpty else { return false }
for token in latexTokens {
let start = token.range.location
let end = start + token.range.length
if location >= start && location <= end { return true }
}
return false
}
}
@@ -0,0 +1,83 @@
//
// MarkdownToken.swift
// MarkdownEngine
//
// Created by Luca Chen on 18.02.26.
//
// Defines the basic Markdown building blocks the editor works with (bold,
// links, code, LaTeX, etc.), plus shared text attributes.
import AppKit
import Foundation
extension NSAttributedString.Key {
public static let wikiLinkID = NSAttributedString.Key("NodeLinkID")
public static let taskCheckbox = NSAttributedString.Key("TaskCheckbox")
}
enum MarkdownTokenKind: Equatable {
case italic
case boldItalic
case bold
case link
case wikiLink
case heading
/// One blockquote line; `markerRanges[0]` is the `>` run, nesting = count of `>`.
case blockquote
case codeBlock
case inlineCode
case blockLatex
case inlineLatex
case imageEmbed
case imageLink
case table
/// A CommonMark backslash escape; marker is the `\`, content the escaped literal char.
case backslashEscape
/// A span contributed by a registered `MarkdownExtension`,
/// carrying the extension's id (e.g. `.extensionSpan("highlight")`).
case extensionSpan(String)
/// A fenced block contributed by a registered `MarkdownExtension`,
/// carrying the extension's id (e.g. `.extensionBlock("container")`).
case extensionBlock(String)
}
struct MarkdownToken {
let kind: MarkdownTokenKind
let range: NSRange
let contentRange: NSRange
let markerRanges: [NSRange]
}
extension MarkdownToken {
func standaloneParagraphRange(in text: NSString) -> NSRange? {
let paragraphRange = text.paragraphRange(for: range)
let paragraphText = text.substring(with: paragraphRange) as NSString
let tokenRelativeRange = NSRange(
location: range.location - paragraphRange.location,
length: range.length
)
let mutableParagraph = paragraphText.mutableCopy() as! NSMutableString
mutableParagraph.replaceCharacters(in: tokenRelativeRange, with: "")
return mutableParagraph.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? paragraphRange : nil
}
func containsSelectionOrStandaloneParagraph(_ selectionLocation: Int, in text: NSString) -> Bool {
let start = range.location
let end = NSMaxRange(range) - 1
if selectionLocation >= start && selectionLocation <= end {
return true
}
guard let paragraphRange = standaloneParagraphRange(in: text) else {
return false
}
let paragraphEnd = NSMaxRange(paragraphRange)
// Reveal source when caret is at document end right after the image, unless that line ends in a newline.
let endsWithNewline = paragraphEnd > paragraphRange.location
&& (text.character(at: paragraphEnd - 1) == 0x0A || text.character(at: paragraphEnd - 1) == 0x0D)
let isAtLastParagraphEnd = selectionLocation == text.length
&& paragraphEnd == text.length && !endsWithNewline
return (selectionLocation >= paragraphRange.location && selectionLocation < paragraphEnd)
|| isAtLastParagraphEnd
}
}

Some files were not shown because too many files have changed in this diff Show More