eleven / ocho Public

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

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/// 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.
226public 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.
241private 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).
246public 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).
286public 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
299private 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