5 Commits
Author SHA1 Message Date
Puranjay Savar Mattas cc27a860e5 Merge branch 'main' into feature/document-editing 2026-08-20 00:12:55 +01:00
Puranjay Savar Mattas d2e213b516 Merge pull request 'chore: vendor swift-markdown-engine as a local submodule' (#11) from chore/vendor-markdown-engine-submodule into main
Reviewed-on: #11
2026-08-20 00:09:01 +01:00
Puranjay Savar Mattas 226d6fb748 chore: switch Xcode to the local swift-markdown-engine submodule
Removed the remote XCRemoteSwiftPackageReference and pointed
MarkdownEngine/MarkdownEngineCodeBlocks/MarkdownEngineLatex at the
XCLocalSwiftPackageReference for Vendor/swift-markdown-engine instead.
Package.resolved drops the now-irrelevant remote pin (HighlighterSwift
and SwiftMath stay remote, only swift-markdown-engine itself moved
local).

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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 23:44:50 +01:00
8 changed files with 166 additions and 60 deletions
+3
View File
@@ -1,3 +1,6 @@
[submodule "docs/reference/outline-openapi"] [submodule "docs/reference/outline-openapi"]
path = docs/reference/outline-openapi path = docs/reference/outline-openapi
url = https://github.com/outline/openapi.git 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
+26 -36
View File
@@ -7,10 +7,10 @@
objects = { objects = {
/* Begin PBXBuildFile section */ /* 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 */; };
FAF99C44302CF1BD00C9949F /* OutlineKit in Frameworks */ = {isa = PBXBuildFile; productRef = FAF99C43302CF1BD00C9949F /* OutlineKit */; }; FAF99C44302CF1BD00C9949F /* OutlineKit in Frameworks */ = {isa = PBXBuildFile; productRef = FAF99C43302CF1BD00C9949F /* OutlineKit */; };
FAF99CAF302D120500C9949F /* MarkdownEngine in Frameworks */ = {isa = PBXBuildFile; productRef = FAF99CAE302D120500C9949F /* MarkdownEngine */; };
FAF99CB1302D120500C9949F /* MarkdownEngineCodeBlocks in Frameworks */ = {isa = PBXBuildFile; productRef = FAF99CB0302D120500C9949F /* MarkdownEngineCodeBlocks */; };
FAF99CB3302D120500C9949F /* MarkdownEngineLatex in Frameworks */ = {isa = PBXBuildFile; productRef = FAF99CB2302D120500C9949F /* MarkdownEngineLatex */; };
/* End PBXBuildFile section */ /* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */ /* Begin PBXContainerItemProxy section */
@@ -59,9 +59,9 @@
isa = PBXFrameworksBuildPhase; isa = PBXFrameworksBuildPhase;
files = ( files = (
FAF99C44302CF1BD00C9949F /* OutlineKit in Frameworks */, FAF99C44302CF1BD00C9949F /* OutlineKit in Frameworks */,
FAF99CB1302D120500C9949F /* MarkdownEngineCodeBlocks in Frameworks */, FA7596B430366A1D0000167E /* MarkdownEngineCodeBlocks in Frameworks */,
FAF99CB3302D120500C9949F /* MarkdownEngineLatex in Frameworks */, FA7596B630366A1D0000167E /* MarkdownEngineLatex in Frameworks */,
FAF99CAF302D120500C9949F /* MarkdownEngine in Frameworks */, FA7596B230366A1D0000167E /* MarkdownEngine in Frameworks */,
); );
}; };
FAF99C24302CE96200C9949F /* Frameworks */ = { FAF99C24302CE96200C9949F /* Frameworks */ = {
@@ -116,9 +116,9 @@
name = Outpost; name = Outpost;
packageProductDependencies = ( packageProductDependencies = (
FAF99C43302CF1BD00C9949F /* OutlineKit */, FAF99C43302CF1BD00C9949F /* OutlineKit */,
FAF99CAE302D120500C9949F /* MarkdownEngine */, FA7596B130366A1D0000167E /* MarkdownEngine */,
FAF99CB0302D120500C9949F /* MarkdownEngineCodeBlocks */, FA7596B330366A1D0000167E /* MarkdownEngineCodeBlocks */,
FAF99CB2302D120500C9949F /* MarkdownEngineLatex */, FA7596B530366A1D0000167E /* MarkdownEngineLatex */,
); );
productName = Outpost; productName = Outpost;
productReference = FAF99C18302CE96100C9949F /* Outpost.app */; productReference = FAF99C18302CE96100C9949F /* Outpost.app */;
@@ -200,7 +200,7 @@
minimizedProjectReferenceProxies = 1; minimizedProjectReferenceProxies = 1;
packageReferences = ( packageReferences = (
FAF99C42302CF1BD00C9949F /* XCLocalSwiftPackageReference "OutlineKit" */, FAF99C42302CF1BD00C9949F /* XCLocalSwiftPackageReference "OutlineKit" */,
FAF99CAD302D120500C9949F /* XCRemoteSwiftPackageReference "swift-markdown-engine" */, FA7596B030366A1D0000167E /* XCLocalSwiftPackageReference "Vendor/swift-markdown-engine" */,
); );
preferredProjectObjectVersion = 77; preferredProjectObjectVersion = 77;
productRefGroup = FAF99C19302CE96100C9949F /* Products */; productRefGroup = FAF99C19302CE96100C9949F /* Products */;
@@ -622,43 +622,33 @@
/* End XCConfigurationList section */ /* End XCConfigurationList section */
/* Begin XCLocalSwiftPackageReference section */ /* Begin XCLocalSwiftPackageReference section */
FA7596B030366A1D0000167E /* XCLocalSwiftPackageReference "Vendor/swift-markdown-engine" */ = {
isa = XCLocalSwiftPackageReference;
relativePath = "Vendor/swift-markdown-engine";
};
FAF99C42302CF1BD00C9949F /* XCLocalSwiftPackageReference "OutlineKit" */ = { FAF99C42302CF1BD00C9949F /* XCLocalSwiftPackageReference "OutlineKit" */ = {
isa = XCLocalSwiftPackageReference; isa = XCLocalSwiftPackageReference;
relativePath = OutlineKit; relativePath = OutlineKit;
}; };
/* End XCLocalSwiftPackageReference section */ /* End XCLocalSwiftPackageReference section */
/* Begin XCRemoteSwiftPackageReference section */
FAF99CAD302D120500C9949F /* XCRemoteSwiftPackageReference "swift-markdown-engine" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/nodes-app/swift-markdown-engine";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 0.12.0;
};
};
/* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */ /* Begin XCSwiftPackageProductDependency section */
FA7596B130366A1D0000167E /* MarkdownEngine */ = {
isa = XCSwiftPackageProductDependency;
productName = MarkdownEngine;
};
FA7596B330366A1D0000167E /* MarkdownEngineCodeBlocks */ = {
isa = XCSwiftPackageProductDependency;
productName = MarkdownEngineCodeBlocks;
};
FA7596B530366A1D0000167E /* MarkdownEngineLatex */ = {
isa = XCSwiftPackageProductDependency;
productName = MarkdownEngineLatex;
};
FAF99C43302CF1BD00C9949F /* OutlineKit */ = { FAF99C43302CF1BD00C9949F /* OutlineKit */ = {
isa = XCSwiftPackageProductDependency; isa = XCSwiftPackageProductDependency;
productName = OutlineKit; productName = OutlineKit;
}; };
FAF99CAE302D120500C9949F /* MarkdownEngine */ = {
isa = XCSwiftPackageProductDependency;
package = FAF99CAD302D120500C9949F /* XCRemoteSwiftPackageReference "swift-markdown-engine" */;
productName = MarkdownEngine;
};
FAF99CB0302D120500C9949F /* MarkdownEngineCodeBlocks */ = {
isa = XCSwiftPackageProductDependency;
package = FAF99CAD302D120500C9949F /* XCRemoteSwiftPackageReference "swift-markdown-engine" */;
productName = MarkdownEngineCodeBlocks;
};
FAF99CB2302D120500C9949F /* MarkdownEngineLatex */ = {
isa = XCSwiftPackageProductDependency;
package = FAF99CAD302D120500C9949F /* XCRemoteSwiftPackageReference "swift-markdown-engine" */;
productName = MarkdownEngineLatex;
};
/* End XCSwiftPackageProductDependency section */ /* End XCSwiftPackageProductDependency section */
}; };
rootObject = FAF99C10302CE96100C9949F /* Project object */; rootObject = FAF99C10302CE96100C9949F /* Project object */;
@@ -1,5 +1,5 @@
{ {
"originHash" : "f233fa96f0c6bdcdbf87f726af38f25704f6d46a156a27dfac47541baa63bf97", "originHash" : "4127e8224149bef00a33500e8db49748a735e1c07626f2255faea52554afef5e",
"pins" : [ "pins" : [
{ {
"identity" : "highlighterswift", "identity" : "highlighterswift",
@@ -10,15 +10,6 @@
"version" : "3.1.0" "version" : "3.1.0"
} }
}, },
{
"identity" : "swift-markdown-engine",
"kind" : "remoteSourceControl",
"location" : "https://github.com/nodes-app/swift-markdown-engine",
"state" : {
"revision" : "e5f7607fc4021181056ef7a09dbb7573dc0237d9",
"version" : "0.12.0"
}
},
{ {
"identity" : "swiftmath", "identity" : "swiftmath",
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
@@ -1,6 +1,7 @@
#if os(macOS) #if os(macOS)
import SwiftUI import SwiftUI
import MarkdownEngine import MarkdownEngine
import MarkdownEngineCodeBlocks
import OutlineKit import OutlineKit
/// Read-only for now document/overview editing isn't wired up yet. Uses /// Read-only for now document/overview editing isn't wired up yet. Uses
@@ -18,7 +19,10 @@ struct CollectionOverviewContent: View {
ScrollView { ScrollView {
NativeTextViewWrapper( NativeTextViewWrapper(
text: $markdown, text: $markdown,
configuration: .init(heightBehavior: .fitsContent), configuration: .init(
services: .init(syntaxHighlighter: CodeSyntaxHighlighting.shared),
heightBehavior: .fitsContent
),
isEditable: false isEditable: false
) )
.padding() .padding()
@@ -1,6 +1,7 @@
#if os(macOS) #if os(macOS)
import SwiftUI import SwiftUI
import MarkdownEngine import MarkdownEngine
import MarkdownEngineCodeBlocks
import OutlineKit import OutlineKit
/// Distraction-free reading view no toolbar/sidebar chrome, larger type. /// Distraction-free reading view no toolbar/sidebar chrome, larger type.
@@ -44,7 +45,10 @@ struct DocumentPresentSheet: View {
.padding(.bottom, 8) .padding(.bottom, 8)
NativeTextViewWrapper( NativeTextViewWrapper(
text: $text, text: $text,
configuration: .init(heightBehavior: .fitsContent), configuration: .init(
services: .init(syntaxHighlighter: CodeSyntaxHighlighting.shared),
heightBehavior: .fitsContent
),
isEditable: false isEditable: false
) )
.font(.system(size: 18)) .font(.system(size: 18))
@@ -3,6 +3,7 @@ import AppKit
import SwiftUI import SwiftUI
import UniformTypeIdentifiers import UniformTypeIdentifiers
import MarkdownEngine import MarkdownEngine
import MarkdownEngineCodeBlocks
import OutlineKit import OutlineKit
/// `NSSavePanel`/`NSPrintOperation`/`NSPasteboard` in the action functions /// `NSSavePanel`/`NSPrintOperation`/`NSPasteboard` in the action functions
@@ -17,6 +18,21 @@ struct DocumentReaderView: View {
/// Outline see `SettingsView.editorDetail`. /// Outline see `SettingsView.editorDetail`.
@AppStorage("outpost.splitViewEnabled") private var isSplitViewEnabled = false @AppStorage("outpost.splitViewEnabled") private var isSplitViewEnabled = false
/// Outline's own "Show line numbers" preference (synced, read via
/// `session.userPreferences`, not `@AppStorage` this one's the
/// server's, not a local-only Outpost setting). No `@Environment`-in-`init`
/// problem here since this is read directly in the view, not the
/// view model.
private var showCodeBlockLineNumbers: Bool {
session.userPreferences?.codeBlockLineNumbers ?? false
}
/// Widened left indent reserved for the number gutter when line numbers
/// are on (default is 12pt, just enough margin, no room for digits).
private static let lineNumberGutterWidth: CGFloat = 32
private var editorCodeBlockStyle: CodeBlockStyle {
showCodeBlockLineNumbers ? .init(horizontalIndent: Self.lineNumberGutterWidth) : .default
}
@State private var viewModel: DocumentReaderViewModel @State private var viewModel: DocumentReaderViewModel
let apiClient: OutlineAPIClient let apiClient: OutlineAPIClient
let document: OutlineDocument let document: OutlineDocument
@@ -44,6 +60,14 @@ struct DocumentReaderView: View {
@State private var isShowingShareSheet = false @State private var isShowingShareSheet = false
@State private var isShowingNewDocumentSheet = false @State private var isShowingNewDocumentSheet = false
@State private var actionErrorMessage: String? @State private var actionErrorMessage: String?
/// Populated live by `NativeTextViewWrapper`'s `onCodeBlockSelectionChange`
/// one array per instance (main pane, split-view preview pane), since
/// each lays the same text out at a different width and gets different
/// rects. Only non-empty when `showCodeBlockLineNumbers` is on (see its
/// doc comment for why the gutter needs `codeBlock.horizontalIndent`
/// widened, which is gated on the same flag).
@State private var readerCodeBlocks: [CodeBlockSelection] = []
@State private var previewCodeBlocks: [CodeBlockSelection] = []
init( init(
apiClient: OutlineAPIClient, apiClient: OutlineAPIClient,
@@ -307,12 +331,24 @@ struct DocumentReaderView: View {
} }
} }
} else { } else {
ZStack(alignment: .topLeading) {
NativeTextViewWrapper( NativeTextViewWrapper(
text: $viewModel.text, text: $viewModel.text,
configuration: .init(heightBehavior: .fitsContent), configuration: .init(
services: .init(syntaxHighlighter: CodeSyntaxHighlighting.shared),
codeBlock: editorCodeBlockStyle,
heightBehavior: .fitsContent
),
documentId: viewModel.documentId, documentId: viewModel.documentId,
isEditable: viewModel.isEffectivelyEditable isEditable: viewModel.isEffectivelyEditable,
onCodeBlockSelectionChange: { readerCodeBlocks = $0 }
) )
if showCodeBlockLineNumbers {
ForEach(readerCodeBlocks) { selection in
CodeBlockLineNumberGutter(selection: selection, gutterWidth: Self.lineNumberGutterWidth)
}
}
}
} }
} }
.padding() .padding()
@@ -360,12 +396,24 @@ struct DocumentReaderView: View {
.frame(minWidth: 300, maxWidth: .infinity, maxHeight: .infinity) .frame(minWidth: 300, maxWidth: .infinity, maxHeight: .infinity)
ScrollView { ScrollView {
ZStack(alignment: .topLeading) {
NativeTextViewWrapper( NativeTextViewWrapper(
text: $viewModel.text, text: $viewModel.text,
configuration: .init(heightBehavior: .fitsContent), configuration: .init(
services: .init(syntaxHighlighter: CodeSyntaxHighlighting.shared),
codeBlock: editorCodeBlockStyle,
heightBehavior: .fitsContent
),
documentId: viewModel.documentId, documentId: viewModel.documentId,
isEditable: false isEditable: false,
onCodeBlockSelectionChange: { previewCodeBlocks = $0 }
) )
if showCodeBlockLineNumbers {
ForEach(previewCodeBlocks) { selection in
CodeBlockLineNumberGutter(selection: selection, gutterWidth: Self.lineNumberGutterWidth)
}
}
}
.padding(8) .padding(8)
.frame(maxWidth: .infinity, alignment: .topLeading) .frame(maxWidth: .infinity, alignment: .topLeading)
} }
@@ -610,4 +658,58 @@ struct DocumentReaderView: View {
operation.run() operation.run()
} }
} }
/// One code block's number gutter, positioned absolutely over a
/// `NativeTextViewWrapper` via `CodeBlockSelection.rect` same overlay
/// pattern MarkdownEngine's own `CodeBlockButton` uses.
///
/// `selection.rect` spans the WHOLE fenced block (open fence line + content
/// + close fence line), matching what the engine actually lays out the
/// fence lines render with invisible (`.clear`) text once the caret leaves
/// the block, but they don't collapse to zero height, so the block is
/// always exactly `content line count + 2` rows tall. `selection.code` is
/// content only, so the row height and number positions below both account
/// for that phantom top/bottom row explicitly instead of dividing by the
/// content line count alone (which would drift the numbers upward, more so
/// per line, the taller the block).
///
/// Known limitation, accepted rather than fixable app-side: a content line
/// that soft-wraps onto a second visual row (MarkdownEngine always
/// char-wraps code blocks, no way to opt out without forking the package)
/// throws this off every row below it reads one line low. Documented in
/// TODO.local.md alongside the same package's other gaps.
private struct CodeBlockLineNumberGutter: View {
let selection: CodeBlockSelection
let gutterWidth: CGFloat
/// `selection.code` (`token.contentRange`) always ends with exactly one
/// trailing `\n` per content line the range runs right up to the
/// start of the closing fence's own line, so the newline that ends the
/// last content line is included, but there's never an unterminated
/// final line to add one more for. Counting `\n` characters directly
/// (not `.components(separatedBy:).count`, which is one too many
/// whenever the string ends in the separator) is what makes a
/// single-line block read "1", not "2".
private var contentLineCount: Int {
max(1, selection.code.reduce(into: 0) { count, char in if char == "\n" { count += 1 } })
}
var body: some View {
let totalRows = CGFloat(contentLineCount + 2)
let rowHeight = selection.rect.height / totalRows
ForEach(0..<contentLineCount, id: \.self) { line in
Text("\(line + 1)")
.font(.system(size: 10, design: .monospaced))
.foregroundStyle(.secondary)
.frame(width: gutterWidth - 6, alignment: .trailing)
.position(
x: selection.rect.minX + (gutterWidth - 6) / 2,
// +1.5 rows: skip the invisible open-fence row, then
// center within this content row.
y: selection.rect.minY + rowHeight * (CGFloat(line) + 1.5)
)
}
.allowsHitTesting(false)
}
}
#endif #endif
@@ -0,0 +1,11 @@
#if os(macOS)
import MarkdownEngineCodeBlocks
/// One `HighlighterSwiftBridge` for the whole app. It owns a JavaScriptCore
/// context (expensive to spin up) plus its own highlight cache, so every
/// `NativeTextViewWrapper` should share this instance rather than each
/// constructing its own.
enum CodeSyntaxHighlighting {
static let shared = HighlighterSwiftBridge()
}
#endif