// Rich text = facets, and facets are BYTE offsets. This is the one piece of the render port with real
// logic in it, and the one piece that can be tested without a phone.
//
// ⛔ WHY NOT RE-PARSE THE TEXT. atproto rich text is NOT the post text run through a regex — it is
// `record.facets`, ranges the AUTHOR published. A link whose display text differs from its target, a
// mention, or a tag not spelled with '#' are all normal and all invisible to a regex. Re-deriving them
// disagrees with what was actually published (favela-web/lib/render.js:163-169 says the same).
//
// ⛔ AND THE OFFSETS ARE BYTES. Slicing the JS string mis-cuts everything after the first non-ASCII
// character — which is most of the world's text, and every emoji. So segmentation goes through the
// UTF-8 encoding.
//
// Run: bare test/richtext.test.mjs
import test from 'brittle'
import { segments } from '../lib/richtext.js'
import { encodeUtf8 } from '../lib/utf8.js'
const utf8 = (s) => encodeUtf8(s)
const at = (s, sub) => { const b = utf8(s); const n = utf8(sub); for (let i = 0; i + n.length <= b.length; i++) { let m = true; for (let j = 0; j < n.length; j++) if (b[i + j] !== n[j]) { m = false; break } if (m) return i } return -1 }
const link = (s, sub, uri) => ({ index: { byteStart: at(s, sub), byteEnd: at(s, sub) + utf8(sub).length }, features: [{ $type: 'app.bsky.richtext.facet#link', uri }] })
const tag = (s, sub, t) => ({ index: { byteStart: at(s, sub), byteEnd: at(s, sub) + utf8(sub).length }, features: [{ $type: 'app.bsky.richtext.facet#tag', tag: t }] })
const mention = (s, sub, did) => ({ index: { byteStart: at(s, sub), byteEnd: at(s, sub) + utf8(sub).length }, features: [{ $type: 'app.bsky.richtext.facet#mention', did }] })
test('no facets — one plain segment, text intact', (t) => {
const s = segments('just words', null)
t.is(s.length, 1)
t.is(s[0].kind, 'text')
t.is(s[0].text, 'just words')
})
test('a link facet becomes its own segment carrying the uri', (t) => {
const text = 'see holepunch.to for more'
const s = segments(text, [link(text, 'holepunch.to', 'https://holepunch.to')])
t.is(s.map((x) => x.text).join(''), text, 'segments reassemble the original text exactly')
const l = s.find((x) => x.kind === 'link')
t.is(l.text, 'holepunch.to')
t.is(l.uri, 'https://holepunch.to')
})
test('⛔ BYTE offsets, not string indices — a facet after an emoji is not mis-cut', (t) => {
const text = '🌱 planted at holepunch.to today'
const s = segments(text, [link(text, 'holepunch.to', 'https://holepunch.to')])
const l = s.find((x) => x.kind === 'link')
t.is(l.text, 'holepunch.to', 'the label is the LINK, not a window shifted by the emoji surrogate pair')
t.is(s.map((x) => x.text).join(''), text, 'and nothing was dropped or duplicated')
})
test('tags and mentions carry their payloads', (t) => {
const text = 'hey @alice.bsky.social about #p2p'
const s = segments(text, [mention(text, '@alice.bsky.social', 'did:plc:alice'), tag(text, '#p2p', 'p2p')])
t.is(s.find((x) => x.kind === 'mention').did, 'did:plc:alice')
t.is(s.find((x) => x.kind === 'tag').tag, 'p2p')
t.is(s.map((x) => x.text).join(''), text)
})
test('a malformed facet set degrades to plain text rather than interleaving', (t) => {
const text = 'overlapping ranges here'
const bad = [
{ index: { byteStart: 0, byteEnd: 11 }, features: [{ $type: 'app.bsky.richtext.facet#tag', tag: 'a' }] },
{ index: { byteStart: 5, byteEnd: 15 }, features: [{ $type: 'app.bsky.richtext.facet#tag', tag: 'b' }] } // overlaps
]
const s = segments(text, bad)
t.is(s.map((x) => x.text).join(''), text, 'the second, overlapping facet is skipped — never nested')
t.is(s.filter((x) => x.kind === 'tag').length, 1)
})
test('out-of-range and inverted facets are dropped', (t) => {
const text = 'short'
const s = segments(text, [
{ index: { byteStart: 0, byteEnd: 999 }, features: [{ $type: 'app.bsky.richtext.facet#tag', tag: 'x' }] },
{ index: { byteStart: 4, byteEnd: 2 }, features: [{ $type: 'app.bsky.richtext.facet#tag', tag: 'y' }] }
])
t.is(s.length, 1)
t.is(s[0].kind, 'text')
t.is(s[0].text, text)
})
test('an unknown feature type shows its text and invents no link', (t) => {
const text = 'a mystery here'
const s = segments(text, [{ index: { byteStart: 2, byteEnd: 9 }, features: [{ $type: 'app.bsky.richtext.facet#quantum' }] }])
t.is(s.map((x) => x.text).join(''), text)
t.absent(s.some((x) => x.kind === 'link'), 'no link was invented')
})
test('a non-https link facet is not turned into a tappable link', (t) => {
const text = 'click javascript:alert(1) here'
const s = segments(text, [link(text, 'javascript:alert(1)', 'javascript:alert(1)')])
t.absent(s.some((x) => x.kind === 'link'), 'only http(s) uris become links')
t.is(s.map((x) => x.text).join(''), text)
})