// facets — turn compose-time text into app.bsky.richtext.facet[] for the record we write.
//
// THE GAP THIS CLOSES. `renderRichText` has always READ facets correctly, so we display everyone else's
// links, mentions and hashtags properly. We never WROTE any: writer.post() built {$type, text, createdAt}
// and nothing else. So every link, @mention and #tag posted from favela arrived as INERT PLAIN TEXT in
// bsky and every other client — unclickable, unsearchable, and a mention that notified nobody. Pure
// asymmetry: we consumed rich text and produced none.
//
// ⚠ BYTE OFFSETS, NOT STRING INDICES. A facet's index is a range into the UTF-8 ENCODING of `text`. JS
// string indices are UTF-16 code units, so any emoji or non-ASCII character before a link shifts the real
// byte offset and every facet after it lands on the wrong characters — which renders as garbled links
// rather than as an error. renderRichText documents the same trap on the read side; this is the write
// side of it. Every offset here goes through TextEncoder.
//
// ⚠ A MENTION NEEDS A DID, NOT A HANDLE. app.bsky.richtext.facet#mention carries `did`. A handle we cannot
// resolve gets NO facet — the text stays plain rather than shipping a mention pointing at a guess. Being
// unlinked is a papercut; mentioning the wrong account is a different person's notification.
//
// Pure + injected: `resolveHandle` is a parameter, so the whole detector is testable with no network.
const enc = new TextEncoder()
const byteLen = (s) => enc.encode(s).length
// Same conservative URL shape the renderer uses: stop before trailing punctuation so "see https://x.com."
// does not swallow the full stop. Kept deliberately in step with render.js's URL_RE — if the two disagree,
// text renders as a link locally and ships without a facet (or the reverse), which is exactly the class of
// silent divergence that costs days.
const URL_RE = /\bhttps?:\/\/[^\s<]+[^\s<.,:;!?)\]}'"]/g
// atproto handles are domain names. Require a dot and a 2+ char TLD so "@someone" (no domain) is not
// mistaken for a handle — bsky does the same, and an unresolvable bare word would be dropped anyway.
const MENTION_RE = /(^|[\s([])@([a-zA-Z0-9][a-zA-Z0-9-]*(?:\.[a-zA-Z0-9][a-zA-Z0-9-]*)+)/g
// Matches render.js TAG_RE: letters, numbers, underscore, unicode-aware.
const TAG_RE = /(^|[\s([])#([\p{L}\p{N}_]+)/gu
// All candidate ranges, as { start, end, type, value } in STRING indices. Sync + pure, so the hard part
// (what counts as a link) is testable on its own.
export function findCandidates (text) {
const t = String(text == null ? '' : text)
const out = []
let m
URL_RE.lastIndex = 0
while ((m = URL_RE.exec(t)) !== null) out.push({ start: m.index, end: m.index + m[0].length, type: 'link', value: m[0] })
MENTION_RE.lastIndex = 0
while ((m = MENTION_RE.exec(t)) !== null) {
const lead = m[1] ? m[1].length : 0
const start = m.index + lead // the '@' itself, not the whitespace the regex ate
out.push({ start, end: start + 1 + m[2].length, type: 'mention', value: m[2] })
}
TAG_RE.lastIndex = 0
while ((m = TAG_RE.exec(t)) !== null) {
const lead = m[1] ? m[1].length : 0
const start = m.index + lead
out.push({ start, end: start + 1 + m[2].length, type: 'tag', value: m[2] })
}
// Sort by position, then drop anything overlapping an earlier match. A '#tag' inside a URL's fragment is
// the common case: the URL wins because it started first, and emitting both would produce interleaved
// facets that no client can render.
out.sort((a, b) => a.start - b.start || b.end - a.end)
const kept = []
let at = -1
for (const c of out) {
if (c.start < at) continue
kept.push(c)
at = c.end
}
return kept
}
// -> app.bsky.richtext.facet[], byte-indexed and sorted. `resolveHandle(handle) -> did|null` is optional;
// without it, mentions are simply not emitted (links and tags still are).
export async function detectFacets (text, { resolveHandle = null } = {}) {
const t = String(text == null ? '' : text)
const cands = findCandidates(t)
if (!cands.length) return []
const facets = []
for (const c of cands) {
// Offsets are computed from the ENCODED prefix, never from the string index.
const byteStart = byteLen(t.slice(0, c.start))
const byteEnd = byteStart + byteLen(t.slice(c.start, c.end))
const index = { byteStart, byteEnd }
if (c.type === 'link') {
facets.push({ index, features: [{ $type: 'app.bsky.richtext.facet#link', uri: c.value }] })
continue
}
if (c.type === 'tag') {
facets.push({ index, features: [{ $type: 'app.bsky.richtext.facet#tag', tag: c.value }] })
continue
}
// mention — only with a resolved DID, never on a guess.
if (!resolveHandle) continue
let did = null
try { did = await resolveHandle(c.value) } catch { did = null }
if (typeof did !== 'string' || !did.startsWith('did:')) continue
facets.push({ index, features: [{ $type: 'app.bsky.richtext.facet#mention', did }] })
}
return facets
}