Files
hyungi_document_server/Sources/AppFeature/Markdown/MarkdownView.swift
T
Hyungi 560efb9554 feat(s3): SwiftUI sage 3-pane shell + 6 pages + AI seam
- 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>
2026-06-04 17:26:02 +09:00

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) }
}
}