diff --git a/ios/Packages/HapiKit/Package.swift b/ios/Packages/HapiKit/Package.swift index 781247fc..11a08073 100644 --- a/ios/Packages/HapiKit/Package.swift +++ b/ios/Packages/HapiKit/Package.swift @@ -12,6 +12,15 @@ let package = Package( products: [ .library(name: "HapiProtocol", targets: ["HapiProtocol"]), .library(name: "HapiClient", targets: ["HapiClient"]), + .library(name: "HapiUI", targets: ["HapiUI"]), + ], + dependencies: [ + // GFM parsing for the custom SwiftUI markdown renderer (HapiUI). + // The org moved from `apple` to `swiftlang`; product name is `Markdown`. + .package(url: "https://github.com/swiftlang/swift-markdown.git", from: "0.5.0"), + // JavaScriptCore-backed highlight.js wrapper for code blocks. Kept + // behind the `SyntaxHighlighting` protocol so it can be swapped out. + .package(url: "https://github.com/raspu/Highlightr.git", from: "2.2.0"), ], targets: [ // Pure protocol layer: wire models, chat pipeline, window logic. @@ -20,7 +29,18 @@ let package = Package( .target(name: "HapiProtocol"), // Transport layer: APIClient, auth, SSE, stores (lands in M1). .target(name: "HapiClient", dependencies: ["HapiProtocol"]), + // Rendering foundation: markdown renderer + pre-parse transforms, + // code blocks with syntax highlighting, unified-diff view, theme. + .target( + name: "HapiUI", + dependencies: [ + "HapiProtocol", + .product(name: "Markdown", package: "swift-markdown"), + .product(name: "Highlightr", package: "Highlightr"), + ] + ), .testTarget(name: "HapiProtocolTests", dependencies: ["HapiProtocol"]), .testTarget(name: "HapiClientTests", dependencies: ["HapiClient"]), + .testTarget(name: "HapiUITests", dependencies: ["HapiUI"]), ] ) diff --git a/ios/Packages/HapiKit/Sources/HapiUI/Code/CodeBlockView.swift b/ios/Packages/HapiKit/Sources/HapiUI/Code/CodeBlockView.swift new file mode 100644 index 00000000..b09209af --- /dev/null +++ b/ios/Packages/HapiKit/Sources/HapiUI/Code/CodeBlockView.swift @@ -0,0 +1,89 @@ +import SwiftUI + +/// Fenced code block: rounded container with a header (language chip + copy +/// button) and horizontally scrollable monospaced body. Highlighting runs off +/// the main thread through the injected `SyntaxHighlighting` engine and is +/// applied when ready; until then (or when the engine declines) the code +/// renders as plain text. +/// +/// Unsupported fence languages the web renders specially (mermaid, math) +/// deliberately fall back to this view per the v1 plan. +public struct CodeBlockView: View { + public let language: String? + public let code: String + + @Environment(\.hapiTheme) private var theme + @Environment(\.hapiSyntaxHighlighter) private var highlighter + @Environment(\.hapiPasteboard) private var pasteboard + + @State private var highlighted: AttributedString? + @State private var showCopied = false + + public init(language: String?, code: String) { + self.language = language + self.code = code + } + + private var displayLanguage: String { + guard let language, !language.isEmpty else { return "text" } + return language.lowercased() + } + + /// Recompute highlighting when content or palette changes. + private var highlightID: String { + "\(displayLanguage)|\(theme.isDark ? "d" : "l")|\(code.count)|\(code.hashValue)" + } + + public var body: some View { + VStack(alignment: .leading, spacing: 0) { + header + ScrollView(.horizontal, showsIndicators: false) { + Text(highlighted ?? AttributedString(code)) + .font(theme.codeFont) + .foregroundStyle(theme.textPrimary) + .textSelection(.enabled) + .padding(12) + } + } + .background(theme.codeBackground) + .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) + .task(id: highlightID) { + highlighted = await highlighter.highlightAsync( + code, + language: language, + dark: theme.isDark + ) + } + } + + private var header: some View { + HStack(spacing: 8) { + Text(displayLanguage) + .font(.system(size: theme.captionSize, design: .monospaced)) + .foregroundStyle(theme.textSecondary) + .lineLimit(1) + Spacer(minLength: 12) + Button { + // Explicit MainActor hop: keeps the @MainActor pasteboard + // call valid regardless of the SDK's Button-action isolation. + let pasteboard = pasteboard + let code = code + Task { @MainActor in + pasteboard.copy(code) + showCopied = true + try? await Task.sleep(nanoseconds: 1_500_000_000) + showCopied = false + } + } label: { + Image(systemName: showCopied ? "checkmark" : "doc.on.doc") + .font(.system(size: theme.captionSize)) + .foregroundStyle(showCopied ? theme.success : theme.textSecondary) + } + .buttonStyle(.plain) + .accessibilityLabel(Text(showCopied ? "Copied" : "Copy code")) + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(theme.codeHeaderBackground) + } +} diff --git a/ios/Packages/HapiKit/Sources/HapiUI/Code/SyntaxHighlighting.swift b/ios/Packages/HapiKit/Sources/HapiUI/Code/SyntaxHighlighting.swift new file mode 100644 index 00000000..d0608a65 --- /dev/null +++ b/ios/Packages/HapiKit/Sources/HapiUI/Code/SyntaxHighlighting.swift @@ -0,0 +1,179 @@ +import Foundation +import SwiftUI +import Highlightr +#if canImport(UIKit) +import UIKit +#elseif canImport(AppKit) +import AppKit +#endif + +// MARK: - SyntaxHighlighting protocol + +/// Pluggable code highlighter. `CodeBlockView` talks to this protocol only, +/// so the Highlightr-backed engine can be replaced (or stubbed in tests) +/// without touching any view code. +public protocol SyntaxHighlighting: Sendable { + /// Returns highlighted text, or nil when the input should render as + /// plain monospaced text (unknown language, oversized input, engine + /// unavailable). Synchronous; may be slow — call off the main thread. + func highlight(_ code: String, language: String?, dark: Bool) -> AttributedString? +} + +public extension SyntaxHighlighting { + /// Runs `highlight` on a background task so view code never blocks the + /// main thread on JavaScriptCore. + func highlightAsync(_ code: String, language: String?, dark: Bool) async -> AttributedString? { + let engine = self + return await Task.detached(priority: .utility) { + engine.highlight(code, language: language, dark: dark) + }.value + } +} + +// MARK: - HighlightrEngine + +/// highlight.js via Highlightr (JavaScriptCore). Lazy shared instance, results +/// cached by (code, language, theme); inputs beyond +/// `highlightedLineLimit` lines are returned as plain text (nil) to keep the +/// JS pass bounded. +public final class HighlightrEngine: SyntaxHighlighting, @unchecked Sendable { + public static let shared = HighlightrEngine() + + /// Above this many lines the code renders unhighlighted. + public static let highlightedLineLimit = 400 + + private static let lightThemeName = "xcode" + private static let darkThemeName = "atom-one-dark" + + private final class CachedHighlight { + let value: AttributedString + init(_ value: AttributedString) { self.value = value } + } + + private let lock = NSLock() + private var engine: Highlightr? + private var engineLoadFailed = false + private var appliedDarkTheme: Bool? + private let cache = NSCache() + + public init() { + cache.countLimit = 64 + } + + public func highlight(_ code: String, language: String?, dark: Bool) -> AttributedString? { + guard let language, !language.isEmpty else { return nil } + let lang = language.lowercased() + if lang == "text" || lang == "plain" || lang == "plaintext" || lang == "txt" { + return nil + } + + // Cheap line cap before any JS work. + var newlines = 0 + for byte in code.utf8 where byte == 0x0A { + newlines += 1 + if newlines > Self.highlightedLineLimit { return nil } + } + + let key = "\(lang)|\(dark ? "d" : "l")|\(code.count)|\(code.hashValue)" as NSString + if let hit = cache.object(forKey: key) { + return hit.value + } + + // Highlightr wraps a single JSContext — serialize all access. + lock.lock() + defer { lock.unlock() } + + if engine == nil && !engineLoadFailed { + engine = Highlightr() + engineLoadFailed = (engine == nil) + } + guard let engine else { return nil } + + if appliedDarkTheme != dark { + engine.setTheme(to: dark ? Self.darkThemeName : Self.lightThemeName) + appliedDarkTheme = dark + } + + guard let highlighted = engine.highlight(code, as: lang, fastRender: true) else { + return nil + } + + let converted = Self.convertToSwiftUIColors(highlighted) + cache.setObject(CachedHighlight(converted), forKey: key) + return converted + } + + /// `NSAttributedString(...)` → `AttributedString` keeps platform colors in + /// the UIKit/AppKit attribute scopes, which SwiftUI `Text` ignores. + /// Rebuild the string run-by-run with the colors copied into the SwiftUI + /// scope (rebuilding avoids mutating while iterating runs). Fonts are + /// dropped on purpose — the view applies the theme's monospaced font. + private static func convertToSwiftUIColors(_ source: NSAttributedString) -> AttributedString { + let attributed = AttributedString(source) + var output = AttributedString() + for run in attributed.runs { + var piece = AttributedString(String(attributed.characters[run.range])) + var color: Color? = nil + #if canImport(UIKit) + if let platformColor = run[AttributeScopes.UIKitAttributes.ForegroundColorAttribute.self] { + color = Color(uiColor: platformColor) + } + #elseif canImport(AppKit) + if let platformColor = run[AttributeScopes.AppKitAttributes.ForegroundColorAttribute.self] { + color = Color(nsColor: platformColor) + } + #endif + if let color { + piece[AttributeScopes.SwiftUIAttributes.ForegroundColorAttribute.self] = color + } + output.append(piece) + } + return output + } +} + +// MARK: - Pasteboard abstraction + +/// Tiny seam over the system pasteboard so copy behavior is testable and no +/// view depends on UIKit directly. +public protocol PasteboardWriting: Sendable { + @MainActor func copy(_ string: String) +} + +public struct SystemPasteboard: PasteboardWriting { + public init() {} + + @MainActor public func copy(_ string: String) { + #if canImport(UIKit) + UIPasteboard.general.string = string + #elseif canImport(AppKit) + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(string, forType: .string) + #endif + } +} + +// MARK: - Environment plumbing + +private struct HapiSyntaxHighlighterKey: EnvironmentKey { + static var defaultValue: any SyntaxHighlighting { HighlightrEngine.shared } +} + +private struct HapiPasteboardKey: EnvironmentKey { + static var defaultValue: any PasteboardWriting { SystemPasteboard() } +} + +public extension EnvironmentValues { + /// The highlighter used by `CodeBlockView`. Defaults to the shared + /// Highlightr engine; inject a stub for tests/previews. + var hapiSyntaxHighlighter: any SyntaxHighlighting { + get { self[HapiSyntaxHighlighterKey.self] } + set { self[HapiSyntaxHighlighterKey.self] = newValue } + } + + /// The pasteboard used by copy buttons. + var hapiPasteboard: any PasteboardWriting { + get { self[HapiPasteboardKey.self] } + set { self[HapiPasteboardKey.self] = newValue } + } +} diff --git a/ios/Packages/HapiKit/Sources/HapiUI/Diff/DiffModel.swift b/ios/Packages/HapiKit/Sources/HapiUI/Diff/DiffModel.swift new file mode 100644 index 00000000..837bde80 --- /dev/null +++ b/ios/Packages/HapiKit/Sources/HapiUI/Diff/DiffModel.swift @@ -0,0 +1,384 @@ +import Foundation + +/// Unified-diff model for rendering (`git diff` output, agent `unified_diff` +/// tool inputs). Pure value types + a tolerant parser. +/// +/// Semantics cross-checked against the web client: +/// - hunk/line handling mirrors `CodexDiffView.parseUnifiedDiff` +/// (`+`/`-`/` ` lines, empty in-hunk lines count as context, the +/// `\ No newline at end of file` marker is not content), +/// - rename/copy and binary handling mirrors `gitParsers.ts` (`R`/`C` both +/// map to "renamed"; binary files report 0 additions / 0 deletions, like +/// numstat's `-` markers). +/// +/// Placement note: this lives in HapiUI (not HapiProtocol) because it is a +/// rendering model; the porcelain-v2 status / numstat parsers from +/// `gitParsers.ts` belong to the files/git work package (A-M4a). +public struct DiffLine: Equatable, Sendable { + public enum Kind: Equatable, Sendable { + case context + case addition + case deletion + /// `\ No newline at end of file` — metadata, not content. + case noNewlineMarker + } + + public var kind: Kind + public var text: String + /// 1-based line number in the old file (nil for additions / markers). + public var oldNumber: Int? + /// 1-based line number in the new file (nil for deletions / markers). + public var newNumber: Int? + + public init(kind: Kind, text: String, oldNumber: Int? = nil, newNumber: Int? = nil) { + self.kind = kind + self.text = text + self.oldNumber = oldNumber + self.newNumber = newNumber + } +} + +public struct DiffHunk: Equatable, Sendable { + public var oldStart: Int + public var oldCount: Int + public var newStart: Int + public var newCount: Int + /// Trailing text on the `@@` line (usually the enclosing function). + public var sectionHeading: String? + public var lines: [DiffLine] + + public init( + oldStart: Int, + oldCount: Int, + newStart: Int, + newCount: Int, + sectionHeading: String? = nil, + lines: [DiffLine] = [] + ) { + self.oldStart = oldStart + self.oldCount = oldCount + self.newStart = newStart + self.newCount = newCount + self.sectionHeading = sectionHeading + self.lines = lines + } + + /// Reconstructed `@@ -a,b +c,d @@ heading` header for display. + public var header: String { + var text = "@@ -\(oldStart),\(oldCount) +\(newStart),\(newCount) @@" + if let sectionHeading, !sectionHeading.isEmpty { + text += " \(sectionHeading)" + } + return text + } +} + +public struct DiffFile: Equatable, Sendable { + public enum ChangeKind: Equatable, Sendable { + case modified + case added + case deleted + /// Covers git rename AND copy entries (gitParsers maps R/C the same). + case renamed + } + + /// Old path (nil for added files / bare hunk-only diffs). + public var oldPath: String? + /// New path (nil for deleted files / bare hunk-only diffs). + public var newPath: String? + public var kind: ChangeKind + public var isBinary: Bool + public var hunks: [DiffHunk] + + public init( + oldPath: String? = nil, + newPath: String? = nil, + kind: ChangeKind = .modified, + isBinary: Bool = false, + hunks: [DiffHunk] = [] + ) { + self.oldPath = oldPath + self.newPath = newPath + self.kind = kind + self.isBinary = isBinary + self.hunks = hunks + } + + public var additions: Int { + hunks.reduce(0) { total, hunk in + total + hunk.lines.filter { $0.kind == .addition }.count + } + } + + public var deletions: Int { + hunks.reduce(0) { total, hunk in + total + hunk.lines.filter { $0.kind == .deletion }.count + } + } + + /// Header label: `old → new` for renames, else the surviving path. + public var displayPath: String { + if kind == .renamed, let oldPath, let newPath, oldPath != newPath { + return "\(oldPath) → \(newPath)" + } + return newPath ?? oldPath ?? "diff" + } +} + +// MARK: - Parser + +public enum UnifiedDiffParser { + // NSRegularExpression is immutable and thread-safe; nonisolated(unsafe) + // sidesteps SDK-dependent Sendable annotation gaps under Swift 6. + nonisolated(unsafe) private static let hunkHeaderRegex = try! NSRegularExpression( + pattern: #"^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(?: (.*))?$"# + ) + nonisolated(unsafe) private static let diffGitRegex = try! NSRegularExpression( + pattern: #"^diff --git (?:"?a/)?(.*?)"? (?:"?b/)?(.*?)"?$"# + ) + + /// Parse a unified diff (optionally with git extended headers, optionally + /// multi-file, optionally headerless "bare" hunks as produced by agent + /// `unified_diff` inputs). Tolerant: unknown lines outside hunks are + /// ignored; hunks are closed when their declared line counts are consumed. + public static func parse(_ text: String) -> [DiffFile] { + var lines = text.components(separatedBy: "\n") + // A trailing newline yields one empty trailing element — drop it so it + // is not mistaken for an in-hunk empty context line. + if lines.last == "" { lines.removeLast() } + + var files: [DiffFile] = [] + var current: DiffFile? = nil + var hunk: DiffHunk? = nil + var oldRemaining = 0 + var newRemaining = 0 + var oldNumber = 0 + var newNumber = 0 + + func closeHunk() { + if let finished = hunk { + current?.hunks.append(finished) + } + hunk = nil + oldRemaining = 0 + newRemaining = 0 + } + + func closeFile() { + closeHunk() + if let finished = current { + // Keep only entries that describe an actual change; a stray + // `--- prose` line outside any diff must not become a file. + let meaningful = !finished.hunks.isEmpty + || finished.isBinary + || finished.kind != .modified + || (finished.oldPath != nil && finished.newPath != nil) + if meaningful { + files.append(finished) + } + } + current = nil + } + + func ensureFile() { + if current == nil { + current = DiffFile() + } + } + + for line in lines { + var inHunk = hunk != nil && (oldRemaining > 0 || newRemaining > 0) + + // In-hunk content must start with +/-/space/backslash (or be an + // empty context line). A structural line while counts are still + // open means the declared counts were wrong (hand-written / LLM + // diffs) — close the hunk and dispatch the line normally. + if inHunk, line.hasPrefix("diff --git ") || line.hasPrefix("@@") { + closeHunk() + inHunk = false + } + + if !inHunk, line.hasPrefix("diff --git ") { + closeFile() + current = DiffFile() + let range = NSRange(line.startIndex.. String? { + guard let groupRange = Range(match.range(at: index), in: line) else { return nil } + return String(line[groupRange]) + } + let oldStart = group(1).flatMap(Int.init) ?? 0 + let oldCount = group(2).flatMap(Int.init) ?? 1 + let newStart = group(3).flatMap(Int.init) ?? 0 + let newCount = group(4).flatMap(Int.init) ?? 1 + hunk = DiffHunk( + oldStart: oldStart, + oldCount: oldCount, + newStart: newStart, + newCount: newCount, + sectionHeading: group(5) + ) + oldRemaining = oldCount + newRemaining = newCount + oldNumber = oldStart - 1 + newNumber = newStart - 1 + // Zero-length sides (e.g. `@@ -0,0 +1,3 @@`) start counting + // from the declared position. + if oldCount == 0 { oldNumber = oldStart } + if newCount == 0 { newNumber = newStart } + if oldRemaining <= 0 && newRemaining <= 0 { + closeHunk() + } + continue + } + + if line.hasPrefix("\\"), let lastHunkIndex = current?.hunks.indices.last { + // `\ No newline at end of file` directly after a closed hunk. + current?.hunks[lastHunkIndex].lines.append( + DiffLine(kind: .noNewlineMarker, text: String(line)) + ) + continue + } + + if line.hasPrefix("--- ") { + ensureFile() + let path = cleanHeaderPath(String(line.dropFirst(4))) + current?.oldPath = path + if path == nil, current?.kind == .modified { + current?.kind = .added + } + continue + } + + if line.hasPrefix("+++ ") { + ensureFile() + let path = cleanHeaderPath(String(line.dropFirst(4))) + current?.newPath = path + if path == nil, current?.kind == .modified { + current?.kind = .deleted + } + continue + } + + if line.hasPrefix("new file mode") { + if current?.kind == .modified { current?.kind = .added } + continue + } + if line.hasPrefix("deleted file mode") { + if current?.kind == .modified { current?.kind = .deleted } + continue + } + if line.hasPrefix("rename from ") || line.hasPrefix("copy from ") { + ensureFile() + current?.kind = .renamed + let prefixLength = line.hasPrefix("rename from ") ? 12 : 10 + current?.oldPath = String(line.dropFirst(prefixLength)) + continue + } + if line.hasPrefix("rename to ") || line.hasPrefix("copy to ") { + ensureFile() + current?.kind = .renamed + let prefixLength = line.hasPrefix("rename to ") ? 10 : 8 + current?.newPath = String(line.dropFirst(prefixLength)) + continue + } + if line.hasPrefix("Binary files ") || line == "GIT binary patch" { + ensureFile() + current?.isBinary = true + continue + } + if line.hasPrefix("index ") + || line.hasPrefix("old mode") + || line.hasPrefix("new mode") + || line.hasPrefix("similarity index") + || line.hasPrefix("dissimilarity index") { + continue + } + + // Anything else outside a hunk (prose, blank separators) — ignore. + } + + closeFile() + return files + } + + /// Strip `a/` / `b/` prefixes, surrounding quotes and tab-appended + /// metadata from `---` / `+++` header paths; `/dev/null` → nil. + static func cleanHeaderPath(_ raw: String) -> String? { + var path = raw + if let tab = path.firstIndex(of: "\t") { + path = String(path[..= 2 { + path = String(path.dropFirst().dropLast()) + } + if path == "/dev/null" { return nil } + if path.hasPrefix("a/") || path.hasPrefix("b/") { + path = String(path.dropFirst(2)) + } + return path.isEmpty ? nil : path + } +} diff --git a/ios/Packages/HapiKit/Sources/HapiUI/Diff/DiffTextView.swift b/ios/Packages/HapiKit/Sources/HapiUI/Diff/DiffTextView.swift new file mode 100644 index 00000000..40f1350d --- /dev/null +++ b/ios/Packages/HapiKit/Sources/HapiUI/Diff/DiffTextView.swift @@ -0,0 +1,199 @@ +import SwiftUI + +/// Unified-diff renderer: per-line rows with a two-column line-number gutter, +/// `+`/`-` colored backgrounds, hunk headers, file headers with +/- stats, +/// horizontal scrolling, and an optional compact mode that shows the first N +/// rows with an expand control. +public struct DiffTextView: View { + public let files: [DiffFile] + public let compact: Bool + public let compactLineLimit: Int + + @State private var expanded = false + @Environment(\.hapiTheme) private var theme + + public init(files: [DiffFile], compact: Bool = false, compactLineLimit: Int = 12) { + self.files = files + self.compact = compact + self.compactLineLimit = compactLineLimit + } + + public init(file: DiffFile, compact: Bool = false, compactLineLimit: Int = 12) { + self.init(files: [file], compact: compact, compactLineLimit: compactLineLimit) + } + + /// Parses `unifiedDiff` and renders every file it contains. + public init(unifiedDiff: String, compact: Bool = false, compactLineLimit: Int = 12) { + self.init( + files: UnifiedDiffParser.parse(unifiedDiff), + compact: compact, + compactLineLimit: compactLineLimit + ) + } + + // MARK: Rows + + private enum RowKind { + case fileHeader(DiffFile) + case hunkHeader(String) + case line(DiffLine) + case binaryNote + } + + private struct Row: Identifiable { + let id: Int + let kind: RowKind + } + + private var allRows: [Row] { + var rows: [Row] = [] + var id = 0 + func append(_ kind: RowKind) { + rows.append(Row(id: id, kind: kind)) + id += 1 + } + for file in files { + append(.fileHeader(file)) + if file.isBinary { + append(.binaryNote) + } + for hunk in file.hunks { + append(.hunkHeader(hunk.header)) + for line in hunk.lines { + append(.line(line)) + } + } + } + return rows + } + + /// Width (in characters) of one gutter column. + private var gutterDigits: Int { + var maxNumber = 1 + for file in files { + for hunk in file.hunks { + for line in hunk.lines { + if let old = line.oldNumber { maxNumber = max(maxNumber, old) } + if let new = line.newNumber { maxNumber = max(maxNumber, new) } + } + } + } + return max(2, String(maxNumber).count) + } + + public var body: some View { + let rows = allRows + let limited = compact && !expanded && rows.count > compactLineLimit + let visible = limited ? Array(rows.prefix(compactLineLimit)) : rows + let digits = gutterDigits + + VStack(alignment: .leading, spacing: 0) { + ScrollView(.horizontal, showsIndicators: false) { + VStack(alignment: .leading, spacing: 0) { + ForEach(visible) { row in + rowView(row, gutterDigits: digits) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + } + if limited { + Button { + expanded = true + } label: { + Text("Show all \(rows.count) lines") + .font(theme.captionFont) + .foregroundStyle(theme.link) + .padding(.horizontal, 12) + .padding(.vertical, 8) + } + .buttonStyle(.plain) + } + } + .background(theme.codeBackground) + .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) + } + + @ViewBuilder + private func rowView(_ row: Row, gutterDigits: Int) -> some View { + switch row.kind { + case .fileHeader(let file): + HStack(spacing: 8) { + Text(file.displayPath) + .font(.system(size: theme.captionSize, design: .monospaced)) + .foregroundStyle(theme.textSecondary) + .lineLimit(1) + if file.kind == .renamed { + badge("renamed", color: theme.warning) + } + if file.isBinary { + badge("binary", color: theme.textHint) + } else { + badge("+\(file.additions)", color: theme.success) + badge("−\(file.deletions)", color: theme.danger) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(theme.codeHeaderBackground) + case .hunkHeader(let header): + Text(header) + .font(theme.codeFont) + .foregroundStyle(theme.textSecondary) + .padding(.horizontal, 12) + .padding(.vertical, 3) + .background(theme.hunkHeaderBackground) + case .binaryNote: + Text("Binary file not shown") + .font(theme.captionFont) + .foregroundStyle(theme.textHint) + .padding(.horizontal, 12) + .padding(.vertical, 6) + case .line(let line): + lineRow(line, gutterDigits: gutterDigits) + } + } + + private func lineRow(_ line: DiffLine, gutterDigits: Int) -> some View { + let (background, foreground, prefix): (Color?, Color, String) = { + switch line.kind { + case .addition: + return (theme.diffAddedBackground, theme.diffAddedForeground, "+") + case .deletion: + return (theme.diffRemovedBackground, theme.diffRemovedForeground, "-") + case .context: + return (nil, theme.textPrimary, " ") + case .noNewlineMarker: + return (nil, theme.textHint, " ") + } + }() + + var text = AttributedString( + paddedNumber(line.oldNumber, width: gutterDigits) + + " " + + paddedNumber(line.newNumber, width: gutterDigits) + + " " + ) + text.foregroundColor = theme.textHint + var content = AttributedString(prefix + " " + line.text) + content.foregroundColor = foreground + text.append(content) + + return Text(text) + .font(theme.codeFont) + .padding(.horizontal, 12) + .padding(.vertical, 1) + .background(background ?? Color.clear) + } + + private func paddedNumber(_ number: Int?, width: Int) -> String { + let digits = number.map(String.init) ?? "" + if digits.count >= width { return digits } + return String(repeating: " ", count: width - digits.count) + digits + } + + private func badge(_ label: String, color: Color) -> some View { + Text(label) + .font(.system(size: theme.captionSize - 1, weight: .medium, design: .monospaced)) + .foregroundStyle(color) + } +} diff --git a/ios/Packages/HapiKit/Sources/HapiUI/Markdown/MarkdownRenderer.swift b/ios/Packages/HapiKit/Sources/HapiUI/Markdown/MarkdownRenderer.swift new file mode 100644 index 00000000..b27c67ea --- /dev/null +++ b/ios/Packages/HapiKit/Sources/HapiUI/Markdown/MarkdownRenderer.swift @@ -0,0 +1,704 @@ +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) -> 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] = theme.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: 10) { + ForEach(Array(blocks.enumerated()), id: \.offset) { _, block in + MarkdownBlockView(block: block) + } + } + } +} + +struct MarkdownBlockView: View { + let block: MarkdownBlockNode + @Environment(\.hapiTheme) private var theme + + var body: some View { + switch block { + case .paragraph(let text): + SwiftUI.Text(hapiStyledText(text, theme: theme)) + .font(theme.bodyFont) + .foregroundStyle(theme.textPrimary) + .textSelection(.enabled) + case .heading(let level, let text): + SwiftUI.Text(hapiStyledText(text, theme: theme)) + .font(headingFont(level)) + .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) + } + .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) + } + } + + private func headingFont(_ level: Int) -> Font { + let size: CGFloat + switch level { + case 1: size = theme.bodySize + 6 + case 2: size = theme.bodySize + 4 + case 3: size = theme.bodySize + 2.5 + case 4: size = theme.bodySize + 1.5 + case 5: size = theme.bodySize + 1 + default: size = theme.bodySize + 0.5 + } + return .system(size: size, weight: .semibold) + } +} + +struct MarkdownListView: View { + let model: MarkdownListModel + @Environment(\.hapiTheme) private var theme + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + 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: theme.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(theme.bodyFont.monospacedDigit()) + .foregroundStyle(theme.textHint) + } else { + SwiftUI.Text("•") + .font(theme.bodyFont) + .foregroundStyle(theme.textHint) + } + } +} + +struct MarkdownTableView: View { + let model: MarkdownTableModel + @Environment(\.hapiTheme) private var theme + + 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)) + .fontWeight(.semibold) + .font(theme.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)) + .font(theme.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(\.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: theme.captionSize)) + .foregroundStyle(theme.textHint) + if let url { + Button { + hapiOpenURL(url) + } label: { + SwiftUI.Text(label) + .font(theme.captionFont) + .foregroundStyle(theme.link) + .underline() + .lineLimit(1) + } + .buttonStyle(.plain) + } else { + SwiftUI.Text(label) + .font(theme.captionFont) + .foregroundStyle(theme.textSecondary) + .lineLimit(1) + } + } + .padding(8) + .background(theme.surface) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + } +} diff --git a/ios/Packages/HapiKit/Sources/HapiUI/Markdown/MarkdownTransforms.swift b/ios/Packages/HapiKit/Sources/HapiUI/Markdown/MarkdownTransforms.swift new file mode 100644 index 00000000..e1b0f6fb --- /dev/null +++ b/ios/Packages/HapiKit/Sources/HapiUI/Markdown/MarkdownTransforms.swift @@ -0,0 +1,658 @@ +import Foundation + +/// Pure string-level ports of the web client's markdown pre-processing +/// (`web/src/lib/remark-*.ts` + `markdown-href-policy.ts` + the URI scheme +/// policy inlined in `markdown-text.tsx`). These run on the raw markdown +/// source BEFORE swift-markdown parses it, or provide pure classification +/// helpers the renderer applies to the parsed tree. +/// +/// Known deltas vs the web (documented per function): +/// - `disableIndentedCode` cannot disable the parser feature (cmark has no +/// such option), so it de-indents would-be indented-code lines instead. +/// - Windows absolute paths are never linked on iOS (the web routes them +/// through session-workspace containment which HapiUI does not have). +public enum MarkdownTransforms { + + // MARK: - Table repair (remark-repair-tables.ts) + + /// Regex for one GFM separator cell: `^\s*:?-+:?\s*$`. + // NSRegularExpression is immutable and thread-safe; nonisolated(unsafe) + // sidesteps SDK-dependent Sendable annotation gaps under Swift 6. + nonisolated(unsafe) private static let separatorCellRegex = try! NSRegularExpression(pattern: #"^\s*:?-+:?\s*$"#) + /// Backtick code spans (so pipes inside them are not counted as cells). + nonisolated(unsafe) private static let codeSpanRegex = try! NSRegularExpression(pattern: "`+[^`]*?`+") + /// Fence opener/closer: up to 3 leading spaces, then ``` / ~~~ runs. + nonisolated(unsafe) private static let fenceRegex = try! NSRegularExpression(pattern: #"^ {0,3}(`{3,}|~{3,})(.*)$"#) + + private static func fullRange(of string: String) -> NSRange { + NSRange(string.startIndex.. Bool { + guard let match = regex.firstMatch(in: string, range: fullRange(of: string)) else { return false } + return match.range == fullRange(of: string) + } + + /// Count pipe-delimited cells in one table row line of raw source. + /// Strips backtick code spans first so pipes inside them are not counted. + /// Skips escaped pipes (`\|`) which are literal characters, not boundaries. + static func countSourceCells(_ line: String) -> Int { + let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) + let masked = codeSpanRegex.stringByReplacingMatches( + in: trimmed, + range: fullRange(of: trimmed), + withTemplate: "\u{FFFC}" + ) + let inner = masked.hasPrefix("|") ? String(masked.dropFirst()) : masked + let stripped = inner.hasSuffix("|") ? String(inner.dropLast()) : inner + var cells = 1 + var escaped = false + for ch in stripped { + if escaped { escaped = false; continue } + if ch == "\\" { escaped = true; continue } + if ch == "|" { cells += 1 } + } + return cells + } + + /// True when every pipe-delimited cell matches the GFM separator pattern. + static func isSeparatorLine(_ line: String) -> Bool { + let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.contains("-") else { return false } + let inner = trimmed.hasPrefix("|") ? String(trimmed.dropFirst()) : trimmed + let stripped = inner.hasSuffix("|") ? String(inner.dropLast()) : inner + let cells = stripped.components(separatedBy: "|") + return !cells.isEmpty && cells.allSatisfy { matchesWhole(separatorCellRegex, $0) } + } + + /// Cell count of a separator line, or nil when the line is not a separator. + static func countSeparatorCells(_ line: String) -> Int? { + guard isSeparatorLine(line) else { return nil } + let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) + let inner = trimmed.hasPrefix("|") ? String(trimmed.dropFirst()) : trimmed + let stripped = inner.hasSuffix("|") ? String(inner.dropLast()) : inner + return stripped.components(separatedBy: "|").count + } + + /// Pad `sepLine` to `targetCols` cells, preserving existing alignment + /// hints. Returns nil when the line already has enough cells or is not a + /// valid separator. + static func padSeparatorLine(_ sepLine: String, targetCols: Int) -> String? { + let trimmed = sepLine.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + + let hasLeading = trimmed.hasPrefix("|") + let hasTrailing = trimmed.hasSuffix("|") + + let inner = hasLeading ? String(trimmed.dropFirst()) : trimmed + let stripped = inner.hasSuffix("|") ? String(inner.dropLast()) : inner + let cells = stripped.components(separatedBy: "|") + + guard cells.count < targetCols else { return nil } + guard cells.allSatisfy({ matchesWhole(separatorCellRegex, $0) }) else { return nil } + + let padded = (cells + Array(repeating: " --- ", count: targetCols - cells.count)) + .joined(separator: "|") + return (hasLeading ? "|" : "") + padded + (hasTrailing ? "|" : "") + } + + /// Scan raw markdown for GFM tables whose separator row has fewer columns + /// than the header row (the streaming-truncation / LLM-output failure + /// mode) and pad the separator in place, so the parser produces a table + /// with all columns intact instead of degrading the block to a paragraph. + /// + /// Table-like lines inside ``` / ~~~ fenced code blocks are never + /// modified; leading whitespace of the separator line is preserved. + public static func repairTables(_ source: String) -> String { + var lines = source.components(separatedBy: "\n") + var changed = false + // Track fence character AND opening length: a ```` fence must not be + // closed by ``` (GFM 4.5: closer must match the marker family AND be + // at least as long); closers must be whitespace-only after the marker. + var fenceChar: Character? = nil + var fenceLength = 0 + + for i in lines.indices { + let line = lines[i] + if let match = fenceRegex.firstMatch(in: line, range: fullRange(of: line)), + let markerRange = Range(match.range(at: 1), in: line), + let restRange = Range(match.range(at: 2), in: line) { + let marker = line[markerRange] + let rest = line[restRange] + let ch = marker.first! + let len = marker.count + if fenceChar == nil { + fenceChar = ch + fenceLength = len + } else if ch == fenceChar && len >= fenceLength + && rest.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + fenceChar = nil + fenceLength = 0 + } + continue + } + if fenceChar != nil { continue } + if i == lines.startIndex { continue } + + let sep = lines[i] + guard isSeparatorLine(sep) else { continue } + + let hdr = lines[i - 1] + // Only repair when the header row starts with | (common LLM form). + guard hdr.trimmingCharacters(in: .whitespacesAndNewlines).hasPrefix("|") else { continue } + + let headerCols = countSourceCells(hdr) + guard let sepCols = countSeparatorCells(sep), sepCols < headerCols else { continue } + + guard let repaired = padSeparatorLine(sep, targetCols: headerCols) else { continue } + // Preserve original leading whitespace so indented tables keep it. + let prefix = String(sep.prefix(while: { $0 == " " || $0 == "\t" })) + lines[i] = prefix + repaired + changed = true + } + + return changed ? lines.joined(separator: "\n") : source + } + + // MARK: - Indented code disable (remark-disable-indented-code.ts) + + /// List-item marker at the start of (whitespace-stripped) line content: + /// `-`, `*`, `+`, or `1.` / `1)` followed by space/tab or end of line. + nonisolated(unsafe) private static let listMarkerRegex = try! NSRegularExpression( + pattern: #"^(?:[-*+]|\d{1,9}[.)])(?:[ \t]|$)"# + ) + + /// Neutralize CommonMark indented code blocks (4+ leading spaces). + /// + /// The web disables micromark's `codeIndented` construct; cmark offers no + /// such switch, so this transform reduces the indentation of would-be + /// indented-code lines to 3 spaces before parsing. LLM output frequently + /// indents quoted text or continuation prose by 4 spaces, which would + /// otherwise misrender as a code block. Fenced code still works normally. + /// + /// Conservative deltas vs the web: + /// - lines inside an active list block are left untouched (so deeply + /// nested lists keep their required indentation); on the web, indented + /// code inside list items also degrades to a paragraph, + /// - an indented line that itself looks like a list marker becomes a real + /// list after de-indenting (the web renders it as paragraph text), + /// - blockquote-prefixed indented code (`> x`) is left untouched. + public static func disableIndentedCode(_ source: String) -> String { + var lines = source.components(separatedBy: "\n") + var changed = false + var fenceChar: Character? = nil + var fenceLength = 0 + // ```/~~~ marker at 4+ spaces of indent: not a real fence for the + // parser, but the author clearly meant a code block — suppress all + // rewriting until its matching closer so the block stays one unit. + var indentedFenceChar: Character? = nil + var indentedFenceLength = 0 + var listActive = false + var previousLineBlank = true + + for i in lines.indices { + let line = lines[i] + + if let match = fenceRegex.firstMatch(in: line, range: fullRange(of: line)), + let markerRange = Range(match.range(at: 1), in: line), + let restRange = Range(match.range(at: 2), in: line) { + let marker = line[markerRange] + let rest = line[restRange] + let ch = marker.first! + let len = marker.count + if fenceChar == nil { + fenceChar = ch + fenceLength = len + } else if ch == fenceChar && len >= fenceLength + && rest.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + fenceChar = nil + fenceLength = 0 + } + previousLineBlank = false + continue + } + if fenceChar != nil { continue } + + // Leading indentation width: space = 1 column, tab = 4 columns. + var indentWidth = 0 + var contentStart = line.startIndex + for ch in line { + if ch == " " { indentWidth += 1 } else if ch == "\t" { indentWidth += 4 } else { break } + contentStart = line.index(after: contentStart) + } + let content = String(line[contentStart...]) + let contentFenceMatch = fenceRegex.firstMatch(in: content, range: fullRange(of: content)) + + if let pseudo = indentedFenceChar { + // Inside an indented fence-lookalike block: leave everything + // untouched, closing on a matching marker of >= length. + if let match = contentFenceMatch, + let markerRange = Range(match.range(at: 1), in: content), + let restRange = Range(match.range(at: 2), in: content) { + let marker = content[markerRange] + if marker.first == pseudo && marker.count >= indentedFenceLength + && content[restRange].trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + indentedFenceChar = nil + indentedFenceLength = 0 + } + } + previousLineBlank = content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + continue + } + + if content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + previousLineBlank = true + continue + } + + if listMarkerRegex.firstMatch(in: content, range: fullRange(of: content)) != nil { + // A list item starts here (the pattern is `^`-anchored). + // A 4+-space marker outside any list would be indented code; + // de-indent it so it starts a list. Inside a list, deeper + // indentation is nesting — leave it untouched. + if !listActive && indentWidth >= 4 { + lines[i] = " " + content + changed = true + } + listActive = true + previousLineBlank = false + continue + } + + if listActive { + if indentWidth == 0 && previousLineBlank { + // Column-0 paragraph after a blank line ends the list. + listActive = false + } else { + // List item continuation / nested content — untouched. + previousLineBlank = false + continue + } + } + + if indentWidth >= 4, let match = contentFenceMatch, + let markerRange = Range(match.range(at: 1), in: content) { + // Opening marker of an indented fence-lookalike block. + let marker = content[markerRange] + indentedFenceChar = marker.first + indentedFenceLength = marker.count + previousLineBlank = false + continue + } + + if indentWidth >= 4 { + lines[i] = " " + content + changed = true + } + previousLineBlank = false + } + + return changed ? lines.joined(separator: "\n") : source + } + + // MARK: - CJK autolink artifacts (remark-strip-cjk-autolink.ts) + + /// CJK / fullwidth sentence-ending punctuation that should never be part + /// of a URL, optionally followed by closing brackets (valid URL chars on + /// their own, stripped only when trailing sentence-enders). + nonisolated(unsafe) private static let trailingCJKPunctRegex = try! NSRegularExpression( + pattern: "(?:[,。、;:!?\u{3000}\u{FF0E}]+[)】」』》〉]*)$" + ) + + /// Split trailing CJK punctuation off an autolinked URL. + /// Returns nil when the URL has no trailing CJK punctuation. + /// + /// Applies only to autolinks (where link text == URL) — explicit + /// `[text](url)` links are never modified, matching the web plugin. + public static func splitTrailingCJKPunctuation(fromAutolink url: String) -> (url: String, trailing: String)? { + guard let match = trailingCJKPunctRegex.firstMatch(in: url, range: fullRange(of: url)), + let range = Range(match.range, in: url), + !range.isEmpty + else { return nil } + return (String(url[.. = [".", ",", ";", "!", "?", ":", ")", ">", "]", "'", "\""] + + static func stripTrailingURIPunctuation(_ uri: String) -> String { + var stripped = Substring(uri) + while let last = stripped.last, uriTrailingPunct.contains(last) { + if last == ")" || last == "]" { + let open: Character = last == ")" ? "(" : "[" + let inner = stripped.dropLast() + let opens = inner.filter { $0 == open }.count + let closes = inner.filter { $0 == last }.count + // The closer balances an earlier opener — belongs to the URL. + if closes < opens { break } + } + stripped = stripped.dropLast() + } + return String(stripped) + } + + /// Detect bare `scheme://…` URIs in plain text. The returned range covers + /// the linkable URL with trailing ASCII and CJK punctuation excluded; the + /// `url` string equals the text at that range. + public static func detectAutolinkRanges(in text: String) -> [(range: Range, url: String)] { + var results: [(Range, String)] = [] + let matches = uriRegex.matches(in: text, range: fullRange(of: text)) + for match in matches { + guard let matchRange = Range(match.range, in: text) else { continue } + var url = String(text[matchRange]) + // ASCII trailing punctuation first (GFM behavior), then CJK + // sentence punctuation (remark-strip-cjk-autolink behavior). + url = stripTrailingURIPunctuation(url) + if let split = splitTrailingCJKPunctuation(fromAutolink: url) { + url = split.url + } + // Require something after "://". + guard let schemeSepRange = url.range(of: "://"), schemeSepRange.upperBound < url.endIndex else { continue } + let end = text.index(matchRange.lowerBound, offsetBy: url.count) + results.append((matchRange.lowerBound.. = [ + "adoc", "astro", "avif", "bat", "bmp", "c", "cfg", "cjs", "conf", "cpp", "css", "csv", + "env", "gif", "go", "gql", "gradle", "graphql", "h", "hpp", "html", "ico", "ini", "java", + "jpeg", "jpg", "js", "json", "jsx", "kt", "lock", "md", "mdx", "mjs", "mmd", "php", "png", + "prisma", "properties", "proto", "ps1", "puml", "py", "rb", "rs", "rst", "scss", "sh", + "sql", "svelte", "svg", "swift", "tex", "toml", "ts", "tsv", "tsx", "txt", "vue", "webp", + "xml", "yaml", "yml", "zsh", + ] + + /// PATH_PATTERN port: `dir/…/file.ext[:line[:col]]` or bare + /// `file.ext[:line[:col]]` (extension validated separately against the + /// allowlist). + nonisolated(unsafe) private static let pathRegex = try! NSRegularExpression( + pattern: #"(?:[A-Za-z]:[\\/]|\./|[A-Za-z0-9_.-]+/)[^\s`"'<>]*?\.(?:[A-Za-z0-9]{1,12}|lock)(?::\d+(?::\d+)?)?|(?:[A-Za-z0-9_.-]+\.(?:[A-Za-z0-9]{1,12}|lock))(?::\d+(?::\d+)?)?"# + ) + + nonisolated(unsafe) private static let lineSuffixRegex = try! NSRegularExpression(pattern: #":(\d+)(?::\d+)?$"#) + + private static let trailingPunctuation: Set = [".", ",", ";", ":", "!", "?"] + + static func splitTrailingPunctuation(_ value: String) -> (path: String, trailing: String) { + var path = Substring(value) + var trailing = "" + + while let last = path.last { + if trailingPunctuation.contains(last) { + trailing = String(last) + trailing + path = path.dropLast() + continue + } + if last == ")" { + // Strip only when the parens are balanced-or-over-closed + // (counts include the trailing `)` itself, like the web's + // `path.split('(').length <= path.split(')').length`). + let opens = path.filter { $0 == "(" }.count + let closes = path.filter { $0 == ")" }.count + if opens <= closes { + trailing = String(last) + trailing + path = path.dropLast() + continue + } + break + } + if last == "]" || last == "}" { + trailing = String(last) + trailing + path = path.dropLast() + continue + } + break + } + + return (String(path), trailing) + } + + /// Strip a `:line[:col]` suffix. + static func stripLineSuffix(_ value: String) -> String { + guard let match = lineSuffixRegex.firstMatch(in: value, range: fullRange(of: value)), + let range = Range(match.range, in: value) + else { return value } + return String(value[.. Int? { + guard let match = lineSuffixRegex.firstMatch(in: value, range: fullRange(of: value)), + let range = Range(match.range(at: 1), in: value) + else { return nil } + return Int(value[range]) + } + + /// `^[A-Za-z]:[\\/]` port. + static func isWindowsAbsolutePath(_ value: String) -> Bool { + var iterator = value.makeIterator() + guard let first = iterator.next(), first.isASCII, first.isLetter else { return false } + guard let second = iterator.next(), second == ":" else { return false } + guard let third = iterator.next(), third == "/" || third == "\\" else { return false } + return true + } + + public static func hasKnownFileExtension(_ value: String) -> Bool { + let path = stripLineSuffix(value).lowercased() + guard let dot = path.lastIndex(of: "."), path.index(after: dot) < path.endIndex else { return false } + let ext = String(path[path.index(after: dot)...]) + return commonFileExtensions.contains(ext) + } + + /// shouldLinkPath port. iOS delta: Windows absolute paths are rejected + /// outright (the web autolinks them behind workspace containment, which + /// HapiUI cannot evaluate — fail closed). + static func shouldLinkPath(_ value: String) -> Bool { + if value.contains("://") { return false } + let path = stripLineSuffix(value) + if path.count < 3 { return false } + if path.hasPrefix("/") || path.hasPrefix("~/") { return false } + if path.hasPrefix("../") || path.contains("/../") { return false } + if isWindowsAbsolutePath(path) { return false } + return hasKnownFileExtension(path) + } + + /// Detect linkable file paths in plain text. The range covers the display + /// text (path plus any `:line[:col]` suffix, trailing punctuation + /// excluded); the link carries the clean path and parsed line number. + public static func detectFilePathRanges(in text: String) -> [(range: Range, link: FilePathLink)] { + var results: [(Range, FilePathLink)] = [] + let matches = pathRegex.matches(in: text, range: fullRange(of: text)) + for match in matches { + guard let matchRange = Range(match.range, in: text) else { continue } + if matchRange.lowerBound > text.startIndex { + let previous = text[text.index(before: matchRange.lowerBound)] + if previous == ":" || previous == "/" || previous == "\\" || previous == "." { + continue + } + } + let raw = String(text[matchRange]) + let (displayPath, _) = splitTrailingPunctuation(raw) + let filePath = stripLineSuffix(displayPath) + guard shouldLinkPath(filePath) else { continue } + + let end = text.index(matchRange.lowerBound, offsetBy: displayPath.count) + let link = FilePathLink(path: filePath, line: lineNumber(fromSuffixOf: displayPath)) + results.append((matchRange.lowerBound.. FilePathLink? { + let trimmed = code.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + guard trimmed.rangeOfCharacter(from: .whitespacesAndNewlines) == nil else { return nil } + + guard let match = pathRegex.firstMatch(in: trimmed, range: fullRange(of: trimmed)), + match.range == fullRange(of: trimmed) + else { return nil } + + let filePath = stripLineSuffix(trimmed) + guard shouldLinkPath(filePath) else { return nil } + return FilePathLink(path: filePath, line: lineNumber(fromSuffixOf: trimmed)) + } + + /// Explicit `[label](relative/file.ext)` rewrite target (rewriteFileLinkNode + /// port). Returns the file link for scheme-less repo-relative allowlisted + /// paths (with `#fragment` / `?query` / `:line` stripped); nil for + /// absolute / parent / scheme-bearing / non-file targets (fail closed). + /// iOS delta: Windows absolute paths are rejected instead of deferred to + /// containment; the line number is preserved on the link (the web drops it). + public static func fileLinkTarget(forExplicitHref href: String) -> FilePathLink? { + guard !href.lowercased().hasPrefix("hapi-file:") else { return nil } + + // Strip #fragment / ?query so `file.md#section` can still rewrite. + var withoutMeta = href + let hashIndex = href.firstIndex(of: "#") + let queryIndex = href.firstIndex(of: "?") + let cut: String.Index? + switch (hashIndex, queryIndex) { + case let (h?, q?): cut = min(h, q) + case let (h?, nil): cut = h + case let (nil, q?): cut = q + default: cut = nil + } + if let cut { withoutMeta = String(href[..&line=`. + public static let urlScheme = "hapi-file" + + public var url: URL? { + var components = URLComponents() + components.scheme = Self.urlScheme + components.host = "" + var items = [URLQueryItem(name: "path", value: path)] + if let line { items.append(URLQueryItem(name: "line", value: String(line))) } + components.queryItems = items + return components.url + } + + /// Parse a `hapi-file://?path=&line=` URL back into a link. + public init?(url: URL) { + guard url.scheme?.lowercased() == Self.urlScheme else { return nil } + guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false), + let items = components.queryItems, + let path = items.first(where: { $0.name == "path" })?.value, + !path.isEmpty + else { return nil } + self.path = path + self.line = items.first(where: { $0.name == "line" })?.value.flatMap(Int.init) + } +} + +// MARK: - HrefPolicy + +/// URI scheme policy for markdown links (port of the policy inlined in +/// `markdown-text.tsx`). Fail closed: anything without a recognizable scheme +/// is blocked (the renderer handles scheme-less hrefs separately via +/// `MarkdownTransforms.fileLinkTarget`). +/// +/// iOS delta vs web: `http` requires confirmation (the web treats it as an +/// IANA-safe scheme and navigates directly; on iOS cleartext HTTP is both an +/// ATS concern and a phishing vector, so it goes through the confirm sheet). +public enum HrefPolicy { + public enum Decision: Equatable, Sendable { + /// Open directly. + case allowed + /// Ask the user before opening (http + custom schemes). + case confirmFirst + /// Never open; render as plain text. + case blocked + } + + /// Schemes that must always be blocked regardless of user preference. + public static let denySchemes: Set = ["javascript", "data", "vbscript", "file"] + + /// Schemes safe to open without confirmation. `http` is intentionally + /// absent (see type-level note). + public static let allowedSchemes: Set = ["https", "mailto", "irc", "ircs", "xmpp"] + + /// Extract the normalized scheme: up to two rounds of percent-decoding + /// (so `javascript%253A` is unwrapped), then ASCII control characters and + /// all whitespace are stripped from the scheme (browsers discard exactly + /// these during navigation, so `java\nscript:` normalizes to + /// `javascript`). Returns nil when no valid `scheme:` prefix exists. + public static func normalizedScheme(of url: String) -> String? { + var value = String(url.drop(while: { $0.isWhitespace })) + for _ in 0..<2 { + guard let next = value.removingPercentEncoding, next != value else { break } + value = next + } + guard let colon = value.firstIndex(of: ":"), colon != value.startIndex else { return nil } + let rawScheme = value[.. Bool { + guard let colon = href.firstIndex(of: ":"), colon != href.startIndex else { return false } + guard let boundary = href.firstIndex(where: { $0 == "/" || $0 == "?" || $0 == "#" }) else { return true } + return colon < boundary + } + + public static func classify(_ url: String) -> Decision { + guard let scheme = normalizedScheme(of: url) else { return .blocked } + if denySchemes.contains(scheme) { return .blocked } + if allowedSchemes.contains(scheme) { return .allowed } + return .confirmFirst + } + + public static func classify(_ url: URL) -> Decision { + classify(url.absoluteString) + } +} diff --git a/ios/Packages/HapiKit/Sources/HapiUI/Theme/HapiTheme.swift b/ios/Packages/HapiKit/Sources/HapiUI/Theme/HapiTheme.swift new file mode 100644 index 00000000..3eda7b23 --- /dev/null +++ b/ios/Packages/HapiKit/Sources/HapiUI/Theme/HapiTheme.swift @@ -0,0 +1,194 @@ +import SwiftUI + +/// Semantic color + typography tokens for the HAPI rendering components. +/// +/// Three fixed palettes are provided: `.light`, `.dark`, and `.oled` +/// (dark with pure-black backgrounds for OLED displays). The app injects +/// the palette via the environment (`.hapiTheme(_:)`); components read it +/// through `@Environment(\.hapiTheme)`. +/// +/// Deliberately minimal — plain value type, fixed token set, no theming DSL. +public struct HapiTheme: Equatable, Sendable { + public enum Variant: String, Equatable, Sendable { + case light + case dark + case oled + } + + public var variant: Variant + + // MARK: Core surfaces & text + + public var background: Color + public var surface: Color + public var textPrimary: Color + public var textSecondary: Color + public var textHint: Color + public var divider: Color + + // MARK: Semantic accents + + public var accent: Color + public var link: Color + public var success: Color + public var warning: Color + public var danger: Color + + // MARK: Code + + public var codeBackground: Color + public var codeHeaderBackground: Color + public var inlineCodeBackground: Color + public var inlineCodeForeground: Color + + // MARK: Markdown blocks + + public var quoteBar: Color + public var quoteBackground: Color + public var tableHeaderBackground: Color + + // MARK: Diff + + public var diffAddedBackground: Color + public var diffAddedForeground: Color + public var diffRemovedBackground: Color + public var diffRemovedForeground: Color + public var hunkHeaderBackground: Color + + // MARK: Typography scale + + public var bodySize: CGFloat + public var codeSize: CGFloat + public var captionSize: CGFloat + + /// True for the `.dark` and `.oled` palettes (drives e.g. the + /// highlight.js theme choice in `CodeBlockView`). + public var isDark: Bool { variant != .light } + + public var bodyFont: Font { .system(size: bodySize) } + public var codeFont: Font { .system(size: codeSize, design: .monospaced) } + public var captionFont: Font { .system(size: captionSize) } + /// Monospaced font for inline code runs inside body text. + public var inlineCodeFont: Font { .system(size: bodySize - 1.5, design: .monospaced) } + + // MARK: - Palettes + + public static let light = HapiTheme( + variant: .light, + background: Color(hapiHex: 0xFFFFFF), + surface: Color(hapiHex: 0xF6F7F9), + textPrimary: Color(hapiHex: 0x1C1D22), + textSecondary: Color(hapiHex: 0x52555E), + textHint: Color(hapiHex: 0x8A8D96), + divider: Color(hapiHex: 0xE5E7EB), + accent: Color(hapiHex: 0x3B82F6), + link: Color(hapiHex: 0x2563EB), + success: Color(hapiHex: 0x16A34A), + warning: Color(hapiHex: 0xD97706), + danger: Color(hapiHex: 0xDC2626), + codeBackground: Color(hapiHex: 0xF6F8FA), + codeHeaderBackground: Color(hapiHex: 0xEEF1F4), + inlineCodeBackground: Color(hapiHex: 0xEEF1F4), + inlineCodeForeground: Color(hapiHex: 0xB01F63), + quoteBar: Color(hapiHex: 0xD1D5DB), + quoteBackground: Color(hapiHex: 0xF8F9FB), + tableHeaderBackground: Color(hapiHex: 0xF2F4F7), + diffAddedBackground: Color(hapiHex: 0xDDF4E4), + diffAddedForeground: Color(hapiHex: 0x116329), + diffRemovedBackground: Color(hapiHex: 0xFFEBE9), + diffRemovedForeground: Color(hapiHex: 0xA40E26), + hunkHeaderBackground: Color(hapiHex: 0xF1F8FF), + bodySize: 15, + codeSize: 13, + captionSize: 11 + ) + + public static let dark = HapiTheme( + variant: .dark, + background: Color(hapiHex: 0x0F1115), + surface: Color(hapiHex: 0x171A21), + textPrimary: Color(hapiHex: 0xE8EAED), + textSecondary: Color(hapiHex: 0xA8ADB8), + textHint: Color(hapiHex: 0x6E7481), + divider: Color(hapiHex: 0x2A2E37), + accent: Color(hapiHex: 0x6C9EFF), + link: Color(hapiHex: 0x7CA9FF), + success: Color(hapiHex: 0x3FB950), + warning: Color(hapiHex: 0xD29922), + danger: Color(hapiHex: 0xF85149), + codeBackground: Color(hapiHex: 0x161B22), + codeHeaderBackground: Color(hapiHex: 0x1F242D), + inlineCodeBackground: Color(hapiHex: 0x262C36), + inlineCodeForeground: Color(hapiHex: 0xF585A8), + quoteBar: Color(hapiHex: 0x3B4048), + quoteBackground: Color(hapiHex: 0x14171C), + tableHeaderBackground: Color(hapiHex: 0x1C2028), + diffAddedBackground: Color(hapiHex: 0x12261E), + diffAddedForeground: Color(hapiHex: 0x3FB950), + diffRemovedBackground: Color(hapiHex: 0x2D1215), + diffRemovedForeground: Color(hapiHex: 0xF85149), + hunkHeaderBackground: Color(hapiHex: 0x161F2E), + bodySize: 15, + codeSize: 13, + captionSize: 11 + ) + + /// Dark palette on pure-black backgrounds (OLED power saving). + public static let oled: HapiTheme = { + var theme = HapiTheme.dark + theme.variant = .oled + theme.background = Color(hapiHex: 0x000000) + theme.surface = Color(hapiHex: 0x0A0A0C) + theme.codeBackground = Color(hapiHex: 0x0B0D10) + theme.codeHeaderBackground = Color(hapiHex: 0x101318) + theme.quoteBackground = Color(hapiHex: 0x050608) + theme.tableHeaderBackground = Color(hapiHex: 0x101216) + theme.divider = Color(hapiHex: 0x1F2329) + return theme + }() + + /// Convenience mapping from the system color scheme. + public static func resolve(for colorScheme: ColorScheme, oledDark: Bool = false) -> HapiTheme { + switch colorScheme { + case .dark: return oledDark ? .oled : .dark + default: return .light + } + } +} + +extension Color { + /// 0xRRGGBB constant color (sRGB, full opacity). + init(hapiHex hex: UInt32) { + self.init( + .sRGB, + red: Double((hex >> 16) & 0xFF) / 255.0, + green: Double((hex >> 8) & 0xFF) / 255.0, + blue: Double(hex & 0xFF) / 255.0, + opacity: 1.0 + ) + } +} + +// MARK: - Environment plumbing + +private struct HapiThemeKey: EnvironmentKey { + static let defaultValue = HapiTheme.light +} + +public extension EnvironmentValues { + /// The active HAPI palette. Defaults to `.light`; the app is expected to + /// inject the palette matching the system appearance at its root: + /// + /// ContentView().hapiTheme(.resolve(for: colorScheme, oledDark: prefersOLED)) + var hapiTheme: HapiTheme { + get { self[HapiThemeKey.self] } + set { self[HapiThemeKey.self] = newValue } + } +} + +public extension View { + /// Injects a HAPI palette for this subtree. + func hapiTheme(_ theme: HapiTheme) -> some View { + environment(\.hapiTheme, theme) + } +} diff --git a/ios/Packages/HapiKit/Tests/HapiUITests/DiffModelTests.swift b/ios/Packages/HapiKit/Tests/HapiUITests/DiffModelTests.swift new file mode 100644 index 00000000..b20ead63 --- /dev/null +++ b/ios/Packages/HapiKit/Tests/HapiUITests/DiffModelTests.swift @@ -0,0 +1,311 @@ +import Foundation +import HapiUI +import Testing + +/// Unified-diff parser fixtures: modify / add / delete / rename / binary / +/// bare hunks. Line semantics follow the web's `CodexDiffView.parseUnifiedDiff` +/// (empty in-hunk line = context, `\` marker is not content); rename/binary +/// semantics follow `gitParsers.ts` (R/C → renamed, binary → 0/0 counts). +@Suite("UnifiedDiffParser") +struct DiffModelTests { + // MARK: - Modify + + private let modifyDiff = """ + diff --git a/src/app.ts b/src/app.ts + index 1234567..89abcde 100644 + --- a/src/app.ts + +++ b/src/app.ts + @@ -1,3 +1,4 @@ import section + import a + -import b + +import b2 + +import c + import d + @@ -10,2 +11,2 @@ + x + y + """ + + @Test func parsesModifiedFile() throws { + let files = UnifiedDiffParser.parse(modifyDiff) + #expect(files.count == 1) + let file = try #require(files.first) + #expect(file.kind == .modified) + #expect(file.oldPath == "src/app.ts") + #expect(file.newPath == "src/app.ts") + #expect(file.isBinary == false) + #expect(file.hunks.count == 2) + #expect(file.additions == 2) + #expect(file.deletions == 1) + #expect(file.displayPath == "src/app.ts") + } + + @Test func parsesHunkHeaderNumbersAndHeading() throws { + let file = try #require(UnifiedDiffParser.parse(modifyDiff).first) + let first = try #require(file.hunks.first) + #expect(first.oldStart == 1) + #expect(first.oldCount == 3) + #expect(first.newStart == 1) + #expect(first.newCount == 4) + #expect(first.sectionHeading == "import section") + #expect(first.header == "@@ -1,3 +1,4 @@ import section") + let second = try #require(file.hunks.last) + #expect(second.sectionHeading == nil) + } + + @Test func computesLineNumbers() throws { + let file = try #require(UnifiedDiffParser.parse(modifyDiff).first) + let lines = try #require(file.hunks.first).lines + #expect(lines.count == 5) + #expect(lines[0] == DiffLine(kind: .context, text: "import a", oldNumber: 1, newNumber: 1)) + #expect(lines[1] == DiffLine(kind: .deletion, text: "import b", oldNumber: 2)) + #expect(lines[2] == DiffLine(kind: .addition, text: "import b2", newNumber: 2)) + #expect(lines[3] == DiffLine(kind: .addition, text: "import c", newNumber: 3)) + #expect(lines[4] == DiffLine(kind: .context, text: "import d", oldNumber: 3, newNumber: 4)) + + let secondHunk = try #require(file.hunks.last).lines + #expect(secondHunk[0].oldNumber == 10) + #expect(secondHunk[0].newNumber == 11) + #expect(secondHunk[1].oldNumber == 11) + #expect(secondHunk[1].newNumber == 12) + } + + // MARK: - Add / delete + + @Test func parsesAddedFile() throws { + let diff = """ + diff --git a/new.txt b/new.txt + new file mode 100644 + index 0000000..e69de29 + --- /dev/null + +++ b/new.txt + @@ -0,0 +1,2 @@ + +hello + +world + """ + let file = try #require(UnifiedDiffParser.parse(diff).first) + #expect(file.kind == .added) + #expect(file.oldPath == nil) + #expect(file.newPath == "new.txt") + #expect(file.additions == 2) + #expect(file.deletions == 0) + #expect(file.hunks.first?.lines.map(\.newNumber) == [1, 2]) + #expect(file.displayPath == "new.txt") + } + + @Test func parsesDeletedFile() throws { + let diff = """ + diff --git a/old.txt b/old.txt + deleted file mode 100644 + index e69de29..0000000 + --- a/old.txt + +++ /dev/null + @@ -1,2 +0,0 @@ + -hello + -world + """ + let file = try #require(UnifiedDiffParser.parse(diff).first) + #expect(file.kind == .deleted) + #expect(file.oldPath == "old.txt") + #expect(file.newPath == nil) + #expect(file.additions == 0) + #expect(file.deletions == 2) + #expect(file.hunks.first?.lines.map(\.oldNumber) == [1, 2]) + #expect(file.displayPath == "old.txt") + } + + // MARK: - Rename + + @Test func parsesPureRename() throws { + let diff = """ + diff --git a/lib/a.ts b/lib/b.ts + similarity index 100% + rename from lib/a.ts + rename to lib/b.ts + """ + let file = try #require(UnifiedDiffParser.parse(diff).first) + #expect(file.kind == .renamed) + #expect(file.oldPath == "lib/a.ts") + #expect(file.newPath == "lib/b.ts") + #expect(file.hunks.isEmpty) + #expect(file.additions == 0) + #expect(file.displayPath == "lib/a.ts → lib/b.ts") + } + + @Test func parsesRenameWithEdits() throws { + let diff = """ + diff --git a/lib/a.ts b/lib/b.ts + similarity index 90% + rename from lib/a.ts + rename to lib/b.ts + index 111..222 100644 + --- a/lib/a.ts + +++ b/lib/b.ts + @@ -1,2 +1,2 @@ + keep + -old + +new + """ + let file = try #require(UnifiedDiffParser.parse(diff).first) + #expect(file.kind == .renamed) + #expect(file.hunks.count == 1) + #expect(file.additions == 1) + #expect(file.deletions == 1) + } + + @Test func copyCountsAsRenamed() throws { + let diff = """ + diff --git a/x.ts b/y.ts + copy from x.ts + copy to y.ts + """ + let file = try #require(UnifiedDiffParser.parse(diff).first) + #expect(file.kind == .renamed) + #expect(file.oldPath == "x.ts") + #expect(file.newPath == "y.ts") + } + + // MARK: - Binary + + @Test func parsesBinaryFile() throws { + let diff = """ + diff --git a/img.png b/img.png + index 1234567..89abcde 100644 + Binary files a/img.png and b/img.png differ + """ + let file = try #require(UnifiedDiffParser.parse(diff).first) + #expect(file.isBinary) + #expect(file.hunks.isEmpty) + // gitParsers numstat parity: binary reports 0/0. + #expect(file.additions == 0) + #expect(file.deletions == 0) + } + + // MARK: - Bare hunks (agent unified_diff inputs) + + @Test func parsesHeaderlessHunk() throws { + let diff = """ + @@ -1,2 +1,2 @@ + a + -b + +c + """ + let files = UnifiedDiffParser.parse(diff) + #expect(files.count == 1) + let file = try #require(files.first) + #expect(file.oldPath == nil) + #expect(file.newPath == nil) + #expect(file.kind == .modified) + #expect(file.hunks.count == 1) + #expect(file.hunks.first?.lines.count == 3) + #expect(file.additions == 1) + #expect(file.deletions == 1) + #expect(file.displayPath == "diff") + } + + @Test func parsesOmittedCountsAsOne() throws { + let diff = """ + @@ -3 +7 @@ + -x + +y + """ + let hunk = try #require(UnifiedDiffParser.parse(diff).first?.hunks.first) + #expect(hunk.oldStart == 3) + #expect(hunk.oldCount == 1) + #expect(hunk.newStart == 7) + #expect(hunk.newCount == 1) + #expect(hunk.lines[0].oldNumber == 3) + #expect(hunk.lines[1].newNumber == 7) + } + + @Test func emptyInHunkLineIsContext() throws { + let diff = """ + --- a/f.txt + +++ b/f.txt + @@ -1,3 +1,3 @@ + a + + b + """ + let lines = try #require(UnifiedDiffParser.parse(diff).first?.hunks.first).lines + #expect(lines.count == 3) + #expect(lines[1] == DiffLine(kind: .context, text: "", oldNumber: 2, newNumber: 2)) + } + + @Test func noNewlineMarkerIsNotContent() throws { + let diff = """ + --- a/f.txt + +++ b/f.txt + @@ -1 +1 @@ + -old + \\ No newline at end of file + +new + \\ No newline at end of file + """ + let file = try #require(UnifiedDiffParser.parse(diff).first) + let lines = try #require(file.hunks.first).lines + #expect(lines.count == 4) + #expect(lines[1].kind == .noNewlineMarker) + #expect(lines[3].kind == .noNewlineMarker) + #expect(lines[1].oldNumber == nil) + #expect(lines[1].newNumber == nil) + #expect(file.additions == 1) + #expect(file.deletions == 1) + } + + // MARK: - Multi-file and junk + + @Test func parsesMultiFileDiff() { + let diff = """ + diff --git a/a.txt b/a.txt + --- a/a.txt + +++ b/a.txt + @@ -1 +1 @@ + -x + +y + diff --git a/b.txt b/b.txt + --- a/b.txt + +++ b/b.txt + @@ -1 +1 @@ + -p + +q + """ + let files = UnifiedDiffParser.parse(diff) + #expect(files.count == 2) + #expect(files.map(\.displayPath) == ["a.txt", "b.txt"]) + #expect(files.allSatisfy { $0.hunks.count == 1 }) + } + + @Test func ignoresProseAndStrayMarkers() { + let text = """ + Here is a summary of changes. + --- notes below + nothing else + """ + #expect(UnifiedDiffParser.parse(text).isEmpty) + #expect(UnifiedDiffParser.parse("").isEmpty) + } + + @Test func closesHunkWhenCountsLie() throws { + // Declared counts promise more lines than delivered; the next file + // header must still start a fresh file. + let diff = """ + diff --git a/a.txt b/a.txt + --- a/a.txt + +++ b/a.txt + @@ -1,5 +1,5 @@ + -x + +y + diff --git a/b.txt b/b.txt + --- a/b.txt + +++ b/b.txt + @@ -1 +1 @@ + -p + +q + """ + let files = UnifiedDiffParser.parse(diff) + #expect(files.count == 2) + #expect(files.last?.newPath == "b.txt") + #expect(files.first?.hunks.count == 1) + } +} diff --git a/ios/Packages/HapiKit/Tests/HapiUITests/FilePathDetectionTests.swift b/ios/Packages/HapiKit/Tests/HapiUITests/FilePathDetectionTests.swift new file mode 100644 index 00000000..a52a1d96 --- /dev/null +++ b/ios/Packages/HapiKit/Tests/HapiUITests/FilePathDetectionTests.swift @@ -0,0 +1,170 @@ +import Foundation +import HapiUI +import Testing + +/// File-path autolink detection (remark-file-path-links port) + the +/// `hapi-file://` URL round trip. +@Suite("File path detection") +struct FilePathDetectionTests { + private func paths(in text: String) -> [String] { + MarkdownTransforms.detectFilePathRanges(in: text).map { $0.link.path } + } + + // MARK: - Positive cases + + @Test func linksRelativeCodePath() { + let text = "see src/lib/foo.ts here" + let hits = MarkdownTransforms.detectFilePathRanges(in: text) + #expect(hits.count == 1) + #expect(hits.first?.link == FilePathLink(path: "src/lib/foo.ts", line: nil)) + if let range = hits.first?.range { + #expect(String(text[range]) == "src/lib/foo.ts") + } + } + + @Test func stripsLineSuffixIntoLineNumber() { + let text = "open src/lib/foo.ts:12 please" + let hits = MarkdownTransforms.detectFilePathRanges(in: text) + #expect(hits.first?.link == FilePathLink(path: "src/lib/foo.ts", line: 12)) + if let range = hits.first?.range { + // Display range keeps the :12 suffix. + #expect(String(text[range]) == "src/lib/foo.ts:12") + } + } + + @Test func lineColumnSuffixKeepsLineOnly() { + let hits = MarkdownTransforms.detectFilePathRanges(in: "at foo.ts:12:5") + #expect(hits.first?.link == FilePathLink(path: "foo.ts", line: 12)) + } + + @Test func linksDotSlashPaths() { + // The `./` prefix stays in the target path (web parity). + #expect(paths(in: "run ./x/y.tsx now") == ["./x/y.tsx"]) + } + + @Test func linksBareFilenames() { + #expect(paths(in: "See screenshot.png and README.md") == ["screenshot.png", "README.md"]) + } + + @Test func excludesTrailingSentencePunctuation() { + let text = "Edit src/a.ts." + let hits = MarkdownTransforms.detectFilePathRanges(in: text) + #expect(hits.first?.link.path == "src/a.ts") + if let range = hits.first?.range { + #expect(String(text[range]) == "src/a.ts") + } + } + + @Test func linksPathInsideParens() { + #expect(paths(in: "(src/a.ts)") == ["src/a.ts"]) + } + + @Test func linksNodeJsLikeTokens() { + // `js` is an allowlisted extension, so `Node.js` links (web parity — + // the allowlist exists to reject TLD lookalikes, not dotted words + // with real code extensions). + #expect(paths(in: "built on Node.js today") == ["Node.js"]) + } + + // MARK: - Negative cases + + @Test func doesNotLinkInsideURLs() { + #expect(paths(in: "https://example.org/x.ts").isEmpty) + #expect(paths(in: "see https://example.org today").isEmpty) + } + + @Test func doesNotLinkTLDLookalikes() { + #expect(paths(in: "visit example.org and example.com and hapi.dev").isEmpty) + } + + @Test func doesNotLinkVersionNumbers() { + #expect(paths(in: "bump to v1.2.3 now").isEmpty) + } + + @Test func doesNotLinkAbsoluteOrParentPaths() { + #expect(paths(in: "/abs/path.ts").isEmpty) + #expect(paths(in: "~/notes.md").isEmpty) + #expect(paths(in: "../rel/foo.ts").isEmpty) + } + + @Test func doesNotLinkWindowsAbsolutePaths() { + // iOS delta: the web autolinks these behind workspace containment; + // HapiUI has no containment context, so it fails closed. + #expect(paths(in: #"open C:\src\a.ts now"#).isEmpty) + } + + @Test func doesNotLinkUnknownExtensions() { + #expect(paths(in: "cargo build --bin foo.abcxyzqwerty").isEmpty) + } + + // MARK: - Inline code linking + + @Test func linksWholeValueInlineCodePath() { + #expect(MarkdownTransforms.filePathLink(forInlineCode: "src/a.ts") == FilePathLink(path: "src/a.ts", line: nil)) + } + + @Test func linksBareFilenameInlineCodeWithLine() { + #expect(MarkdownTransforms.filePathLink(forInlineCode: "README.md:12") == FilePathLink(path: "README.md", line: 12)) + } + + @Test func linksMermaidSourceInlineCode() { + #expect(MarkdownTransforms.filePathLink(forInlineCode: "docs/flow.mmd") == FilePathLink(path: "docs/flow.mmd", line: nil)) + } + + @Test func rejectsCodeSnippetsInInlineCode() { + #expect(MarkdownTransforms.filePathLink(forInlineCode: "npm run build") == nil) + #expect(MarkdownTransforms.filePathLink(forInlineCode: "str.split()") == nil) + #expect(MarkdownTransforms.filePathLink(forInlineCode: "Math.PI") == nil) + #expect(MarkdownTransforms.filePathLink(forInlineCode: "a=b.js") == nil) + #expect(MarkdownTransforms.filePathLink(forInlineCode: "x.md#y") == nil) + #expect(MarkdownTransforms.filePathLink(forInlineCode: "") == nil) + } + + @Test func rejectsUnsafeInlineCodePaths() { + #expect(MarkdownTransforms.filePathLink(forInlineCode: "/etc/passwd.conf") == nil) + #expect(MarkdownTransforms.filePathLink(forInlineCode: "../secrets.env") == nil) + } + + // MARK: - Explicit link rewrite + + @Test func rewritesRelativeMarkdownLink() { + #expect(MarkdownTransforms.fileLinkTarget(forExplicitHref: "docs/a.md") == FilePathLink(path: "docs/a.md", line: nil)) + } + + @Test func rewritesRelativeLinkWithLineSuffix() { + #expect(MarkdownTransforms.fileLinkTarget(forExplicitHref: "docs/a.md:7") == FilePathLink(path: "docs/a.md", line: 7)) + } + + @Test func rewritesRelativeLinkStrippingFragment() { + #expect(MarkdownTransforms.fileLinkTarget(forExplicitHref: "docs/a.md#section") == FilePathLink(path: "docs/a.md", line: nil)) + } + + @Test func rejectsNonFileHrefs() { + #expect(MarkdownTransforms.fileLinkTarget(forExplicitHref: "/settings") == nil) + #expect(MarkdownTransforms.fileLinkTarget(forExplicitHref: "/abs/file.md") == nil) + #expect(MarkdownTransforms.fileLinkTarget(forExplicitHref: "../up.md") == nil) + #expect(MarkdownTransforms.fileLinkTarget(forExplicitHref: "https://x.com/a.md") == nil) + #expect(MarkdownTransforms.fileLinkTarget(forExplicitHref: #"C:\x\y.ts"#) == nil) + #expect(MarkdownTransforms.fileLinkTarget(forExplicitHref: "#section") == nil) + } + + // MARK: - hapi-file URL round trip + + @Test func filePathLinkURLRoundTripsWithLine() throws { + let link = FilePathLink(path: "src/a b.ts", line: 12) + let url = try #require(link.url) + #expect(url.scheme == "hapi-file") + #expect(FilePathLink(url: url) == link) + } + + @Test func filePathLinkURLRoundTripsWithoutLine() throws { + let link = FilePathLink(path: "README.md") + let url = try #require(link.url) + #expect(FilePathLink(url: url) == link) + } + + @Test func rejectsForeignURLs() throws { + let https = try #require(URL(string: "https://example.com/?path=x")) + #expect(FilePathLink(url: https) == nil) + } +} diff --git a/ios/Packages/HapiKit/Tests/HapiUITests/HrefPolicyTests.swift b/ios/Packages/HapiKit/Tests/HapiUITests/HrefPolicyTests.swift new file mode 100644 index 00000000..67fa2e23 --- /dev/null +++ b/ios/Packages/HapiKit/Tests/HapiUITests/HrefPolicyTests.swift @@ -0,0 +1,110 @@ +import Foundation +import HapiUI +import Testing + +/// URI scheme policy (port of the policy inlined in `markdown-text.tsx`). +/// iOS delta covered below: `http` confirms instead of navigating directly. +@Suite("HrefPolicy") +struct HrefPolicyTests { + // MARK: - Allowed + + @Test func httpsIsAllowed() { + #expect(HrefPolicy.classify("https://example.com/a?b=c") == .allowed) + } + + @Test func mailtoIsAllowed() { + #expect(HrefPolicy.classify("mailto:x@example.com") == .allowed) + } + + @Test func ircAndXmppAreAllowed() { + #expect(HrefPolicy.classify("irc://irc.libera.chat/swift") == .allowed) + #expect(HrefPolicy.classify("ircs://irc.libera.chat/swift") == .allowed) + #expect(HrefPolicy.classify("xmpp:romeo@example.net") == .allowed) + } + + @Test func schemeComparisonIsCaseInsensitive() { + #expect(HrefPolicy.classify("HTTPS://example.com") == .allowed) + } + + // MARK: - Confirm first + + @Test func httpRequiresConfirmation() { + // iOS delta: the web treats http as IANA-safe and navigates directly. + #expect(HrefPolicy.classify("http://example.com") == .confirmFirst) + } + + @Test func customSchemesRequireConfirmation() { + #expect(HrefPolicy.classify("vscode://file/a.swift") == .confirmFirst) + #expect(HrefPolicy.classify("obsidian://open?vault=x") == .confirmFirst) + #expect(HrefPolicy.classify("shortcuts://run") == .confirmFirst) + } + + // MARK: - Blocked + + @Test func denySchemesAreBlocked() { + #expect(HrefPolicy.classify("javascript:alert(1)") == .blocked) + #expect(HrefPolicy.classify("data:text/html;base64,PGI+") == .blocked) + #expect(HrefPolicy.classify("vbscript:msgbox(1)") == .blocked) + #expect(HrefPolicy.classify("file:///etc/passwd") == .blocked) + } + + @Test func denyIsCaseInsensitive() { + #expect(HrefPolicy.classify("JaVaScRiPt:alert(1)") == .blocked) + } + + @Test func leadingWhitespaceDoesNotBypass() { + #expect(HrefPolicy.classify(" javascript:alert(1)") == .blocked) + #expect(HrefPolicy.classify("\tjavascript:alert(1)") == .blocked) + } + + @Test func controlCharactersInSchemeDoNotBypass() { + #expect(HrefPolicy.classify("java\nscript:alert(1)") == .blocked) + #expect(HrefPolicy.classify("java\tscript:alert(1)") == .blocked) + #expect(HrefPolicy.classify("java\u{01}script:alert(1)") == .blocked) + } + + @Test func percentEncodedSchemeDoesNotBypass() { + #expect(HrefPolicy.classify("javascript%3Aalert(1)") == .blocked) + #expect(HrefPolicy.classify("jav%61script:alert(1)") == .blocked) + } + + @Test func doublePercentEncodedSchemeDoesNotBypass() { + #expect(HrefPolicy.classify("javascript%253Aalert(1)") == .blocked) + } + + @Test func schemelessHrefsAreBlockedByThePolicy() { + // The renderer never sends scheme-less hrefs here (they go through + // the file-link path), so the policy fails closed on them. + #expect(HrefPolicy.classify("/settings") == .blocked) + #expect(HrefPolicy.classify("./a.md") == .blocked) + #expect(HrefPolicy.classify("relative/path.md") == .blocked) + #expect(HrefPolicy.classify("#anchor") == .blocked) + #expect(HrefPolicy.classify("//example.com/x") == .blocked) + #expect(HrefPolicy.classify("") == .blocked) + #expect(HrefPolicy.classify(":missing") == .blocked) + } + + // MARK: - hasScheme + + @Test func hasSchemeDetection() { + #expect(HrefPolicy.hasScheme("mailto:foo@bar")) + #expect(HrefPolicy.hasScheme("https://x")) + #expect(HrefPolicy.hasScheme("vscode:open")) + #expect(!HrefPolicy.hasScheme("/settings")) + #expect(!HrefPolicy.hasScheme("./foo")) + #expect(!HrefPolicy.hasScheme("#section")) + #expect(!HrefPolicy.hasScheme("?q=1")) + #expect(!HrefPolicy.hasScheme("/path:colon")) + #expect(!HrefPolicy.hasScheme("//host/path")) + #expect(!HrefPolicy.hasScheme(":lead")) + } + + // MARK: - normalizedScheme + + @Test func normalizedSchemeExtraction() { + #expect(HrefPolicy.normalizedScheme(of: "HTTPS://x") == "https") + #expect(HrefPolicy.normalizedScheme(of: "java\nscript:x") == "javascript") + #expect(HrefPolicy.normalizedScheme(of: "no-colon-here") == nil) + #expect(HrefPolicy.normalizedScheme(of: ":starts-with-colon") == nil) + } +} diff --git a/ios/Packages/HapiKit/Tests/HapiUITests/MarkdownBlockTreeTests.swift b/ios/Packages/HapiKit/Tests/HapiUITests/MarkdownBlockTreeTests.swift new file mode 100644 index 00000000..1fe7c067 --- /dev/null +++ b/ios/Packages/HapiKit/Tests/HapiUITests/MarkdownBlockTreeTests.swift @@ -0,0 +1,279 @@ +import Foundation +import HapiUI +import Testing + +/// Markdown source → `MarkdownBlockNode` tree conversion (logic only — no +/// view rendering). This exercises the swift-markdown visitor, the pre-parse +/// transforms wiring, link policy application and file-path autolinking. +@Suite("MarkdownBlockTree") +struct MarkdownBlockTreeTests { + private typealias Run = (text: String, link: URL?, intent: InlinePresentationIntent?) + + private func runs(_ attributed: AttributedString) -> [Run] { + attributed.runs.map { run in + (String(attributed.characters[run.range]), run.link, run.inlinePresentationIntent) + } + } + + private func plain(_ attributed: AttributedString) -> String { + String(attributed.characters) + } + + private func paragraphText(_ node: MarkdownBlockNode) -> AttributedString? { + if case .paragraph(let text) = node { return text } + return nil + } + + // MARK: - Basic blocks + + @Test func buildsPlainParagraph() throws { + let blocks = MarkdownBlockTree.build(from: "hello world") + #expect(blocks.count == 1) + let text = try #require(paragraphText(blocks[0])) + #expect(plain(text) == "hello world") + } + + @Test func buildsInlineIntents() throws { + let blocks = MarkdownBlockTree.build(from: "**bold** and *it* and ~~gone~~ and `code`") + let text = try #require(paragraphText(blocks[0])) + let all = runs(text) + #expect(all.contains { $0.text == "bold" && $0.intent?.contains(.stronglyEmphasized) == true }) + #expect(all.contains { $0.text == "it" && $0.intent?.contains(.emphasized) == true }) + #expect(all.contains { $0.text == "gone" && $0.intent?.contains(.strikethrough) == true }) + #expect(all.contains { $0.text == "code" && $0.intent?.contains(.code) == true }) + } + + @Test func buildsHeadingWithLevel() throws { + let blocks = MarkdownBlockTree.build(from: "## Title") + guard case .heading(let level, let text) = try #require(blocks.first) else { + Issue.record("expected heading") + return + } + #expect(level == 2) + #expect(plain(text) == "Title") + } + + @Test func buildsFencedCodeBlock() throws { + let blocks = MarkdownBlockTree.build(from: "```swift\nlet x = 1\n```") + guard case .codeBlock(let language, let code) = try #require(blocks.first) else { + Issue.record("expected code block") + return + } + #expect(language == "swift") + #expect(code == "let x = 1") + } + + @Test func mermaidFenceStaysACodeBlock() throws { + // v1 plan: mermaid/math degrade to plain code blocks. + let blocks = MarkdownBlockTree.build(from: "```mermaid\ngraph TD\n```") + guard case .codeBlock(let language, _) = try #require(blocks.first) else { + Issue.record("expected code block") + return + } + #expect(language == "mermaid") + } + + @Test func buildsBlockquote() throws { + let blocks = MarkdownBlockTree.build(from: "> quoted") + guard case .blockquote(let children) = try #require(blocks.first) else { + Issue.record("expected blockquote") + return + } + let text = try #require(paragraphText(try #require(children.first))) + #expect(plain(text) == "quoted") + } + + @Test func buildsThematicBreak() { + let blocks = MarkdownBlockTree.build(from: "a\n\n---\n\nb") + #expect(blocks.count == 3) + #expect(blocks[1] == .thematicBreak) + } + + @Test func buildsImagePlaceholder() throws { + let blocks = MarkdownBlockTree.build(from: "![shot](https://x.com/y.png)") + guard case .image(let alt, let destination) = try #require(blocks.first) else { + Issue.record("expected image") + return + } + #expect(alt == "shot") + #expect(destination == "https://x.com/y.png") + } + + @Test func htmlBlockRendersAsLiteralParagraph() throws { + let blocks = MarkdownBlockTree.build(from: "
\nraw\n
") + let text = try #require(paragraphText(try #require(blocks.first))) + #expect(plain(text).contains("
")) + } + + // MARK: - Lists + + @Test func buildsTaskList() throws { + let blocks = MarkdownBlockTree.build(from: "- [x] done\n- [ ] todo") + guard case .list(let model) = try #require(blocks.first) else { + Issue.record("expected list") + return + } + #expect(model.isOrdered == false) + #expect(model.items.count == 2) + #expect(model.items[0].checkbox == true) + #expect(model.items[1].checkbox == false) + } + + @Test func buildsOrderedListWithStart() throws { + let blocks = MarkdownBlockTree.build(from: "3. three\n4. four") + guard case .list(let model) = try #require(blocks.first) else { + Issue.record("expected list") + return + } + #expect(model.isOrdered) + #expect(model.startIndex == 3) + #expect(model.items.count == 2) + #expect(model.items[0].checkbox == nil) + } + + @Test func preservesThreeLevelListNesting() throws { + let blocks = MarkdownBlockTree.build(from: "- a\n - b\n - c") + guard case .list(let outer) = try #require(blocks.first) else { + Issue.record("expected list") + return + } + guard case .list(let middle)? = outer.items.first?.blocks.last else { + Issue.record("expected nested list at depth 2") + return + } + guard case .list(let inner)? = middle.items.first?.blocks.last else { + Issue.record("expected nested list at depth 3") + return + } + let text = try #require(paragraphText(try #require(inner.items.first?.blocks.first))) + #expect(plain(text) == "c") + } + + // MARK: - Tables + + @Test func buildsTableFromValidSource() throws { + let blocks = MarkdownBlockTree.build(from: "| a | b |\n|:--|--:|\n| 1 | 2 |") + guard case .table(let model) = try #require(blocks.first) else { + Issue.record("expected table") + return + } + #expect(model.header.map { String($0.characters) } == ["a", "b"]) + #expect(model.rows.map { row in row.map { String($0.characters) } } == [["1", "2"]]) + #expect(model.columnAlignments == [.left, .right]) + } + + @Test func repairsTruncatedSeparatorIntoFullTable() throws { + // The broken separator would degrade to a paragraph without the + // repair transform; the tree must still contain a 3-column table. + let blocks = MarkdownBlockTree.build(from: "| A | B | C |\n|---|---|\n| 1 | 2 | 3 |") + guard case .table(let model) = try #require(blocks.first) else { + Issue.record("expected repaired table") + return + } + #expect(model.header.count == 3) + #expect(model.rows == [["1", "2", "3"].map { AttributedString($0) }]) + } + + @Test func indentedProseStaysProse() throws { + let blocks = MarkdownBlockTree.build(from: "intro:\n\n quoted line") + #expect(blocks.count == 2) + let text = try #require(paragraphText(blocks[1])) + #expect(plain(text) == "quoted line") + } + + // MARK: - Links + + @Test func explicitHTTPSLinkGetsLinkAttribute() throws { + let blocks = MarkdownBlockTree.build(from: "[site](https://example.com/a)") + let text = try #require(paragraphText(blocks[0])) + let linked = runs(text).compactMap { $0.link } + #expect(linked == [URL(string: "https://example.com/a")]) + } + + @Test func blockedSchemeRendersAsPlainText() throws { + let blocks = MarkdownBlockTree.build(from: "[x](javascript:alert(1))") + let text = try #require(paragraphText(blocks[0])) + #expect(runs(text).allSatisfy { $0.link == nil }) + #expect(plain(text) == "x") + } + + @Test func customSchemeKeepsLinkForConfirmFlow() throws { + let blocks = MarkdownBlockTree.build(from: "[open](vscode://file/a.swift)") + let text = try #require(paragraphText(blocks[0])) + #expect(runs(text).compactMap { $0.link } == [URL(string: "vscode://file/a.swift")]) + } + + @Test func relativeFileLinkRewritesToHapiFile() throws { + let blocks = MarkdownBlockTree.build(from: "[doc](docs/a.md)") + let text = try #require(paragraphText(blocks[0])) + let url = try #require(runs(text).compactMap { $0.link }.first) + #expect(url.scheme == "hapi-file") + #expect(FilePathLink(url: url) == FilePathLink(path: "docs/a.md", line: nil)) + #expect(plain(text) == "doc") + } + + @Test func schemelessNonFileLinkIsInert() throws { + let blocks = MarkdownBlockTree.build(from: "[settings](/settings)") + let text = try #require(paragraphText(blocks[0])) + #expect(runs(text).allSatisfy { $0.link == nil }) + #expect(plain(text) == "settings") + } + + @Test func bareURLIsAutolinked() throws { + let blocks = MarkdownBlockTree.build(from: "see https://example.com now") + let text = try #require(paragraphText(blocks[0])) + let all = runs(text) + #expect(all.contains { $0.text == "https://example.com" && $0.link == URL(string: "https://example.com") }) + } + + @Test func bareURLDropsTrailingCJKPunctuation() throws { + let boundary = MarkdownBlockTree.build(from: "见 https://example.com。") + let text = try #require(paragraphText(boundary[0])) + let all = runs(text) + #expect(all.contains { $0.text == "https://example.com" && $0.link == URL(string: "https://example.com") }) + #expect(plain(text) == "见 https://example.com。") + // The punctuation itself is not part of any link run. + #expect(all.allSatisfy { !($0.text.contains("。") && $0.link != nil) }) + } + + @Test func filePathInProseGetsHapiFileLink() throws { + let blocks = MarkdownBlockTree.build(from: "see src/app.ts:12 ok") + let text = try #require(paragraphText(blocks[0])) + let hit = try #require(runs(text).first { $0.link != nil }) + #expect(hit.text == "src/app.ts:12") + let url = try #require(hit.link) + #expect(FilePathLink(url: url) == FilePathLink(path: "src/app.ts", line: 12)) + } + + @Test func inlineCodePathGetsLinkAndCodeIntent() throws { + let blocks = MarkdownBlockTree.build(from: "`src/app.ts`") + let text = try #require(paragraphText(blocks[0])) + let hit = try #require(runs(text).first) + #expect(hit.intent?.contains(.code) == true) + let url = try #require(hit.link) + #expect(FilePathLink(url: url)?.path == "src/app.ts") + } + + @Test func inlineCodeSnippetStaysUnlinked() throws { + let blocks = MarkdownBlockTree.build(from: "`npm run build`") + let text = try #require(paragraphText(blocks[0])) + let hit = try #require(runs(text).first) + #expect(hit.intent?.contains(.code) == true) + #expect(hit.link == nil) + } + + @Test func linkLabelTextIsNotAutolinkedAgain() throws { + // Inside an explicit link, path-like label text must not sprout + // nested hapi-file links (web: transform skips link parents). + let blocks = MarkdownBlockTree.build(from: "[src/app.ts](https://example.com)") + let text = try #require(paragraphText(blocks[0])) + let linked = Set(runs(text).compactMap { $0.link?.absoluteString }) + #expect(linked == ["https://example.com"]) + } + + @Test func smartPunctuationSubstitutionIsDisabled() throws { + let blocks = MarkdownBlockTree.build(from: "\"quoted\" -- and...") + let text = try #require(paragraphText(blocks[0])) + #expect(plain(text) == "\"quoted\" -- and...") + } +} diff --git a/ios/Packages/HapiKit/Tests/HapiUITests/MarkdownTransformsTests.swift b/ios/Packages/HapiKit/Tests/HapiUITests/MarkdownTransformsTests.swift new file mode 100644 index 00000000..95c4f13b --- /dev/null +++ b/ios/Packages/HapiKit/Tests/HapiUITests/MarkdownTransformsTests.swift @@ -0,0 +1,269 @@ +import Foundation +import HapiUI +import Testing + +/// Ports of the web transform tests (`remark-repair-tables.test.ts`, +/// `remark-strip-cjk-autolink.test.ts`, plus the indented-code and autolink +/// behaviors). Inline expectations mirror the web semantics except where a +/// documented iOS delta applies. +@Suite("MarkdownTransforms") +struct MarkdownTransformsTests { + // MARK: - repairTables + + @Test func padsTwoCellSeparatorForThreeColumnHeader() { + let input = "| A | B | C |\n|---|---|\n| 1 | 2 | 3 |" + let output = MarkdownTransforms.repairTables(input) + let lines = output.components(separatedBy: "\n") + #expect(lines[1] == "|---|---| --- |") + #expect(lines[0] == "| A | B | C |") + #expect(lines[2] == "| 1 | 2 | 3 |") + } + + @Test func padsOneCellSeparatorForFourColumnHeader() { + let input = "| a | b | c | d |\n|-|\n| 1 | 2 | 3 | 4 |" + let output = MarkdownTransforms.repairTables(input) + let separator = output.components(separatedBy: "\n")[1] + let cellCount = separator.components(separatedBy: "|") + .filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty } + .count + #expect(cellCount == 4) + } + + @Test func leavesMatchingSeparatorUnchanged() { + let input = "| A | B |\n|---|---|\n| 1 | 2 |" + #expect(MarkdownTransforms.repairTables(input) == input) + } + + @Test func leavesSeparatorWithMoreCellsThanHeaderUnchanged() { + let input = "| a | b |\n|---|---|---|" + #expect(MarkdownTransforms.repairTables(input) == input) + } + + @Test func skipsSeparatorNotFollowingPipeHeader() { + let input = "A | B | C\n---|---" + #expect(MarkdownTransforms.repairTables(input) == input) + } + + @Test func ignoresTableLikeLinesInsideBacktickFence() { + let input = "```\n| A | B | C |\n|---|---|\n```" + #expect(MarkdownTransforms.repairTables(input) == input) + } + + @Test func ignoresTableLikeLinesInsideTildeFence() { + let input = "~~~\n| A | B | C |\n|---|---|\n~~~" + #expect(MarkdownTransforms.repairTables(input) == input) + } + + @Test func fourBacktickFenceIsNotClosedByThreeBackticks() { + let input = "````\n```\n| A | B | C |\n|---|---|\n````" + #expect(MarkdownTransforms.repairTables(input) == input) + } + + @Test func oppositeMarkerInsideFenceDoesNotFlipState() { + let input = "~~~\n```\n| A | B | C |\n|---|---|\n~~~" + #expect(MarkdownTransforms.repairTables(input) == input) + } + + @Test func closingFenceWithInfoTextDoesNotClose() { + let input = "```\ntext\n``` info\n| A | B | C |\n|---|---|" + #expect(MarkdownTransforms.repairTables(input) == input) + } + + @Test func repairsTableAfterFenceCloses() { + let input = "```\ncode\n```\n| A | B | C |\n|---|---|" + let output = MarkdownTransforms.repairTables(input) + let lines = output.components(separatedBy: "\n") + #expect(lines[4] == "|---|---| --- |") + } + + @Test func preservesAlignmentHints() { + let input = "| a | b | c |\n|:--|--:|" + let output = MarkdownTransforms.repairTables(input) + #expect(output.components(separatedBy: "\n")[1] == "|:--|--:| --- |") + } + + @Test func escapedPipeInHeaderIsLiteral() { + // `\|` is one literal pipe char, so the header has 2 cells — the + // 2-cell separator already matches and must stay untouched. + let input = "| a \\| b | c |\n|---|---|" + #expect(MarkdownTransforms.repairTables(input) == input) + } + + @Test func pipeInsideCodeSpanIsNotACellBoundary() { + let input = "| `a|b` | c |\n|---|---|" + #expect(MarkdownTransforms.repairTables(input) == input) + } + + @Test func preservesIndentationOfRepairedSeparator() { + let input = " | a | b | c |\n |---|---|" + let output = MarkdownTransforms.repairTables(input) + #expect(output.components(separatedBy: "\n")[1] == " |---|---| --- |") + } + + @Test func truncatedDataRowIsLeftAlone() { + // Mid-row streaming truncation: GFM tolerates short body rows, so no + // string-level repair is needed (web parity). + let input = "| a | b | c |\n|---|---|---|\n| 1 | 2" + #expect(MarkdownTransforms.repairTables(input) == input) + } + + @Test func repairsBrokenTableAndLeavesValidOne() { + let input = "| A | B |\n|---|---|\n| 1 | 2 |\n\n| X | Y | Z |\n|---|---|\n| 7 | 8 | 9 |" + let output = MarkdownTransforms.repairTables(input) + let lines = output.components(separatedBy: "\n") + #expect(lines[1] == "|---|---|") + #expect(lines[5] == "|---|---| --- |") + } + + // MARK: - disableIndentedCode + + @Test func deindentsTopLevelIndentedText() { + let input = "para:\n\n quoted reply" + let output = MarkdownTransforms.disableIndentedCode(input) + #expect(output == "para:\n\n quoted reply") + } + + @Test func deindentsDeeplyIndentedText() { + let input = "para:\n\n eight spaces" + let output = MarkdownTransforms.disableIndentedCode(input) + #expect(output == "para:\n\n eight spaces") + } + + @Test func treatsTabAsIndentation() { + let input = "para:\n\n\ttabbed" + let output = MarkdownTransforms.disableIndentedCode(input) + #expect(output == "para:\n\n tabbed") + } + + @Test func leavesFencedCodeAlone() { + let input = "```\n indented in fence\n```" + #expect(MarkdownTransforms.disableIndentedCode(input) == input) + } + + @Test func leavesListContinuationAlone() { + let input = "1. item\n\n continuation text" + #expect(MarkdownTransforms.disableIndentedCode(input) == input) + } + + @Test func leavesNestedListsAlone() { + let input = "- a\n - b\n - c\n - d" + #expect(MarkdownTransforms.disableIndentedCode(input) == input) + } + + @Test func deindentsAfterListEnds() { + let input = "- item\n\ntext\n\n stray indent" + let output = MarkdownTransforms.disableIndentedCode(input) + #expect(output == "- item\n\ntext\n\n stray indent") + } + + @Test func deindentsTopLevelIndentedListMarker() { + // iOS delta: becomes a real list (web renders paragraph text). + let input = "para\n\n - foo" + let output = MarkdownTransforms.disableIndentedCode(input) + #expect(output == "para\n\n - foo") + } + + @Test func leavesIndentedFenceLookalikeBlockIntact() { + // A 4-space-indented ``` block stays one untouched unit. + let input = "text\n\n ```\n let x = 1\n ```" + #expect(MarkdownTransforms.disableIndentedCode(input) == input) + } + + @Test func resumesDeindentingAfterIndentedFenceLookalikeCloses() { + let input = "text\n\n ```\n body\n ```\n\n tail" + let output = MarkdownTransforms.disableIndentedCode(input) + #expect(output == "text\n\n ```\n body\n ```\n\n tail") + } + + // MARK: - splitTrailingCJKPunctuation + + @Test func stripsTrailingFullwidthComma() { + let result = MarkdownTransforms.splitTrailingCJKPunctuation(fromAutolink: "https://example.com,") + #expect(result?.url == "https://example.com") + #expect(result?.trailing == ",") + } + + @Test func stripsTrailingIdeographicFullStop() { + let result = MarkdownTransforms.splitTrailingCJKPunctuation(fromAutolink: "https://example.com/a。") + #expect(result?.url == "https://example.com/a") + #expect(result?.trailing == "。") + } + + @Test func stripsMultipleTrailingCJKPunctuation() { + let result = MarkdownTransforms.splitTrailingCJKPunctuation(fromAutolink: "https://example.com。,") + #expect(result?.url == "https://example.com") + #expect(result?.trailing == "。,") + } + + @Test func stripsSentenceEnderFollowedByClosingBracket() { + let result = MarkdownTransforms.splitTrailingCJKPunctuation(fromAutolink: "https://example.com/a。)") + #expect(result?.url == "https://example.com/a") + #expect(result?.trailing == "。)") + } + + @Test func keepsCJKCharactersInsideThePath() { + #expect(MarkdownTransforms.splitTrailingCJKPunctuation(fromAutolink: "https://example.com/文档") == nil) + } + + @Test func keepsLoneFullwidthClosingBracket() { + // Brackets alone (no sentence-ender) may be part of the URL. + #expect(MarkdownTransforms.splitTrailingCJKPunctuation(fromAutolink: "https://example.com/a)") == nil) + } + + @Test func returnsNilForCleanURL() { + #expect(MarkdownTransforms.splitTrailingCJKPunctuation(fromAutolink: "https://example.com/a") == nil) + } + + // MARK: - detectAutolinkRanges + + @Test func detectsBareHTTPSURL() { + let text = "see https://example.com/x now" + let hits = MarkdownTransforms.detectAutolinkRanges(in: text) + #expect(hits.count == 1) + #expect(hits.first?.url == "https://example.com/x") + if let range = hits.first?.range { + #expect(String(text[range]) == "https://example.com/x") + } + } + + @Test func detectsCustomSchemeURL() { + let hits = MarkdownTransforms.detectAutolinkRanges(in: "open obsidian://vault/Note here") + #expect(hits.first?.url == "obsidian://vault/Note") + } + + @Test func stripsTrailingASCIIPunctuation() { + let hits = MarkdownTransforms.detectAutolinkRanges(in: "See obsidian://x.") + #expect(hits.first?.url == "obsidian://x") + } + + @Test func keepsBalancedParensInURL() { + let hits = MarkdownTransforms.detectAutolinkRanges(in: "x obsidian://open?file=Note(1) y") + #expect(hits.first?.url == "obsidian://open?file=Note(1)") + } + + @Test func stripsWikipediaStyleTrailingParen() { + let hits = MarkdownTransforms.detectAutolinkRanges( + in: "(https://en.wikipedia.org/wiki/Foo_(bar)) end" + ) + #expect(hits.first?.url == "https://en.wikipedia.org/wiki/Foo_(bar)") + } + + @Test func stripsTrailingCJKFromDetectedURL() { + // Sentence-ending punctuation right after the URL (before whitespace + // or end of text) must stay out of the link. Note: with NO whitespace + // after the punctuation, GFM swallows everything into the URL — that + // (web-matching) behavior is intentional and not stripped here. + let hits = MarkdownTransforms.detectAutolinkRanges(in: "见 https://example.com, 谢谢") + #expect(hits.count == 1) + #expect(hits.first?.url == "https://example.com") + + let atEnd = MarkdownTransforms.detectAutolinkRanges(in: "见 https://example.com。") + #expect(atEnd.first?.url == "https://example.com") + } + + @Test func ignoresSchemelessAndNonURIText() { + #expect(MarkdownTransforms.detectAutolinkRanges(in: "no links here").isEmpty) + #expect(MarkdownTransforms.detectAutolinkRanges(in: "javascript:alert(1)").isEmpty) + #expect(MarkdownTransforms.detectAutolinkRanges(in: "a ratio of 3://").isEmpty) + } +} diff --git a/ios/Packages/HapiKit/Tests/HapiUITests/ThemeTests.swift b/ios/Packages/HapiKit/Tests/HapiUITests/ThemeTests.swift new file mode 100644 index 00000000..3f4e5371 --- /dev/null +++ b/ios/Packages/HapiKit/Tests/HapiUITests/ThemeTests.swift @@ -0,0 +1,34 @@ +import SwiftUI +import HapiUI +import Testing + +@Suite("HapiTheme") +struct ThemeTests { + @Test func variantsAndDarkFlag() { + #expect(HapiTheme.light.variant == .light) + #expect(HapiTheme.dark.variant == .dark) + #expect(HapiTheme.oled.variant == .oled) + #expect(!HapiTheme.light.isDark) + #expect(HapiTheme.dark.isDark) + #expect(HapiTheme.oled.isDark) + } + + @Test func oledUsesPureBlackBackground() { + #expect(HapiTheme.oled.background == Color(.sRGB, red: 0, green: 0, blue: 0, opacity: 1)) + // Everything else inherits the dark palette. + #expect(HapiTheme.oled.textPrimary == HapiTheme.dark.textPrimary) + #expect(HapiTheme.oled.diffAddedBackground == HapiTheme.dark.diffAddedBackground) + } + + @Test func palettesAreDistinct() { + #expect(HapiTheme.light != HapiTheme.dark) + #expect(HapiTheme.dark != HapiTheme.oled) + } + + @Test func resolveMapsColorScheme() { + #expect(HapiTheme.resolve(for: .light) == .light) + #expect(HapiTheme.resolve(for: .dark) == .dark) + #expect(HapiTheme.resolve(for: .dark, oledDark: true) == .oled) + #expect(HapiTheme.resolve(for: .light, oledDark: true) == .light) + } +} diff --git a/ios/README.md b/ios/README.md index 3a101378..cd468272 100644 --- a/ios/README.md +++ b/ios/README.md @@ -6,8 +6,13 @@ it shares only the protocol contract (`docs/api/`) and the golden fixtures ## Requirements -- Xcode 16 or newer (iOS 17 SDK). No third-party dependencies in M0. +- Xcode 16 or newer (iOS 17 SDK). - Deployment target: iOS 17.0. +- Runtime dependencies (SPM, declared in `Packages/HapiKit/Package.swift`, + used only by the `HapiUI` target): `swiftlang/swift-markdown` (GFM parsing + for the custom renderer) and `raspu/Highlightr` (code highlighting, kept + behind a protocol so it is swappable). `HapiProtocol`/`HapiClient` stay + dependency-free. ## Build @@ -49,6 +54,25 @@ ios/ shared/fixtures/**. HapiClient Transport layer: APIClient, AuthManager (single-flight refresh), SSEClient, @Observable stores, snapshots. + HapiUI Rendering foundation (M2e). SwiftUI, no app coupling: + Markdown/ MarkdownTransforms (string-level ports of + the web remark plugins: table repair, + indented-code disable, CJK autolink strip, + file-path + bare-URL detection, HrefPolicy) + and MarkdownRenderer (swift-markdown + visitor -> block tree -> SwiftUI views; + links flow through the \.hapiOpenURL + environment action, workspace files use + hapi-file://?path=&line= URLs) + Code/ CodeBlockView + SyntaxHighlighting + protocol with the Highlightr engine + (off-main, cached, 400-line cap) + Diff/ UnifiedDiffParser + DiffTextView + (hunks, +/- gutters, compact/expand) + Theme/ HapiTheme palettes (light/dark/OLED) + via the \.hapiTheme environment + The app target does not import HapiUI yet; it gets + wired in with the chat views (M2f). ``` The app target stays thin; features live in `HapiKit` so they are testable