Something went wrong. Try again.
A fully featured CLI used as an alternative to AppView applications (Bsky.app/mu.social)
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180// Post text, the way Bluesky reads it.//// A post's text is plain; what makes a mention a mention, a link a link and a// tag a tag is a *facet*: a byte range of the UTF-8 text and what it means.// Facets are found here as the official app finds them: @handles (resolved to// DIDs, and left plain when they do not resolve), URLs with a scheme or bare// domains with a known top-level domain, and #hashtags.//// Lengths are graphemes — what a person counts as characters — and a post// holds 300 of them. Text that is longer can be split into a thread.
export const MAX_GRAPHEMES = 300;
export interface Facet { index: { byteStart: number; byteEnd: number }; features: Array< | { $type: 'app.bsky.richtext.facet#mention'; did: string } | { $type: 'app.bsky.richtext.facet#link'; uri: string } | { $type: 'app.bsky.richtext.facet#tag'; tag: string } >;}
const segmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' });const encoder = new TextEncoder();
export function graphemeLength(text: string): number { let count = 0; for (const _ of segmenter.segment(text)) count++; return count;}
function byteOffset(text: string, index: number): number { return encoder.encode(text.slice(0, index)).length;}
/** Top-level domains a bare `example.com` is linked for; with a scheme, any URL is. */const TLDS = new Set(('com net org edu gov mil int io co ai app dev social me info biz xyz online site tech blog news cloud page ' + 'art design studio space live tv fm gg zone link club fyi wtf lol cafe shop store systems network digital world ' + 'ac ad ae ag al am ar as at au az ba be bg br by ca cc ch cl cn cx cz de dk ee es eu fi fr ge gr gs hk hr hu id ie il in is it ' + 'jp ke kr kz la li lt lu lv ly ma md me mk mn ms mx my nl no nu nz pe ph pl pt pw ro rs ru se sg si sk so st su th tk tl tn to tr tw ' + 'ua uk us uy uz vc ve vn ws za').split(' '));
export interface Detected { start: number; end: number; kind: 'mention' | 'link' | 'tag'; /** The handle, the URL (with a scheme), or the tag (without the #). */ value: string;}
/** Find mentions, links and tags, by string index. */export function detect(text: string): Detected[] { const found: Detected[] = [];
for (const match of text.matchAll(/(^|[\s(])(@([a-zA-Z0-9.-]+(?:\.[a-zA-Z]{2,})))(?![\w.-]*\w)/g)) { const handle = match[3]!.replace(/\.+$/, ''); if (!/^([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]([a-z0-9-]{0,61}[a-z0-9])?$/i.test(handle)) continue; const start = match.index + match[1]!.length; found.push({ start, end: start + 1 + handle.length, kind: 'mention', value: handle.toLowerCase() }); }
for (const match of text.matchAll(/(^|[\s(])((https?:\/\/[^\s]+)|((?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+([a-z][a-z0-9]*)(?:[/?#][^\s]*)?))/gim)) { let url = match[2]!; if (!match[3]) { const tld = match[5]!.toLowerCase(); if (!TLDS.has(tld)) continue; url = `https://${url}`; } // Trailing punctuation belongs to the sentence, and an unbalanced // closing parenthesis to the text around it. let shown = match[2]!; for (;;) { const trimmed = shown.replace(/[.,;:!?"'”’]+$/, ''); const opens = (trimmed.match(/\(/g) ?? []).length; const closes = (trimmed.match(/\)/g) ?? []).length; const balanced = closes > opens && trimmed.endsWith(')') ? trimmed.slice(0, -1) : trimmed; if (balanced === shown) break; shown = balanced; } if (!match[3] && !/\./.test(shown)) continue; url = match[3] ? shown : `https://${shown}`; try { new URL(url); } catch { continue; } const start = match.index + match[1]!.length; found.push({ start, end: start + shown.length, kind: 'link', value: url }); }
for (const match of text.matchAll(/(^|\s)[##]((?!️)[^\s ⃢]*[^\d\s\p{P} ⃢]+[^\s ⃢]*)?/gu)) { let tag = match[2]; if (!tag) continue; tag = tag.trim().replace(/\p{P}+$/gu, ''); if (!tag || graphemeLength(tag) > 64) continue; const start = match.index + match[1]!.length; found.push({ start, end: start + 1 + tag.length, kind: 'tag', value: tag }); }
// A tag or mention inside a URL is part of the URL. const links = found.filter((each) => each.kind === 'link'); return found .filter((each) => each.kind === 'link' || !links.some((link) => each.start >= link.start && each.start < link.end)) .sort((a, b) => a.start - b.start);}
/** * The facets of `text`. `resolve` turns a handle into a DID; a mention it * cannot resolve is left as plain text, as it would be in the app. */export async function facets(text: string, resolve: (handle: string) => Promise<string | undefined>): Promise<Facet[]> { const result: Facet[] = []; for (const each of detect(text)) { const index = { byteStart: byteOffset(text, each.start), byteEnd: byteOffset(text, each.end) }; if (each.kind === 'mention') { const did = await resolve(each.value).catch(() => undefined); if (did) result.push({ index, features: [{ $type: 'app.bsky.richtext.facet#mention', did }] }); } else if (each.kind === 'link') { result.push({ index, features: [{ $type: 'app.bsky.richtext.facet#link', uri: each.value }] }); } else { result.push({ index, features: [{ $type: 'app.bsky.richtext.facet#tag', tag: each.value }] }); } } return result;}
/** * Split text into posts of at most `max` graphemes: at paragraph breaks where * possible, then sentences, then words, and mid-word only for a word longer * than a post. Links and mentions are never cut, being words. */export function split(text: string, max = MAX_GRAPHEMES): string[] { const posts: string[] = []; let rest = text.trim(); while (graphemeLength(rest) > max) { const graphemes = [...segmenter.segment(rest)].map((each) => each.segment); const head = graphemes.slice(0, max).join(''); let cut = -1; for (const pattern of [/\n\s*\n/g, /[.!?…][)"'”’]*\s/g, /\s/g]) { let last = -1; for (const match of head.matchAll(pattern)) { const at = match.index + match[0].length; if (at > head.length * 0.3) last = at; } if (last > 0) { cut = last; break; } } if (cut <= 0) cut = head.length; posts.push(rest.slice(0, cut).trim()); rest = rest.slice(cut).trim(); } if (rest) posts.push(rest); return posts;}
/** * Text with its facets made visible for a terminal: links whose text was * shortened show where they go. */export function expandLinks(text: string, list: Facet[] | undefined): string { if (!list?.length) return text; const bytes = encoder.encode(text); const decoder = new TextDecoder(); let out = ''; let at = 0; for (const facet of [...list].sort((a, b) => a.index.byteStart - b.index.byteStart)) { const link = facet.features.find((feature) => feature.$type === 'app.bsky.richtext.facet#link') as { uri: string } | undefined; if (!link || facet.index.byteStart < at || facet.index.byteEnd > bytes.length) continue; const shown = decoder.decode(bytes.slice(facet.index.byteStart, facet.index.byteEnd)); out += decoder.decode(bytes.slice(at, facet.index.byteEnd)); const bare = link.uri.replace(/^https?:\/\//, '').replace(/\/$/, ''); const plain = shown.replace(/^https?:\/\//, '').replace(/…$|\.\.\.$/, '').replace(/\/$/, ''); if (!bare.startsWith(plain) || shown.endsWith('…') || shown.endsWith('...')) out += ` <${link.uri}>`; at = facet.index.byteEnd; } return out + decoder.decode(bytes.slice(at));}