import type { Message } from "$lib/types/Message"; // Helpers to derive social-preview text (og: tags and thumbnail images) from // shared conversations. User-authored content is untrusted: it can contain // control characters, bidi overrides, zero-width characters, emoji and scripts // the thumbnail font cannot render, so everything funnels through the // sanitizers below. // // Regexes are built from codepoint tables (instead of unicode escapes) to keep // the source ASCII-only and the covered ranges reviewable. type CodepointRange = [start: number, end: number]; function rangeClass(ranges: CodepointRange[]): string { const part = (cp: number) => "\\u{" + cp.toString(16) + "}"; return ranges.map(([a, b]) => (a === b ? part(a) : part(a) + "-" + part(b))).join(""); } // Control chars, soft hyphen, bidi/format controls, zero-width chars, variation // selectors, BOM, interlinear annotation marks. Replaced with spaces so words // they separated do not merge. const INVISIBLE_RE = new RegExp( "[" + rangeClass([ [0x0000, 0x001f], // C0 controls (incl. \n and \t; whitespace is collapsed later) [0x007f, 0x009f], // DEL + C1 controls [0x00ad, 0x00ad], // soft hyphen [0x061c, 0x061c], // arabic letter mark [0x180e, 0x180e], // mongolian vowel separator [0x200b, 0x200f], // zero-width space/joiners, LRM/RLM [0x2028, 0x202e], // line/paragraph separators, bidi embeddings/overrides [0x2060, 0x2064], // word joiner, invisible operators [0x2066, 0x206f], // bidi isolates, deprecated format characters [0xfe00, 0xfe0f], // variation selectors [0xfeff, 0xfeff], // BOM / zero-width no-break space [0xfff9, 0xfffb], // interlinear annotation ]) + "]", "gu" ); // Emoji & pictographs, regional indicators (flags), skin tone modifiers, keycap // combiner. Removed entirely: the bundled Inter font has no glyphs for them. const EMOJI_RE = new RegExp( "\\p{Extended_Pictographic}|[" + rangeClass([ [0x1f1e6, 0x1f1ff], // regional indicators [0x1f3fb, 0x1f3ff], // skin tone modifiers [0x20e3, 0x20e3], // combining enclosing keycap ]) + "]", "gu" ); // Approximation of Inter's glyph coverage: Latin (+extensions incl. Vietnamese), // combining diacritics, IPA, Greek (+extended), Cyrillic, punctuation, currency, // letterlike, number forms, arrows, math operators, misc technical, geometric // shapes. Anything outside renders as tofu in the thumbnail, so runs of such // characters are replaced with a single ellipsis. const RENDERABLE_RE = new RegExp( "[" + rangeClass([ [0x0020, 0x007e], // basic latin [0x00a0, 0x024f], // latin-1 supplement + latin extended-A/B [0x0250, 0x02ff], // IPA + spacing modifier letters [0x0300, 0x036f], // combining diacritical marks [0x0370, 0x03ff], // greek [0x0400, 0x052f], // cyrillic + supplement [0x1e00, 0x1eff], // latin extended additional (vietnamese) [0x1f00, 0x1fff], // greek extended [0x2000, 0x206f], // general punctuation (invisibles already stripped) [0x20a0, 0x20bf], // currency symbols [0x2100, 0x214f], // letterlike symbols [0x2150, 0x218b], // number forms [0x2190, 0x21ff], // arrows [0x2200, 0x22ff], // mathematical operators [0x2300, 0x23ff], // miscellaneous technical [0x25a0, 0x25ff], // geometric shapes [0x2c60, 0x2c7f], // latin extended-C [0xfb00, 0xfb06], // latin ligatures ]) + "]", "u" ); const ELLIPSIS = String.fromCharCode(0x2026); // NUL is stripped by INVISIBLE_RE, so it can safely mark non-renderable runs. const SENTINEL = String.fromCharCode(0); /** * Clean user text for use in `` tags (og:description, og:title). * Keeps all scripts (CJK etc. are fine in metadata), strips control and * invisible/bidi characters, collapses whitespace and truncates on a word * boundary. */ export function cleanTextForMeta(text: string, maxLength: number): string { let out = text.normalize("NFC").replace(INVISIBLE_RE, " "); out = out.replace(/\s+/g, " ").trim(); if (out.length > maxLength) { out = out.slice(0, maxLength).replace(/\s+\S*$/, "") + ELLIPSIS; } return out; } /** * Clean user text for rendering inside the share thumbnail, where only the * bundled Inter font is available. On top of `cleanTextForMeta`, emoji are * removed and runs of non-renderable characters (CJK, Arabic, ...) become a * single ellipsis. Returns "" when not enough renderable content remains, so * callers can fall back to a generic card. */ export function renderableThumbnailText(text: string, maxLength: number): string { let out = cleanTextForMeta(text, maxLength); out = out.replace(EMOJI_RE, ""); out = [...out].map((ch) => (RENDERABLE_RE.test(ch) ? ch : SENTINEL)).join(""); out = out.replace(new RegExp(SENTINEL + "+", "g"), ELLIPSIS); // Collapse whitespace and merge ellipses left adjacent after replacements out = out .replace(/\s+/g, " ") .replace(new RegExp(ELLIPSIS + "(\\s*" + ELLIPSIS + ")+", "g"), ELLIPSIS) .trim(); const meaningful = out.replace(new RegExp("[" + ELLIPSIS + "\\s]", "g"), ""); if (meaningful.length < 8) return ""; return out; } /** * First user prompt of a conversation: walk the message tree from the root * (skipping system messages) and return the first user message content. * Falls back to array order for malformed trees. */ export function extractFirstUserPrompt( messages: Pick[], rootMessageId?: Message["id"] ): string { const byId = new Map(messages.map((message) => [message.id, message])); let current = rootMessageId ? byId.get(rootMessageId) : undefined; for (let depth = 0; current && depth <= messages.length; depth++) { if (current.from === "user" && current.content.trim()) { return current.content; } current = current.children?.length ? byId.get(current.children[0]) : undefined; } return messages.find((m) => m.from === "user" && m.content.trim())?.content ?? ""; }