Business Logic Model: Unit 5 — Export & Polish
Pipeline Overview
Export Flow (HTML):
User triggers Cmd+Shift+E
|
v
[1] EditorViewModel.exportHTML()
| Reads sourceText from document
v
[2] ExportService.generateHTML(from: sourceText, title: documentTitle)
| swift-markdown AST walk → HTML tags
| Wrap in HTMLTemplate with CSS
v
[3] Save Dialog (NSSavePanel)
| User selects destination
v
[4] Write HTML file to disk
Export Flow (PDF):
User triggers Cmd+Shift+P
|
v
[1] EditorViewModel.exportPDF()
| Reads sourceText from document
v
[2] ExportService.generateHTML(from: sourceText, title: documentTitle)
| Same HTML generation as above
v
[3] ExportService.generatePDF(from: html)
| Load HTML in offscreen WKWebView
| WKWebView.createPDF(configuration:)
v
[4] Save Dialog (NSSavePanel)
| User selects destination
v
[5] Write PDF data to disk
[1] Export Trigger
Trigger: Menu item or keyboard shortcut (Cmd+Shift+E for HTML, Cmd+Shift+P for PDF)
Logic:
- Read
sourceTextfromEditorViewModel - Derive
titlefrom document file name (without.mdextension), or “Untitled” for unsaved documents - Derive
baseURLfrom document file URL’s parent directory (for relative image paths) - Call the appropriate
ExportServicemethod
[2] HTML Generation Pipeline (ExportService.generateHTML)
Input: source: String (raw Markdown), title: String
Logic:
- Parse source with
MarkdownParser.parse(source)→MarkupAST (swift-markdown) -
Walk the AST depth-first, converting each node to HTML tags:
AST Node HTML Output Heading(level: n)<h{n}>...</h{n}>Paragraph<p>...</p>Strong<strong>...</strong>Emphasis<em>...</em>Strikethrough<del>...</del>InlineCode<code>...</code>CodeBlock(language:)<pre><code class="language-{lang}">...</code></pre>Link(destination:)<a href="{url}">...</a>Image(source:, title:)<img src="{src}" alt="{alt}" title="{title}">UnorderedList<ul>...<li>...</li>...</ul>OrderedList<ol start="{n}">...<li>...</li>...</ol>ListItemwith checkbox<li><input type="checkbox" disabled {checked}>...</li>BlockQuote<blockquote>...</blockquote>Table<table>...<thead>...<tbody>...</table>ThematicBreak<hr>SoftBreak` ` (space) LineBreak<br>TextEscaped plain text - Escape HTML entities in text content (
<,>,&,",') - Preserve image
srcas-is (relative paths remain relative) - Assemble
HTMLTemplate:- Set
titlefrom parameter - Set
cssStylesfrom built-in default CSS - Set
bodyHTMLfrom generated HTML - Set
baseURLfrom document location (passed through)
- Set
- Call
HTMLTemplate.assembleFullDocument()→ complete HTML string - Return HTML string encoded as UTF-8
Concurrency: Runs on ExportService actor (isolated). AST walk is synchronous within the actor.
[3] PDF Generation Pipeline (ExportService.generatePDF)
Input: html: String (complete HTML document from step 2)
Logic:
- Create an offscreen
WKWebView(not added to any window)- Configure with
WKWebViewConfiguration:suppressesIncrementalRendering = true(wait for full load)
- Configure with
- Load the HTML string into
WKWebViewvialoadHTMLString(_:baseURL:)baseURLset to document directory for relative image resolution
- Wait for
WKNavigationDelegate.webView(_:didFinish:)callback- Guard with timeout (
PDFConfiguration.timeoutInterval, default 30s) - If timeout fires before load completes, throw
ExportError.pdfRenderingTimedOut
- Guard with timeout (
- Call
WKWebView.createPDF(configuration:)with:WKPDFConfiguration.rectset fromPDFConfiguration.paperSizeandmarginsallowTransparentBackground = false
- Receive
Data(PDF bytes) from completion handler - Clean up WKWebView (remove from memory)
- Return PDF
Data
Concurrency: WKWebView must be created and operated on the main thread (@MainActor). The ExportService.generatePDF method internally dispatches to MainActor for WebView operations, then returns the result to the caller.
Main Actor Isolation for WKWebView:
actor ExportService {
func generatePDF(from html: String, baseURL: URL?, configuration: PDFConfiguration) async throws -> Data {
try await MainActor.run {
// Create WKWebView, load HTML, await navigation, create PDF
}
}
}
[4] Save Dialog
Input: ExportResult with generated data
Logic:
- Present
NSSavePanel:allowedContentTypes:[exportResult.format.utType]nameFieldStringValue:exportResult.suggestedFileNamedirectoryURL: Same directory as source.mdfile, or user’s Documents folder
- If user confirms (
.OK):- Write
exportResult.datato selected URL - On success: no additional feedback (file appears in Finder)
- On failure: present
NSAlertwithExportError.fileWriteFailed
- Write
- If user cancels: no action
Sidebar Data Flow
App Launch / Document Open / Document Close
|
v
[1] Sidebar Load
| Query NSDocumentController.shared.recentDocumentURLs
| Convert to [SidebarItem]
v
[2] Sidebar Display
| SidebarView renders items sorted by lastOpened descending
| Current document highlighted
v
[3] User Interaction
| Click item → open document
| Toggle visibility → Cmd+Shift+L or toolbar button
[1] Sidebar Load
Trigger: App launch, document open, document close, or sidebar becomes visible
Logic:
- Read
NSDocumentController.shared.recentDocumentURLs(macOS manages this list) - For each URL, construct
SidebarItem:displayName: file name without.mdextensionurl: the file URLlastOpened: file’s last access date from file attributes (URLResourceKey.contentAccessDateKey)isCurrentDocument: compare withNSDocumentController.shared.currentDocument?.fileURL
- Sort by
lastOpeneddescending (most recent first) - Update
SidebarState.items
[2] Sidebar Display
Input: SidebarState
Logic:
- If
sidebarState.isVisible == false, sidebar column is collapsed (zero width) - Render
ListofSidebarItementries - Current document row shows a distinct selection highlight
- Empty state: show “No Recent Files” placeholder text
[3] User Interaction — Open File
Input: User clicks a SidebarItem
Logic:
- Set
sidebarState.selectedItemIDto clicked item - Call
NSDocumentController.shared.openDocument(withContentsOf: item.url, display: true) - The document system handles window creation and editor attachment
Dark Mode Propagation
User changes AppearanceMode in Preferences
|
v
[1] EditorSettings.appearanceOverride updated
| @Observable triggers SwiftUI view updates
v
[2] NSApp.appearance updated
| Maps AppearanceMode → NSAppearance
v
[3] All views re-render
| NSWindow inherits new appearance
| SwiftUI views adapt automatically via system colors
| NSTextView attributed strings re-rendered
v
[4] RenderCache invalidated
| All cached block renders flushed
| Full re-render of editor content
[1] Appearance Override Update
Trigger: User selects appearance mode in Preferences (Picker)
Logic:
EditorSettings.shared.appearanceOverrideis set to new value- Persist to UserDefaults via
EditorSettings.save()
[2] NSApp.appearance Propagation
Logic:
- Observe
EditorSettings.shared.appearanceOverrideinShoechooApp - Map to
NSAppearance:.system→ setNSApp.appearance = nil(inherit system).light→ setNSApp.appearance = NSAppearance(named: .aqua).dark→ setNSApp.appearance = NSAppearance(named: .darkAqua)
- All
NSWindowinstances inherit the app-level appearance - SwiftUI environment
.colorSchemeupdates automatically
[3] View Re-rendering
Logic:
- SwiftUI views using system colors (
.primary,.secondary,.background) adapt automatically SidebarViewbackground and text colors update viaNSColordynamic resolutionEditorViewtoolbar adapts via.windowToolbarStyleWYSIWYGTextViewtriggers full re-render:RenderCache.invalidateAll()(BR-05.6 from Unit 1)- All blocks re-rendered with new appearance colors
- Syntax highlighting colors resolve to new values via
NSColorsemantic colors
[4] RenderCache Invalidation
Logic:
- Detect appearance change via
NSApplication.effectiveAppearanceobservation - Call
RenderCache.invalidateAll() - Trigger selective re-render for all visible blocks
- TextKit 2 display update applies new attributed strings