import Foundation import Markdown import SwiftUI // MARK: - hapiOpenURL environment /// Link-open handler for everything the markdown renderer emits: regular /// URLs (after `HrefPolicy` filtering) and `hapi-file://?path=&line=` file /// references (decode with `FilePathLink(url:)`). The app decides what to do /// (SFSafariViewController, session file viewer, confirm sheet for /// `.confirmFirst` schemes); HapiUI never opens anything by itself. public struct HapiOpenURLAction: Sendable { public typealias Handler = @Sendable (URL) -> Void private let handler: Handler public init(handler: @escaping Handler) { self.handler = handler } public func callAsFunction(_ url: URL) { handler(url) } } private struct HapiOpenURLKey: EnvironmentKey { static let defaultValue = HapiOpenURLAction { _ in } } public extension EnvironmentValues { /// Handler invoked for links tapped inside HapiUI markdown content. /// Defaults to a no-op; the app must install its own handler. var hapiOpenURL: HapiOpenURLAction { get { self[HapiOpenURLKey.self] } set { self[HapiOpenURLKey.self] = newValue } } } // MARK: - Block model public struct MarkdownTableModel: Equatable, Sendable { public enum Alignment: Equatable, Sendable { case left case center case right } public var columnAlignments: [Alignment?] public var header: [AttributedString] public var rows: [[AttributedString]] public init(columnAlignments: [Alignment?], header: [AttributedString], rows: [[AttributedString]]) { self.columnAlignments = columnAlignments self.header = header self.rows = rows } } public struct MarkdownListModel: Equatable, Sendable { public struct Item: Equatable, Sendable { /// nil for plain list items; true/false for GFM task list items. public var checkbox: Bool? public var blocks: [MarkdownBlockNode] public init(checkbox: Bool? = nil, blocks: [MarkdownBlockNode]) { self.checkbox = checkbox self.blocks = blocks } } public var isOrdered: Bool public var startIndex: Int public var items: [Item] public init(isOrdered: Bool, startIndex: Int = 1, items: [Item]) { self.isOrdered = isOrdered self.startIndex = startIndex self.items = items } } /// Renderer-ready markdown block tree. Built from source text by /// `MarkdownBlockTree.build(from:)`; pure data, so the conversion is unit /// testable without rendering any view. public indirect enum MarkdownBlockNode: Equatable, Sendable { case paragraph(AttributedString) case heading(level: Int, AttributedString) case codeBlock(language: String?, code: String) case blockquote([MarkdownBlockNode]) case list(MarkdownListModel) case table(MarkdownTableModel) case thematicBreak /// Markdown image reference. Chat images arrive via tool results, not /// markdown, so this renders as a placeholder link row. case image(alt: String, destination: String?) } // MARK: - Block tree builder public enum MarkdownBlockTree { /// Pre-processes the raw source (table repair + indented-code disable, /// mirroring the web's remark plugin order), parses it with /// swift-markdown (GFM tables/strikethrough/task lists; smart-quote /// substitution disabled to match remark output), and folds the tree /// into renderer-ready blocks. public static func build(from markdown: String) -> [MarkdownBlockNode] { let prepared = MarkdownTransforms.disableIndentedCode( MarkdownTransforms.repairTables(markdown) ) let document = Document(parsing: prepared, options: [.disableSmartOpts]) var builder = BlockBuilder() return builder.visit(document) } } struct BlockBuilder: MarkupVisitor { typealias Result = [MarkdownBlockNode] mutating func defaultVisit(_ markup: Markup) -> [MarkdownBlockNode] { // Unknown containers (custom blocks, directives): flatten children. markup.children.flatMap { visit($0) } } mutating func visitDocument(_ document: Document) -> [MarkdownBlockNode] { document.children.flatMap { visit($0) } } mutating func visitParagraph(_ paragraph: Paragraph) -> [MarkdownBlockNode] { let children = Array(paragraph.children) if children.count == 1, let image = children[0] as? Markdown.Image { var inline = InlineBuilder(insideLink: true) let alt = String(inline.content(of: image).characters) return [.image(alt: alt, destination: image.source)] } var inline = InlineBuilder() return [.paragraph(inline.content(of: paragraph))] } mutating func visitHeading(_ heading: Heading) -> [MarkdownBlockNode] { var inline = InlineBuilder() let level = min(max(heading.level, 1), 6) return [.heading(level: level, inline.content(of: heading))] } mutating func visitCodeBlock(_ codeBlock: CodeBlock) -> [MarkdownBlockNode] { var code = codeBlock.code if code.hasSuffix("\n") { code = String(code.dropLast()) } // The fence info string may carry extra tokens ("swift lineNumbers"); // only the first token is the language. var language: String? = nil if let info = codeBlock.language { let token = info.split(separator: " ").first.map(String.init) ?? info language = token.isEmpty ? nil : token } return [.codeBlock(language: language, code: code)] } mutating func visitHTMLBlock(_ html: HTMLBlock) -> [MarkdownBlockNode] { // The web drops raw HTML (react-markdown default). Showing the // literal source is the conservative visible choice on iOS. var raw = html.rawHTML if raw.hasSuffix("\n") { raw = String(raw.dropLast()) } return [.paragraph(AttributedString(raw))] } mutating func visitBlockQuote(_ blockQuote: BlockQuote) -> [MarkdownBlockNode] { [.blockquote(blockQuote.children.flatMap { visit($0) })] } mutating func visitUnorderedList(_ unorderedList: UnorderedList) -> [MarkdownBlockNode] { [.list(makeList(from: unorderedList, ordered: false, start: 1))] } mutating func visitOrderedList(_ orderedList: OrderedList) -> [MarkdownBlockNode] { [.list(makeList(from: orderedList, ordered: true, start: Int(clamping: orderedList.startIndex)))] } mutating func visitThematicBreak(_ thematicBreak: ThematicBreak) -> [MarkdownBlockNode] { [.thematicBreak] } mutating func visitTable(_ table: Markdown.Table) -> [MarkdownBlockNode] { let columnCount = max(table.maxColumnCount, 1) var inline = InlineBuilder() var header: [AttributedString] = [] for cell in table.head.cells { header.append(inline.content(of: cell)) } while header.count < columnCount { header.append(AttributedString()) } var rows: [[AttributedString]] = [] for row in table.body.rows { var cells: [AttributedString] = [] for cell in row.cells { cells.append(inline.content(of: cell)) } while cells.count < columnCount { cells.append(AttributedString()) } rows.append(cells) } var alignments: [MarkdownTableModel.Alignment?] = table.columnAlignments.map { alignment -> MarkdownTableModel.Alignment? in switch alignment { case .left: return .left case .center: return .center case .right: return .right case nil: return nil } } while alignments.count < columnCount { alignments.append(nil) } return [.table(MarkdownTableModel( columnAlignments: alignments, header: header, rows: rows ))] } private mutating func makeList( from container: some ListItemContainer, ordered: Bool, start: Int ) -> MarkdownListModel { var items: [MarkdownListModel.Item] = [] for item in container.listItems { let checkbox: Bool? = item.checkbox.map { $0 == .checked } let blocks = item.children.flatMap { visit($0) } items.append(MarkdownListModel.Item(checkbox: checkbox, blocks: blocks)) } return MarkdownListModel(isOrdered: ordered, startIndex: start, items: items) } } // MARK: - Inline builder /// Folds inline markup into an `AttributedString`: /// - bold / italic / strikethrough / inline code via /// `InlinePresentationIntent` (theme styling is applied at render time), /// - links via the `.link` attribute after `HrefPolicy` filtering (blocked /// schemes render as plain text; scheme-less hrefs only survive as /// workspace file links — anything else is inert, matching the web's /// fail-closed rule), /// - bare `scheme://` URLs and workspace file paths in plain text are /// auto-linked (`MarkdownTransforms.detectAutolinkRanges` / /// `detectFilePathRanges`); file references use `hapi-file://` URLs. struct InlineBuilder: MarkupVisitor { typealias Result = AttributedString /// Inside a link (or alt text) no nested autolinking happens — mirrors /// the web plugin skipping `link` parents. var insideLink: Bool init(insideLink: Bool = false) { self.insideLink = insideLink } /// Concatenated visit of all children. mutating func content(of markup: Markup) -> AttributedString { var output = AttributedString() for child in markup.children { output += visit(child) } return output } mutating func defaultVisit(_ markup: Markup) -> AttributedString { content(of: markup) } mutating func visitText(_ text: Markdown.Text) -> AttributedString { insideLink ? AttributedString(text.string) : Self.linkified(text.string) } mutating func visitSoftBreak(_ softBreak: SoftBreak) -> AttributedString { AttributedString(" ") } mutating func visitLineBreak(_ lineBreak: LineBreak) -> AttributedString { AttributedString("\n") } mutating func visitInlineHTML(_ inlineHTML: InlineHTML) -> AttributedString { AttributedString(inlineHTML.rawHTML) } mutating func visitSymbolLink(_ symbolLink: SymbolLink) -> AttributedString { var piece = AttributedString(symbolLink.destination ?? "") piece.inlinePresentationIntent = .code return piece } mutating func visitEmphasis(_ emphasis: Emphasis) -> AttributedString { Self.addingIntent(.emphasized, to: content(of: emphasis)) } mutating func visitStrong(_ strong: Strong) -> AttributedString { Self.addingIntent(.stronglyEmphasized, to: content(of: strong)) } mutating func visitStrikethrough(_ strikethrough: Strikethrough) -> AttributedString { Self.addingIntent(.strikethrough, to: content(of: strikethrough)) } mutating func visitInlineCode(_ inlineCode: InlineCode) -> AttributedString { var piece = AttributedString(inlineCode.code) piece.inlinePresentationIntent = .code if !insideLink, let fileLink = MarkdownTransforms.filePathLink(forInlineCode: inlineCode.code), let url = fileLink.url { piece.link = url } return piece } mutating func visitLink(_ link: Markdown.Link) -> AttributedString { let wasInsideLink = insideLink insideLink = true var label = content(of: link) insideLink = wasInsideLink var target = link.destination ?? "" var trailing: AttributedString? = nil // Angle autolinks (``) have text == destination; strip // trailing CJK punctuation out of the link (remark-strip-cjk-autolink // parity — explicit `[text](url)` links are never modified). let plain = String(label.characters) if !target.isEmpty, plain == target, let split = MarkdownTransforms.splitTrailingCJKPunctuation(fromAutolink: target) { target = split.url label = AttributedString(split.url) trailing = AttributedString(split.trailing) } var result = label if target.isEmpty { // No destination — plain text. } else if !HrefPolicy.hasScheme(target) { // Scheme-less: only repo-relative workspace files become links; // everything else path-like renders inert (web #1452 rule; SPA // routes have no iOS equivalent). if let fileLink = MarkdownTransforms.fileLinkTarget(forExplicitHref: target), let url = fileLink.url { result.link = url } } else { switch HrefPolicy.classify(target) { case .blocked: break case .allowed, .confirmFirst: if let url = Self.makeURL(from: target) { result.link = url } } } if let trailing { result += trailing } return result } mutating func visitImage(_ image: Markdown.Image) -> AttributedString { let wasInsideLink = insideLink insideLink = true let altText = content(of: image) insideLink = wasInsideLink let alt = String(altText.characters) let label = alt.isEmpty ? (image.source ?? "image") : alt var piece = AttributedString(label) if let source = image.source, HrefPolicy.hasScheme(source), HrefPolicy.classify(source) != .blocked, let url = Self.makeURL(from: source) { piece.link = url } return piece } // MARK: helpers static func addingIntent( _ intent: InlinePresentationIntent, to attributed: AttributedString ) -> AttributedString { // Rebuild run-by-run instead of mutating in place: attribute writes // count as mutations and would invalidate the run iterator. var output = AttributedString() for run in attributed.runs { var piece = AttributedString(attributed[run.range]) let existing = run.inlinePresentationIntent ?? [] piece.inlinePresentationIntent = existing.union(intent) output.append(piece) } return output } static func linkified(_ string: String) -> AttributedString { var output = AttributedString() var cursor = string.startIndex for hit in MarkdownTransforms.detectAutolinkRanges(in: string) { if hit.range.lowerBound > cursor { output += filePathLinked(String(string[cursor.. AttributedString { let hits = MarkdownTransforms.detectFilePathRanges(in: string) guard !hits.isEmpty else { return AttributedString(string) } var output = AttributedString() var cursor = string.startIndex for (range, link) in hits { if range.lowerBound > cursor { output += AttributedString(String(string[cursor.. URL? { if let url = URL(string: string) { return url } guard let encoded = string.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) else { return nil } return URL(string: encoded) } } // MARK: - Theme application /// Applies palette-dependent styling to a built inline string: inline-code /// runs get the monospaced font + chip colors, link runs get the link color /// and underline. Bold/italic/strikethrough render natively from /// `InlinePresentationIntent`. func hapiStyledText(_ source: AttributedString, theme: HapiTheme, typography: HapiTypography) -> AttributedString { var output = AttributedString() for run in source.runs { var piece = AttributedString(source[run.range]) if let intent = run.inlinePresentationIntent, intent.contains(.code) { piece[AttributeScopes.SwiftUIAttributes.FontAttribute.self] = typography.inlineCodeFont piece[AttributeScopes.SwiftUIAttributes.ForegroundColorAttribute.self] = theme.inlineCodeForeground piece[AttributeScopes.SwiftUIAttributes.BackgroundColorAttribute.self] = theme.inlineCodeBackground } if run.link != nil { piece[AttributeScopes.SwiftUIAttributes.ForegroundColorAttribute.self] = theme.link piece[AttributeScopes.SwiftUIAttributes.UnderlineStyleAttribute.self] = .single } output.append(piece) } return output } // MARK: - Views /// SwiftUI markdown renderer. Parses in `init` (cheap enough for chat-sized /// content; streaming updates re-parse on each source change) and renders the /// block tree. Link taps — including `hapi-file://` workspace references — /// are delivered to `\.hapiOpenURL`. public struct MarkdownView: View { private let blocks: [MarkdownBlockNode] @Environment(\.hapiOpenURL) private var hapiOpenURL public init(markdown: String) { self.blocks = MarkdownBlockTree.build(from: markdown) } public init(blocks: [MarkdownBlockNode]) { self.blocks = blocks } public var body: some View { let open = hapiOpenURL MarkdownBlockListView(blocks: blocks) .environment(\.openURL, OpenURLAction { url in // Route AttributedString link taps into the app handler. open(url) return .handled }) } } struct MarkdownBlockListView: View { let blocks: [MarkdownBlockNode] var body: some View { VStack(alignment: .leading, spacing: 0) { ForEach(Array(blocks.enumerated()), id: \.offset) { index, block in MarkdownBlockView(block: block) .padding(.top, Self.spacing(before: block, after: index > 0 ? blocks[index - 1] : nil)) } } } // Pure layout calculation; does not need View's inferred MainActor isolation. nonisolated static func spacing(before block: MarkdownBlockNode, after previous: MarkdownBlockNode?) -> CGFloat { guard let previous else { return 0 } if case .heading = block { return 24 } if case .heading = previous { return 8 } return 12 } } struct MarkdownBlockView: View { let block: MarkdownBlockNode @Environment(\.hapiTheme) private var theme @Environment(\.hapiTypography) private var typography var body: some View { switch block { case .paragraph(let text): SwiftUI.Text(hapiStyledText(text, theme: theme, typography: typography)) .font(typography.bodyFont) .lineSpacing(typography.bodyLineSpacing) .fixedSize(horizontal: false, vertical: true) .foregroundStyle(theme.textPrimary) .textSelection(.enabled) case .heading(let level, let text): SwiftUI.Text(hapiStyledText(text, theme: theme, typography: typography)) .font(typography.headingFont(level)) .fixedSize(horizontal: false, vertical: true) .foregroundStyle(theme.textPrimary) .textSelection(.enabled) case .codeBlock(let language, let code): CodeBlockView(language: language, code: code) case .blockquote(let children): HStack(alignment: .top, spacing: 10) { RoundedRectangle(cornerRadius: 1.5) .fill(theme.quoteBar) .frame(width: 3) MarkdownBlockListView(blocks: children) } .fixedSize(horizontal: false, vertical: true) .padding(10) .background(theme.quoteBackground) .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) case .list(let model): MarkdownListView(model: model) case .table(let model): MarkdownTableView(model: model) case .thematicBreak: Rectangle() .fill(theme.divider) .frame(height: 1) .padding(.vertical, 4) case .image(let alt, let destination): MarkdownImageRow(alt: alt, destination: destination) } } } struct MarkdownListView: View { let model: MarkdownListModel @Environment(\.hapiTheme) private var theme @Environment(\.hapiTypography) private var typography var body: some View { VStack(alignment: .leading, spacing: 8) { ForEach(Array(model.items.enumerated()), id: \.offset) { index, item in HStack(alignment: .firstTextBaseline, spacing: 8) { marker(for: item, index: index) MarkdownBlockListView(blocks: item.blocks) } } } } @ViewBuilder private func marker(for item: MarkdownListModel.Item, index: Int) -> some View { if let checked = item.checkbox { SwiftUI.Image(systemName: checked ? "checkmark.square.fill" : "square") .font(.system(size: typography.bodySize - 2)) .foregroundStyle(checked ? theme.accent : theme.textHint) .accessibilityLabel(SwiftUI.Text(checked ? "Completed" : "Not completed")) } else if model.isOrdered { SwiftUI.Text("\(model.startIndex + index).") .font(typography.bodyFont.monospacedDigit()) .foregroundStyle(theme.textHint) } else { SwiftUI.Text("•") .font(typography.bodyFont) .foregroundStyle(theme.textHint) } } } struct MarkdownTableView: View { let model: MarkdownTableModel @Environment(\.hapiTheme) private var theme @Environment(\.hapiTypography) private var typography var body: some View { ScrollView(.horizontal, showsIndicators: false) { Grid(alignment: .topLeading, horizontalSpacing: 0, verticalSpacing: 0) { GridRow { ForEach(Array(model.header.enumerated()), id: \.offset) { index, cell in SwiftUI.Text(hapiStyledText(cell, theme: theme, typography: typography)) .fontWeight(.semibold) .font(typography.bodyFont) .foregroundStyle(theme.textPrimary) .padding(.horizontal, 10) .padding(.vertical, 6) .gridColumnAlignment(columnAlignment(index)) .background(theme.tableHeaderBackground) } } ForEach(Array(model.rows.enumerated()), id: \.offset) { _, row in Divider() GridRow { ForEach(Array(row.enumerated()), id: \.offset) { index, cell in SwiftUI.Text(hapiStyledText(cell, theme: theme, typography: typography)) .font(typography.bodyFont) .foregroundStyle(theme.textPrimary) .padding(.horizontal, 10) .padding(.vertical, 6) .gridColumnAlignment(columnAlignment(index)) } } } } } .background(theme.surface) .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) } private func columnAlignment(_ index: Int) -> HorizontalAlignment { guard index < model.columnAlignments.count, let alignment = model.columnAlignments[index] else { return .leading } switch alignment { case .left: return .leading case .center: return .center case .right: return .trailing } } } struct MarkdownImageRow: View { let alt: String let destination: String? @Environment(\.hapiTheme) private var theme @Environment(\.hapiTypography) private var typography @Environment(\.hapiOpenURL) private var hapiOpenURL var body: some View { let label = alt.isEmpty ? (destination ?? "image") : alt let url: URL? = destination.flatMap { dest in guard HrefPolicy.hasScheme(dest), HrefPolicy.classify(dest) != .blocked else { return nil } return InlineBuilder.makeURL(from: dest) } HStack(spacing: 6) { SwiftUI.Image(systemName: "photo") .font(.system(size: typography.captionSize)) .foregroundStyle(theme.textHint) if let url { Button { hapiOpenURL(url) } label: { SwiftUI.Text(label) .font(typography.captionFont) .foregroundStyle(theme.link) .underline() .lineLimit(1) } .buttonStyle(.plain) } else { SwiftUI.Text(label) .font(typography.captionFont) .foregroundStyle(theme.textSecondary) .lineLimit(1) } } .padding(8) .background(theme.surface) .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) } }