// Turning `@alice0` into a mention.
//
// ⛔ `markdown-strip.md` §3.4: "a bare @alice with no target is plain text with no facet — never a
// guess". That rule constrains the CODEC, which has no registry and genuinely cannot know who was
// meant. It does not forbid RESOLVING one where the answer is known — the namespace says which feed
// `alice0` names, and the feed's own founder says whether it agrees. Resolution is not a guess, and
// the codec still never does it.
//
// ⛔ This lives in the daemon rather than in a surface. The CLI writes posts, a phone will write
// posts, and a feature that exists in one client is not the feature.
/**
* ⛔ A NAME or a handle. The first version required the trailing number and so matched `@alice0`
* and never `@alice` — and nobody types the number. It exists so a name is never scarce; a person
* types the name. Every mention anybody actually wrote was left as plain text.
*
* ⚠ `(^|[^\w@/])` keeps an EMAIL from matching: `alice@alice0.example` has a word character before
* the @, and a URL path has a slash.
*/
const AT = /(^|[^\w@/])@([a-z][a-z0-9._-]{0,31})\b/gi
/**
* Segments of `md` that are PROSE, with code left out.
*
* ⛔ A regex over the whole body rewrites inside code, and a code sample is exactly where an @ is
* most likely to appear without meaning a person. Fenced blocks and inline spans are skipped whole.
*/
function prose (md) {
const out = []
let i = 0
// Fences first, then inline spans inside what is left.
const fence = /```[\s\S]*?```|~~~[\s\S]*?~~~/g
let m
while ((m = fence.exec(md)) !== null) {
if (m.index > i) out.push([i, m.index])
i = m.index + m[0].length
}
if (i < md.length) out.push([i, md.length])
const final = []
for (const [start, end] of out) {
const chunk = md.slice(start, end)
let j = 0
const span = /`[^`\n]*`/g
let s
while ((s = span.exec(chunk)) !== null) {
if (s.index > j) final.push([start + j, start + s.index])
j = s.index + s[0].length
}
if (j < chunk.length) final.push([start + j, end])
}
return final
}
// Is this @ already inside a markdown link — either its label or its target?
// ⛔ Without this, saving a post twice nests the link inside itself.
function insideLink (md, at) {
const before = md.lastIndexOf('[', at)
if (before !== -1) {
const close = md.indexOf(']', before)
if (close !== -1 && close > at && md[close + 1] === '(') return true
}
const paren = md.lastIndexOf('(', at)
if (paren !== -1 && md[paren - 1] === ']') {
const end = md.indexOf(')', paren)
if (end !== -1 && end > at) return true
}
return false
}
/**
* Rewrite bare handles into mentions.
*
* `resolve(token)` returns `{ root, verdict }` or null. The token is a HANDLE (`alice0`) or a bare
* NAME (`alice`); the caller decides which, because only it holds the namespace.
*
* ⚠ A bare name resolves only when exactly ONE confirmed claimant holds it. NAMING.md §3.2 — an
* unresolvable name fails visibly rather than having a choice guessed for it — and picking "the
* first alice" would be that guess wearing a helpful face. One candidate is not a guess.
*
* ⛔ CONFIRMED only. A handle claimed at somebody else's feed key is the rename attack, and turning
* one into a link here would be that attack arriving through the composer. `unheld` is left alone
* too: we cannot check a feed we do not hold, and linking unchecked is how a check becomes
* decorative.
*/
export async function linkMentions (body, resolve) {
const md = String(body == null ? '' : body)
if (!md.includes('@')) return md
const regions = prose(md)
const inProse = (idx) => regions.some(([s, e]) => idx >= s && idx < e)
// ⚠ One lookup per DISTINCT handle. A post naming somebody ten times asks once.
const wanted = new Set()
for (const m of md.matchAll(AT)) {
const at = m.index + m[1].length
if (!inProse(at) || insideLink(md, at)) continue
wanted.add(m[2].toLowerCase())
}
if (!wanted.size) return md
const found = new Map()
for (const h of wanted) {
let r = null
try { r = await resolve(h) } catch {}
if (r && r.verdict === 'confirmed' && /^[0-9a-f]{64}$/i.test(r.root || '')) found.set(h, r.root)
}
if (!found.size) return md
let out = ''
let last = 0
for (const m of md.matchAll(AT)) {
const at = m.index + m[1].length
const root = found.get(m[2].toLowerCase())
if (!root || !inProse(at) || insideLink(md, at)) continue
out += md.slice(last, at) + '[@' + m[2] + '](favela://' + root + ')'
last = at + 1 + m[2].length
}
return out + md.slice(last)
}
export default { linkMentions }