// // MarkdownView.swift // Zyquo Cloud // // Author: Simon-Pierre Boucher // Mail: contact@spboucher.ai // // Full Markdown rendering for chat messages per the Phase 4 spec: the // swift-markdown AST is walked once into a lightweight [MarkdownBlock] model // (memoized by text hash — streaming deltas re-parse only the changed text), // then rendered as SwiftUI views built entirely from ZyquoTheme tokens. // Links use the AttributedString .link attribute, so SwiftUI.Text routes // clicks through the environment's openURL automatically. Parsing is // resilient to incomplete Markdown (unterminated fences etc.) — swift-markdown // degrades gracefully, so streaming partial text never crashes. // import SwiftUI import Markdown // MARK: - MarkdownView struct MarkdownView: View { let fontSize: Double private let blocks: [MarkdownBlock] init(text: String, fontSize: Double) { self.fontSize = fontSize self.blocks = MarkdownBlockParser.parse(text: text, fontSize: fontSize) } var body: some View { VStack(alignment: .leading, spacing: ZyquoSpacing.sm) { ForEach(blocks) { block in MarkdownBlockView(block: block, fontSize: fontSize) } } .foregroundStyle(ZyquoColor.textPrimary) } } // MARK: - Block model /// One rendered Markdown block. Ids are assigned in document order at parse /// time so `ForEach` stays stable within a parse. struct MarkdownBlock: Identifiable { let id: Int let kind: Kind enum Kind { case paragraph(AttributedString) case heading(AttributedString, level: Int) case code(String, language: String?) case quote([MarkdownBlock]) case list(ListData) case table(TableData) case thematicBreak } struct ListData { let ordered: Bool let start: Int let items: [ListItemData] } struct ListItemData: Identifiable { let id: Int /// nil = plain item; true/false = task-list checkbox state. let checked: Bool? let blocks: [MarkdownBlock] } struct TableData { let alignments: [TextAlignment] let header: [AttributedString] let rows: [[AttributedString]] func alignment(forColumn column: Int) -> TextAlignment { column < alignments.count ? alignments[column] : .leading } } } // MARK: - Parser /// Walks the swift-markdown `Document` AST into `[MarkdownBlock]`. Results are /// memoized by (text, fontSize) so re-renders during streaming only re-parse /// when the text actually changes. enum MarkdownBlockParser { /// Heading sizes scale off the user's chat font size. private static func headingSize(level: Int, base: Double) -> Double { switch level { case 1: return base * 1.55 case 2: return base * 1.35 case 3: return base * 1.2 default: return base * 1.05 } } static func parse(text: String, fontSize: Double) -> [MarkdownBlock] { let key = CacheKey(textHash: text.hashValue, length: text.count, fontBits: fontSize.bitPattern) cacheLock.lock() if let hit = cache[key] { cacheLock.unlock() return hit } cacheLock.unlock() let document = Document(parsing: text) var counter = 0 let blocks = convertBlocks(of: document, fontSize: fontSize, counter: &counter) cacheLock.lock() if cache.count > cacheCapacity { cache.removeAll(keepingCapacity: true) } cache[key] = blocks cacheLock.unlock() return blocks } // MARK: Cache private struct CacheKey: Hashable { let textHash: Int let length: Int let fontBits: UInt64 } private static let cacheLock = NSLock() private static let cacheCapacity = 32 private static var cache: [CacheKey: [MarkdownBlock]] = [:] // MARK: Block conversion private static func convertBlocks(of parent: Markup, fontSize: Double, counter: inout Int) -> [MarkdownBlock] { parent.children.compactMap { convertBlock($0, fontSize: fontSize, counter: &counter) } } private static func convertBlock(_ markup: Markup, fontSize: Double, counter: inout Int) -> MarkdownBlock? { counter += 1 let id = counter switch markup { case let heading as Heading: let font = Font.system(size: headingSize(level: heading.level, base: fontSize), weight: .semibold) let content = inlineText(of: heading, fontSize: fontSize, baseFont: font) return MarkdownBlock(id: id, kind: .heading(content, level: heading.level)) case let paragraph as Paragraph: let content = inlineText(of: paragraph, fontSize: fontSize, baseFont: ZyquoFont.body(size: fontSize)) guard !content.characters.isEmpty else { return nil } return MarkdownBlock(id: id, kind: .paragraph(content)) case let codeBlock as CodeBlock: var code = codeBlock.code if code.hasSuffix("\n") { code.removeLast() } return MarkdownBlock(id: id, kind: .code(code, language: codeBlock.language)) case let quote as BlockQuote: return MarkdownBlock(id: id, kind: .quote(convertBlocks(of: quote, fontSize: fontSize, counter: &counter))) case let list as UnorderedList: let items = convertListItems(of: list, fontSize: fontSize, counter: &counter) return MarkdownBlock(id: id, kind: .list(.init(ordered: false, start: 1, items: items))) case let list as OrderedList: let items = convertListItems(of: list, fontSize: fontSize, counter: &counter) return MarkdownBlock(id: id, kind: .list(.init(ordered: true, start: Int(list.startIndex), items: items))) case let table as Markdown.Table: return MarkdownBlock(id: id, kind: .table(convertTable(table, fontSize: fontSize))) case is ThematicBreak: return MarkdownBlock(id: id, kind: .thematicBreak) case let html as HTMLBlock: var raw = html.rawHTML if raw.hasSuffix("\n") { raw.removeLast() } return MarkdownBlock(id: id, kind: .code(raw, language: "html")) default: // Unknown block: fall back to its re-formatted Markdown source. let source = markup.format().trimmingCharacters(in: .whitespacesAndNewlines) guard !source.isEmpty else { return nil } var content = AttributedString(source) content.font = ZyquoFont.body(size: fontSize) return MarkdownBlock(id: id, kind: .paragraph(content)) } } private static func convertListItems(of list: Markup, fontSize: Double, counter: inout Int) -> [MarkdownBlock.ListItemData] { list.children.compactMap { child in guard let item = child as? Markdown.ListItem else { return nil } counter += 1 let id = counter let checked: Bool? switch item.checkbox { case .checked: checked = true case .unchecked: checked = false case nil: checked = nil } return MarkdownBlock.ListItemData( id: id, checked: checked, blocks: convertBlocks(of: item, fontSize: fontSize, counter: &counter) ) } } private static func convertTable(_ table: Markdown.Table, fontSize: Double) -> MarkdownBlock.TableData { let alignments: [TextAlignment] = table.columnAlignments.map { alignment in switch alignment { case .center: return .center case .right: return .trailing default: return .leading } } let headerFont = ZyquoFont.bodyEmphasis(size: fontSize) let header = table.head.children.compactMap { cell -> AttributedString? in guard let cell = cell as? Markdown.Table.Cell else { return nil } return inlineText(of: cell, fontSize: fontSize, baseFont: headerFont) } let bodyFont = ZyquoFont.body(size: fontSize) let rows = table.body.children.compactMap { row -> [AttributedString]? in guard let row = row as? Markdown.Table.Row else { return nil } return row.children.compactMap { cell -> AttributedString? in guard let cell = cell as? Markdown.Table.Cell else { return nil } return inlineText(of: cell, fontSize: fontSize, baseFont: bodyFont) } } return MarkdownBlock.TableData(alignments: alignments, header: header, rows: rows) } // MARK: Inline conversion private static func inlineText( of parent: Markup, fontSize: Double, baseFont: Font, bold: Bool = false, italic: Bool = false ) -> AttributedString { var result = AttributedString() for child in parent.children { result += inlineFragment(child, fontSize: fontSize, baseFont: baseFont, bold: bold, italic: italic) } return result } private static func inlineFragment( _ markup: Markup, fontSize: Double, baseFont: Font, bold: Bool, italic: Bool ) -> AttributedString { switch markup { case let text as Markdown.Text: return styled(text.string, baseFont: baseFont, bold: bold, italic: italic) case let emphasis as Emphasis: return inlineText(of: emphasis, fontSize: fontSize, baseFont: baseFont, bold: bold, italic: true) case let strong as Strong: return inlineText(of: strong, fontSize: fontSize, baseFont: baseFont, bold: true, italic: italic) case let code as InlineCode: var segment = AttributedString(code.code) segment.font = ZyquoFont.code(size: max(fontSize - 1, 1)) segment.backgroundColor = ZyquoColor.surfaceSecondary return segment case let link as Markdown.Link: var segment = inlineText(of: link, fontSize: fontSize, baseFont: baseFont, bold: bold, italic: italic) segment.foregroundColor = ZyquoColor.accent if let destination = link.destination, let url = URL(string: destination) { segment.link = url } return segment case let image as Markdown.Image: // No inline image loading in chat transcripts: render the alt text // (or the source) as a link to the image. var segment = inlineText(of: image, fontSize: fontSize, baseFont: baseFont, bold: bold, italic: italic) if segment.characters.isEmpty, let source = image.source { segment = styled(source, baseFont: baseFont, bold: bold, italic: italic) } segment.foregroundColor = ZyquoColor.accent if let source = image.source, let url = URL(string: source) { segment.link = url } return segment case let strike as Strikethrough: var segment = inlineText(of: strike, fontSize: fontSize, baseFont: baseFont, bold: bold, italic: italic) segment[AttributeScopes.SwiftUIAttributes.StrikethroughStyleAttribute.self] = .single return segment case is SoftBreak: return styled(" ", baseFont: baseFont, bold: bold, italic: italic) case is LineBreak: return styled("\n", baseFont: baseFont, bold: bold, italic: italic) case let html as InlineHTML: return styled(html.rawHTML, baseFont: baseFont, bold: bold, italic: italic) default: return styled(markup.format(), baseFont: baseFont, bold: bold, italic: italic) } } private static func styled(_ string: String, baseFont: Font, bold: Bool, italic: Bool) -> AttributedString { var segment = AttributedString(string) var font = baseFont if bold { font = font.bold() } if italic { font = font.italic() } segment.font = font return segment } } // MARK: - Block rendering private struct MarkdownBlockView: View { let block: MarkdownBlock let fontSize: Double /// Blockquote accent bar width (Phase 4 spec: 3pt accent left bar). private static let quoteBarWidth: CGFloat = 3 var body: some View { switch block.kind { case .paragraph(let content): SwiftUI.Text(content) .lineSpacing(fontSize * ZyquoFont.bodyLineSpacingFactor) .textSelection(.enabled) .fixedSize(horizontal: false, vertical: true) case .heading(let content, _): SwiftUI.Text(content) .textSelection(.enabled) .fixedSize(horizontal: false, vertical: true) .padding(.top, ZyquoSpacing.xxs) case .code(let code, let language): CodeBlockView(code: code, language: language, fontSize: fontSize) case .quote(let children): HStack(alignment: .top, spacing: ZyquoSpacing.sm) { RoundedRectangle(cornerRadius: Self.quoteBarWidth / 2, style: .continuous) .fill(ZyquoColor.accent) .frame(width: Self.quoteBarWidth) VStack(alignment: .leading, spacing: ZyquoSpacing.xs) { ForEach(children) { child in MarkdownBlockView(block: child, fontSize: fontSize) } } .foregroundStyle(ZyquoColor.textSecondary) } case .list(let data): listView(data) case .table(let data): tableView(data) case .thematicBreak: ZyquoHairline() .padding(.vertical, ZyquoSpacing.xxs) } } // MARK: Lists private func listView(_ data: MarkdownBlock.ListData) -> some View { VStack(alignment: .leading, spacing: ZyquoSpacing.xxs) { ForEach(Array(data.items.enumerated()), id: \.element.id) { offset, item in HStack(alignment: .firstTextBaseline, spacing: ZyquoSpacing.xs) { marker(for: item, ordinal: data.start + offset, ordered: data.ordered) VStack(alignment: .leading, spacing: ZyquoSpacing.xxs) { ForEach(item.blocks) { child in MarkdownBlockView(block: child, fontSize: fontSize) } } } } } } @ViewBuilder private func marker(for item: MarkdownBlock.ListItemData, ordinal: Int, ordered: Bool) -> some View { if let checked = item.checked { Image(systemName: checked ? "checkmark.square.fill" : "square") .font(ZyquoFont.body(size: fontSize)) .foregroundStyle(checked ? ZyquoColor.accent : ZyquoColor.textSecondary) } else if ordered { SwiftUI.Text("\(ordinal).") .font(ZyquoFont.body(size: fontSize).monospacedDigit()) .foregroundStyle(ZyquoColor.textSecondary) .frame(minWidth: ZyquoSpacing.lg, alignment: .trailing) } else { SwiftUI.Text("•") .font(ZyquoFont.body(size: fontSize)) .foregroundStyle(ZyquoColor.textSecondary) } } // MARK: Tables private func tableView(_ data: MarkdownBlock.TableData) -> some View { Grid(alignment: .topLeading, horizontalSpacing: 0, verticalSpacing: 0) { GridRow { ForEach(data.header.indices, id: \.self) { column in tableCell( data.header[column], data: data, column: column, tinted: true, isLastRow: data.rows.isEmpty ) } } ForEach(data.rows.indices, id: \.self) { rowIndex in GridRow { ForEach(data.rows[rowIndex].indices, id: \.self) { column in tableCell( data.rows[rowIndex][column], data: data, column: column, tinted: rowIndex % 2 == 1, isLastRow: rowIndex == data.rows.count - 1 ) } } } } .clipShape(RoundedRectangle(cornerRadius: ZyquoRadius.small, style: .continuous)) .overlay( RoundedRectangle(cornerRadius: ZyquoRadius.small, style: .continuous) .strokeBorder(ZyquoColor.border, lineWidth: ZyquoMetrics.hairline) ) } private func tableCell( _ content: AttributedString, data: MarkdownBlock.TableData, column: Int, tinted: Bool, isLastRow: Bool ) -> some View { let alignment = data.alignment(forColumn: column) let columnCount = max(data.header.count, data.rows.map(\.count).max() ?? 0) let isLastColumn = column == columnCount - 1 return SwiftUI.Text(content) .multilineTextAlignment(alignment) .textSelection(.enabled) .fixedSize(horizontal: false, vertical: true) .padding(.horizontal, ZyquoSpacing.sm) .padding(.vertical, ZyquoSpacing.xs) .frame(maxWidth: .infinity, alignment: frameAlignment(for: alignment)) .background(tinted ? ZyquoColor.surfaceSecondary : Color.clear) .overlay(alignment: .bottom) { if !isLastRow { ZyquoHairline() } } .overlay(alignment: .trailing) { if !isLastColumn { Rectangle() .fill(ZyquoColor.border) .frame(width: ZyquoMetrics.hairline) } } } private func frameAlignment(for alignment: TextAlignment) -> Alignment { switch alignment { case .leading: return .leading case .center: return .center case .trailing: return .trailing } } }