eleven / messenger Public

Clone
git clone https://amadan.net/eleven/messenger

Plain git — no account needed to clone.

Download

Download this file

1import 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
9public 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.
21private 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.
26private let trailingRE = try! NSRegularExpression(pattern: "[.,!?;:'\")\\]}*_~`]+$")
27private 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.
32private 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.
38private 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
50private func ns(_ s: String) -> NSString { s as NSString }
51
52// format.js tokenizeText: split into plain text + validated links.
53func 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~.
91private 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.
118public 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).
138public enum BlockToken: Equatable {
139 case p(String)
140 case code(String, lang: String)
141 case quote(String)
142}
143
144private let fenceRE = try! NSRegularExpression(pattern: "^\\s*```(.*)$")
145private let quoteRE = try! NSRegularExpression(pattern: "^\\s*> ?(.*)$")
146
147private 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).
155public 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.
208public 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// Compiled once — youtubeId runs from every link-bearing bubble's body, and
223// NSRegularExpression construction is the expensive half.
224private let ytStartRE = try! NSRegularExpression(pattern: "^(?:(\\d+)h)?(?:(\\d+)m)?(?:(\\d+)s)?$")
225
226/// format.js youtubeId: parse a YouTube URL into (id, start seconds), or nil
227/// for anything else. Covers the shapes people paste: watch?v=, youtu.be/,
228/// /shorts/, /live/, /embed/, /v/, with t= / start= offsets (90, 90s, 1h2m3s).
229public func youtubeId(_ url: URL) -> (id: String, start: Int)? {
230 guard let scheme = url.scheme, scheme == "http" || scheme == "https",
231 var host = url.host()?.lowercased() else { return nil }
232 if host.hasPrefix("www.") { host = String(host.dropFirst(4)) }
233 if host.hasPrefix("m.") { host = String(host.dropFirst(2)) }
234 let segments = url.path().split(separator: "/").map(String.init)
235 let query = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems ?? []
236 func param(_ name: String) -> String? { query.first { $0.name == name }?.value }
237 var id = ""
238 if host == "youtu.be" {
239 id = segments.first ?? ""
240 } else if host == "youtube.com" || host == "youtube-nocookie.com" || host == "music.youtube.com" {
241 if url.path() == "/watch" {
242 id = param("v") ?? ""
243 } else if let first = segments.first, ["shorts", "live", "embed", "v"].contains(first) {
244 id = segments.count > 1 ? segments[1] : ""
245 }
246 } else {
247 return nil
248 }
249 guard id.range(of: "^[A-Za-z0-9_-]{11}$", options: .regularExpression) != nil else { return nil }
250 let t = param("t") ?? param("start") ?? ""
251 var start = 0
252 if t.range(of: "^\\d+s?$", options: .regularExpression) != nil {
253 start = Int(t.hasSuffix("s") ? String(t.dropLast()) : t) ?? 0
254 } else if let m = ytStartRE
255 .firstMatch(in: t, range: NSRange(location: 0, length: (t as NSString).length)) {
256 let ns = t as NSString
257 func group(_ i: Int) -> Int {
258 m.range(at: i).location == NSNotFound ? 0 : Int(ns.substring(with: m.range(at: i))) ?? 0
259 }
260 if (1...3).contains(where: { m.range(at: $0).location != NSNotFound }) {
261 start = group(1) * 3600 + group(2) * 60 + group(3)
262 }
263 }
264 return (id, start)
265}
266
267/// format.js emojiOnlyCount: 1–3 emoji (and nothing else) → their count, else 0.
268/// Drives the web's jumbo-emoji treatment (large, bubble-less).
269public func emojiOnlyCount(_ text: String) -> Int {
270 let s = text.trimmingCharacters(in: .whitespacesAndNewlines)
271 guard !s.isEmpty else { return 0 }
272 var count = 0
273 for ch in s {
274 if ch.isWhitespace { continue } // whitespace between emoji is allowed
275 guard ch.isChatEmoji else { return 0 } // a non-emoji grapheme → it's text
276 count += 1
277 if count > 3 { return 0 } // more than 3 → normal message
278 }
279 return count
280}
281
282private extension Character {
283 // A grapheme that renders as emoji: an emoji-presentation scalar, or an
284 // emoji scalar forced pictographic by a variation selector / multi-scalar
285 // sequence (flags, ZWJ families, skin tones). Digits/# stay text.
286 var isChatEmoji: Bool {
287 guard let first = unicodeScalars.first, first.properties.isEmoji else { return false }
288 return first.properties.isEmojiPresentation || unicodeScalars.count > 1
289 }
290}
291