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 ?? "";
}