import type { EmojiNode, LinkPreview, RichTextDocument, TextNode } from "./ast.js"; import { DocBuilder, normalizeHandle, normalizeTag, type ParseOptions } from "./builder.js"; import { decodeEntities } from "./entities.js"; import { LINK_PREVIEW_LIMITS } from "./limits.js"; import { neutralizeText, truncateCodePoints } from "./text.js"; import { DEFAULT_MEDIA_PROXY, isProxiedMediaUrl, parseSafeHttpUrl, toProxiedMediaUrl, type MediaProxyPolicy } from "./url.js"; type Unknown = Record; const isObj = (v: unknown): v is Unknown => typeof v === "object" && v !== null && !Array.isArray(v); const arr = (v: unknown): readonly unknown[] => (Array.isArray(v) ? v : []); /** * Rebuilds a document from untrusted JSON (DB row, API payload, websocket * frame). Unknown node types, bad URLs and non-proxied media are dropped; * text is re-neutralised; limits are re-applied. The renderer always calls * this, so a forged AST is as inert as a parsed one. */ export function coerceRichTextDocument(value: unknown, options: ParseOptions = {}): RichTextDocument { const builder = new DocBuilder({ ...options, emojis: [] }); const policy = options.mediaProxy ?? DEFAULT_MEDIA_PROXY; if (!isObj(value) || value.type !== "doc") return builder.finish(); if (value.truncated === true) builder.truncated = true; const leaf = (n: unknown): (TextNode | EmojiNode)[] => { if (!isObj(n)) return []; if (n.type === "text" && typeof n.text === "string") return builder.inlineText(n.text); if (n.type === "emoji" && typeof n.shortcode === "string" && /^[A-Za-z0-9_+-]{1,64}$/.test(n.shortcode)) { return isProxiedMediaUrl(n.src, policy) ? [{ type: "emoji", shortcode: n.shortcode, src: n.src }] : [{ type: "text", text: `:${n.shortcode}:` }]; } return []; }; const capped = (items: unknown): readonly unknown[] => { const all = arr(items); if (all.length <= builder.limits.maxNodes) return all; builder.truncated = true; return all.slice(0, builder.limits.maxNodes); }; for (const p of capped(value.children)) { if (!isObj(p) || p.type !== "paragraph") continue; for (const n of capped(p.children)) { if (!isObj(n)) continue; switch (n.type) { case "text": if (typeof n.text === "string") builder.text(n.text); break; case "emoji": for (const e of leaf(n)) builder.pushLeaf(e); break; case "linebreak": builder.lineBreak(); break; case "link": builder.link(typeof n.href === "string" ? n.href : "", arr(n.children).slice(0, 64).flatMap(leaf)); break; case "mention": if (typeof n.handle === "string" && normalizeHandle(n.handle)) builder.mention(n.handle, typeof n.href === "string" ? n.href : undefined); break; case "hashtag": if (typeof n.tag === "string" && normalizeTag(n.tag)) builder.hashtag(n.tag, typeof n.href === "string" ? n.href : undefined); break; } } builder.endParagraph(); } return builder.finish(); } export type RawLinkPreview = { readonly url?: unknown; readonly title?: unknown; readonly description?: unknown; readonly siteName?: unknown; readonly image?: unknown; }; function previewText(value: unknown, max: number): string | undefined { if (typeof value !== "string") return undefined; // OpenGraph fields are text: entities are decoded, tags are NOT interpreted. const text = neutralizeText(decodeEntities(value.slice(0, max * 4))).replace(/\s+/g, " ").trim(); return text === "" ? undefined : truncateCodePoints(text, max); } /** * Link-preview card data (OpenGraph/oEmbed/Mastodon `card`). Returns null when * the target is not a safe http(s) URL. The image is rewritten to the proxy * or dropped. Accepts either raw provider data or a previously built preview. */ export function parseLinkPreview(raw: unknown, mediaProxy: MediaProxyPolicy = DEFAULT_MEDIA_PROXY): LinkPreview | null { if (!isObj(raw)) return null; const safe = parseSafeHttpUrl(raw.type === "linkPreview" ? raw.href : raw.url); if (!safe) return null; const image = isProxiedMediaUrl(raw.image, mediaProxy) ? raw.image : toProxiedMediaUrl(raw.image, mediaProxy); const title = previewText(raw.title, LINK_PREVIEW_LIMITS.title); const description = previewText(raw.description, LINK_PREVIEW_LIMITS.description); const siteName = previewText(raw.siteName, LINK_PREVIEW_LIMITS.siteName); return { type: "linkPreview", href: safe.href, host: safe.host, ...(title !== undefined && { title }), ...(description !== undefined && { description }), ...(siteName !== undefined && { siteName }), ...(image !== null && { image }), }; }