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) } }