import SwiftUI /// Block-level markdown for READMEs: headings, fenced code, lists, quotes, /// paragraphs. Inline styling comes from AttributedString's own markdown /// parsing; blocks are split here because it only does inline. struct MarkdownView: View { let markdown: String var body: some View { VStack(alignment: .leading, spacing: 12) { ForEach(Array(blocks.enumerated()), id: \.offset) { _, block in blockView(block) } } } // MARK: - Block model private enum Block { case heading(level: Int, text: String) case code(String) case quote(String) case bullet([String]) case ordered([String]) case rule case paragraph(String) } private var blocks: [Block] { var blocks: [Block] = [] var lines = markdown.components(separatedBy: "\n")[...] while let line = lines.first { let trimmed = line.trimmingCharacters(in: .whitespaces) if trimmed.hasPrefix("```") { lines = lines.dropFirst() var code: [String] = [] while let next = lines.first, !next.trimmingCharacters(in: .whitespaces).hasPrefix("```") { code.append(next) lines = lines.dropFirst() } lines = lines.dropFirst() // closing fence blocks.append(.code(code.joined(separator: "\n"))) continue } if trimmed.hasPrefix("#") { let level = trimmed.prefix(while: { $0 == "#" }).count let text = trimmed.drop(while: { $0 == "#" }).trimmingCharacters(in: .whitespaces) blocks.append(.heading(level: min(level, 4), text: text)) lines = lines.dropFirst() continue } if trimmed == "---" || trimmed == "***" || trimmed == "___" { blocks.append(.rule) lines = lines.dropFirst() continue } if trimmed.hasPrefix(">") { var quote: [String] = [] while let next = lines.first?.trimmingCharacters(in: .whitespaces), next.hasPrefix(">") { quote.append(next.dropFirst().trimmingCharacters(in: .whitespaces)) lines = lines.dropFirst() } blocks.append(.quote(quote.joined(separator: " "))) continue } if trimmed.hasPrefix("- ") || trimmed.hasPrefix("* ") || trimmed.hasPrefix("+ ") { var items: [String] = [] while let next = lines.first?.trimmingCharacters(in: .whitespaces), next.hasPrefix("- ") || next.hasPrefix("* ") || next.hasPrefix("+ ") { items.append(String(next.dropFirst(2))) lines = lines.dropFirst() } blocks.append(.bullet(items)) continue } if trimmed.range(of: #"^\d+\. "#, options: .regularExpression) != nil { var items: [String] = [] while let next = lines.first?.trimmingCharacters(in: .whitespaces), let range = next.range(of: #"^\d+\. "#, options: .regularExpression) { items.append(String(next[range.upperBound...])) lines = lines.dropFirst() } blocks.append(.ordered(items)) continue } if trimmed.isEmpty { lines = lines.dropFirst() continue } // Paragraph: consume until a blank line or another block start. var paragraph: [String] = [] while let next = lines.first { let t = next.trimmingCharacters(in: .whitespaces) if t.isEmpty || t.hasPrefix("#") || t.hasPrefix("```") || t.hasPrefix(">") || t.hasPrefix("- ") || t.hasPrefix("* ") { break } paragraph.append(t) lines = lines.dropFirst() } blocks.append(.paragraph(paragraph.joined(separator: " "))) } return blocks } // MARK: - Rendering @ViewBuilder private func blockView(_ block: Block) -> some View { switch block { case .heading(let level, let text): inline(text) .font(headingFont(level)) .padding(.top, level <= 2 ? 8 : 4) case .code(let code): ScrollView(.horizontal) { Text(code) .font(.gbMono(.caption)) .padding(10) } .background(Color.gbCodeBackground, in: RoundedRectangle(cornerRadius: 2)) case .quote(let text): HStack(spacing: 10) { RoundedRectangle(cornerRadius: 2) .fill(.tertiary) .frame(width: 3) inline(text).foregroundStyle(.secondary) } .fixedSize(horizontal: false, vertical: true) case .bullet(let items): VStack(alignment: .leading, spacing: 4) { ForEach(Array(items.enumerated()), id: \.offset) { _, item in HStack(alignment: .firstTextBaseline, spacing: 8) { Text("•") inline(item) } } } case .ordered(let items): VStack(alignment: .leading, spacing: 4) { ForEach(Array(items.enumerated()), id: \.offset) { index, item in HStack(alignment: .firstTextBaseline, spacing: 8) { Text("\(index + 1).").monospacedDigit() inline(item) } } } case .rule: Divider() case .paragraph(let text): inline(text) } } private func inline(_ text: String) -> Text { if let attributed = try? AttributedString( markdown: text, options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace) ) { return Text(attributed) } return Text(text) } private func headingFont(_ level: Int) -> Font { switch level { case 1: .title2.bold() case 2: .title3.bold() case 3: .headline default: .subheadline.bold() } } }