32 Commits
Author SHA1 Message Date
Puranjay Savar Mattas 7c083b0ba5 fix: silence unused withLock result warning in OutlineImageProvider
lock.withLock { inFlight.remove(url) } inside the defer returned
Set.remove's String? result unused - withLock mirrors its closure's
return type, so an unused Set.remove leaked through as an unused
withLock result. Explicitly discard it inside the closure instead.
2026-08-21 04:40:33 +01:00
Puranjay Savar Mattas d0b6b35bda fix: lower project file format from objectVersion 110 to 77
Xcode Cloud (running a stable, non-beta Xcode) rejected the project
outright: "cannot be opened because it is in a future Xcode project
file format (110)". 110 is whatever the local beta Xcode (this whole
project has been developed against Xcode 27 beta) bumps the format to
on save - not something the project actually needs.

The project itself already had the answer: preferredProjectObjectVersion
= 77 was already present, Xcode's own note that 77 is the actual
minimum format required (it's what introduced fileSystemSynchronizedGroups,
which this project uses - the mechanism that's been letting new source
files just get picked up without explicit PBXFileReference entries all
session). Set objectVersion to match.

Opening/saving this project again in the local beta Xcode will very
likely bump objectVersion back to 110 silently - worth checking this
value before any future push if that happens.
2026-08-21 04:35:43 +01:00
Puranjay Savar Mattas 289e3062a1 chore: bump build number to 3 (0.1.0 build 3)
CURRENT_PROJECT_VERSION 2 -> 3 for the Outpost app target only
(Debug + Release), test targets left alone. OutpostVersion.swift's
buildNumber fallback and doc-comment updated to match.
2026-08-21 03:41:03 +01:00
Puranjay Savar Mattas 71070daf86 chore: bump build number to 2 (0.1.0 build 2)
CURRENT_PROJECT_VERSION 1 -> 2 for the Outpost app target (Debug +
Release only, test targets left alone, same scope as the
MARKETING_VERSION bump). OutpostVersion.swift's buildNumber fallback
and doc-comment example updated to match.
2026-08-21 02:55:19 +01:00
Puranjay Savar Mattas f1cc8607b0 fix: lower macOS deployment target from 27.0 to 14.0
MACOSX_DEPLOYMENT_TARGET was 27.0 across all 6 build configs (app +
both test targets) - an artifact of Xcode defaulting it to match the
beta SDK this is developed against, not an actual code requirement.
At 27.0 the app would only install for people running a macOS beta
that doesn't even exist for the general public yet.

14.0 is the real floor, already documented as the target in
CLAUDE.md ("macOS 14+ minimum, needed for mature AttributedString/
TextKit 2 APIs") and required independently by @Observable/SwiftData,
which are used throughout (SessionStore, StarStore, TipJarStore,
OfflineCacheStore, APIFailureCenter, etc.). Everything else added
this session - StoreKit 2, ImageRenderer, requestReview, CryptoKit -
is available well before 14, so nothing pushes the floor higher.

README's "macOS 27+" end-user requirement corrected to 14+ to match
(the "Xcode 27+" line just above it is a separate, still-accurate
build-from-source requirement, left alone).

Not compiler-verified against the 14.0 SDK - this project has never
actually been built with this deployment target before (always 27.0
during this session's development), so a real Xcode build now may
surface "only available in macOS 15+/26+" errors on any API used
without realizing it needed newer than 14. Needs an actual build
before submission, not just a lower number in the project file.
2026-08-21 02:53:26 +01:00
Puranjay Savar Mattas 48377668b0 Merge pull request 'v0.1.0 stability, security, and App Store readiness' (#14) from chore/v0.1.0-stability-audit into main
Reviewed-on: #14
2026-08-21 02:02:23 +01:00
Puranjay Savar Mattas 8db8e985a7 release: drop alpha framing, TestFlight badge -> Mac App Store, bump to 0.1.0
App Store link: https://apps.apple.com/us/app/outpost-for-outline/id6802736230

README's TestFlight badge replaced with Apple's official "Download on
the Mac App Store" badge (docs/assets/mac-app-store-badge.svg, black
lockup, from Apple's official marketing badge kit), linked to the
real App Store listing. "Early alpha" language dropped from README,
CONTRIBUTING.md, SECURITY.md, and both Gitea issue templates -
these are now "0.1.x"/"early" rather than "0.0.x"/"alpha", matching
the actual release.

OutpostVersion.releaseStage is now "" instead of "ALPHA" - About page
and the Settings sidebar footer both read through this single source
of truth, so this alone drops the "-ALPHA" suffix everywhere it was
shown without touching either call site.

MARKETING_VERSION bumped 0.0.4 -> 0.1.0 for the Outpost target
(Debug + Release) - left OutpostTests/OutpostUITests' MARKETING_VERSION
alone, that's just Xcode's unrelated template default for test
bundles, never shown to a user.

Not compiler-verified - Outpost app target has no CLI build path.
2026-08-21 01:58:39 +01:00
Puranjay Savar Mattas b8ce517b60 perf: stop recomputing derived state on every SwiftUI render
An audit for v0.1.0 turned up the same pattern in four places: a
computed property doing real work (filtering/sorting/scoring a
collection), read multiple times per render including from
unrelated state changes (selection, hover, scroll), so the work
reran far more often than the underlying data actually changed.
Converted each to a @State cache recomputed only via onChange of its
real inputs:

- DocumentSearchSheet: matchingLineIndices re-scanned the whole
  document per access, read once per visible row plus twice more in
  the header/step logic - O(n^2) case-insensitive scan per frame on
  a large document. Also split into an ordered array (for
  currentMatchIndex/stepping) plus a parallel Set for the per-row
  highlight check, which was an O(k) linear .contains before.
- CollectionDocumentsOutline: tree rebuilt the whole dictionary-
  grouped, recursively-sorted document tree on every body
  evaluation, not just when documents/sortOption actually changed.
- CommandPaletteView: results re-scored and re-sorted the entire
  index (up to the whole local workspace cache in Full Workspace
  mode) on every render, including ones from selectedIndex moving as
  arrow keys are pressed.
- CollectionOverviewView: sortedDocuments re-sorted on every render;
  same pattern, smaller blast radius (capped at 100 docs).

Also:
- HomeViewModel.fetchPinnedThrowing fetched each pinned document
  serially in a for loop (one round trip at a time) - switched to a
  TaskGroup so latency doesn't scale with pin count, results
  reordered back to pins.list's own order since task completion
  order isn't submission order.
- AvatarCropperView.renderFinalImage ran ImageRenderer + JPEG
  compression synchronously on the main actor from the "Use Photo"
  button tap. ImageRenderer itself has to stay on the main actor (it
  captures live SwiftUI state), but JPEG compression on the already-
  rendered bitmap has no SwiftUI dependency left - hopped that part
  to a detached Task via tiffRepresentation (plain Data, unlike
  NSImage itself isn't Sendable) so it doesn't hitch the UI.

No crash risks or retain cycles found in the same audit (no try!/
as!, force-unwraps essentially absent outside a hardcoded URL
literal, weak self already used where it matters) - this is purely
the perf half of the findings.

Not compiler-verified - Outpost app target has no CLI build path.
2026-08-21 01:48:20 +01:00
Puranjay Savar Mattas 5de445daa5 feat(app): rewire account footer menu - App Store feedback, support link
Center-aligned TipJarView's "Support Outpost" heading and thank-you/
error text to match the rest of AboutInfoView (was VStack(alignment:
.leading), out of place among everything else there being centered).

AccountFooter's Documentation/API Documentation/Changelog links
pointed at Outpost's own repo or the signed-in Outline server's own
/developers page - not actually useful here, removed along with the
now-unused repositoryURL/issuesURL/apiDocumentationURL. Send Us
Feedback and Report a Bug (previously both just opening the Gitea
issues page) collapsed into one "Leave Us Feedback" wired to Apple's
native requestReview() prompt - there's no separate Apple-native
channel for "bug" vs "feedback", so one button covers both.

Added "Support Outpost" near the bottom of the same menu, alongside
Profile/Settings (same visual weight, not pinned to the top) - opens
Settings -> About, same navigation the app-menu's "About Outpost"
command already uses.

Also checking in the shared Xcode scheme (previously untracked/
nonexistent) now that it references Configuration.storekit, so the
StoreKit testing setup travels with the repo instead of being
machine-local.

Not compiler-verified - Outpost app target has no CLI build path.
2026-08-21 01:38:11 +01:00
Puranjay Savar Mattas 8ca5735019 feat(app): tip jar (StoreKit consumables) in Settings -> About
Four consumable IAP tiers - Small (0.99), Medium (2.99), Large (4.99),
Generous (9.99), product ids com.psmattas.OutpostApp.tip.{small,
medium,large,generous}. TipJarStore loads them via
Product.products(for:), purchases via product.purchase(), finishes
the transaction immediately on success - consumables have no
entitlement to persist or restore (a tip doesn't unlock anything), so
there's none of the Transaction.currentEntitlements restore-on-launch
logic a real purchase would need. TipJarView shows one button per
tier (price + name, StoreKit's own localized display strings) with a
"Thank you!" after success or a plain message on failure - no manual
retry button, tapping a tier again just re-attempts.

Wired into AboutInfoView between the source link and the copyright
line.

Added Configuration.storekit (4 products matching the IDs above) for
local testing in Xcode without needing real App Store Connect
products yet - enable it via Edit Scheme -> Run/Preview -> Options ->
StoreKit Configuration. Before actually shipping, the same 4 product
IDs need to exist for real in App Store Connect (Consumable type,
matching reference names) - the local file doesn't create anything
there.

Not compiler-verified - Outpost app target has no CLI build path.
2026-08-21 01:23:48 +01:00
Puranjay Savar Mattas f624ce6c9f feat(app): clear the cache encryption key on sign-out, note it in Settings
SessionStore.signOut() now clears the offline cache's Keychain-stored
encryption key alongside the API token, and wipes the cache/pending-
write storage itself (CachingOutlineAPIClient.clearEverythingForSignOut())
before doing so - so a previous account's cached content isn't sitting
there readable (even in principle, if the on-disk rows survive) by
whoever signs in next on the same machine. signOut() is async now to
do this properly instead of firing a detached Task; both call sites
(the logout confirmation dialog, delete-account) updated.

Settings -> Offline & Sync now states plainly that the local cache is
encrypted at rest and cleared on log out - not compiler-verified
(Outpost app target has no CLI build path), worth a look in Xcode.
2026-08-21 01:15:26 +01:00
Puranjay Savar Mattas 20baab78c0 feat(outlinekit): encrypt the offline cache at rest
Both CachedPayload and PendingOperation only ever stored their
payload as plain JSON on disk (SwiftData/SQLite, no encryption of its
own) - readable by anyone with access to the logged-in session, per
the earlier discussion on where this cache lives. Adds AES-GCM
encryption at the one place raw bytes cross into/out of
OfflineCacheStore (CachingOutlineAPIClient, which already owns
encode/decode) - OfflineCacheStore itself stays a dumb opaque-blob
store, since its key/id/kind columns can't be encrypted without
breaking the #Predicate queries built against them.

Key management (KeychainCacheEncryptionKeyStore, mirrors
KeychainTokenStore exactly): a random 256-bit key, generated once and
Keychain-stored, not derived from anything guessable. It doesn't need
deriving to survive an uninstall/reinstall either - Keychain items
are scoped to the app's code signature, not its on-disk presence, so
a reinstall of the same app regains access to the same key
automatically (same reason a saved API token already survives a
reinstall today). If the on-disk cache also happens to survive
(dragging the .app to the Trash doesn't clean ~/Library/Containers),
a reinstall can still read it.

A row written before this shipped (still plaintext) or encrypted
under a since-cleared key just fails to decrypt and is treated as a
cache miss - same as any other decode failure, so it silently
refetches and re-caches encrypted rather than crashing. No explicit
migration needed.

Also: OfflineCacheStore.clearEverything() wipes both the cache AND
the pending write queue (clearAll(), used by Settings' "Clear All
Cache", still only touches the cache - it shouldn't silently discard
someone's unsynced edits). Exposed as
CachingOutlineAPIClient.clearEverythingForSignOut(), for sign-out to
use alongside clearing the key.

CacheEncryptionKeyStoring is a protocol (like TokenStoring) so tests
never touch the real Keychain - existing CachingOutlineAPIClientTests
now inject an in-memory StaticCacheEncryptionKeyStore. 8 new tests
(retry/failure-log tests from the previous commit plus 3 new ones
here: ciphertext isn't plaintext JSON, a cleared key makes old rows
unreadable, clearEverythingForSignOut wipes both tables). 95/95
passing.
2026-08-21 01:15:15 +01:00
Puranjay Savar Mattas 1580510cfb fix(engine): silence PERF logs by default, fix "modifying state during view update"
PerfTrace was opt-out (MD_PERF=0 to silence) so every Debug build
printed a PERF line per keystroke unconditionally. Flipped to opt-in
(MD_PERF=1 to enable) - still fully available for future perf work,
just quiet by default.

"Modifying state during view update": onCodeBlockSelectionChange,
onSelectedTextChange, and onCommentAnchorRectsChange could all fire
synchronously from inside NativeTextViewWrapper.updateNSView's own
call stack - a programmatic edit (pendingInlineReplacement/
pendingTextInsertion/pendingTextRangeReplacement) re-enters
textViewDidChangeSelection/textDidChange synchronously (AppKit resets
selection on edit), which is still a SwiftUI view update in progress.
Calling straight into the embedder's @State setter there is exactly
what trips the warning. Routed all three through new
fireCodeBlockSelectionChange/fireSelectedTextChange/
fireCommentAnchorRectsChange helpers on the coordinator that defer
one runloop tick via DispatchQueue.main.async - same technique
NativeTextViewWrapper already uses to clear its own pending*
bindings, just centralized instead of ad-hoc per call site.

322/322 tests passing.
2026-08-21 00:56:37 +01:00
Puranjay Savar Mattas 5b876d7085 chore: remove Keyboard Shortcuts menu item and window
No real app-specific shortcuts existed - the panel only listed
Return/⌘,/⌘W/⌘Q (standard macOS conventions everyone already knows,
and it didn't even include the app's actual shortcuts like ⌘K for
Command Palette). Removed the menu item from AccountFooter's bottom
menu, the Window scene that hosted it, and KeyboardShortcutsView
itself plus WindowConfigurator.swift (disablesFullScreen() had no
other caller once this was gone).
2026-08-21 00:50:39 +01:00
Puranjay Savar Mattas 5d5cda9cea fix: RetryPolicy.withRetry closure label, missing Foundation import
RetryPolicy.withRetry's operation param wasn't anonymous (_), so the
14 Outpost call sites that pass the closure in parens - RetryPolicy.
withRetry({ ... }) - rather than as a trailing closure failed to
compile ("Missing argument label 'operation:'" cascading into
nonsense errors about maxAttempts). OutlineKit's own internal call
sites all happened to use trailing-closure syntax, so this only
showed up once the app target actually got compiled. One-line fix at
the declaration (_ operation:) instead of touching every call site -
trailing-closure calls are unaffected either way.

APIFailureCenter.swift used Date/TimeInterval/URL/URLComponents/
URLQueryItem while only importing Observation and OutlineKit -
missing import Foundation. Other new files in the same commit escaped
this because they import SwiftUI, which re-exports Foundation
transitively; this one didn't.

OutlineKit: 92/92 still passing.
2026-08-21 00:48:17 +01:00
Puranjay Savar Mattas 7369b46b87 feat(app): auto-retry every remaining try?-swallowed API call, add repeated-failure banner
Second half of the silent-failure fix - OutlineKit's RetryPolicy and
CachingOutlineAPIClient tracking landed in 512c6d2, this wires the
rest of the app onto it.

Every bare `try? await apiClient.X(...)` that bypasses
CachingOutlineAPIClient's own caching (listPins, listSubscriptions,
listViews, listStars, documentUsers, listUsers, listComments,
currentUser, installationInfo, authInfo, deleteAttachment - the
"pass-through" methods) now goes through RetryPolicy.withRetry first,
so a single transient blip gets absorbed automatically instead of
just returning nil. Calls that were already routed through
CachingOutlineAPIClient's cached-read path (documentInfo,
listDocuments, listCollections, etc.) are left alone - they picked up
retry and repeated-failure tracking for free from the previous commit
and wrapping them again would've just retried twice.

New: APIFailureCenter (Root/) turns CachingOutlineAPIClient's
repeatedFailureSummaries() into a banner - RootView polls it every
30s while signed in (cheap, no network call of its own) and shows
RepeatedFailureBanner for whichever category is currently past the
threshold. No manual "Retry" button - the retries already happened
automatically before the banner ever appears, so the only actions are
Report (opens a prefilled Gitea issue - category, generic error
description, app/OS version, no document content or server URL) and
dismiss, which starts a 15-minute cooldown so a still-flaky operation
doesn't immediately pop the same banner back up.

Not compiler-verified - the Outpost app target has no CLI build path,
only OutlineKit does (92/92 passing as of the previous commit, no
OutlineKit changes here).
2026-08-21 00:45:58 +01:00
Puranjay Savar Mattas 512c6d22bf feat(outlinekit): auto-retry with backoff and repeated-failure tracking
Foundation for turning the app's try?-swallowed API failures (see the
pins bug) into something self-diagnosing instead of silent, without
a manual "Retry" button nagging the user for every blip.

RetryPolicy.withRetry wraps a call with exponential backoff, but only
for OutlineAPIError.transport - a decode/auth/server error will look
identical on a second try, so those fail immediately instead of
burning the cooldown window. CachingOutlineAPIClient now runs every
live call (both the cached-read path and the queueable-write path)
through it, and keeps a per-category sliding-window failure log:
repeatedFailureSummaries() surfaces a category only once it's failed
3+ times in 5 minutes with a structural (non-transport) error -
plain connectivity loss is deliberately excluded since that already
has its own offline UI elsewhere, and logging it here too would just
be a redundant second banner every time Wi-Fi drops.

Pull-based (polled), not push - this actor has no UI dependency of
its own, so the Outpost-side banner reads this periodically instead
of the client taking a callback. Categories are coarse (documents,
collections, pins, subscriptions, stars, drafts, etc.) and the
summaries carry no document content or server URL, only a generic
error description - safe to show a user or attach to a bug report
as-is.

10 new tests (RetryPolicyTests + CachingOutlineAPIClientTests),
92/92 passing overall.
2026-08-21 00:41:26 +01:00
Puranjay Savar Mattas aa02153b5d chore: hide unbuilt Workspace section and Advanced coming-soon rows
App Store review won't accept a settings section that's just "Coming
Soon" placeholders. Workspace (all 14 sub-sections: details,
authentication, security, ai, members, groups, templates, emojis,
applications, shared, links, webhooks, importData, exportData) had
zero built content, so it's filtered out of SettingsSidebarList
entirely rather than shown with a Coming Soon badge - via a new
visibleCategories helper (categories with at least one isImplemented
section), not by touching the SettingsSection enum itself, so nothing
else that switches over it needs to change.

Advanced's three comingSoonRow placeholders (Export All Data,
Developer Diagnostics, Reset Local Database) are commented out the
same way, comingSoonRow() itself kept (unused for now) so re-enabling
either is a one-line job once real content lands. Both marked TODO.
2026-08-21 00:34:53 +01:00
Puranjay Savar Mattas 0393fe267e Merge pull request 'feat: Comments, drafts/publish, code block tooling, and editing preferences' (#13) from feature/document-editing into main
Reviewed-on: #13
2026-08-21 00:04:52 +01:00
Puranjay Savar Mattas c36c70382b redesign: pinned document cards on Home
PinnedDocumentCard was a squat single-line HStack chip (12/9 padding,
cornerRadius 8, flat tertiary fill) - in a grid of several it read as a
row of tab/segmented-control buttons rather than distinct documents.
Restyled to the same tall VStack card shape as DocumentCardView (the
tab grids below it): icon row, 2-line headline title, relative-date
caption, subtle border. The one thing that still marks these as
pinned vs. the grids below is an accent-filled circular pin badge in
the corner, replacing the old plain gray pin glyph.

Also wired the new pointerCursorOnHover() modifier onto the pinned
card buttons, same as every other clickable row in the sidebar/lists.
2026-08-20 22:26:21 +01:00
Puranjay Savar Mattas f01c2b9467 feat: pointer cursor preference
App-side chrome: new "Pointer Cursor" toggle (Settings -> Editor,
outpost.pointerCursorEnabled, on by default) driving a shared
.pointerCursorOnHover() view modifier (push/pop NSCursor.pointingHand,
macOS-only, no-op on iOS) wired onto every clickable sidebar/list row:
collection tree rows + disclosure chevron, flat collection list,
document outline rows, collection-overview tab bar + document/search
rows, global search results, command palette rows.

In-document link hover: MarkdownEditorConfiguration gets a new
pointerCursorOverLinksWhileEditing flag (default true). Read-only mode
already showed a pointing hand over links unconditionally; editable
mode never did (I-beam only) until now - applyReadOnlyCursor in
NativeTextView+CursorRects.swift now applies the same over any .link
range while editing too, gated by the flag. Wiki links already carry
.link alongside their own custom .wikiLinkID, so they're covered with
no extra work.

Closes out the last item from the original preferences-wiring scope.
2026-08-20 22:21:36 +01:00
Puranjay Savar Mattas 283186b28f feat: code block language picker
Top-left pill overlay on every code block (opposite corner from the
engine's own copy button) showing the current language; tapping opens
a curated ~20-language menu that rewrites just the fence line,
content untouched.

New engine mechanism to support it: CodeBlockSelection now exposes
fenceRange (the opening ```lang line's range, straight from the
tokenizer's own markerRanges[0] - was already computed, just never
exposed) plus a generic TextRangeReplacementRequest/
pendingTextRangeReplacement, since the existing pendingTextInsertion
only replaces at the current caret/selection, not an arbitrary
already-known range like a fence line the user isn't necessarily
positioned at.

Horizontal scroll for long lines investigated and dropped, per
explicit instruction after weighing it: the only viable path (a real,
selectable/editable independently-scrolling text region) needs an
embedded NSTextAttachmentViewProvider-backed NSTextView per code
block, which would split selection/copy/undo into two contexts
(the code block's own vs. the surrounding document's) instead of one
unified editing surface - a different regression, not a clean win.
Code blocks keep wrapping.
2026-08-20 22:07:48 +01:00
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
213 changed files with 29745 additions and 314 deletions
+1 -1
View File
@@ -6,7 +6,7 @@ body:
- type: markdown
attributes:
value: |
Outpost is early alpha — please check the version in About (or your build's commit) is current before filing, and mention which platform (macOS only, for now) and OS version you're on.
Outpost is early — please check the version in About (or your build's commit) is current before filing, and mention which platform (macOS only, for now) and OS version you're on.
- type: input
id: summary
attributes:
+1 -1
View File
@@ -6,7 +6,7 @@ body:
- type: markdown
attributes:
value: |
Outpost is early alpha and tracking Outline's own web app for parity (see [`CLAUDE.md`](../../CLAUDE.md) for the phased build order) — a request that's "just do what web Outline does" is easier to act on than a net-new idea.
Outpost is early and tracking Outline's own web app for parity (see [`CLAUDE.md`](../../CLAUDE.md) for the phased build order) — a request that's "just do what web Outline does" is easier to act on than a net-new idea.
- type: input
id: summary
attributes:
-3
View File
@@ -1,6 +1,3 @@
[submodule "docs/reference/outline-openapi"]
path = docs/reference/outline-openapi
url = https://github.com/outline/openapi.git
[submodule "Vendor/swift-markdown-engine"]
path = Vendor/swift-markdown-engine
url = https://github.com/nodes-app/swift-markdown-engine.git
+1 -1
View File
@@ -2,7 +2,7 @@
Thank you for contributing. Please read this guide before opening issues or PRs.
Outpost is early alpha (`0.0.x`) — expect the codebase and conventions here to shift as Phase 1 (see [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md)) settles. If something in this guide is stale, flag it.
Outpost is early (`0.1.x`) — expect the codebase and conventions here to keep evolving as later phases (see [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md)) land. If something in this guide is stale, flag it.
---
@@ -0,0 +1,16 @@
import Foundation
import CryptoKit
/// Boundary over wherever the offline cache's symmetric encryption key
/// lives. Mirrors `TokenStoring` same reasoning, different secret.
public protocol CacheEncryptionKeyStoring: Sendable {
/// Returns the existing key, generating and persisting a new random one
/// on first use if none exists yet.
func key() throws -> SymmetricKey
/// Called on sign-out. Anything still encrypted with the cleared key
/// becomes permanently unreadable that's the point, not a bug: the
/// next person signed in on this machine shouldn't be able to read a
/// previous account's cached content just because the on-disk rows
/// happen to still be there.
func clear() throws
}
@@ -0,0 +1,24 @@
import Foundation
import CryptoKit
/// AES-GCM at the boundary where `CachingOutlineAPIClient` writes to/reads
/// from `OfflineCacheStore`. Encryption lives at this layer rather than
/// inside `OfflineCacheStore` itself that stays a dumb opaque-blob store;
/// its `key`/`id`/`kind` columns can't be encrypted without breaking the
/// `#Predicate` queries built directly against them.
enum CachePayloadCryptor {
enum CryptoError: Error {
case sealingFailed
}
static func encrypt(_ data: Data, key: SymmetricKey) throws -> Data {
let sealedBox = try AES.GCM.seal(data, using: key)
guard let combined = sealedBox.combined else { throw CryptoError.sealingFailed }
return combined
}
static func decrypt(_ data: Data, key: SymmetricKey) throws -> Data {
let sealedBox = try AES.GCM.SealedBox(combined: data)
return try AES.GCM.open(sealedBox, using: key)
}
}
@@ -1,4 +1,5 @@
import Foundation
import CryptoKit
/// Decorates `LiveOutlineAPIClient` (or any `OutlineAPIClient`) with offline
/// support at the existing protocol boundary, so no view model needs to know
@@ -36,11 +37,33 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
private let encoder: JSONEncoder
private let decoder: JSONDecoder
private let keyEncoder: JSONEncoder
private let encryptionKeyStore: CacheEncryptionKeyStoring
/// Resolved lazily and kept for this instance's lifetime a fresh
/// instance is created on every sign-in/sign-out anyway (see
/// `SessionStore.makeAPIClient`), so there's no staleness risk, just
/// one fewer Keychain round-trip per cache read/write.
private var cachedEncryptionKey: SymmetricKey?
public init(live: OutlineAPIClient, cache: OfflineCacheStore, defaults: UserDefaults = .standard) {
/// Recent failure timestamps per category see `recordFailure` /
/// `repeatedFailureSummaries()`. A category only shows up there once it's
/// failed `failureThreshold` times within `failureWindow`; a single
/// transient blip (which `RetryPolicy` already tries to absorb) never
/// reaches this at all.
private var failureLog: [String: [Date]] = [:]
private var lastFailureMessage: [String: String] = [:]
private let failureWindow: TimeInterval = 300
private let failureThreshold: Int = 3
public init(
live: OutlineAPIClient,
cache: OfflineCacheStore,
defaults: UserDefaults = .standard,
encryptionKeyStore: CacheEncryptionKeyStoring = KeychainCacheEncryptionKeyStore()
) {
self.live = live
self.cache = cache
self.defaults = defaults
self.encryptionKeyStore = encryptionKeyStore
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
@@ -62,7 +85,7 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
// MARK: - Cached reads
public func documentInfo(id: String) async throws -> OutlineDocument {
try await cachedFetch(key: "document:\(id)") { try await self.live.documentInfo(id: id) }
try await cachedFetch(key: "document:\(id)", category: "document") { try await self.live.documentInfo(id: id) }
}
public func listDocuments(
@@ -72,7 +95,7 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
limit: Int
) async throws -> [OutlineDocument] {
let key = "documents:\(collectionId ?? "-"):\(parentDocumentId ?? "-"):\(offset):\(limit)"
return try await cachedFetch(key: key) {
return try await cachedFetch(key: key, category: "documents") {
try await self.live.listDocuments(
collectionId: collectionId,
parentDocumentId: parentDocumentId,
@@ -83,23 +106,29 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
}
public func documentsList(_ request: DocumentsListRequest) async throws -> [OutlineDocument] {
try await cachedFetch(key: requestKey("documentsList", request)) { try await self.live.documentsList(request) }
try await cachedFetch(key: requestKey("documentsList", request), category: "documents") { try await self.live.documentsList(request) }
}
public func listViewedDocuments(offset: Int, limit: Int) async throws -> [OutlineDocument] {
try await cachedFetch(key: "documentsViewed:\(offset):\(limit)") {
try await cachedFetch(key: "documentsViewed:\(offset):\(limit)", category: "documents-viewed") {
try await self.live.listViewedDocuments(offset: offset, limit: limit)
}
}
public func listDrafts(_ request: ListDraftsRequest) async throws -> [OutlineDocument] {
try await cachedFetch(key: "documentsDrafts:\(request.offset):\(request.limit)", category: "drafts") {
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 cachedFetch(key: "collections:\(offset):\(limit)", category: "collections") {
try await self.live.listCollections(offset: offset, limit: limit)
}
}
public func collectionInfo(id: String) async throws -> OutlineCollection {
try await cachedFetch(key: "collection:\(id)") { try await self.live.collectionInfo(id: id) }
try await cachedFetch(key: "collection:\(id)", category: "collections") { try await self.live.collectionInfo(id: id) }
}
// MARK: - Queueable writes
@@ -115,10 +144,12 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
public func createDocument(_ request: CreateDocumentRequest) async throws -> OutlineDocument {
if !isManualOfflineModeEnabled {
do {
let result = try await live.createDocument(request)
let result = try await RetryPolicy.withRetry { try await self.live.createDocument(request) }
recordSuccess(category: "documents-write")
await cacheDocument(result)
return result
} catch {
recordWriteFailureIfStructural(category: "documents-write", error)
return await queueDocumentCreate(request)
}
}
@@ -128,10 +159,12 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
public func updateDocument(_ request: UpdateDocumentRequest) async throws -> OutlineDocument {
if !isManualOfflineModeEnabled {
do {
let result = try await live.updateDocument(request)
let result = try await RetryPolicy.withRetry { try await self.live.updateDocument(request) }
recordSuccess(category: "documents-write")
await cacheDocument(result)
return result
} catch {
recordWriteFailureIfStructural(category: "documents-write", error)
return try await queueDocumentUpdate(request, dueTo: error)
}
}
@@ -141,10 +174,12 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
public func updateCollection(_ request: UpdateCollectionRequest) async throws -> OutlineCollection {
if !isManualOfflineModeEnabled {
do {
let result = try await live.updateCollection(request)
let result = try await RetryPolicy.withRetry { try await self.live.updateCollection(request) }
recordSuccess(category: "collections-write")
await cacheCollection(result)
return result
} catch {
recordWriteFailureIfStructural(category: "collections-write", error)
return try await queueCollectionUpdate(request, dueTo: error)
}
}
@@ -153,7 +188,14 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
public func createPin(_ request: CreatePinRequest) async throws -> OutlinePin {
if !isManualOfflineModeEnabled {
do { return try await live.createPin(request) } catch { return await queuePinCreate(request) }
do {
let result = try await RetryPolicy.withRetry { try await self.live.createPin(request) }
recordSuccess(category: "pins")
return result
} catch {
recordWriteFailureIfStructural(category: "pins", error)
return await queuePinCreate(request)
}
}
return await queuePinCreate(request)
}
@@ -162,9 +204,11 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
if await cancelIfNeverSynced(id: id) { return }
if !isManualOfflineModeEnabled {
do {
try await live.deletePin(id: id)
try await RetryPolicy.withRetry { try await self.live.deletePin(id: id) }
recordSuccess(category: "pins")
return
} catch {
recordWriteFailureIfStructural(category: "pins", error)
await enqueue(.deletePin, payload: IDPayload(id: id), id: "delete-pin-\(id)")
return
}
@@ -174,7 +218,14 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
public func createSubscription(_ request: CreateSubscriptionRequest) async throws -> OutlineSubscription {
if !isManualOfflineModeEnabled {
do { return try await live.createSubscription(request) } catch { return await queueSubscriptionCreate(request) }
do {
let result = try await RetryPolicy.withRetry { try await self.live.createSubscription(request) }
recordSuccess(category: "subscriptions")
return result
} catch {
recordWriteFailureIfStructural(category: "subscriptions", error)
return await queueSubscriptionCreate(request)
}
}
return await queueSubscriptionCreate(request)
}
@@ -183,9 +234,11 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
if await cancelIfNeverSynced(id: id) { return }
if !isManualOfflineModeEnabled {
do {
try await live.deleteSubscription(id: id)
try await RetryPolicy.withRetry { try await self.live.deleteSubscription(id: id) }
recordSuccess(category: "subscriptions")
return
} catch {
recordWriteFailureIfStructural(category: "subscriptions", error)
await enqueue(.deleteSubscription, payload: IDPayload(id: id), id: "delete-subscription-\(id)")
return
}
@@ -195,14 +248,28 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
public func starDocument(_ request: StarDocumentRequest) async throws -> OutlineStar {
if !isManualOfflineModeEnabled {
do { return try await live.starDocument(request) } catch { return await queueStarDocumentCreate(request) }
do {
let result = try await RetryPolicy.withRetry { try await self.live.starDocument(request) }
recordSuccess(category: "stars")
return result
} catch {
recordWriteFailureIfStructural(category: "stars", error)
return await queueStarDocumentCreate(request)
}
}
return await queueStarDocumentCreate(request)
}
public func starCollection(_ request: StarCollectionRequest) async throws -> OutlineStar {
if !isManualOfflineModeEnabled {
do { return try await live.starCollection(request) } catch { return await queueStarCollectionCreate(request) }
do {
let result = try await RetryPolicy.withRetry { try await self.live.starCollection(request) }
recordSuccess(category: "stars")
return result
} catch {
recordWriteFailureIfStructural(category: "stars", error)
return await queueStarCollectionCreate(request)
}
}
return await queueStarCollectionCreate(request)
}
@@ -211,9 +278,11 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
if await cancelIfNeverSynced(id: id) { return }
if !isManualOfflineModeEnabled {
do {
try await live.deleteStar(id: id)
try await RetryPolicy.withRetry { try await self.live.deleteStar(id: id) }
recordSuccess(category: "stars")
return
} catch {
recordWriteFailureIfStructural(category: "stars", error)
await enqueue(.deleteStar, payload: IDPayload(id: id), id: "delete-star-\(id)")
return
}
@@ -303,6 +372,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)
}
@@ -343,6 +440,10 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
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)
}
@@ -407,6 +508,15 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
await cache.clearAll()
}
/// Sign-out only see `OfflineCacheStore.clearEverything()` and
/// `CacheEncryptionKeyStoring.clear()`. Callers must clear the
/// encryption key too (this actor doesn't own that decision); wiping
/// the storage here without it would leave the key to be reused by
/// whoever signs in next.
public func clearEverythingForSignOut() async {
await cache.clearEverything()
}
/// Replays every queued operation against `live`, in the order they were
/// queued. Each is independent one failing doesn't block the rest.
public func flushPendingOperations() async -> SyncFlushSummary {
@@ -515,43 +625,87 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
/// 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) }
return payloads.compactMap { decryptedDecode(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) }
return payloads.compactMap { decryptedDecode(OutlineCollection.self, from: $0) }
}
// MARK: - Helpers
private func cachedFetch<T: Codable>(key: String, fetch: () async throws -> T) async throws -> T {
private func cachedFetch<T: Codable>(key: String, category: String, fetch: () async throws -> T) async throws -> T {
// Manual offline mode means "skip the network entirely," not just
// "prefer it" without this check, a read would still hit `live`
// (and succeed, showing content beyond whatever's cached) any time
// the device actually had a connection, defeating the point of
// deliberately testing/working as if offline.
if isManualOfflineModeEnabled {
if let data = await cache.load(forKey: key), let cached = try? decoder.decode(T.self, from: data) {
if let data = await cache.load(forKey: key), let cached = decryptedDecode(T.self, from: data) {
return cached
}
throw OutlineAPIError.transport(URLError(.notConnectedToInternet))
}
do {
let result = try await fetch()
if let data = try? encoder.encode(result) {
let result = try await RetryPolicy.withRetry { try await fetch() }
recordSuccess(category: category)
if let data = encryptedEncode(result) {
await cache.save(data, forKey: key)
}
return result
} catch {
if let data = await cache.load(forKey: key), let cached = try? decoder.decode(T.self, from: data) {
// Only a structural failure (decode/auth/server the server
// answered, but something's actually wrong) counts toward the
// repeated-failure log. Plain connectivity loss already has its
// own offline UI elsewhere; logging it here too would just be a
// second banner for the same thing every time Wi-Fi drops.
if !RetryPolicy.isRetryable(error) {
recordFailure(category: category, message: errorDescription(error))
}
if let data = await cache.load(forKey: key), let cached = decryptedDecode(T.self, from: data) {
return cached
}
throw error
}
}
private func resolvedEncryptionKey() throws -> SymmetricKey {
if let cachedEncryptionKey { return cachedEncryptionKey }
let key = try encryptionKeyStore.key()
cachedEncryptionKey = key
return key
}
/// Encrypts before it ever reaches SwiftData. `nil` on any failure
/// (matches the shape of the plain `try? encoder.encode(...)` this
/// replaces) a Keychain hiccup here should behave exactly like an
/// encode failure already did: skip caching this one value, not crash.
private func encryptedEncode(_ value: some Encodable) -> Data? {
guard let plain = try? encoder.encode(value), let key = try? resolvedEncryptionKey() else { return nil }
return try? CachePayloadCryptor.encrypt(plain, key: key)
}
/// Decrypts + decodes a value previously written by `encryptedEncode`.
/// A row written before this feature shipped (still plaintext JSON, or
/// anything encrypted under a key that's since been cleared by
/// sign-out) fails to decrypt and returns `nil` here same as any
/// other decode failure, so `cachedFetch` treats it as a cache miss and
/// refetches, not a crash.
private func decryptedDecode<T: Decodable>(_ type: T.Type, from data: Data) -> T? {
guard let key = try? resolvedEncryptionKey(), let plain = try? CachePayloadCryptor.decrypt(data, key: key) else { return nil }
return try? decoder.decode(type, from: plain)
}
/// Throwing counterpart for `replay(_:)`, where a genuine decode
/// failure needs to propagate (so `flushPendingOperations` records it
/// as a failed sync attempt) instead of silently vanishing.
private func decryptedDecodeThrowing<T: Decodable>(_ type: T.Type, from data: Data) throws -> T {
let plain = try CachePayloadCryptor.decrypt(data, key: try resolvedEncryptionKey())
return try decoder.decode(type, from: plain)
}
private func requestKey(_ prefix: String, _ request: some Encodable) -> String {
guard let data = try? keyEncoder.encode(request), let json = String(data: data, encoding: .utf8) else {
return prefix
@@ -560,19 +714,19 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
}
private func cacheDocument(_ document: OutlineDocument) async {
if let data = try? encoder.encode(document) {
if let data = encryptedEncode(document) {
await cache.save(data, forKey: "document:\(document.id)")
}
}
private func cacheCollection(_ collection: OutlineCollection) async {
if let data = try? encoder.encode(collection) {
if let data = encryptedEncode(collection) {
await cache.save(data, forKey: "collection:\(collection.id)")
}
}
private func enqueue(_ kind: PendingOperationKind, payload: some Encodable, id: String) async {
guard let data = try? encoder.encode(payload) else { return }
guard let data = encryptedEncode(payload) else { return }
await cache.enqueueOperation(id: id, kind: kind.rawValue, payload: data)
}
@@ -611,7 +765,7 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
private func queueDocumentUpdate(_ request: UpdateDocumentRequest, dueTo error: Error?) async throws -> OutlineDocument {
guard let baseData = await cache.load(forKey: "document:\(request.id)"),
let base = try? decoder.decode(OutlineDocument.self, from: baseData) else {
let base = decryptedDecode(OutlineDocument.self, from: baseData) else {
throw error ?? OutlineAPIError.transport(URLError(.notConnectedToInternet))
}
let mergedText = request.append == true ? base.text + (request.text ?? "") : (request.text ?? base.text)
@@ -640,7 +794,7 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
// of it and the update would just fail every retry.
if merged.id.hasPrefix("pending-"),
let createOp = await cache.pendingOperations().first(where: { $0.id == merged.id && $0.kind == PendingOperationKind.createDocument.rawValue }),
let createRequest = try? decoder.decode(CreateDocumentRequest.self, from: createOp.payload) {
let createRequest = decryptedDecode(CreateDocumentRequest.self, from: createOp.payload) {
let resolvedCreate = CreateDocumentRequest(
title: merged.title,
text: merged.text,
@@ -662,7 +816,7 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
private func queueCollectionUpdate(_ request: UpdateCollectionRequest, dueTo error: Error?) async throws -> OutlineCollection {
guard let baseData = await cache.load(forKey: "collection:\(request.id)"),
let base = try? decoder.decode(OutlineCollection.self, from: baseData) else {
let base = decryptedDecode(OutlineCollection.self, from: baseData) else {
throw error ?? OutlineAPIError.transport(URLError(.notConnectedToInternet))
}
let merged = OutlineCollection(
@@ -712,44 +866,84 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
}
switch kind {
case .createDocument:
let request = try decoder.decode(CreateDocumentRequest.self, from: operation.payload)
let request = try decryptedDecodeThrowing(CreateDocumentRequest.self, from: operation.payload)
let result = try await live.createDocument(request)
await cacheDocument(result)
// The placeholder id (== operation.id) is now a dead orphan
// nothing server-side will ever answer to it again.
await cache.removeCacheEntry(forKey: "document:\(operation.id)")
case .updateDocument:
let request = try decoder.decode(UpdateDocumentRequest.self, from: operation.payload)
let request = try decryptedDecodeThrowing(UpdateDocumentRequest.self, from: operation.payload)
let result = try await live.updateDocument(request)
await cacheDocument(result)
case .updateCollection:
let request = try decoder.decode(UpdateCollectionRequest.self, from: operation.payload)
let request = try decryptedDecodeThrowing(UpdateCollectionRequest.self, from: operation.payload)
let result = try await live.updateCollection(request)
await cacheCollection(result)
case .createPin:
let request = try decoder.decode(CreatePinRequest.self, from: operation.payload)
let request = try decryptedDecodeThrowing(CreatePinRequest.self, from: operation.payload)
_ = try await live.createPin(request)
case .deletePin:
let request = try decoder.decode(IDPayload.self, from: operation.payload)
let request = try decryptedDecodeThrowing(IDPayload.self, from: operation.payload)
try await live.deletePin(id: request.id)
case .createSubscription:
let request = try decoder.decode(CreateSubscriptionRequest.self, from: operation.payload)
let request = try decryptedDecodeThrowing(CreateSubscriptionRequest.self, from: operation.payload)
_ = try await live.createSubscription(request)
case .deleteSubscription:
let request = try decoder.decode(IDPayload.self, from: operation.payload)
let request = try decryptedDecodeThrowing(IDPayload.self, from: operation.payload)
try await live.deleteSubscription(id: request.id)
case .starDocument:
let request = try decoder.decode(StarDocumentRequest.self, from: operation.payload)
let request = try decryptedDecodeThrowing(StarDocumentRequest.self, from: operation.payload)
_ = try await live.starDocument(request)
case .deleteStar:
let request = try decoder.decode(IDPayload.self, from: operation.payload)
let request = try decryptedDecodeThrowing(IDPayload.self, from: operation.payload)
try await live.deleteStar(id: request.id)
case .starCollection:
let request = try decoder.decode(StarCollectionRequest.self, from: operation.payload)
let request = try decryptedDecodeThrowing(StarCollectionRequest.self, from: operation.payload)
_ = try await live.starCollection(request)
}
}
/// Writes always queue on any failure (existing behavior, unchanged)
/// this only decides whether the failure is worth logging. A structural
/// error (decode/auth/server) queuing for later replay will likely just
/// fail the same way again next sync; a transient one might not. Either
/// way the queue doesn't change, only whether it's counted toward
/// `repeatedFailureSummaries()`.
private func recordWriteFailureIfStructural(category: String, _ error: Error) {
guard !RetryPolicy.isRetryable(error) else { return }
recordFailure(category: category, message: errorDescription(error))
}
private func recordFailure(category: String, message: String) {
let now = Date()
var timestamps = (failureLog[category] ?? []).filter { now.timeIntervalSince($0) < failureWindow }
timestamps.append(now)
failureLog[category] = timestamps
lastFailureMessage[category] = message
}
private func recordSuccess(category: String) {
failureLog[category] = nil
lastFailureMessage[category] = nil
}
/// Categories that have failed `failureThreshold`+ times within the last
/// `failureWindow` seconds meant to be polled periodically (see
/// `RootView`), not pushed, since this actor has no UI-facing dependency
/// of its own. A single blip never shows up here: `RetryPolicy` absorbs
/// transient failures before they're ever logged, and only structural
/// ones (decode/auth/server) get logged at all see
/// `recordWriteFailureIfStructural` and `cachedFetch`.
public func repeatedFailureSummaries() async -> [RepeatedFailure] {
let now = Date()
return failureLog.compactMap { category, timestamps in
let recent = timestamps.filter { now.timeIntervalSince($0) < failureWindow }
guard recent.count >= failureThreshold, let message = lastFailureMessage[category] else { return nil }
return RepeatedFailure(category: category, message: message, count: recent.count)
}
}
private func errorDescription(_ error: Error) -> String {
if let apiError = error as? OutlineAPIError {
switch apiError {
@@ -0,0 +1,76 @@
import Foundation
import CryptoKit
import Security
/// Real Keychain-backed `CacheEncryptionKeyStoring`. The key is a plain
/// random 256-bit value deliberately NOT derived from anything guessable
/// (bundle id, device id, etc.). It doesn't need deriving to survive an
/// uninstall/reinstall of the app either: Keychain items are scoped to the
/// requesting app's code signature (bundle id + team id), not its on-disk
/// presence, so reinstalling the same app regains access to the same
/// Keychain item automatically exactly how `KeychainTokenStore`'s saved
/// token already survives a reinstall today. If the on-disk cache also
/// happens to survive (macOS doesn't clean out `~/Library/Containers` just
/// because the .app bundle was dragged to the Trash), the reinstalled app
/// can still decrypt it; a different app, or the same app after an explicit
/// sign-out (`clear()`), can't.
public final class KeychainCacheEncryptionKeyStore: CacheEncryptionKeyStoring, @unchecked Sendable {
private let service: String
private let account: String
public init(service: String = "com.outpost.outlinekit", account: String = "offline-cache-encryption-key") {
self.service = service
self.account = account
}
public func key() throws -> SymmetricKey {
if let existing = try? readKey() { return existing }
let generated = SymmetricKey(size: .bits256)
try store(generated)
return generated
}
public func clear() throws {
let status = SecItemDelete(baseQuery() as CFDictionary)
guard status == errSecSuccess || status == errSecItemNotFound else {
throw TokenStoreError.deleteFailed(status)
}
}
private func readKey() throws -> SymmetricKey {
var query = baseQuery()
query[kSecReturnData as String] = true
query[kSecMatchLimit as String] = kSecMatchLimitOne
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess, let data = result as? Data else {
throw TokenStoreError.notFound
}
return SymmetricKey(data: data)
}
private func store(_ key: SymmetricKey) throws {
let data = key.withUnsafeBytes { Data($0) }
let query = baseQuery()
let existsStatus = SecItemCopyMatching(query as CFDictionary, nil)
if existsStatus == errSecSuccess {
let updateStatus = SecItemUpdate(query as CFDictionary, [kSecValueData as String: data] as CFDictionary)
guard updateStatus == errSecSuccess else { throw TokenStoreError.storeFailed(updateStatus) }
} else {
var addQuery = query
addQuery[kSecValueData as String] = data
let addStatus = SecItemAdd(addQuery as CFDictionary, nil)
guard addStatus == errSecSuccess else { throw TokenStoreError.storeFailed(addStatus) }
}
}
private func baseQuery() -> [String: Any] {
[
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account
]
}
}
@@ -67,6 +67,22 @@ public actor OfflineCacheStore {
try? modelContext.save()
}
/// Wipes both the read-through cache AND the pending write queue
/// used only on sign-out (see `SessionStore.signOut()`), since the
/// encryption key backing every row here is about to be cleared too.
/// Anything left un-wiped would just become permanently undecryptable
/// garbage instead of readable by the next account signed in on this
/// machine deliberately more thorough than `clearAll()` (Settings'
/// "Clear All Cache", which never touches pending writes; that button
/// shouldn't silently discard someone's unsynced edits).
public func clearEverything() {
let cachedDescriptor = FetchDescriptor<CachedPayload>()
(try? modelContext.fetch(cachedDescriptor))?.forEach { modelContext.delete($0) }
let pendingDescriptor = FetchDescriptor<PendingOperation>()
(try? modelContext.fetch(pendingDescriptor))?.forEach { modelContext.delete($0) }
try? modelContext.save()
}
// MARK: - Offline write queue
/// Upserts by `id` a second call with the same id (an edit coalescing
@@ -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
@@ -75,6 +92,14 @@ public protocol OutlineAPIClient: Sendable {
/// 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.
@@ -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)
}
@@ -278,6 +310,45 @@ public actor LiveOutlineAPIClient: OutlineAPIClient {
}
}
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))
}
@@ -485,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,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,19 @@
import Foundation
/// A category of API call that's failed repeatedly within a short window
/// see `CachingOutlineAPIClient.repeatedFailureSummaries()`. Deliberately
/// carries no request/response payload, document content, or server URL:
/// this is meant to be safe to show a user or attach to a bug report as-is.
public struct RepeatedFailure: Sendable, Identifiable, Equatable {
public let category: String
public let message: String
public let count: Int
public var id: String { category }
public init(category: String, message: String, count: Int) {
self.category = category
self.message = message
self.count = count
}
}
@@ -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,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
}
}
@@ -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,38 @@
import Foundation
/// Automatic retry-with-backoff for API calls, so a single transient network
/// blip doesn't turn into a user-visible failure (or a silently swallowed
/// one) the way one `try?` used to.
///
/// Only retries `OutlineAPIError.transport` a dropped connection or
/// timeout might succeed a second later. Everything else (`.decoding`,
/// `.unauthorized`, `.notFound`, `.server`, `.tokenUnavailable`) is retried
/// zero times: a response-shape mismatch or a 404 will look exactly the same
/// on attempt two, so retrying just burns the cooldown window for nothing
/// callers should treat those as immediate failures instead.
public enum RetryPolicy {
public static func withRetry<T: Sendable>(
maxAttempts: Int = 3,
initialDelay: Duration = .seconds(1),
_ operation: () async throws -> T
) async throws -> T {
var attempt = 1
var delay = initialDelay
while true {
do {
return try await operation()
} catch {
guard attempt < maxAttempts, isRetryable(error) else { throw error }
attempt += 1
try? await Task.sleep(for: delay)
delay *= 2
}
}
}
static func isRetryable(_ error: Error) -> Bool {
guard let apiError = error as? OutlineAPIError else { return false }
if case .transport = apiError { return true }
return false
}
}
@@ -1,4 +1,5 @@
import XCTest
import CryptoKit
@testable import OutlineKit
private struct NotStubbed: Error {}
@@ -29,6 +30,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 +73,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() }
@@ -91,6 +100,7 @@ private final class StubOutlineAPIClient: OutlineAPIClient, @unchecked Sendable
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() }
@@ -107,6 +117,22 @@ private final class StubOutlineAPIClient: OutlineAPIClient, @unchecked Sendable
private struct StubTransportError: Error {}
/// In-memory `CacheEncryptionKeyStoring` tests must never touch the real
/// Keychain (would pollute the developer's machine and can hang/fail in a
/// sandboxed CI runner with no Keychain access).
private final class StaticCacheEncryptionKeyStore: CacheEncryptionKeyStoring, @unchecked Sendable {
private var stored: SymmetricKey?
func key() throws -> SymmetricKey {
if let stored { return stored }
let generated = SymmetricKey(size: .bits256)
stored = generated
return generated
}
func clear() throws { stored = nil }
}
final class CachingOutlineAPIClientTests: XCTestCase {
private func makeCache() throws -> OfflineCacheStore {
OfflineCacheStore(modelContainer: try OfflineCacheStore.makeContainer(inMemory: true))
@@ -136,7 +162,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
let stub = StubOutlineAPIClient()
let document = makeDocument()
stub.documentInfoHandler = { _ in document }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
let result = try await sut.documentInfo(id: "doc-1")
@@ -152,7 +178,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
if callCount == 1 { return document }
throw StubTransportError()
}
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
// First call succeeds and populates the cache.
_ = try await sut.documentInfo(id: "doc-1")
@@ -166,7 +192,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
func testDocumentInfoRethrowsWhenLiveFailsAndCacheIsEmpty() async throws {
let stub = StubOutlineAPIClient()
stub.documentInfoHandler = { _ in throw StubTransportError() }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
do {
_ = try await sut.documentInfo(id: "doc-1")
@@ -185,7 +211,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
if callCount == 1 { return collections }
throw StubTransportError()
}
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
_ = try await sut.listCollections(offset: 0, limit: 25)
let result = try await sut.listCollections(offset: 0, limit: 25)
@@ -198,7 +224,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
let docOne = makeDocument(id: "doc-1", title: "One")
let docTwo = makeDocument(id: "doc-2", title: "Two")
stub.documentInfoHandler = { id in id == "doc-1" ? docOne : docTwo }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
_ = try await sut.documentInfo(id: "doc-1")
_ = try await sut.documentInfo(id: "doc-2")
@@ -218,7 +244,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
let original = makeDocument(id: "doc-1", title: "Original")
stub.documentInfoHandler = { _ in original }
stub.updateDocumentHandler = { _ in throw StubTransportError() }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
// Populate the cache with the base document first (as a real open would).
_ = try await sut.documentInfo(id: "doc-1")
@@ -239,7 +265,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
func testUpdateDocumentRethrowsWhenDocumentWasNeverCached() async throws {
let stub = StubOutlineAPIClient()
stub.updateDocumentHandler = { _ in throw StubTransportError() }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
do {
_ = try await sut.updateDocument(UpdateDocumentRequest(id: "never-seen", title: "x"))
@@ -254,7 +280,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
let original = makeDocument(id: "doc-1", title: "Original")
stub.documentInfoHandler = { _ in original }
stub.updateDocumentHandler = { _ in throw StubTransportError() }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
_ = try await sut.documentInfo(id: "doc-1")
_ = try await sut.updateDocument(UpdateDocumentRequest(id: "doc-1", title: "First Edit"))
@@ -267,7 +293,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
func testPinThenUnpinBeforeSyncCancelsOutWithoutQueuingADelete() async throws {
let stub = StubOutlineAPIClient()
stub.createPinHandler = { _ in throw StubTransportError() }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
let pin = try await sut.createPin(CreatePinRequest(documentId: "doc-1"))
XCTAssertTrue(pin.id.hasPrefix("pending-"))
@@ -285,7 +311,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
let original = makeDocument(id: "doc-1", title: "Original")
stub.documentInfoHandler = { _ in original }
stub.updateDocumentHandler = { _ in throw StubTransportError() }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
_ = try await sut.documentInfo(id: "doc-1")
_ = try await sut.updateDocument(UpdateDocumentRequest(id: "doc-1", title: "Edited Offline"))
@@ -309,7 +335,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
let original = makeDocument(id: "doc-1", title: "Original")
stub.documentInfoHandler = { _ in original }
stub.updateDocumentHandler = { _ in throw StubTransportError() }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
_ = try await sut.documentInfo(id: "doc-1")
_ = try await sut.updateDocument(UpdateDocumentRequest(id: "doc-1", title: "Edited Offline"))
@@ -336,7 +362,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
liveCallCount += 1
return OutlinePin(id: "real-id", documentId: "doc-1", collectionId: nil, index: nil)
}
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), defaults: defaults)
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), defaults: defaults, encryptionKeyStore: StaticCacheEncryptionKeyStore())
let pin = try await sut.createPin(CreatePinRequest(documentId: "doc-1"))
@@ -361,7 +387,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
return [cachedCollection]
}
let cache = try makeCache()
let sut = CachingOutlineAPIClient(live: stub, cache: cache, defaults: defaults)
let sut = CachingOutlineAPIClient(live: stub, cache: cache, defaults: defaults, encryptionKeyStore: StaticCacheEncryptionKeyStore())
// Online first populates the cache normally.
let firstResult = try await sut.listCollections(offset: 0, limit: 25)
@@ -380,7 +406,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
func testCacheStorageSummaryReflectsCachedItems() async throws {
let stub = StubOutlineAPIClient()
stub.documentInfoHandler = { _ in self.makeDocument() }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
_ = try await sut.documentInfo(id: "doc-1")
let summary = await sut.cacheStorageSummary()
@@ -409,7 +435,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
return (0..<count).map { self.makeCollection(id: "col-\(offset + $0)") }
}
stub.listDocumentsHandler = { _, _, _, _ in [] }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
let summary = await sut.performFullSync()
@@ -430,7 +456,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
return offset == 0 ? [self.makeDocument(id: "doc-1"), self.makeDocument(id: "doc-2")] : []
}
let cache = try makeCache()
let sut = CachingOutlineAPIClient(live: stub, cache: cache)
let sut = CachingOutlineAPIClient(live: stub, cache: cache, encryptionKeyStore: StaticCacheEncryptionKeyStore())
let summary = await sut.performFullSync()
XCTAssertEqual(summary.documentsCount, 2)
@@ -460,7 +486,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
}
}
let cache = try makeCache()
let sut = CachingOutlineAPIClient(live: stub, cache: cache)
let sut = CachingOutlineAPIClient(live: stub, cache: cache, encryptionKeyStore: StaticCacheEncryptionKeyStore())
let summary = await sut.performFullSync()
@@ -475,7 +501,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
func testCreateDocumentQueuesAndReturnsUsableDocumentWhenOffline() async throws {
let stub = StubOutlineAPIClient()
stub.createDocumentHandler = { _ in throw StubTransportError() }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
let created = try await sut.createDocument(
CreateDocumentRequest(title: "New Doc", text: "hello", collectionId: "col-1")
@@ -499,7 +525,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
let stub = StubOutlineAPIClient()
stub.createDocumentHandler = { _ in throw StubTransportError() }
stub.updateDocumentHandler = { _ in throw StubTransportError() }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
let created = try await sut.createDocument(
CreateDocumentRequest(title: "Untitled", text: "", collectionId: "col-1")
@@ -519,7 +545,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
func testFlushingAPendingCreateReconcilesThePlaceholderIdToTheRealOne() async throws {
let stub = StubOutlineAPIClient()
stub.createDocumentHandler = { _ in throw StubTransportError() }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
let created = try await sut.createDocument(
CreateDocumentRequest(title: "New Doc", text: "hello", collectionId: "col-1")
@@ -544,4 +570,169 @@ final class CachingOutlineAPIClientTests: XCTestCase {
// expected nothing left under the old id
}
}
// MARK: - Repeated-failure tracking
/// A structural failure (decode/auth/server) isn't retried it fails
/// the same way every time, so `RetryPolicy` gives up after one attempt
/// and it's logged immediately. No cache entry means no fallback either,
/// so every call rethrows.
func testRepeatedDecodingFailuresSurfaceAfterThreshold() async throws {
let stub = StubOutlineAPIClient()
stub.documentInfoHandler = { _ in throw OutlineAPIError.decoding(NotStubbed()) }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
for _ in 0..<3 {
_ = try? await sut.documentInfo(id: "doc-1")
}
let summaries = await sut.repeatedFailureSummaries()
XCTAssertEqual(summaries.count, 1)
XCTAssertEqual(summaries.first?.category, "document")
XCTAssertEqual(summaries.first?.count, 3)
}
/// Two failures alone shouldn't trip the banner only three or more
/// within the window counts as "repeated."
func testFewerThanThresholdFailuresDoNotSurface() async throws {
let stub = StubOutlineAPIClient()
stub.documentInfoHandler = { _ in throw OutlineAPIError.decoding(NotStubbed()) }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
for _ in 0..<2 {
_ = try? await sut.documentInfo(id: "doc-1")
}
let summaries = await sut.repeatedFailureSummaries()
XCTAssertTrue(summaries.isEmpty)
}
/// A later success clears the category entirely a transient run of
/// bad luck shouldn't leave a stale banner up after things recover.
func testSuccessAfterRepeatedFailuresClearsTheLog() async throws {
let stub = StubOutlineAPIClient()
let document = makeDocument()
var callCount = 0
stub.documentInfoHandler = { _ in
callCount += 1
if callCount <= 3 { throw OutlineAPIError.decoding(NotStubbed()) }
return document
}
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
for _ in 0..<3 {
_ = try? await sut.documentInfo(id: "doc-1")
}
let beforeRecovery = await sut.repeatedFailureSummaries()
XCTAssertEqual(beforeRecovery.count, 1)
_ = try await sut.documentInfo(id: "doc-1")
let afterRecovery = await sut.repeatedFailureSummaries()
XCTAssertTrue(afterRecovery.isEmpty)
}
/// Plain connectivity loss (`OutlineAPIError.transport`) already has its
/// own offline UI elsewhere it shouldn't also pile up in the repeated-
/// failure log and pop a second, redundant banner.
func testTransportFailuresDoNotCountTowardTheRepeatedFailureLog() async throws {
let stub = StubOutlineAPIClient()
stub.documentInfoHandler = { _ in throw OutlineAPIError.transport(URLError(.notConnectedToInternet)) }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
_ = try? await sut.documentInfo(id: "doc-1")
let summaries = await sut.repeatedFailureSummaries()
XCTAssertTrue(summaries.isEmpty)
}
/// Different categories (document reads vs. pin writes) track
/// independently a broken pins endpoint shouldn't mask, or be masked
/// by, unrelated document failures, and one crossing the threshold
/// shouldn't drag an unrelated one along with it.
func testFailuresInDifferentCategoriesDoNotMix() async throws {
let stub = StubOutlineAPIClient()
stub.documentInfoHandler = { _ in throw OutlineAPIError.decoding(NotStubbed()) }
stub.createPinHandler = { _ in throw OutlineAPIError.decoding(NotStubbed()) }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
// Document reads cross the threshold...
for _ in 0..<3 {
_ = try? await sut.documentInfo(id: "doc-1")
}
// ...pin creates don't.
for _ in 0..<2 {
_ = try? await sut.createPin(CreatePinRequest(documentId: "doc-1", collectionId: nil))
}
let summaries = await sut.repeatedFailureSummaries()
XCTAssertEqual(summaries.map(\.category), ["document"])
}
// MARK: - At-rest encryption
func testCachedPayloadIsNotStoredAsPlaintextJSON() async throws {
let stub = StubOutlineAPIClient()
let document = makeDocument(title: "Secret Title")
stub.documentInfoHandler = { _ in document }
let cache = try makeCache()
let sut = CachingOutlineAPIClient(live: stub, cache: cache, encryptionKeyStore: StaticCacheEncryptionKeyStore())
_ = try await sut.documentInfo(id: "doc-1")
let raw = await cache.load(forKey: "document:doc-1")
XCTAssertNotNil(raw)
// A plain JSON encode would contain the literal title text in the
// clear - ciphertext shouldn't, and shouldn't even parse as JSON.
XCTAssertNil(String(data: raw!, encoding: .utf8)?.range(of: "Secret Title"))
XCTAssertThrowsError(try JSONDecoder().decode(OutlineDocument.self, from: raw!))
}
/// Simulates sign-out (key cleared) followed by a fresh sign-in (a new
/// `CachingOutlineAPIClient` instance, same underlying on-disk cache,
/// same shape as `SessionStore.makeAPIClient` always creating a new
/// instance) anything still on disk from before is unreadable under
/// the new key, which is the entire point of clearing it on sign-out.
func testCacheIsUnreadableAfterTheEncryptionKeyIsCleared() async throws {
let stub = StubOutlineAPIClient()
stub.documentInfoHandler = { _ in self.makeDocument() }
let cache = try makeCache()
let keyStore = StaticCacheEncryptionKeyStore()
let beforeSignOut = CachingOutlineAPIClient(live: stub, cache: cache, encryptionKeyStore: keyStore)
_ = try await beforeSignOut.documentInfo(id: "doc-1")
try keyStore.clear()
stub.documentInfoHandler = { _ in throw StubTransportError() }
let afterSignIn = CachingOutlineAPIClient(live: stub, cache: cache, encryptionKeyStore: keyStore)
do {
_ = try await afterSignIn.documentInfo(id: "doc-1")
XCTFail("Expected the now-undecryptable cache entry to be unusable")
} catch is StubTransportError {
// expected live fails, and the leftover cache entry can't be
// decrypted under the new key either, so there's no fallback.
}
}
func testClearEverythingForSignOutWipesBothCacheAndPendingQueue() async throws {
let stub = StubOutlineAPIClient()
stub.documentInfoHandler = { _ in self.makeDocument() }
stub.updateDocumentHandler = { _ in throw StubTransportError() }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
_ = try await sut.documentInfo(id: "doc-1")
_ = try? await sut.updateDocument(UpdateDocumentRequest(id: "doc-1", title: "Offline edit"))
let summaryBefore = await sut.cacheStorageSummary()
let pendingBefore = await sut.pendingOperations()
XCTAssertGreaterThan(summaryBefore.itemCount, 0)
XCTAssertFalse(pendingBefore.isEmpty)
await sut.clearEverythingForSignOut()
let summaryAfter = await sut.cacheStorageSummary()
let pendingAfter = await sut.pendingOperations()
XCTAssertEqual(summaryAfter.itemCount, 0)
XCTAssertTrue(pendingAfter.isEmpty)
}
}
@@ -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 = """
@@ -0,0 +1,69 @@
import XCTest
@testable import OutlineKit
private struct PlainError: Error {}
final class RetryPolicyTests: XCTestCase {
func testSucceedsOnFirstAttemptWithoutRetrying() async throws {
var callCount = 0
let result = try await RetryPolicy.withRetry(initialDelay: .milliseconds(1)) {
callCount += 1
return "ok"
}
XCTAssertEqual(result, "ok")
XCTAssertEqual(callCount, 1)
}
func testRetriesTransportErrorsAndSucceedsOnceItStopsFailing() async throws {
var callCount = 0
let result = try await RetryPolicy.withRetry(initialDelay: .milliseconds(1)) { () -> String in
callCount += 1
if callCount < 3 { throw OutlineAPIError.transport(URLError(.timedOut)) }
return "ok"
}
XCTAssertEqual(result, "ok")
XCTAssertEqual(callCount, 3)
}
func testGivesUpAfterMaxAttemptsAndRethrowsTheLastError() async throws {
var callCount = 0
do {
_ = try await RetryPolicy.withRetry(maxAttempts: 3, initialDelay: .milliseconds(1)) { () -> String in
callCount += 1
throw OutlineAPIError.transport(URLError(.timedOut))
}
XCTFail("Expected the persistent failure to be rethrown")
} catch {
XCTAssertEqual(callCount, 3)
}
}
/// A decode failure means the response is structurally wrong trying
/// again gets the exact same wrong response, so it isn't worth the
/// cooldown window the way a network blip is.
func testDoesNotRetryNonTransportErrors() async throws {
var callCount = 0
do {
_ = try await RetryPolicy.withRetry(initialDelay: .milliseconds(1)) { () -> String in
callCount += 1
throw OutlineAPIError.decoding(PlainError())
}
XCTFail("Expected the decoding error to be rethrown without retrying")
} catch {
XCTAssertEqual(callCount, 1)
}
}
func testDoesNotRetryErrorsThatAreNotOutlineAPIErrors() async throws {
var callCount = 0
do {
_ = try await RetryPolicy.withRetry(initialDelay: .milliseconds(1)) { () -> String in
callCount += 1
throw PlainError()
}
XCTFail("Expected the error to be rethrown without retrying")
} catch {
XCTAssertEqual(callCount, 1)
}
}
}
+29 -15
View File
@@ -3,13 +3,14 @@
archiveVersion = 1;
classes = {
};
objectVersion = 110;
objectVersion = 77;
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 */; };
/* End PBXBuildFile 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; };
@@ -61,6 +63,7 @@
FAF99C44302CF1BD00C9949F /* OutlineKit in Frameworks */,
FA7596B430366A1D0000167E /* MarkdownEngineCodeBlocks in Frameworks */,
FA7596B630366A1D0000167E /* MarkdownEngineLatex in Frameworks */,
FADBE087303734FE001E69F0 /* ImagePlayground.framework in Frameworks */,
FA7596B230366A1D0000167E /* MarkdownEngine in 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>";
@@ -390,7 +402,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
CURRENT_PROJECT_VERSION = 3;
DEVELOPMENT_TEAM = CW6GQT9SK5;
ENABLE_APP_SANDBOX = YES;
ENABLE_HARDENED_RUNTIME = YES;
@@ -412,21 +424,22 @@
IPHONEOS_DEPLOYMENT_TARGET = 27.0;
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.4;
MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = 0.1.0;
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;
@@ -441,7 +454,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
CURRENT_PROJECT_VERSION = 3;
DEVELOPMENT_TEAM = CW6GQT9SK5;
ENABLE_APP_SANDBOX = YES;
ENABLE_HARDENED_RUNTIME = YES;
@@ -463,21 +476,22 @@
IPHONEOS_DEPLOYMENT_TARGET = 27.0;
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.4;
MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = 0.1.0;
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;
@@ -491,7 +505,7 @@
DEVELOPMENT_TEAM = CW6GQT9SK5;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 27.0;
MACOSX_DEPLOYMENT_TARGET = 27.0;
MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.psmattas.OutpostTests;
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -517,7 +531,7 @@
DEVELOPMENT_TEAM = CW6GQT9SK5;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 27.0;
MACOSX_DEPLOYMENT_TARGET = 27.0;
MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.psmattas.OutpostTests;
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -542,7 +556,7 @@
DEVELOPMENT_TEAM = CW6GQT9SK5;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 27.0;
MACOSX_DEPLOYMENT_TARGET = 27.0;
MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.psmattas.OutpostUITests;
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -567,7 +581,7 @@
DEVELOPMENT_TEAM = CW6GQT9SK5;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 27.0;
MACOSX_DEPLOYMENT_TARGET = 27.0;
MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.psmattas.OutpostUITests;
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -0,0 +1,101 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2700"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "FAF99C17302CE96100C9949F"
BuildableName = "Outpost.app"
ReferencedContainer = "container:Outpost.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "FAF99C26302CE96200C9949F"
BuildableName = "OutpostTests.xctest"
ReferencedContainer = "container:Outpost.xcodeproj">
</BuildableReference>
</TestableReference>
<TestableReference
skipped = "NO"
parallelizable = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "FAF99C30302CE96200C9949F"
BuildableName = "OutpostUITests.xctest"
ReferencedContainer = "container:Outpost.xcodeproj">
</BuildableReference>
</TestableReference>
</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"
queueDebuggingEnabled = "No">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "FAF99C17302CE96100C9949F"
BuildableName = "Outpost.app"
ReferencedContainer = "container:Outpost.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<StoreKitConfigurationFileReference
identifier = "../../Outpost/Configuration.storekit">
</StoreKitConfigurationFileReference>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "FAF99C17302CE96100C9949F"
BuildableName = "Outpost.app"
ReferencedContainer = "container:Outpost.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
+74
View File
@@ -0,0 +1,74 @@
{
"identifier" : "12E4A6D1-6B8A-4C2E-9F3A-0D1B2C3E4F5A",
"nonRenewingSubscriptions" : [],
"products" : [
{
"displayPrice" : "0.99",
"familyShareable" : false,
"internalID" : "992ABE97-F5C4-4F80-8FB0-382BDF47DAB6",
"localizations" : [
{
"description" : "A small tip to support Outpost's development.",
"displayName" : "Small Tip",
"locale" : "en_US"
}
],
"productID" : "com.psmattas.OutpostApp.tip.small",
"referenceName" : "Small Tip",
"type" : "Consumable"
},
{
"displayPrice" : "2.99",
"familyShareable" : false,
"internalID" : "316DEB73-6BA3-4F6B-BD54-D17A1CE61938",
"localizations" : [
{
"description" : "A medium tip to support Outpost's development.",
"displayName" : "Medium Tip",
"locale" : "en_US"
}
],
"productID" : "com.psmattas.OutpostApp.tip.medium",
"referenceName" : "Medium Tip",
"type" : "Consumable"
},
{
"displayPrice" : "4.99",
"familyShareable" : false,
"internalID" : "37936F94-AC21-4A39-BC85-CAE6609E170D",
"localizations" : [
{
"description" : "A large tip to support Outpost's development.",
"displayName" : "Large Tip",
"locale" : "en_US"
}
],
"productID" : "com.psmattas.OutpostApp.tip.large",
"referenceName" : "Large Tip",
"type" : "Consumable"
},
{
"displayPrice" : "9.99",
"familyShareable" : false,
"internalID" : "E072C1AC-2719-483E-8A54-F4924808B724",
"localizations" : [
{
"description" : "A generous tip to support Outpost's development.",
"displayName" : "Generous Tip",
"locale" : "en_US"
}
],
"productID" : "com.psmattas.OutpostApp.tip.generous",
"referenceName" : "Generous Tip",
"type" : "Consumable"
}
],
"settings" : {
"_askToBuyEnabled" : false
},
"subscriptionGroups" : [],
"version" : {
"major" : 3,
"minor" : 0
}
}
+5
View File
@@ -48,6 +48,11 @@ struct AboutInfoView: View {
}
.font(.callout)
Divider()
.frame(maxWidth: 240)
TipJarView()
Text("© \(copyrightYear) Puranjay Savar Mattas")
.font(.caption2)
.foregroundStyle(.tertiary)
+75
View File
@@ -0,0 +1,75 @@
#if os(macOS)
import SwiftUI
import StoreKit
/// One button per consumable tip tier no "restore purchases" (nothing to
/// restore, consumables aren't entitlements) and no manual retry: a failed
/// load just shows a message, tapping a tier again re-attempts naturally.
struct TipJarView: View {
@State private var store = TipJarStore()
var body: some View {
VStack(spacing: 8) {
Text("Support Outpost")
.font(.callout.weight(.semibold))
if store.isLoading && store.products.isEmpty {
ProgressView()
.controlSize(.small)
} else if !store.products.isEmpty {
HStack(spacing: 8) {
ForEach(store.products) { product in
tipButton(for: product)
}
}
}
switch store.purchaseState {
case .thankYou:
Label("Thank you!", systemImage: "heart.fill")
.font(.caption)
.foregroundStyle(.pink)
case .failed(let message):
Text(message)
.font(.caption)
.foregroundStyle(.secondary)
case .idle, .purchasing:
EmptyView()
}
}
.task { await store.loadProductsIfNeeded() }
}
private func tipButton(for product: Product) -> some View {
Button {
Task { await store.purchase(product) }
} label: {
VStack(spacing: 2) {
if isPurchasing(product) {
ProgressView()
.controlSize(.small)
} else {
Text(product.displayPrice)
.font(.callout.weight(.semibold))
}
Text(product.displayName)
.font(.caption2)
.foregroundStyle(.secondary)
}
.frame(minWidth: 64)
.padding(.vertical, 6)
}
.buttonStyle(.bordered)
.disabled(isAnyPurchaseInFlight)
}
private func isPurchasing(_ product: Product) -> Bool {
store.purchaseState == .purchasing(product.id)
}
private var isAnyPurchaseInFlight: Bool {
if case .purchasing = store.purchaseState { return true }
return false
}
}
#endif
+14 -23
View File
@@ -1,5 +1,6 @@
#if os(macOS)
import SwiftUI
import StoreKit
import OutlineKit
/// Uses a plain `Button` + `.popover` rather than `Menu`. A `Menu` whose label
@@ -9,21 +10,13 @@ import OutlineKit
struct AccountFooter: View {
@Environment(SessionStore.self) private var session
@Environment(AppNavigation.self) private var navigation
@Environment(\.openURL) private var openURL
@Environment(\.openWindow) private var openWindow
@Environment(\.requestReview) private var requestReview
@AppStorage("outpost.appearance") private var appearance: AppAppearance = .system
@AppStorage(CachingOutlineAPIClient.offlineModeDefaultsKey) private var isOfflineModeEnabled = false
@State private var isMenuPresented = false
@State private var isShowingLogoutConfirmation = false
@State private var isShowingProfile = false
private let repositoryURL = URL(string: "https://git.psmattas.com/psmattas/Outpost")!
private let issuesURL = URL(string: "https://git.psmattas.com/psmattas/Outpost/issues")!
private var apiDocumentationURL: URL? {
session.serverURL?.appendingPathComponent("developers")
}
var body: some View {
Button {
isMenuPresented = true
@@ -63,20 +56,10 @@ struct AccountFooter: View {
private var menuContent: some View {
VStack(alignment: .leading, spacing: 2) {
menuItem("Keyboard Shortcuts…") { openWindow(id: "keyboard-shortcuts") }
Divider()
menuItem("Documentation") { openURL(repositoryURL) }
if let apiDocumentationURL {
menuItem("API Documentation") { openURL(apiDocumentationURL) }
}
menuItem("Changelog") { openURL(repositoryURL) }
Divider()
menuItem("Send Us Feedback") { openURL(issuesURL) }
menuItem("Report a Bug") { openURL(issuesURL) }
// Apple's own review/feedback prompt there's no separate
// native channel for "bug" vs. "feedback", so one button covers
// both.
menuItem("Leave Us Feedback") { requestReview() }
Divider()
@@ -106,6 +89,14 @@ struct AccountFooter: View {
// as "Invalid attempt to open a new transaction during CA
// commit") letting the popover's dismissal finish first avoids it.
menuItem("Settings…") { Task { @MainActor in navigation.isShowingSettings = true } }
// Same deferred-Task reasoning as Settings above this also
// sets isShowingSettings synchronously.
menuItem("Support Outpost") {
Task { @MainActor in
navigation.selectedSettingsSection = .about
navigation.isShowingSettings = true
}
}
Divider()
@@ -74,8 +74,10 @@ struct AvatarCropperView: View {
Button("Cancel", role: .cancel, action: onCancel)
Spacer()
Button("Use Photo") {
if let data = renderFinalImage() {
onConfirm(data)
Task {
if let data = await renderFinalImage() {
onConfirm(data)
}
}
}
.buttonStyle(.borderedProminent)
@@ -100,15 +102,26 @@ struct AvatarCropperView: View {
.clipped()
}
/// `ImageRenderer` itself has to run on the main actor (it captures live
/// SwiftUI view state), but JPEG compression on the bitmap it produces
/// is pure CPU work with no SwiftUI dependency left hopping off for
/// just that part avoids a visible hitch on tapping "Use Photo".
/// `tiffRepresentation` (plain `Data`, unlike `NSImage` itself) is what
/// actually crosses the actor boundary; mirrors `NSImage.jpegData(
/// compressionQuality:)`'s own logic rather than calling it directly, so
/// crossing doesn't require handing a non-Sendable `NSImage` to a
/// detached task.
@MainActor
private func renderFinalImage() -> Data? {
private func renderFinalImage() async -> 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)
guard let tiffData = renderer.nsImage?.tiffRepresentation else { return nil }
return await Task.detached(priority: .userInitiated) {
NSBitmapImageRep(data: tiffData)?.representation(using: .jpeg, properties: [.compressionFactor: 0.9])
}.value
}
}
#endif
@@ -1,39 +0,0 @@
#if os(macOS)
import SwiftUI
struct KeyboardShortcutsView: View {
private struct Shortcut: Identifiable {
let id = UUID()
let action: String
let keys: String
}
private let shortcuts: [Shortcut] = [
Shortcut(action: "Sign In", keys: ""),
Shortcut(action: "Preferences", keys: "⌘ ,"),
Shortcut(action: "Close Window", keys: "⌘ W"),
Shortcut(action: "Quit Outpost", keys: "⌘ Q")
]
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text("Keyboard Shortcuts")
.font(.title3.bold())
VStack(spacing: 10) {
ForEach(shortcuts) { shortcut in
HStack {
Text(shortcut.action)
Spacer()
Text(shortcut.keys)
.foregroundStyle(.secondary)
.monospaced()
}
}
}
}
.padding(24)
.frame(width: 280)
}
}
#endif
@@ -42,7 +42,15 @@ struct SettingsSidebarList: View {
Divider()
List(selection: $selection) {
ForEach(SettingsCategory.allCases) { category in
// TODO: Workspace is entirely `!isImplemented` placeholders
// right now (details/authentication/security/ai/members/
// groups/templates/emojis/applications/shared/links/
// webhooks/importData/exportData) App Store review won't
// accept a section that's just "Coming Soon" rows, so it's
// filtered out of the sidebar below (via `visibleCategories`)
// until real content lands. Remove the filter once at least
// one Workspace section is built.
ForEach(visibleCategories) { category in
let sections = SettingsSection.allCases.filter { $0.category == category }
Section {
ForEach(sections) { section in
@@ -91,6 +99,14 @@ struct SettingsSidebarList: View {
.task(id: isEffectivelyOnline) { await refreshOutlineVersion() }
}
/// Categories with at least one built (`isImplemented`) section see the
/// TODO above the `ForEach` that uses this.
private var visibleCategories: [SettingsCategory] {
SettingsCategory.allCases.filter { category in
SettingsSection.allCases.contains { $0.category == category && $0.isImplemented }
}
}
private var versionFooter: some View {
VStack(alignment: .leading, spacing: 2) {
Text("Outpost \(OutpostVersion.displayString)")
@@ -109,7 +125,7 @@ struct SettingsSidebarList: View {
private func refreshOutlineVersion() async {
guard isEffectivelyOnline, let apiClient = session.apiClient else { return }
outlineVersion = try? await apiClient.installationInfo().version
outlineVersion = try? await RetryPolicy.withRetry({ try await apiClient.installationInfo().version })
}
}
#endif
+76 -7
View File
@@ -15,6 +15,10 @@ struct SettingsView: View {
@Environment(SessionStore.self) private var session
@AppStorage("outpost.appearance") private var appearance: AppAppearance = .system
@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
@AppStorage("outpost.pointerCursorEnabled") private var isPointerCursorEnabled = true
@AppStorage("outpost.commandPaletteEnabled") private var isCommandPaletteEnabled = true
@AppStorage("outpost.commandPaletteFullWorkspaceSearch") private var isCommandPaletteFullWorkspaceSearch = false
@AppStorage(CachingOutlineAPIClient.offlineModeDefaultsKey) private var isOfflineModeEnabled = false
@@ -96,6 +100,7 @@ struct SettingsView: View {
switch section {
case .appearance: appearanceDetail
case .editor: editorDetail
case .navigation: navigationDetail
case .profile: profileDetail
case .preferences: preferencesDetail
case .notifications: notificationsDetail
@@ -166,6 +171,57 @@ struct SettingsView: View {
Divider().frame(maxWidth: 480)
VStack(alignment: .leading, spacing: 6) {
Toggle("Autocomplete", isOn: $isAutocompleteEnabled)
Text("Show inline predictive-text suggestions while typing, same as Notes and TextEdit. Accept with Tab or →.")
.font(.caption)
.foregroundStyle(.secondary)
}
.frame(maxWidth: 480, alignment: .leading)
Divider().frame(maxWidth: 480)
VStack(alignment: .leading, spacing: 6) {
Toggle("Writing Tools", isOn: $isWritingToolsEnabled)
Text("Proofread, rewrite, summarize, or compose text using system-provided Apple Intelligence tools. Available on supported Macs and macOS versions.")
.font(.caption)
.foregroundStyle(.secondary)
}
.frame(maxWidth: 480, alignment: .leading)
Divider().frame(maxWidth: 480)
VStack(alignment: .leading, spacing: 6) {
Toggle("Image Playground", isOn: $isImagePlaygroundEnabled)
Text("Adds a \"Create Image with Image Playground\" button to the reader toolbar — generates an image from a text description (or your current selection, if any) and inserts it into the document. Requires macOS 15.1+ and a supported Mac — the toggle has no effect where it isn't available.")
.font(.caption)
.foregroundStyle(.secondary)
}
.frame(maxWidth: 480, alignment: .leading)
Divider().frame(maxWidth: 480)
VStack(alignment: .leading, spacing: 6) {
Toggle("Pointer Cursor", isOn: $isPointerCursorEnabled)
Text("Show a pointing-hand cursor when hovering sidebar rows and links in a document, instead of the default arrow or I-beam.")
.font(.caption)
.foregroundStyle(.secondary)
}
.frame(maxWidth: 480, alignment: .leading)
}
}
// MARK: - Navigation
/// Local-only settings for finding your way around the app separate
/// from Editor, which is scoped to how documents are actually edited.
private var navigationDetail: some View {
VStack(alignment: .leading, spacing: 16) {
sectionHeader
Text("Settings for finding documents and collections.")
.font(.subheadline)
.foregroundStyle(.secondary)
VStack(alignment: .leading, spacing: 6) {
Toggle("Command Palette", isOn: $isCommandPaletteEnabled)
Text("Press ⌘K to quickly jump to a document or collection. Always searches locally on your device — never a network request while typing.")
@@ -562,7 +618,7 @@ struct SettingsView: View {
defer { isDeletingAccount = false }
do {
try await apiClient.deleteAccount()
session.signOut()
await session.signOut()
} catch {
deleteAccountErrorMessage = outlineErrorMessage(error, fallback: "Couldn't delete your account.")
}
@@ -1126,6 +1182,11 @@ struct SettingsView: View {
VStack(alignment: .leading, spacing: 20) {
sectionHeader
Label("Everything cached here is encrypted at rest with a key stored in Keychain, cleared automatically when you log out.", systemImage: "lock.fill")
.font(.caption)
.foregroundStyle(.secondary)
.frame(maxWidth: 480, alignment: .leading)
VStack(alignment: .leading, spacing: 6) {
Toggle("Offline Mode", isOn: $isOfflineModeEnabled)
Text("Skip the network entirely and work from what's already been cached. Turn this off to reconnect.")
@@ -1269,9 +1330,10 @@ struct SettingsView: View {
}
.frame(maxWidth: 480, alignment: .leading)
Divider()
.frame(maxWidth: 480)
// TODO: Export All Data / Developer Diagnostics / Reset Local
// Database aren't built yet App Store review won't accept
// "Coming Soon" rows, so commented out until real content lands.
/*
VStack(alignment: .leading, spacing: 12) {
comingSoonRow("Export All Data")
comingSoonRow("Developer Diagnostics")
@@ -1279,6 +1341,10 @@ struct SettingsView: View {
}
.frame(maxWidth: 480, alignment: .leading)
Divider()
.frame(maxWidth: 480)
*/
Divider()
.frame(maxWidth: 480)
@@ -1344,6 +1410,9 @@ struct SettingsView: View {
)
}
/// Only referenced from the commented-out block above right now kept
/// (not deleted) so re-enabling those rows is a one-line uncomment once
/// they're actually built.
private func comingSoonRow(_ title: String) -> some View {
HStack {
Text(title)
@@ -1447,7 +1516,7 @@ struct SettingsView: View {
private func refreshProfile() async {
guard let apiClient = session.apiClient else { return }
guard let fresh = try? await apiClient.currentUser() else { return }
guard let fresh = try? await RetryPolicy.withRetry({ try await apiClient.currentUser() }) else { return }
session.applyUpdatedProfile(fresh)
}
@@ -1479,7 +1548,7 @@ struct SettingsView: View {
// Best-effort the new avatar is already live either way, this
// just stops the old upload from sitting around unreferenced.
if let previousAttachmentId {
try? await apiClient.deleteAttachment(id: previousAttachmentId)
try? await RetryPolicy.withRetry({ try await apiClient.deleteAttachment(id: previousAttachmentId) })
}
} catch {
avatarErrorMessage = outlineErrorMessage(error, fallback: "Couldn't upload this photo.")
@@ -1496,7 +1565,7 @@ struct SettingsView: View {
session.applyUpdatedProfile(updated)
avatarErrorMessage = nil
if let previousAttachmentId {
try? await apiClient.deleteAttachment(id: previousAttachmentId)
try? await RetryPolicy.withRetry({ try await apiClient.deleteAttachment(id: previousAttachmentId) })
}
} catch {
avatarErrorMessage = outlineErrorMessage(error, fallback: "Couldn't remove this photo.")
@@ -30,8 +30,15 @@ struct CollectionDocumentsOutline: View {
/// every document in the tree.
@State private var pinsByDocumentID: [String: OutlinePin] = [:]
private var tree: [DocumentNode] {
buildDocumentTree(from: viewModel.documents, sortedBy: sortOption)
/// Recomputed only when `viewModel.documents`/`sortOption` actually change
/// (below) instead of being a computed property this rebuilt the whole
/// dictionary-grouped, recursively-sorted tree on every `body` evaluation,
/// including renders triggered by unrelated state (selection, hover,
/// pins) that don't change the tree's shape at all.
@State private var tree: [DocumentNode] = []
private func rebuildTree() {
tree = buildDocumentTree(from: viewModel.documents, sortedBy: sortOption)
}
init(
@@ -86,11 +93,14 @@ struct CollectionDocumentsOutline: View {
.task(id: "\(refreshToken)-\(externalRefreshToken)") {
await viewModel.load()
await loadPins()
rebuildTree()
}
.onChange(of: viewModel.documents) { rebuildTree() }
.onChange(of: sortOption) { rebuildTree() }
}
private func loadPins() async {
guard let pins = try? await apiClient.listPins(ListPinsRequest(collectionId: collection.id)) else { return }
guard let pins = try? await RetryPolicy.withRetry({ try await apiClient.listPins(ListPinsRequest(collectionId: collection.id)) }) else { return }
pinsByDocumentID = Dictionary(uniqueKeysWithValues: pins.map { ($0.documentId, $0) })
}
}
@@ -157,6 +167,7 @@ private struct DocumentNodeRow: View {
.frame(width: 12)
}
.buttonStyle(.plain)
.pointerCursorOnHover()
}
Button {
@@ -186,6 +197,7 @@ private struct DocumentNodeRow: View {
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.pointerCursorOnHover()
}
.padding(.vertical, 6)
.padding(.horizontal, 4)
@@ -34,6 +34,7 @@ struct CollectionListContent: View {
CollectionRowView(collection: collection)
}
.buttonStyle(.plain)
.pointerCursorOnHover()
}
}
}
@@ -10,9 +10,13 @@ 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 {
@@ -20,7 +24,7 @@ struct CollectionOverviewContent: View {
NativeTextViewWrapper(
text: $markdown,
configuration: .init(
services: .init(syntaxHighlighter: CodeSyntaxHighlighting.shared),
services: .init(images: imageProvider, syntaxHighlighter: CodeSyntaxHighlighting.shared),
heightBehavior: .fitsContent
),
isEditable: false
@@ -28,6 +32,12 @@ struct CollectionOverviewContent: View {
.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))
@@ -32,8 +34,15 @@ struct CollectionOverviewView: View {
self.onOpenDocument = onOpenDocument
}
private var sortedDocuments: [OutlineDocument] {
selectedTab.sorted(viewModel.documents)
/// Recomputed only when `viewModel.documents`/`selectedTab` actually
/// change (below) instead of being a computed property re-sorted on
/// every render capped at 100 documents per collection page, so lower
/// blast radius than the sidebar/command-palette versions of this same
/// pattern, but the same fix.
@State private var sortedDocuments: [OutlineDocument] = []
private func resortDocuments() {
sortedDocuments = selectedTab.sorted(viewModel.documents)
}
var body: some View {
@@ -59,7 +68,7 @@ struct CollectionOverviewView: View {
if !trimmedSearchQuery.isEmpty {
searchResultsList
} else if selectedTab == .overview {
CollectionOverviewContent(collection: collection)
CollectionOverviewContent(apiClient: apiClient, collection: collection)
} else {
documentList
}
@@ -92,6 +101,8 @@ struct CollectionOverviewView: View {
await viewModel.checkForRemoteChanges()
}
}
.onChange(of: viewModel.documents) { resortDocuments() }
.onChange(of: selectedTab) { resortDocuments() }
}
// Spans the full window width, centered, directly under the toolbar
@@ -114,6 +125,7 @@ struct CollectionOverviewView: View {
)
}
.buttonStyle(.plain)
.pointerCursorOnHover()
}
Spacer(minLength: 0)
}
@@ -150,6 +162,7 @@ struct CollectionOverviewView: View {
DocumentRowView(document: document)
}
.buttonStyle(.plain)
.pointerCursorOnHover()
}
}
}
@@ -182,6 +195,7 @@ struct CollectionOverviewView: View {
.padding(.vertical, 2)
}
.buttonStyle(.plain)
.pointerCursorOnHover()
}
}
}
@@ -56,6 +56,7 @@ struct CollectionTreeRow: View {
)
}
.buttonStyle(.plain)
.pointerCursorOnHover()
.animation(.easeInOut(duration: 0.15), value: isExpanded)
.contextMenu { contextMenuContent }
@@ -41,22 +41,31 @@ struct CommandPaletteView: View {
}
}
private var results: [Result] {
/// Recomputed only when `query`/`collections`/`documents` actually change
/// (below) instead of being a computed property Full Workspace mode's
/// index can be large (every document in the local cache, sub-documents
/// included), and this was re-scanning + re-sorting the entire thing on
/// every render, including ones triggered by unrelated state like
/// `selectedIndex` changing as arrow keys move the selection.
@State private var results: [Result] = []
private func recomputeResults() {
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))
results = (collections.map(Result.collection) + documents.map(Result.document))
.prefix(20)
.map { $0 }
return
}
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)
results = scored.sorted { $0.1 < $1.1 }.prefix(30).map(\.0)
}
/// Lower is better exact match, then prefix match, then earliest
@@ -100,7 +109,10 @@ struct CommandPaletteView: View {
.textFieldStyle(.plain)
.font(.title3)
.focused($isSearchFieldFocused)
.onChange(of: query) { selectedIndex = 0 }
.onChange(of: query) {
selectedIndex = 0
recomputeResults()
}
.onSubmit { selectCurrent() }
// Attached directly on the field itself, not an
// ancestor confirmed live that .onKeyPress on the
@@ -134,6 +146,7 @@ struct CommandPaletteView: View {
resultRow(result, isSelected: index == selectedIndex)
.id(index)
.contentShape(Rectangle())
.pointerCursorOnHover()
.onTapGesture {
selectedIndex = index
selectCurrent()
@@ -168,6 +181,8 @@ struct CommandPaletteView: View {
isSearchFieldFocused = true
await loadResults()
}
.onChange(of: collections) { recomputeResults() }
.onChange(of: documents) { recomputeResults() }
}
private func resultRow(_ result: Result, isSelected: Bool) -> some View {
@@ -445,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 RetryPolicy.withRetry({ 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
@@ -17,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 {
@@ -46,7 +52,7 @@ struct DocumentPresentSheet: View {
NativeTextViewWrapper(
text: $text,
configuration: .init(
services: .init(syntaxHighlighter: CodeSyntaxHighlighting.shared),
services: .init(images: imageProvider, syntaxHighlighter: CodeSyntaxHighlighting.shared),
heightBehavior: .fitsContent
),
isEditable: false
@@ -71,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() }
}
@@ -5,6 +5,9 @@ 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
@@ -17,6 +20,23 @@ struct DocumentReaderView: View {
/// 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
@AppStorage("outpost.pointerCursorEnabled") private var isPointerCursorEnabled = 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
@@ -32,6 +52,20 @@ struct DocumentReaderView: View {
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
@@ -50,16 +84,65 @@ 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?
/// Pushed by `CodeBlockLanguagePicker` to rewrite a code block's fence
/// line when the user switches its language from the picker.
@State private var pendingCodeBlockLanguageChange: TextRangeReplacementRequest?
/// 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
@@ -84,6 +167,7 @@ struct DocumentReaderView: View {
// 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
@@ -141,6 +225,44 @@ struct DocumentReaderView: View {
DocumentShareSheet(apiClient: apiClient, documentId: viewModel.documentId)
}
// 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)
}
}
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() }
@@ -181,6 +303,12 @@ 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.
@@ -199,6 +327,13 @@ struct DocumentReaderView: View {
.task {
await viewModel.loadInsightsEnabledState()
}
.task {
loadedComments = (try? await RetryPolicy.withRetry({
try await apiClient.listComments(
ListCommentsRequest(documentId: viewModel.documentId, includeAnchorText: true)
)
})) ?? []
}
.task {
while !Task.isCancelled {
await viewModel.loadViewers()
@@ -246,6 +381,38 @@ struct DocumentReaderView: View {
} message: {
Text(actionErrorMessage ?? "")
}
.sheet(isPresented: $isShowingCommentsSheet) {
DocumentCommentsSheet(
apiClient: apiClient,
document: document,
focusedCommentId: focusedCommentId,
pendingAnchorText: pendingCommentAnchorText,
onCommentsChanged: {
Task {
loadedComments = (try? await RetryPolicy.withRetry({
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()
@@ -269,6 +436,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
@@ -334,20 +554,44 @@ struct DocumentReaderView: View {
ZStack(alignment: .topLeading) {
NativeTextViewWrapper(
text: $viewModel.text,
pendingTextInsertion: $pendingTextInsertion,
pendingTextRangeReplacement: $pendingCodeBlockLanguageChange,
configuration: .init(
services: .init(syntaxHighlighter: CodeSyntaxHighlighting.shared),
services: .init(images: imageProvider, syntaxHighlighter: CodeSyntaxHighlighting.shared),
codeBlock: editorCodeBlockStyle,
heightBehavior: .fitsContent
textSubstitution: editorTextSubstitution,
textCompletion: editorTextCompletion,
writingTools: editorWritingTools,
heightBehavior: .fitsContent,
pointerCursorOverLinksWhileEditing: isPointerCursorEnabled
),
documentId: viewModel.documentId,
isEditable: viewModel.isEffectivelyEditable,
onCodeBlockSelectionChange: { readerCodeBlocks = $0 }
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)
}
}
if viewModel.isEffectivelyEditable {
ForEach(readerCodeBlocks) { selection in
CodeBlockLanguagePicker(selection: selection, documentId: viewModel.documentId) { request in
pendingCodeBlockLanguageChange = request
}
}
}
ForEach(commentAnchorRects) { anchor in
CommentAnchorMarker(rect: anchor.rect) {
focusedCommentId = anchor.id
pendingCommentAnchorText = nil
isShowingCommentsSheet = true
}
}
}
}
}
@@ -375,11 +619,13 @@ struct DocumentReaderView: View {
}
}
/// Left is a plain, unrendered raw-text editor (deliberately not
/// `NativeTextViewWrapper` just the literal Markdown source); 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.
/// 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
@@ -389,30 +635,47 @@ struct DocumentReaderView: View {
/// follow-up, not attempted here.
private var splitEditorView: some View {
HSplitView {
TextEditor(text: $viewModel.text)
.font(.system(.body, design: .monospaced))
.scrollContentBackground(.hidden)
.padding(8)
.frame(minWidth: 300, maxWidth: .infinity, maxHeight: .infinity)
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(syntaxHighlighter: CodeSyntaxHighlighting.shared),
services: .init(images: imageProvider, syntaxHighlighter: CodeSyntaxHighlighting.shared),
codeBlock: editorCodeBlockStyle,
heightBehavior: .fitsContent
),
documentId: viewModel.documentId,
isEditable: false,
onCodeBlockSelectionChange: { previewCodeBlocks = $0 }
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)
@@ -457,10 +720,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
}
@@ -678,6 +948,110 @@ struct DocumentReaderView: View {
/// 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.
/// Top-left pill overlay on each code block opposite corner from the
/// engine's own copy button (top-right), same `Color.clear` + `.position()`
/// sizing trick `CodeBlockButton` uses. Tapping opens a curated language
/// list (not the full ~190-language highlight.js catalog same "small
/// curated set, not the whole thing" call as the emoji reaction picker);
/// picking one rewrites just the fence line via
/// `CodeBlockSelection.fenceRange`, leaving the block's content untouched.
private struct CodeBlockLanguagePicker: View {
let selection: CodeBlockSelection
let documentId: String
let onRequest: (TextRangeReplacementRequest) -> Void
private static let languages: [(label: String, tag: String?)] = [
("Plain Text", nil),
("Swift", "swift"),
("Python", "python"),
("JavaScript", "javascript"),
("TypeScript", "typescript"),
("Bash", "bash"),
("JSON", "json"),
("YAML", "yaml"),
("HTML", "html"),
("CSS", "css"),
("SQL", "sql"),
("Java", "java"),
("Kotlin", "kotlin"),
("C", "c"),
("C++", "cpp"),
("C#", "csharp"),
("Go", "go"),
("Rust", "rust"),
("Ruby", "ruby"),
("PHP", "php"),
("Markdown", "markdown"),
]
private var currentLabel: String {
guard let language = selection.language, !language.isEmpty else { return "Plain Text" }
return Self.languages.first { $0.tag == language }?.label ?? language.uppercased()
}
var body: some View {
Color.clear
.frame(width: selection.rect.width, height: selection.rect.height)
.overlay(alignment: .topLeading) {
Menu {
ForEach(Self.languages, id: \.label) { entry in
Button(entry.label) {
onRequest(TextRangeReplacementRequest(
documentId: documentId,
range: selection.fenceRange,
replacement: "```\(entry.tag ?? "")\n"
))
}
}
} label: {
Text(currentLabel.uppercased())
.font(.system(size: 10, weight: .medium))
.foregroundStyle(.white.opacity(0.8))
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Color.black.opacity(0.3), in: RoundedRectangle(cornerRadius: 6))
}
.menuStyle(.borderlessButton)
.fixedSize()
.padding(.top, 6)
.padding(.leading, 8)
}
.position(
x: selection.rect.midX,
y: selection.rect.midY
)
}
}
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
@@ -712,4 +1086,44 @@ private struct CodeBlockLineNumberGutter: View {
.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,6 +9,9 @@ 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 isLoading = false
var errorMessage: String?
@@ -76,6 +79,8 @@ final class DocumentReaderViewModel {
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
@@ -95,6 +100,8 @@ 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
@@ -104,14 +111,18 @@ final class DocumentReaderViewModel {
}
func loadViewers() async {
guard let views = try? await apiClient.listViews(ListViewsRequest(documentId: documentId)) else { return }
// A single blip here used to just leave `viewers` empty forever with
// no sign anything went wrong retry-with-backoff absorbs that;
// `try?` still covers the "still failing after retries" case, same
// silent-but-harmless fallback as before (an empty viewers list).
guard let views = try? await RetryPolicy.withRetry({ try await apiClient.listViews(ListViewsRequest(documentId: documentId)) }) else { return }
viewers = views.filter { $0.lastViewedAt != nil }
}
func loadPinAndSubscriptionState() async {
// `collectionId: nil` = Home pins. This menu's Pin action is "Pin to
// Home", not "Pin to Collection" those are distinct on the server.
if let pins = try? await apiClient.listPins(ListPinsRequest(collectionId: nil)),
if let pins = try? await RetryPolicy.withRetry({ try await apiClient.listPins(ListPinsRequest(collectionId: nil)) }),
let match = pins.first(where: { $0.documentId == documentId }) {
isPinned = true
pinId = match.id
@@ -120,7 +131,7 @@ final class DocumentReaderViewModel {
pinId = nil
}
if let subscriptions = try? await apiClient.listSubscriptions(ListSubscriptionsRequest(documentId: documentId)),
if let subscriptions = try? await RetryPolicy.withRetry({ try await apiClient.listSubscriptions(ListSubscriptionsRequest(documentId: documentId)) }),
let match = subscriptions.first {
isSubscribed = true
subscriptionId = match.id
@@ -22,9 +22,26 @@ struct DocumentSearchSheet: View {
@State private var currentMatchIndex = 0
@FocusState private var isSearchFieldFocused: Bool
private var matchingLineIndices: [Int] {
guard !query.isEmpty else { return [] }
return lines.indices.filter { lines[$0].localizedCaseInsensitiveContains(query) }
/// Recomputed only when `query`/`lines` actually change (`recomputeMatches()`)
/// instead of being a computed property this used to re-scan the whole
/// document on every access, and it's read multiple times per row
/// (`isCurrentMatch`, the highlight check) on every SwiftUI re-render, so a
/// large document turned into an O(n²) case-insensitive scan per frame.
@State private var matchingLineIndices: [Int] = []
/// O(1) membership for the per-row highlight check below `matchingLineIndices`
/// stays an ordered array (needed for `currentMatchIndex`/stepping), this is
/// just a parallel lookup so a common search term with many matches doesn't
/// make every row's highlight check an O(k) linear scan.
@State private var matchingLineIndexSet: Set<Int> = []
private func recomputeMatches() {
guard !query.isEmpty else {
matchingLineIndices = []
matchingLineIndexSet = []
return
}
matchingLineIndices = lines.indices.filter { lines[$0].localizedCaseInsensitiveContains(query) }
matchingLineIndexSet = Set(matchingLineIndices)
}
var body: some View {
@@ -35,7 +52,10 @@ struct DocumentSearchSheet: View {
TextField("Search in \"\(document.title.isEmpty ? "Untitled" : document.title)\"", text: $query)
.textFieldStyle(.plain)
.focused($isSearchFieldFocused)
.onChange(of: query) { currentMatchIndex = 0 }
.onChange(of: query) {
currentMatchIndex = 0
recomputeMatches()
}
if !matchingLineIndices.isEmpty {
Text("\(currentMatchIndex + 1) of \(matchingLineIndices.count)")
@@ -83,7 +103,7 @@ struct DocumentSearchSheet: View {
.padding(.horizontal, 4)
.background(
isCurrentMatch(index) ? Color.yellow.opacity(0.4)
: matchingLineIndices.contains(index) ? Color.yellow.opacity(0.15)
: matchingLineIndexSet.contains(index) ? Color.yellow.opacity(0.15)
: Color.clear
)
.id(index)
@@ -131,6 +151,7 @@ struct DocumentSearchSheet: View {
do {
let text = try await apiClient.documentInfo(id: document.id).text
lines = text.components(separatedBy: "\n")
recomputeMatches()
} catch {
errorMessage = outlineErrorMessage(error, fallback: "Couldn't load this document.")
}
@@ -370,7 +370,7 @@ struct DocumentShareSheet: View {
private func loadMembers() async {
isLoadingMembers = true
defer { isLoadingMembers = false }
members = (try? await apiClient.documentUsers(ListDocumentUsersRequest(id: documentId))) ?? []
members = (try? await RetryPolicy.withRetry({ try await apiClient.documentUsers(ListDocumentUsersRequest(id: documentId)) })) ?? []
}
private func searchUsers(_ query: String) async {
@@ -381,7 +381,7 @@ struct DocumentShareSheet: View {
}
isSearchingUsers = true
defer { isSearchingUsers = false }
userSearchResults = (try? await apiClient.listUsers(ListUsersRequest(query: trimmed))) ?? []
userSearchResults = (try? await RetryPolicy.withRetry({ try await apiClient.listUsers(ListUsersRequest(query: trimmed)) })) ?? []
}
private func addUser(_ user: OutlineUser) async {
@@ -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 }
}
+1
View File
@@ -101,6 +101,7 @@ struct HomeView: View {
PinnedDocumentCard(document: document)
}
.buttonStyle(.plain)
.pointerCursorOnHover()
}
}
.padding(.horizontal, 24)
+22 -8
View File
@@ -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
}
}
@@ -81,16 +83,25 @@ final class HomeViewModel {
/// `pins.list` only returns pin records, not the documents themselves
/// fetches each pinned document individually. Pins are a small curated
/// set (unlike a full collection tree), so the N+1 here is acceptable
/// where it wouldn't be in the sidebar.
/// where it wouldn't be in the sidebar but they're fetched concurrently
/// (a `TaskGroup`, not a serial loop) so latency doesn't scale with pin
/// count; `documentInfo` already goes through `CachingOutlineAPIClient`'s
/// own cached-read/retry path either way.
private func fetchPinnedThrowing() async throws -> [OutlineDocument] {
let pins = try await apiClient.listPins(ListPinsRequest(collectionId: nil))
var documents: [OutlineDocument] = []
for pin in pins {
if let document = try? await apiClient.documentInfo(id: pin.documentId) {
documents.append(document)
let pins = try await RetryPolicy.withRetry { try await apiClient.listPins(ListPinsRequest(collectionId: nil)) }
let client = apiClient
let documentsByID: [String: OutlineDocument] = await withTaskGroup(of: (String, OutlineDocument?).self) { group in
for pin in pins {
group.addTask { (pin.documentId, try? await client.documentInfo(id: pin.documentId)) }
}
var result: [String: OutlineDocument] = [:]
for await (id, document) in group {
if let document { result[id] = document }
}
return result
}
return documents
// Preserve pins.list's own order rather than task-completion order.
return pins.compactMap { documentsByID[$0.documentId] }
}
private func fetch(tab: HomeTab) async throws -> [OutlineDocument] {
@@ -115,6 +126,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,12 +137,13 @@ final class HomeViewModel {
case .popular: popular = documents
case .recentlyUpdated: recentlyUpdated = documents
case .createdByMe: createdByMe = documents
case .drafts: drafts = documents
}
}
private func resolveCurrentUserID() async throws -> String {
if let currentUserID { return currentUserID }
let user = try await apiClient.currentUser()
let user = try await RetryPolicy.withRetry { try await apiClient.currentUser() }
currentUserID = user.id
return user.id
}
+40 -28
View File
@@ -2,45 +2,57 @@
import SwiftUI
import OutlineKit
/// Deliberately distinct from `DocumentCardView` the pinned section is for
/// a quick scan of a small curated set, not browsing, so this is a dense
/// single-line row rather than a tall card, with an explicit pin glyph so
/// it doesn't read the same as the tab grids below it.
/// Same tall-card shape as `DocumentCardView` (the tab grids below it) so the
/// pinned section reads as a set of cards, not a row of tab-like chips the
/// accent-filled pin badge is the one thing that marks these as pinned.
struct PinnedDocumentCard: View {
@Environment(StarStore.self) private var starStore
let document: OutlineDocument
var body: some View {
HStack(spacing: 10) {
if let emoji = document.emoji {
Text(emoji)
.font(.title3)
} else {
Image(systemName: "doc.text")
.font(.body)
.foregroundStyle(.secondary)
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 6) {
if let emoji = document.emoji {
Text(emoji)
.font(.title2)
} else {
Image(systemName: "doc.text")
.font(.title3)
.foregroundStyle(.secondary)
}
Spacer()
if starStore.isStarred(documentId: document.id) {
Image(systemName: "star.fill")
.font(.caption)
.foregroundStyle(.yellow)
}
Image(systemName: "pin.fill")
.font(.caption2)
.foregroundStyle(.white)
.padding(5)
.background(Color.accentColor, in: Circle())
}
Text(document.title.isEmpty ? "Untitled" : document.title)
.font(.callout.weight(.medium))
.lineLimit(1)
.font(.headline)
.lineLimit(2)
.multilineTextAlignment(.leading)
.frame(maxWidth: .infinity, alignment: .leading)
Spacer(minLength: 0)
if starStore.isStarred(documentId: document.id) {
Image(systemName: "star.fill")
.font(.caption2)
.foregroundStyle(.yellow)
}
Image(systemName: "pin.fill")
.font(.caption2)
Text(document.updatedAt, format: .relative(presentation: .named))
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(.horizontal, 12)
.padding(.vertical, 9)
.frame(maxWidth: .infinity, alignment: .leading)
.background(.fill.tertiary, in: RoundedRectangle(cornerRadius: 8))
.padding(14)
.frame(maxWidth: .infinity, minHeight: 96, alignment: .topLeading)
.background(.fill.tertiary, in: RoundedRectangle(cornerRadius: 12))
.overlay(
RoundedRectangle(cornerRadius: 12)
.strokeBorder(Color.primary.opacity(0.08), lineWidth: 1)
)
}
}
#endif
@@ -134,6 +134,7 @@ struct GlobalSearchResultsView: View {
.padding(.vertical, 2)
}
.buttonStyle(.plain)
.pointerCursorOnHover()
}
}
}
+25 -8
View File
@@ -34,6 +34,7 @@ struct OutpostApp: App {
.onAppear { applyMacAppearance() }
.onChange(of: appearance) { _, _ in applyMacAppearance() }
.logoutConfirmationDialog(isPresented: $isShowingLogoutConfirmation, session: session)
.background(TransparentTitlebarWindowAccessor())
#endif
}
#if os(macOS)
@@ -74,14 +75,6 @@ struct OutpostApp: App {
}
}
#endif
#if os(macOS)
Window("Keyboard Shortcuts", id: "keyboard-shortcuts") {
KeyboardShortcutsView()
.disablesFullScreen()
}
.windowResizability(.contentSize)
#endif
}
#if os(macOS)
@@ -101,3 +94,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
+67
View File
@@ -0,0 +1,67 @@
import Foundation
import Observation
import OutlineKit
/// Turns `CachingOutlineAPIClient.repeatedFailureSummaries()` into a banner
/// the user can actually see and act on, instead of a silently-swallowed
/// `try?` see the pins bug this whole mechanism exists to catch a repeat
/// of. `RootView` polls the client periodically and feeds results in via
/// `update(with:)`; nothing here talks to the network directly.
@MainActor
@Observable
final class APIFailureCenter {
/// The single most-relevant category to show right now, or nil if
/// nothing's currently past the threshold (or everything past it has
/// been dismissed and is still in its cooldown).
private(set) var activeBanner: RepeatedFailure?
/// Categories the user's already dismissed, and when suppressed from
/// reappearing until `dismissCooldown` passes, so a still-flaky
/// operation doesn't pop the same banner right back up a few seconds
/// after being told to go away.
private var dismissedAt: [String: Date] = [:]
private let dismissCooldown: TimeInterval = 900
/// Called from `RootView`'s poll loop with the latest snapshot from
/// `CachingOutlineAPIClient`. Picks the worst-offending category
/// (highest failure count) that isn't in cooldown; clears the banner
/// entirely once nothing qualifies (e.g. the user went back online and
/// everything recovered).
func update(with summaries: [RepeatedFailure]) {
let now = Date()
dismissedAt = dismissedAt.filter { now.timeIntervalSince($0.value) < dismissCooldown }
let eligible = summaries
.filter { dismissedAt[$0.category] == nil }
.sorted { $0.count > $1.count }
activeBanner = eligible.first
}
/// Dismiss without reporting starts that category's cooldown so it
/// won't immediately reappear on the next poll if it's still failing.
func dismiss() {
guard let category = activeBanner?.category else { return }
dismissedAt[category] = Date()
activeBanner = nil
}
/// Everything folded into the report is safe to paste into a public bug
/// tracker as-is: a category name, a generic error description, and
/// version numbers no document content, no server URL, no token.
func reportURL(appVersion: String, osVersion: String) -> URL? {
guard let banner = activeBanner else { return nil }
var components = URLComponents(string: "https://git.psmattas.com/psmattas/Outpost/issues/new")
let body = """
Outpost kept failing to \(banner.category) (\(banner.count) times in the last few minutes).
Error: \(banner.message)
App version: \(appVersion)
macOS: \(osVersion)
<!-- Anything else you can add about what you were doing when this started would help. -->
"""
components?.queryItems = [URLQueryItem(name: "body", value: body)]
return components?.url
}
}
+5 -3
View File
@@ -33,7 +33,7 @@ enum SettingsCategory: String, CaseIterable, Identifiable {
/// explicitly built yet. Content lands section by section.
enum SettingsSection: String, CaseIterable, Identifiable, Hashable {
// General (ours)
case appearance, editor, offlineSync, advanced, about
case appearance, editor, navigation, offlineSync, advanced, about
// Account
case profile, preferences, notifications, passkeys, apiAccess
@@ -45,7 +45,7 @@ enum SettingsSection: String, CaseIterable, Identifiable, Hashable {
var category: SettingsCategory {
switch self {
case .appearance, .editor, .offlineSync, .advanced, .about:
case .appearance, .editor, .navigation, .offlineSync, .advanced, .about:
return .general
case .profile, .preferences, .notifications, .passkeys, .apiAccess:
return .account
@@ -58,6 +58,7 @@ enum SettingsSection: String, CaseIterable, Identifiable, Hashable {
switch self {
case .appearance: return "Appearance"
case .editor: return "Editor"
case .navigation: return "Navigation"
case .offlineSync: return "Offline & Sync"
case .advanced: return "Advanced"
case .about: return "About"
@@ -87,6 +88,7 @@ enum SettingsSection: String, CaseIterable, Identifiable, Hashable {
switch self {
case .appearance: return "paintbrush"
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"
@@ -117,7 +119,7 @@ enum SettingsSection: String, CaseIterable, Identifiable, Hashable {
/// specified and built.
var isImplemented: Bool {
switch self {
case .appearance, .editor, .offlineSync, .advanced, .about, .profile, .preferences, .notifications, .passkeys, .apiAccess:
case .appearance, .editor, .navigation, .offlineSync, .advanced, .about, .profile, .preferences, .notifications, .passkeys, .apiAccess:
return true
default:
return false
+38
View File
@@ -0,0 +1,38 @@
#if os(macOS)
import SwiftUI
/// Shown when the same category of API call has failed repeatedly within a
/// few minutes (see `APIFailureCenter`) the self-diagnosing replacement
/// for a `try?` that used to fail silently. No manual "Retry" button: the
/// retries already happened automatically before this ever appears, so all
/// that's left worth offering is reporting it and moving on.
struct RepeatedFailureBanner: View {
let message: String
let onReport: () -> Void
let onDismiss: () -> Void
var body: some View {
HStack(spacing: 8) {
Image(systemName: "exclamationmark.triangle")
.foregroundStyle(.orange)
Text(message)
.font(.callout)
.lineLimit(2)
Spacer(minLength: 8)
Button("Report", action: onReport)
.buttonStyle(.borderedProminent)
.controlSize(.small)
Button {
onDismiss()
} label: {
Image(systemName: "xmark")
}
.buttonStyle(.plain)
.foregroundStyle(.secondary)
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(Color.orange.opacity(0.12))
}
}
#endif
+60
View File
@@ -3,8 +3,10 @@ import OutlineKit
struct RootView: View {
@Environment(SessionStore.self) private var session
@Environment(\.openURL) private var openURL
@State private var welcomeName: String?
@State private var starStore = StarStore()
@State private var failureCenter = APIFailureCenter()
@AppStorage("outpost.fullLocalSyncEnabled") private var isFullLocalSyncEnabled = false
@AppStorage(CachingOutlineAPIClient.offlineModeDefaultsKey) private var isOfflineModeEnabled = false
@@ -34,10 +36,39 @@ struct RootView: View {
}
}
.environment(starStore)
.environment(failureCenter)
#if os(macOS)
.overlay(alignment: .top) {
if let banner = failureCenter.activeBanner {
RepeatedFailureBanner(
message: bannerMessage(for: banner),
onReport: { reportActiveFailure() },
onDismiss: { failureCenter.dismiss() }
)
.transition(.move(edge: .top).combined(with: .opacity))
}
}
.animation(.easeInOut(duration: 0.2), value: failureCenter.activeBanner)
#endif
.animation(.easeInOut(duration: 0.45), value: welcomeName != nil)
.task {
await session.refreshTeamInfoIfNeeded()
}
// Repeated (non-transient) API failures already get an automatic
// retry-with-backoff inside CachingOutlineAPIClient itself this
// just surfaces the ones that kept failing anyway, on a cheap poll
// (the client's own state, no network call of its own) rather than
// a push, since the client is a plain actor with no UI dependency.
.task(id: session.isSignedIn) {
guard session.isSignedIn else { return }
while !Task.isCancelled {
if let cachingClient = session.cachingClient {
let summaries = await cachingClient.repeatedFailureSummaries()
failureCenter.update(with: summaries)
}
try? await Task.sleep(for: .seconds(30))
}
}
.task(id: session.isSignedIn) {
if session.isSignedIn, let apiClient = session.apiClient {
await starStore.load(apiClient: apiClient)
@@ -89,6 +120,35 @@ struct RootView: View {
}
}
#if os(macOS)
/// Category names are internal plumbing (`documents-write`, `pins`,
/// `collections-write`, ...) this is the one place they turn into
/// something a user reads, so a new category added later just needs a
/// case here, not a rewrite of the tracking/polling underneath it.
private func bannerMessage(for failure: RepeatedFailure) -> String {
switch failure.category {
case "documents-write": return "Outpost is having trouble saving your document edits."
case "collections-write": return "Outpost is having trouble saving collection changes."
case "pins": return "Outpost is having trouble updating pins."
case "subscriptions": return "Outpost is having trouble updating subscriptions."
case "stars": return "Outpost is having trouble updating stars."
case "document", "documents": return "Outpost is having trouble loading documents."
case "collections": return "Outpost is having trouble loading collections."
case "drafts": return "Outpost is having trouble loading drafts."
default: return "Outpost is having trouble talking to the server (\(failure.category))."
}
}
private func reportActiveFailure() {
guard let url = failureCenter.reportURL(
appVersion: OutpostVersion.displayString,
osVersion: ProcessInfo.processInfo.operatingSystemVersionString
) else { return }
openURL(url)
failureCenter.dismiss()
}
#endif
private func startWelcomeTransition(_ result: AuthViewModel.AuthResult) {
welcomeName = result.user.name
session.signIn(serverURL: result.serverURL, user: result.user, team: result.team)
+33 -6
View File
@@ -15,6 +15,7 @@ final class SessionStore {
private static let userPreferencesDefaultsKey = "outline.userPreferences"
private let tokenStore: TokenStoring
private let cacheEncryptionKeyStore: CacheEncryptionKeyStoring
private let defaults: UserDefaults
var isSignedIn: Bool
@@ -43,8 +44,13 @@ final class SessionStore {
defaults.string(forKey: Self.serverURLDefaultsKey).flatMap(URL.init(string:))
}
init(tokenStore: TokenStoring = KeychainTokenStore(), defaults: UserDefaults = .standard) {
init(
tokenStore: TokenStoring = KeychainTokenStore(),
cacheEncryptionKeyStore: CacheEncryptionKeyStoring = KeychainCacheEncryptionKeyStore(),
defaults: UserDefaults = .standard
) {
self.tokenStore = tokenStore
self.cacheEncryptionKeyStore = cacheEncryptionKeyStore
self.defaults = defaults
self.cacheStore = (try? OfflineCacheStore.makeContainer()).map(OfflineCacheStore.init(modelContainer:))
@@ -53,7 +59,12 @@ final class SessionStore {
if hasToken, let storedServerURL {
isSignedIn = true
(apiClient, cachingClient) = Self.makeAPIClient(serverURL: storedServerURL, tokenStore: tokenStore, cache: cacheStore)
(apiClient, cachingClient) = Self.makeAPIClient(
serverURL: storedServerURL,
tokenStore: tokenStore,
cacheEncryptionKeyStore: cacheEncryptionKeyStore,
cache: cacheStore
)
userPreferences = Self.loadCachedPreferences(defaults: defaults)
} else {
// Keychain and the sandboxed UserDefaults container don't
@@ -73,7 +84,12 @@ final class SessionStore {
func signIn(serverURL: URL, user: OutlineUser, team: OutlineTeam) {
defaults.set(serverURL.absoluteString, forKey: Self.serverURLDefaultsKey)
(apiClient, cachingClient) = Self.makeAPIClient(serverURL: serverURL, tokenStore: tokenStore, cache: cacheStore)
(apiClient, cachingClient) = Self.makeAPIClient(
serverURL: serverURL,
tokenStore: tokenStore,
cacheEncryptionKeyStore: cacheEncryptionKeyStore,
cache: cacheStore
)
apply(user: user, team: team, serverURL: serverURL)
isSignedIn = true
}
@@ -99,6 +115,7 @@ final class SessionStore {
private static func makeAPIClient(
serverURL: URL,
tokenStore: TokenStoring,
cacheEncryptionKeyStore: CacheEncryptionKeyStoring,
cache: OfflineCacheStore?
) -> (OutlineAPIClient, CachingOutlineAPIClient?) {
let live = LiveOutlineAPIClient(
@@ -106,12 +123,22 @@ final class SessionStore {
tokenStore: tokenStore
)
guard let cache else { return (live, nil) }
let caching = CachingOutlineAPIClient(live: live, cache: cache)
let caching = CachingOutlineAPIClient(live: live, cache: cache, encryptionKeyStore: cacheEncryptionKeyStore)
return (caching, caching)
}
func signOut() {
/// Clears everything scoped to this sign-in: the API token, the offline
/// cache's encryption key, and the cache/pending-write storage itself
/// (in that order wiping storage before the key would leave it
/// readable a moment longer than necessary, and wiping the key without
/// the storage would leave permanently-undecryptable rows sitting
/// around instead of actually freeing anything). Whoever signs in next
/// on this machine gets a clean slate, not a previous account's
/// leftover cached content.
func signOut() async {
try? tokenStore.clear()
await cachingClient?.clearEverythingForSignOut()
try? cacheEncryptionKeyStore.clear()
defaults.removeObject(forKey: Self.serverURLDefaultsKey)
isSignedIn = false
userId = nil
@@ -136,7 +163,7 @@ final class SessionStore {
/// in-memory state didn't.
func refreshTeamInfoIfNeeded() async {
guard isSignedIn, teamName == nil, let apiClient, let serverURL else { return }
guard let auth = try? await apiClient.authInfo() else { return }
guard let auth = try? await RetryPolicy.withRetry({ try await apiClient.authInfo() }) else { return }
apply(user: auth.user, team: auth.team, serverURL: serverURL)
}
+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,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
+8 -6
View File
@@ -7,24 +7,26 @@ import Foundation
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"
/// expected to stay a plain dotted-numeric string, not `0.1.0-ALPHA`.
/// Empty since the App Store release (no more alpha/beta suffix)
/// `displayString`/`fullVersionString` just show the plain version now.
static let releaseStage = ""
static var shortVersion: String {
Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0.0.1"
Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0.1.0"
}
static var buildNumber: String {
Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "1"
Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "3"
}
/// e.g. `"0.0.3-ALPHA"` for compact display (sidebar footer).
/// e.g. `"0.1.0"` 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.
/// e.g. `"Version 0.1.0 (3)"` for the About page.
static var fullVersionString: String {
"Version \(displayString) (\(buildNumber))"
}
+38
View File
@@ -0,0 +1,38 @@
import SwiftUI
#if os(macOS)
import AppKit
/// Pointing-hand cursor while the mouse is over a clickable sidebar/list row,
/// gated by the "Pointer Cursor" setting (Settings Editor Pointer Cursor).
/// `didPush` tracks whether this instance actually pushed a cursor, so a
/// mid-hover toggle of the preference can never leave `NSCursor`'s push/pop
/// stack unbalanced pop only fires for a push this same hover made.
private struct PointerCursorOnHover: ViewModifier {
@AppStorage("outpost.pointerCursorEnabled") private var isEnabled = true
@State private var didPush = false
func body(content: Content) -> some View {
content.onHover { hovering in
if hovering {
guard isEnabled else { return }
NSCursor.pointingHand.push()
didPush = true
} else if didPush {
NSCursor.pop()
didPush = false
}
}
}
}
extension View {
func pointerCursorOnHover() -> some View {
modifier(PointerCursorOnHover())
}
}
#else
extension View {
func pointerCursorOnHover() -> some View { self }
}
#endif
+1 -1
View File
@@ -22,7 +22,7 @@ final class StarStore {
}
func load(apiClient: OutlineAPIClient) async {
guard let stars = try? await apiClient.listStars(ListStarsRequest(offset: 0, limit: 250)) else { return }
guard let stars = try? await RetryPolicy.withRetry({ try await apiClient.listStars(ListStarsRequest(offset: 0, limit: 250)) }) else { return }
documentStars = Dictionary(uniqueKeysWithValues: stars.compactMap { star in
star.documentId.map { ($0, star) }
})
+71
View File
@@ -0,0 +1,71 @@
import StoreKit
import Observation
/// Backs the tip jar in Settings About. Consumables only a tip doesn't
/// unlock anything, so there's no entitlement to persist or restore, and
/// finishing the transaction immediately (rather than checking
/// `Transaction.currentEntitlements` on launch, the way a real purchase
/// would need to) is correct here.
@MainActor
@Observable
final class TipJarStore {
enum PurchaseState: Equatable {
case idle
case purchasing(String)
case thankYou(String)
case failed(String)
}
/// Must match the consumable In-App Purchase products created in App
/// Store Connect for this app exactly, including the bundle id prefix.
static let productIDs = [
"com.psmattas.OutpostApp.tip.small",
"com.psmattas.OutpostApp.tip.medium",
"com.psmattas.OutpostApp.tip.large",
"com.psmattas.OutpostApp.tip.generous"
]
private(set) var products: [Product] = []
private(set) var isLoading = false
var purchaseState: PurchaseState = .idle
/// Tip options don't change during a session no reason to refetch
/// every time the About screen appears.
func loadProductsIfNeeded() async {
guard products.isEmpty, !isLoading else { return }
isLoading = true
defer { isLoading = false }
do {
let fetched = try await Product.products(for: Self.productIDs)
// Keep the order defined above (small -> generous), not
// whatever order the App Store happens to return them in.
products = Self.productIDs.compactMap { id in fetched.first { $0.id == id } }
if products.isEmpty {
purchaseState = .failed("Tip options aren't available right now.")
}
} catch {
purchaseState = .failed("Couldn't load tip options. Check your connection and try again.")
}
}
func purchase(_ product: Product) async {
purchaseState = .purchasing(product.id)
do {
switch try await product.purchase() {
case .success(let verification):
guard case .verified(let transaction) = verification else {
purchaseState = .failed("Couldn't verify this purchase.")
return
}
await transaction.finish()
purchaseState = .thankYou(product.id)
case .userCancelled, .pending:
purchaseState = .idle
@unknown default:
purchaseState = .idle
}
} catch {
purchaseState = .failed("Something went wrong completing the purchase.")
}
}
}
@@ -8,7 +8,7 @@ extension View {
titleVisibility: .visible
) {
Button("Log Out", role: .destructive) {
session.signOut()
Task { await session.signOut() }
}
Button("Cancel", role: .cancel) {}
} message: {
-38
View File
@@ -1,38 +0,0 @@
#if os(macOS)
import SwiftUI
import AppKit
/// Grabs the hosting `NSWindow` once it's attached to a screen, for the
/// handful of things SwiftUI's `Window` scene doesn't expose a modifier for.
private struct WindowConfigurator: NSViewRepresentable {
let configure: (NSWindow) -> Void
func makeNSView(context: Context) -> NSView {
let view = NSView()
DispatchQueue.main.async {
if let window = view.window {
configure(window)
}
}
return view
}
func updateNSView(_ nsView: NSView, context: Context) {}
}
extension View {
/// `Window` scenes default to a full standard titlebar, fullscreen
/// (green) button included not appropriate for fixed-size reference
/// panels like About or Keyboard Shortcuts, which have no reason to
/// support fullscreen at all.
func disablesFullScreen() -> some View {
background(
WindowConfigurator { window in
window.collectionBehavior.remove(.fullScreenPrimary)
window.collectionBehavior.insert(.fullScreenNone)
window.standardWindowButton(.zoomButton)?.isHidden = true
}
)
}
}
#endif
+8 -4
View File
@@ -5,14 +5,14 @@
<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 href="https://apps.apple.com/us/app/outpost-for-outline/id6802736230">
<img src="docs/assets/mac-app-store-badge.svg" height="40" alt="Download on the Mac App Store">
</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.
> **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
@@ -21,7 +21,7 @@ Outline's web app is great, but there's no native Apple client with full editing
## Requirements
- 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
- macOS 14+ to run the app. iOS/iPadOS support is planned but not in the current build (see the note above)
- A self-hosted (or hosted) Outline instance with API access
## Setup
@@ -46,6 +46,10 @@ Outpost itself is licensed under the [Business Source License 1.1](./LICENSE)
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.
+3 -3
View File
@@ -18,9 +18,9 @@ within 14 days depending on severity.
## Supported Versions
Outpost is in early alpha (`0.0.x`) — there's no stable release line
yet. Only the most recent tagged release receives fixes; please make
sure you're on the latest alpha before reporting.
Outpost is early (`0.1.x`) — there's no stable release line yet. Only
the most recent tagged release receives fixes; please make sure
you're on the latest release before reporting.
| Version | Supported |
| :--- | :---: |
+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,737 @@
//
// 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
/// Show a pointing-hand cursor over links while editing (not just in
/// read-only mode, where it always shows regardless of this flag).
///
/// On by default. The embedder's "pointer cursor" preference maps
/// straight to this off just means the I-beam stays over links like
/// any other text, since a link's edge zone still repositions the caret
/// for editing rather than navigating (see `clickedOnLink`).
public var pointerCursorOverLinksWhileEditing: 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,
pointerCursorOverLinksWhileEditing: Bool = true
) {
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
self.pointerCursorOverLinksWhileEditing = pointerCursorOverLinksWhileEditing
}
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,137 @@
//
// 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=1 in the run scheme to enable.
// Off by default even in Debug opt-in, not opt-out, so a normal
// debug run stays quiet.
// 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"] == "1"
/// 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))
}
}

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