// // MarkdownText.swift // Zyquo Local // // Author: Simon-Pierre Boucher // Mail: contact@spboucher.ai // import Markdown import SwiftUI /// Full Markdown rendering: headings, lists, quotes, tables (as text), /// inline styles via AttributedString, and syntax-highlighted code blocks /// with a copy button. struct MarkdownText: View { let markdown: String init(_ markdown: String) { self.markdown = markdown } var body: some View { let blocks = MarkdownBlockParser.parse(markdown) VStack(alignment: .leading, spacing: ZyquoTheme.Spacing.xs) { ForEach(blocks) { block in switch block.kind { case .paragraph(let text): InlineMarkdown(text: text) case .heading(let text, let level): InlineMarkdown(text: text, font: headingFont(level)) case .code(let code, let language): CodeBlockView(code: code, language: language) case .quote(let text): HStack(alignment: .top, spacing: ZyquoTheme.Spacing.xs) { RoundedRectangle(cornerRadius: 2) .fill(ZyquoTheme.accent.opacity(0.5)) .frame(width: 3) InlineMarkdown(text: text, color: ZyquoTheme.textSecondary) } case .listItem(let text, let marker): HStack(alignment: .top, spacing: ZyquoTheme.Spacing.xs) { Text(marker) .font(ZyquoTheme.chatBody.monospacedDigit()) .foregroundStyle(ZyquoTheme.textSecondary) InlineMarkdown(text: text) } case .rule: Rectangle() .fill(ZyquoTheme.border) .frame(height: ZyquoTheme.hairline) .padding(.vertical, ZyquoTheme.Spacing.xxs) } } } } private func headingFont(_ level: Int) -> Font { switch level { case 1: .system(size: ZyquoTheme.chatFontSize + 6, weight: .semibold) case 2: .system(size: ZyquoTheme.chatFontSize + 4, weight: .semibold) default: .system(size: ZyquoTheme.chatFontSize + 2, weight: .semibold) } } } /// Inline markdown (bold/italic/code/links) via AttributedString. private struct InlineMarkdown: View { let text: String var font: Font? var color: Color = ZyquoTheme.textPrimary var body: some View { Text(attributed) .font(font ?? ZyquoTheme.chatBody) .foregroundStyle(color) .lineSpacing(ZyquoTheme.lineSpacing(fontSize: ZyquoTheme.chatFontSize) * 0.55) .tint(ZyquoTheme.accent) .frame(maxWidth: .infinity, alignment: .leading) } private var attributed: AttributedString { (try? AttributedString( markdown: text, options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace) )) ?? AttributedString(text) } } /// Code block: language label, copy button, lightweight syntax highlighting. struct CodeBlockView: View { let code: String let language: String? @State private var copied = false var body: some View { VStack(alignment: .leading, spacing: 0) { HStack { Text(language?.isEmpty == false ? language! : "code") .font(ZyquoTheme.caption) .foregroundStyle(ZyquoTheme.textTertiary) Spacer() Button { NSPasteboard.general.clearContents() NSPasteboard.general.setString(code, forType: .string) copied = true Task { try? await Task.sleep(for: .seconds(1.4)) copied = false } } label: { Label(copied ? "Copied" : "Copy", systemImage: copied ? "checkmark" : "doc.on.doc") .font(ZyquoTheme.caption) .foregroundStyle(copied ? ZyquoTheme.success : ZyquoTheme.textSecondary) } .buttonStyle(.plain) } .padding(.horizontal, ZyquoTheme.Spacing.s) .padding(.vertical, ZyquoTheme.Spacing.xxs) Rectangle().fill(ZyquoTheme.border).frame(height: ZyquoTheme.hairline) ScrollView(.horizontal, showsIndicators: false) { Text(SyntaxHighlighter.highlight(code, language: language)) .font(ZyquoTheme.chatCode) .lineSpacing(2.5) .textSelection(.enabled) .padding(ZyquoTheme.Spacing.s) } } .background(ZyquoTheme.surfaceSecondary, in: RoundedRectangle(cornerRadius: ZyquoTheme.Radius.s)) .overlay( RoundedRectangle(cornerRadius: ZyquoTheme.Radius.s) .stroke(ZyquoTheme.border, lineWidth: ZyquoTheme.hairline) ) } } // MARK: - Block parsing (swift-markdown → renderable blocks) struct MarkdownBlock: Identifiable { enum Kind { case paragraph(String) case heading(String, Int) case code(String, String?) case quote(String) case listItem(String, marker: String) case rule } let id: Int let kind: Kind } enum MarkdownBlockParser { static func parse(_ text: String) -> [MarkdownBlock] { let document = Document(parsing: text) var blocks: [MarkdownBlock] = [] var counter = 0 func add(_ kind: MarkdownBlock.Kind) { blocks.append(MarkdownBlock(id: counter, kind: kind)) counter += 1 } func visit(_ markup: Markup) { switch markup { case let heading as Heading: add(.heading(heading.plainInlineText, heading.level)) case let code as CodeBlock: add(.code(code.code.trimmingCharacters(in: .newlines), code.language)) case let quote as BlockQuote: let inner = quote.children .compactMap { ($0 as? Paragraph)?.plainInlineText } .joined(separator: "\n") add(.quote(inner)) case let list as UnorderedList: for item in list.listItems { add(.listItem(item.inlineText, marker: "•")) } case let list as OrderedList: for (i, item) in list.listItems.enumerated() { add(.listItem(item.inlineText, marker: "\(Int(list.startIndex) + i).")) } case is ThematicBreak: add(.rule) case let paragraph as Paragraph: add(.paragraph(paragraph.plainInlineText)) case let table as Markdown.Table: // Render tables as monospace text rows. var lines: [String] = [] let head = table.head.cells.map { $0.plainText }.joined(separator: " | ") lines.append(head) lines.append(String(repeating: "—", count: max(8, head.count))) for row in table.body.rows { lines.append(row.cells.map { $0.plainText }.joined(separator: " | ")) } add(.code(lines.joined(separator: "\n"), nil)) default: for child in markup.children { visit(child) } } } for child in document.children { visit(child) } return blocks } } extension Markup { /// Re-serializes inline children so AttributedString can restyle them. var plainInlineText: String { children.compactMap { ($0 as? InlineMarkup)?.format() }.joined() } } extension ListItem { var inlineText: String { children .compactMap { child -> String? in (child as? Paragraph)?.plainInlineText } .joined(separator: "\n") } } extension Markdown.Table.Cell { var plainText: String { children.compactMap { ($0 as? InlineMarkup)?.plainText }.joined() } } // MARK: - Lightweight syntax highlighting enum SyntaxHighlighter { private static let keywords: Set = [ // Swift / general "func", "let", "var", "if", "else", "for", "while", "return", "import", "struct", "class", "enum", "protocol", "extension", "guard", "switch", "case", "default", "break", "continue", "in", "actor", "await", "async", "throws", "throw", "try", "catch", "static", "private", "public", // Python / JS / others "def", "elif", "lambda", "None", "True", "False", "self", "pass", "const", "function", "=>", "new", "this", "null", "undefined", "true", "false", "nil", "typeof", "instanceof", "void", "int", "float", "double", "bool", "string", "match", "impl", "fn", "mut", ] static func highlight(_ code: String, language: String?) -> AttributedString { var result = AttributedString() for (index, rawLine) in code.split(separator: "\n", omittingEmptySubsequences: false).enumerated() { if index > 0 { result += AttributedString("\n") } result += highlightLine(String(rawLine)) } return result } private static func highlightLine(_ line: String) -> AttributedString { // Whole-line comments let trimmed = line.trimmingCharacters(in: .whitespaces) if trimmed.hasPrefix("//") || trimmed.hasPrefix("#") || trimmed.hasPrefix("--") { var comment = AttributedString(line) comment.foregroundColor = ZyquoTheme.Syntax.comment return comment } var result = AttributedString() var current = "" var inString: Character? = nil func flushWord(_ word: String) { var part = AttributedString(word) if keywords.contains(word) { part.foregroundColor = ZyquoTheme.Syntax.keyword } else if word.first?.isNumber == true, Double(word) != nil { part.foregroundColor = ZyquoTheme.Syntax.number } result += part } func flush() { guard !current.isEmpty else { return } if inString != nil { var part = AttributedString(current) part.foregroundColor = ZyquoTheme.Syntax.string result += part } else { flushWord(current) } current = "" } for char in line { if let quote = inString { current.append(char) if char == quote { flush() inString = nil } } else if char == "\"" || char == "'" { flush() inString = char current.append(char) } else if char.isLetter || char.isNumber || char == "_" || char == "." { current.append(char) } else { flush() result += AttributedString(String(char)) } } flush() return result } }