560efb9554
- AppFeature: SageTheme tokens, AppModel (@MainActor @Observable store), RootView (DEVONthink NavigationSplitView), Dashboard/Documents(MD-first+pending fallback+?token= download)/Search/Ask/Memos/Digest pages
- AI seam: AIService actor + AIResult, AppAIComposition (MockAIProvider x4 tiers), AICompletionView (numbered citations + always-visible routing badge), backend picker with visible explicit-unavailable error
- MarkdownView: block-aware renderer (GFM table separator-row skip, AttributedString inline-only)
- DSApp: thin @main, injects FixtureDSClient + mock AIRouter (zero backend / zero LLM)
swift build (full app) + swift test (19) green under Swift 6 strict concurrency. Sources/AI untouched (isolation vs freeze 17f8830 = clean).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
188 lines
7.3 KiB
Swift
188 lines
7.3 KiB
Swift
import SwiftUI
|
|
|
|
/// Lightweight block-aware Markdown renderer. SwiftUI's `AttributedString(markdown:)` is INLINE-only —
|
|
/// it silently drops block structure including GFM TABLES (the completed fixture's UCS-66 table would
|
|
/// vanish). This splits blocks by hand (headings, lists, pipe tables, fenced code, blockquote,
|
|
/// paragraphs) and renders each natively; inline emphasis/links use AttributedString per line.
|
|
/// Full CommonMark fidelity is a deferred dependency swap (swift-markdown / MarkdownUI).
|
|
public struct MarkdownView: View {
|
|
let markdown: String
|
|
public init(_ markdown: String) { self.markdown = markdown }
|
|
|
|
public var body: some View {
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
ForEach(Array(MarkdownBlock.parse(markdown).enumerated()), id: \.offset) { _, block in
|
|
view(for: block)
|
|
}
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func view(for block: MarkdownBlock) -> some View {
|
|
switch block {
|
|
case .heading(let level, let text):
|
|
inline(text)
|
|
.font(headingFont(level))
|
|
.foregroundStyle(Sage.ink)
|
|
.padding(.top, level <= 2 ? 6 : 2)
|
|
case .paragraph(let text):
|
|
inline(text).font(.body).foregroundStyle(Sage.ink)
|
|
case .listItem(let text):
|
|
HStack(alignment: .top, spacing: 8) {
|
|
Text("•").foregroundStyle(Sage.brand)
|
|
inline(text).font(.body).foregroundStyle(Sage.ink)
|
|
}
|
|
case .quote(let text):
|
|
inline(text)
|
|
.font(.body).foregroundStyle(Sage.muted)
|
|
.padding(.leading, 10)
|
|
.overlay(Rectangle().fill(Sage.brand).frame(width: 3), alignment: .leading)
|
|
case .code(let text):
|
|
Text(text)
|
|
.font(.system(.callout, design: .monospaced))
|
|
.foregroundStyle(Sage.ink)
|
|
.padding(10)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.background(Sage.surface, in: RoundedRectangle(cornerRadius: 8))
|
|
case .table(let header, let rows):
|
|
tableView(header: header, rows: rows)
|
|
}
|
|
}
|
|
|
|
private func tableView(header: [String], rows: [[String]]) -> some View {
|
|
let columns = max(header.count, rows.map(\.count).max() ?? 0)
|
|
return VStack(spacing: 0) {
|
|
tableRow(cells: header, columns: columns, isHeader: true)
|
|
ForEach(Array(rows.enumerated()), id: \.offset) { _, row in
|
|
tableRow(cells: row, columns: columns, isHeader: false)
|
|
}
|
|
}
|
|
.background(Sage.card, in: RoundedRectangle(cornerRadius: 8))
|
|
.overlay(RoundedRectangle(cornerRadius: 8).stroke(Sage.line))
|
|
}
|
|
|
|
private func tableRow(cells: [String], columns: Int, isHeader: Bool) -> some View {
|
|
HStack(spacing: 0) {
|
|
ForEach(0..<columns, id: \.self) { i in
|
|
inline(i < cells.count ? cells[i] : "")
|
|
.font(isHeader ? .callout.weight(.semibold) : .callout)
|
|
.foregroundStyle(isHeader ? Sage.ink : Sage.muted)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.padding(.horizontal, 8).padding(.vertical, 6)
|
|
}
|
|
}
|
|
.background(isHeader ? Sage.surface : Sage.card)
|
|
.overlay(Rectangle().fill(Sage.line).frame(height: 1), alignment: .bottom)
|
|
}
|
|
|
|
private func inline(_ s: String) -> Text {
|
|
if let attr = try? AttributedString(markdown: s, options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace)) {
|
|
return Text(attr)
|
|
}
|
|
return Text(s)
|
|
}
|
|
|
|
private func headingFont(_ level: Int) -> Font {
|
|
switch level {
|
|
case 1: return .title2.weight(.bold)
|
|
case 2: return .title3.weight(.semibold)
|
|
case 3: return .headline
|
|
default: return .subheadline.weight(.semibold)
|
|
}
|
|
}
|
|
}
|
|
|
|
enum MarkdownBlock {
|
|
case heading(Int, String)
|
|
case paragraph(String)
|
|
case listItem(String)
|
|
case quote(String)
|
|
case code(String)
|
|
case table([String], [[String]])
|
|
|
|
static func parse(_ md: String) -> [MarkdownBlock] {
|
|
var blocks: [MarkdownBlock] = []
|
|
let lines = md.components(separatedBy: "\n")
|
|
var i = 0
|
|
var para: [String] = []
|
|
|
|
func flushPara() {
|
|
if !para.isEmpty {
|
|
blocks.append(.paragraph(para.joined(separator: " ")))
|
|
para.removeAll()
|
|
}
|
|
}
|
|
|
|
while i < lines.count {
|
|
let raw = lines[i]
|
|
let line = raw.trimmingCharacters(in: .whitespaces)
|
|
|
|
// fenced code
|
|
if line.hasPrefix("```") {
|
|
flushPara()
|
|
var code: [String] = []
|
|
i += 1
|
|
while i < lines.count, !lines[i].trimmingCharacters(in: .whitespaces).hasPrefix("```") {
|
|
code.append(lines[i]); i += 1
|
|
}
|
|
blocks.append(.code(code.joined(separator: "\n")))
|
|
i += 1
|
|
continue
|
|
}
|
|
|
|
// GFM pipe table: a header row followed by an alignment separator row (| --- | :--: |)
|
|
if line.contains("|"), i + 1 < lines.count, isTableSeparator(lines[i + 1]) {
|
|
flushPara()
|
|
let header = splitRow(line)
|
|
var rows: [[String]] = []
|
|
i += 2 // skip header + separator (the separator is NOT data)
|
|
while i < lines.count, lines[i].trimmingCharacters(in: .whitespaces).contains("|"),
|
|
!lines[i].trimmingCharacters(in: .whitespaces).isEmpty {
|
|
rows.append(splitRow(lines[i])); i += 1
|
|
}
|
|
blocks.append(.table(header, rows))
|
|
continue
|
|
}
|
|
|
|
if line.isEmpty { flushPara(); i += 1; continue }
|
|
|
|
if line.hasPrefix("#") {
|
|
flushPara()
|
|
let level = line.prefix(while: { $0 == "#" }).count
|
|
let text = line.drop(while: { $0 == "#" }).trimmingCharacters(in: .whitespaces)
|
|
blocks.append(.heading(min(level, 6), text))
|
|
i += 1; continue
|
|
}
|
|
if line.hasPrefix("> ") {
|
|
flushPara()
|
|
blocks.append(.quote(String(line.dropFirst(2))))
|
|
i += 1; continue
|
|
}
|
|
if line.hasPrefix("- ") || line.hasPrefix("* ") || line.hasPrefix("+ ") {
|
|
flushPara()
|
|
blocks.append(.listItem(String(line.dropFirst(2))))
|
|
i += 1; continue
|
|
}
|
|
|
|
para.append(line)
|
|
i += 1
|
|
}
|
|
flushPara()
|
|
return blocks
|
|
}
|
|
|
|
/// A separator row is all dashes/colons/pipes/spaces, e.g. `| --- | :--: |`.
|
|
private static func isTableSeparator(_ line: String) -> Bool {
|
|
let t = line.trimmingCharacters(in: .whitespaces)
|
|
guard t.contains("-"), t.contains("|") else { return false }
|
|
return t.allSatisfy { $0 == "-" || $0 == ":" || $0 == "|" || $0 == " " }
|
|
}
|
|
|
|
private static func splitRow(_ line: String) -> [String] {
|
|
var t = line.trimmingCharacters(in: .whitespaces)
|
|
if t.hasPrefix("|") { t.removeFirst() }
|
|
if t.hasSuffix("|") { t.removeLast() }
|
|
return t.components(separatedBy: "|").map { $0.trimmingCharacters(in: .whitespaces) }
|
|
}
|
|
}
|