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