| 1 | import Foundation |
| 2 | |
| 3 | // The native mirror of web/static/format.js's rich-token pipeline — the same |
| 4 | // regexes, the same precedence (links first, never formatted inside), the same |
| 5 | // flat single-level output — so a message formats identically in both feeds. |
| 6 | // Keep in lockstep with format.js; parity cases from test/format.test.mjs run |
| 7 | // in RichTextTests. Block-level structure (``` fences, > quotes) is a follow-up. |
| 8 | |
| 9 | public enum RichToken: Equatable { |
| 10 | case text(String) |
| 11 | case link(String, URL) // display text + validated http(s) href |
| 12 | case bold(String) |
| 13 | case italic(String) |
| 14 | case boldItalic(String) // ***both*** — the one combined mark |
| 15 | case strike(String) |
| 16 | case code(String) |
| 17 | } |
| 18 | |
| 19 | // format.js URL_RE / TRAILING / SCHEME_RE / WWW_RE, via ICU (NSRegularExpression) |
| 20 | // so the lookbehind/lookahead in FORMAT_RE work identically. |
| 21 | private let urlRE = try! NSRegularExpression( |
| 22 | pattern: "https?://[^\\s<]+|www\\.[^\\s<]+|[a-z0-9][a-z0-9-]*(?:\\.[a-z0-9-]+)+(?:/[^\\s<]*)?", |
| 23 | options: [.caseInsensitive]) |
| 24 | // Emphasis markers join the strip (format.js twin): "**github.com/apps/x**" |
| 25 | // must not get the closing ** swallowed into the href. |
| 26 | private let trailingRE = try! NSRegularExpression(pattern: "[.,!?;:'\")\\]}*_~`]+$") |
| 27 | private let formatRE = try! NSRegularExpression( |
| 28 | pattern: "`([^`\\n]+)`|\\*\\*\\*([^*\\n]+)\\*\\*\\*|\\*\\*([^*\\n]+)\\*\\*|\\*([^*\\n]+)\\*|(?<![\\w])_([^_\\n]+)_(?![\\w])|~([^~\\n]+)~") |
| 29 | |
| 30 | // format.js BARE_TLDS: scheme-less "host.tld" links only for real TLDs, so |
| 31 | // node.js / index.html / main.go stay plain text. |
| 32 | private let bareTLDs: Set<String> = Set( |
| 33 | ("com org net edu gov int io ie co dev app ai me tv fm gg news info biz xyz " + |
| 34 | "eu uk us ca au nz de fr es it nl se no fi dk pl pt be ch at gr jp kr cn in " + |
| 35 | "br mx za ru").split(separator: " ").map(String.init)) |
| 36 | |
| 37 | // format.js linkHref: the http(s) href for a matched token, or nil. |
| 38 | private func linkHref(_ token: String) -> URL? { |
| 39 | let lower = token.lowercased() |
| 40 | if lower.hasPrefix("http://") || lower.hasPrefix("https://") { return URL(string: token) } |
| 41 | if lower.hasPrefix("www.") { return URL(string: "https://" + token) } |
| 42 | let host = token.split(whereSeparator: { "/?#".contains($0) }).first.map(String.init) ?? token |
| 43 | let labels = host.split(separator: ".") |
| 44 | if labels.count >= 2, bareTLDs.contains(labels.last!.lowercased()) { |
| 45 | return URL(string: "https://" + token) |
| 46 | } |
| 47 | return nil |
| 48 | } |
| 49 | |
| 50 | private func ns(_ s: String) -> NSString { s as NSString } |
| 51 | |
| 52 | // format.js tokenizeText: split into plain text + validated links. |
| 53 | func tokenizeText(_ text: String) -> [(text: String, href: URL?)] { |
| 54 | let str = ns(text) |
| 55 | var tokens: [(String, URL?)] = [] |
| 56 | var last = 0 |
| 57 | for m in urlRE.matches(in: text, range: NSRange(location: 0, length: str.length)) { |
| 58 | var urlRange = m.range |
| 59 | var trail = "" |
| 60 | let match = str.substring(with: m.range) |
| 61 | if let t = trailingRE.firstMatch(in: match, range: NSRange(location: 0, length: ns(match).length)) { |
| 62 | trail = ns(match).substring(with: t.range) |
| 63 | urlRange.length -= t.range.length |
| 64 | } |
| 65 | let url = str.substring(with: urlRange) |
| 66 | let lower = url.lowercased() |
| 67 | let bare = !lower.hasPrefix("http://") && !lower.hasPrefix("https://") && !lower.hasPrefix("www.") |
| 68 | // A scheme-less candidate glued to a preceding word char or '@' is |
| 69 | // mid-word or an email's domain — leave it as plain text. |
| 70 | if bare, m.range.location > 0 { |
| 71 | let prev = str.substring(with: NSRange(location: m.range.location - 1, length: 1)) |
| 72 | if prev.range(of: "[\\w@.]", options: .regularExpression) != nil { continue } |
| 73 | } |
| 74 | guard let href = linkHref(url) else { continue } // node.js / index.html — not a link |
| 75 | if m.range.location > last { |
| 76 | tokens.append((str.substring(with: NSRange(location: last, length: m.range.location - last)), nil)) |
| 77 | } |
| 78 | tokens.append((url, href)) |
| 79 | if !trail.isEmpty { tokens.append((trail, nil)) } |
| 80 | last = m.range.location + m.range.length |
| 81 | } |
| 82 | if last < str.length { |
| 83 | tokens.append((str.substring(from: last), nil)) |
| 84 | } |
| 85 | if tokens.isEmpty { tokens.append(("", nil)) } |
| 86 | return tokens |
| 87 | } |
| 88 | |
| 89 | // format.js formatSegment: inline `code` / ***bold italic*** / **bold** / |
| 90 | // *italic* / _italic_ / ~strike~. |
| 91 | private func formatSegment(_ text: String, into out: inout [RichToken]) { |
| 92 | let str = ns(text) |
| 93 | var last = 0 |
| 94 | for m in formatRE.matches(in: text, range: NSRange(location: 0, length: str.length)) { |
| 95 | if m.range.location > last { |
| 96 | out.append(.text(str.substring(with: NSRange(location: last, length: m.range.location - last)))) |
| 97 | } |
| 98 | let makers: [(Int, (String) -> RichToken)] = [ |
| 99 | (1, RichToken.code), (2, RichToken.boldItalic), (3, RichToken.bold), |
| 100 | (4, RichToken.italic), (5, RichToken.italic), (6, RichToken.strike), |
| 101 | ] |
| 102 | for (group, make) in makers { |
| 103 | let r = m.range(at: group) |
| 104 | if r.location != NSNotFound { |
| 105 | out.append(make(str.substring(with: r))) |
| 106 | break |
| 107 | } |
| 108 | } |
| 109 | last = m.range.location + m.range.length |
| 110 | } |
| 111 | if last < str.length { |
| 112 | out.append(.text(str.substring(from: last))) |
| 113 | } |
| 114 | } |
| 115 | |
| 116 | /// format.js richTokens: ordered links + formatted runs + plain text; links |
| 117 | /// take precedence and are never formatted inside. |
| 118 | public func richTokens(_ text: String) -> [RichToken] { |
| 119 | var out: [RichToken] = [] |
| 120 | for (t, href) in tokenizeText(text) { |
| 121 | if let href { |
| 122 | out.append(.link(t, href)) |
| 123 | } else { |
| 124 | formatSegment(t, into: &out) |
| 125 | } |
| 126 | } |
| 127 | return out.filter { |
| 128 | if case .text("") = $0 { return false } |
| 129 | return true |
| 130 | } |
| 131 | } |
| 132 | |
| 133 | // ---- block-level structure (format.js blockTokens) ------------------------- |
| 134 | |
| 135 | /// One block of a message, parsed BEFORE inline formatting so markers inside a |
| 136 | /// fence stay verbatim: normal text, a ``` fence (rendered verbatim), or a run |
| 137 | /// of "> " quote lines (markers stripped, still inline-formatted). |
| 138 | public enum BlockToken: Equatable { |
| 139 | case p(String) |
| 140 | case code(String, lang: String) |
| 141 | case quote(String) |
| 142 | } |
| 143 | |
| 144 | private let fenceRE = try! NSRegularExpression(pattern: "^\\s*```(.*)$") |
| 145 | private let quoteRE = try! NSRegularExpression(pattern: "^\\s*> ?(.*)$") |
| 146 | |
| 147 | private func match(_ re: NSRegularExpression, _ line: String) -> String? { |
| 148 | let ns = line as NSString |
| 149 | guard let m = re.firstMatch(in: line, range: NSRange(location: 0, length: ns.length)) else { return nil } |
| 150 | return ns.substring(with: m.range(at: 1)) |
| 151 | } |
| 152 | |
| 153 | /// format.js blockTokens, ported line-for-line (the eight parity cases from |
| 154 | /// test/format.test.mjs run in RichTextTests). |
| 155 | public func blockTokens(_ text: String) -> [BlockToken] { |
| 156 | let lines = text.components(separatedBy: "\n") |
| 157 | // Fast path: no block markers anywhere — one plain block, text untouched. |
| 158 | if !lines.contains(where: { match(fenceRE, $0) != nil || match(quoteRE, $0) != nil }) { |
| 159 | return [.p(text)] |
| 160 | } |
| 161 | var blocks: [BlockToken] = [] |
| 162 | var plain: [String] = [] |
| 163 | func flush(beforeBlock: Bool) { |
| 164 | // Trim blank lines that only exist to separate text from a block. |
| 165 | if !blocks.isEmpty { while let f = plain.first, f.trimmingCharacters(in: .whitespaces).isEmpty { plain.removeFirst() } } |
| 166 | if beforeBlock { while let l = plain.last, l.trimmingCharacters(in: .whitespaces).isEmpty { plain.removeLast() } } |
| 167 | if !plain.isEmpty { blocks.append(.p(plain.joined(separator: "\n"))) } |
| 168 | plain = [] |
| 169 | } |
| 170 | var i = 0 |
| 171 | while i < lines.count { |
| 172 | // An opening fence's info string never contains a backtick — that's an |
| 173 | // inline-code line ("```js``` opens a fence"), not a block. |
| 174 | if let info = match(fenceRE, lines[i]), !info.contains("`") { |
| 175 | flush(beforeBlock: true) |
| 176 | var body: [String] = [] |
| 177 | i += 1 |
| 178 | while i < lines.count, lines[i].trimmingCharacters(in: .whitespaces) != "```" { |
| 179 | body.append(lines[i]) |
| 180 | i += 1 |
| 181 | } |
| 182 | i += 1 // skip the closing fence; an unterminated fence runs to the end |
| 183 | blocks.append(.code(body.joined(separator: "\n"), |
| 184 | lang: info.trimmingCharacters(in: .whitespaces))) |
| 185 | continue |
| 186 | } |
| 187 | if let first = match(quoteRE, lines[i]) { |
| 188 | flush(beforeBlock: true) |
| 189 | var body = [first] |
| 190 | i += 1 |
| 191 | while i < lines.count, let q = match(quoteRE, lines[i]) { |
| 192 | body.append(q) |
| 193 | i += 1 |
| 194 | } |
| 195 | blocks.append(.quote(body.joined(separator: "\n"))) |
| 196 | continue |
| 197 | } |
| 198 | plain.append(lines[i]) |
| 199 | i += 1 |
| 200 | } |
| 201 | flush(beforeBlock: false) |
| 202 | if blocks.isEmpty { blocks.append(.p("")) } |
| 203 | return blocks |
| 204 | } |
| 205 | |
| 206 | /// format.js firstLink: the first URL in a message, skipping code fences — |
| 207 | /// what the link-preview card unfurls. |
| 208 | public func firstLink(_ text: String) -> URL? { |
| 209 | for block in blockTokens(text) { |
| 210 | switch block { |
| 211 | case .code: |
| 212 | continue |
| 213 | case .p(let body), .quote(let body): |
| 214 | if let hit = tokenizeText(body).first(where: { $0.href != nil })?.href { |
| 215 | return hit |
| 216 | } |
| 217 | } |
| 218 | } |
| 219 | return nil |
| 220 | } |
| 221 | |
| 222 | /// Every URL in a message, in order, skipping code fences — firstLink's loop |
| 223 | /// without the early return. The Links tab is an inventory (the web extractor |
| 224 | /// in threadmedia.js extracts every href the same way), so the same URL |
| 225 | /// posted twice is two entries. |
| 226 | public func allLinks(_ text: String) -> [URL] { |
| 227 | var out: [URL] = [] |
| 228 | for block in blockTokens(text) { |
| 229 | switch block { |
| 230 | case .code: |
| 231 | continue |
| 232 | case .p(let body), .quote(let body): |
| 233 | out.append(contentsOf: tokenizeText(body).compactMap { $0.href }) |
| 234 | } |
| 235 | } |
| 236 | return out |
| 237 | } |
| 238 | |
| 239 | // Compiled once — youtubeId runs from every link-bearing bubble's body, and |
| 240 | // NSRegularExpression construction is the expensive half. |
| 241 | private let ytStartRE = try! NSRegularExpression(pattern: "^(?:(\\d+)h)?(?:(\\d+)m)?(?:(\\d+)s)?$") |
| 242 | |
| 243 | /// format.js youtubeId: parse a YouTube URL into (id, start seconds), or nil |
| 244 | /// for anything else. Covers the shapes people paste: watch?v=, youtu.be/, |
| 245 | /// /shorts/, /live/, /embed/, /v/, with t= / start= offsets (90, 90s, 1h2m3s). |
| 246 | public func youtubeId(_ url: URL) -> (id: String, start: Int)? { |
| 247 | guard let scheme = url.scheme, scheme == "http" || scheme == "https", |
| 248 | var host = url.host()?.lowercased() else { return nil } |
| 249 | if host.hasPrefix("www.") { host = String(host.dropFirst(4)) } |
| 250 | if host.hasPrefix("m.") { host = String(host.dropFirst(2)) } |
| 251 | let segments = url.path().split(separator: "/").map(String.init) |
| 252 | let query = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems ?? [] |
| 253 | func param(_ name: String) -> String? { query.first { $0.name == name }?.value } |
| 254 | var id = "" |
| 255 | if host == "youtu.be" { |
| 256 | id = segments.first ?? "" |
| 257 | } else if host == "youtube.com" || host == "youtube-nocookie.com" || host == "music.youtube.com" { |
| 258 | if url.path() == "/watch" { |
| 259 | id = param("v") ?? "" |
| 260 | } else if let first = segments.first, ["shorts", "live", "embed", "v"].contains(first) { |
| 261 | id = segments.count > 1 ? segments[1] : "" |
| 262 | } |
| 263 | } else { |
| 264 | return nil |
| 265 | } |
| 266 | guard id.range(of: "^[A-Za-z0-9_-]{11}$", options: .regularExpression) != nil else { return nil } |
| 267 | let t = param("t") ?? param("start") ?? "" |
| 268 | var start = 0 |
| 269 | if t.range(of: "^\\d+s?$", options: .regularExpression) != nil { |
| 270 | start = Int(t.hasSuffix("s") ? String(t.dropLast()) : t) ?? 0 |
| 271 | } else if let m = ytStartRE |
| 272 | .firstMatch(in: t, range: NSRange(location: 0, length: (t as NSString).length)) { |
| 273 | let ns = t as NSString |
| 274 | func group(_ i: Int) -> Int { |
| 275 | m.range(at: i).location == NSNotFound ? 0 : Int(ns.substring(with: m.range(at: i))) ?? 0 |
| 276 | } |
| 277 | if (1...3).contains(where: { m.range(at: $0).location != NSNotFound }) { |
| 278 | start = group(1) * 3600 + group(2) * 60 + group(3) |
| 279 | } |
| 280 | } |
| 281 | return (id, start) |
| 282 | } |
| 283 | |
| 284 | /// format.js emojiOnlyCount: 1–3 emoji (and nothing else) → their count, else 0. |
| 285 | /// Drives the web's jumbo-emoji treatment (large, bubble-less). |
| 286 | public func emojiOnlyCount(_ text: String) -> Int { |
| 287 | let s = text.trimmingCharacters(in: .whitespacesAndNewlines) |
| 288 | guard !s.isEmpty else { return 0 } |
| 289 | var count = 0 |
| 290 | for ch in s { |
| 291 | if ch.isWhitespace { continue } // whitespace between emoji is allowed |
| 292 | guard ch.isChatEmoji else { return 0 } // a non-emoji grapheme → it's text |
| 293 | count += 1 |
| 294 | if count > 3 { return 0 } // more than 3 → normal message |
| 295 | } |
| 296 | return count |
| 297 | } |
| 298 | |
| 299 | private extension Character { |
| 300 | // A grapheme that renders as emoji: an emoji-presentation scalar, or an |
| 301 | // emoji scalar forced pictographic by a variation selector / multi-scalar |
| 302 | // sequence (flags, ZWJ families, skin tones). Digits/# stay text. |
| 303 | var isChatEmoji: Bool { |
| 304 | guard let first = unicodeScalars.first, first.properties.isEmoji else { return false } |
| 305 | return first.properties.isEmojiPresentation || unicodeScalars.count > 1 |
| 306 | } |
| 307 | } |
| 308 | |