import type { EmojiNode, InlineNode, LinkNode, ParagraphNode, RichTextDocument, TextNode } from "./ast.js"; import { RICH_TEXT_VERSION } from "./ast.js"; import { DEFAULT_LIMITS, type SafeContentLimits } from "./limits.js"; import { neutralizeText } from "./text.js"; import { DEFAULT_MEDIA_PROXY, parseSafeHttpUrl, toProxiedMediaUrl, type MediaProxyPolicy } from "./url.js"; /** Provider-supplied custom emoji (Mastodon `emojis[]`, Twitch emote sets, …). */ export type CustomEmoji = { readonly shortcode: string; readonly url: string }; export type ParseOptions = { readonly limits?: Partial; readonly mediaProxy?: MediaProxyPolicy; readonly emojis?: readonly CustomEmoji[]; }; const SHORTCODE = /^[A-Za-z0-9_+-]{1,64}$/; const SHORTCODE_IN_TEXT = /:([A-Za-z0-9_+-]{1,64}):/g; const MAX_EMOJIS = 500; export function normalizeHandle(raw: string): string | null { const handle = neutralizeText(raw).trim().replace(/^@+/, ""); return /^[\p{L}\p{N}_.-]{1,64}(?:@[\p{L}\p{N}.-]{1,253})?$/u.test(handle) ? handle : null; } export function normalizeTag(raw: string): string | null { const tag = neutralizeText(raw).trim().replace(/^#+/, ""); return /^[\p{L}\p{N}\p{M}_]{1,100}$/u.test(tag) ? tag : null; } /** Accumulates a document while enforcing node limits; every write is sanitised. */ export class DocBuilder { readonly limits: SafeContentLimits; readonly mediaProxy: MediaProxyPolicy; private readonly emojis = new Map(); private readonly paragraphs: ParagraphNode[] = []; private current: InlineNode[] = []; private nodes = 0; private chars = 0; truncated = false; constructor(options: ParseOptions = {}) { this.limits = { ...DEFAULT_LIMITS, ...options.limits }; this.mediaProxy = options.mediaProxy ?? DEFAULT_MEDIA_PROXY; for (const emoji of (options.emojis ?? []).slice(0, MAX_EMOJIS)) { if (typeof emoji?.shortcode !== "string" || !SHORTCODE.test(emoji.shortcode)) continue; const src = toProxiedMediaUrl(emoji.url, this.mediaProxy); if (src) this.emojis.set(emoji.shortcode, src); } } /** Clips raw input to the configured length, flagging truncation. */ clip(input: string): string { if (input.length <= this.limits.maxInputLength) return input; this.truncated = true; return input.slice(0, this.limits.maxInputLength); } private take(): boolean { if (this.nodes >= this.limits.maxNodes) { this.truncated = true; return false; } this.nodes++; return true; } /** Splits text into text + emoji nodes. */ inlineText(raw: string): (TextNode | EmojiNode)[] { let text = neutralizeText(raw); // Total text budget; parse paths are already clipped, this bounds forged ASTs. const room = this.limits.maxInputLength - this.chars; if (text.length > room) { this.truncated = true; text = text.slice(0, Math.max(room, 0)); } this.chars += text.length; if (text === "") return []; const out: (TextNode | EmojiNode)[] = []; if (this.emojis.size === 0) return [{ type: "text", text }]; let last = 0; for (const match of text.matchAll(SHORTCODE_IN_TEXT)) { const shortcode = match[1] as string; const src = this.emojis.get(shortcode); if (!src) continue; if (match.index > last) out.push({ type: "text", text: text.slice(last, match.index) }); out.push({ type: "emoji", shortcode, src }); last = match.index + match[0].length; } if (last < text.length) out.push({ type: "text", text: text.slice(last) }); return out; } private push(node: InlineNode): void { const prev = this.current[this.current.length - 1]; if (node.type === "text" && prev?.type === "text") { this.current[this.current.length - 1] = { type: "text", text: prev.text + node.text }; return; } if (this.take()) this.current.push(node); } /** Appends an already-sanitised text/emoji node. */ pushLeaf(node: TextNode | EmojiNode): void { this.push(node); } text(raw: string): void { for (const node of this.inlineText(raw)) this.push(node); } lineBreak(): void { this.push({ type: "linebreak" }); } /** Emits a link if `href` is safe, otherwise the label as inert text. */ link(href: string, label: readonly (TextNode | EmojiNode)[]): void { const safe = parseSafeHttpUrl(href, this.limits.maxUrlLength); if (!safe) { for (const node of label) this.push(node); return; } const children = label.length > 0 ? mergeText(label) : [{ type: "text" as const, text: safe.host }]; if (this.nodes + children.length > this.limits.maxNodes) { this.truncated = true; return; } this.nodes += children.length; this.push({ type: "link", href: safe.href, host: safe.host, children } satisfies LinkNode); } mention(rawHandle: string, href?: string): void { const handle = normalizeHandle(rawHandle); if (!handle) return this.text(rawHandle); const safe = href === undefined ? null : parseSafeHttpUrl(href, this.limits.maxUrlLength); this.push(safe ? { type: "mention", handle, href: safe.href } : { type: "mention", handle }); } hashtag(rawTag: string, href?: string): void { const tag = normalizeTag(rawTag); if (!tag) return this.text(rawTag); const safe = href === undefined ? null : parseSafeHttpUrl(href, this.limits.maxUrlLength); this.push(safe ? { type: "hashtag", tag, href: safe.href } : { type: "hashtag", tag }); } endParagraph(): void { const children = trimParagraph(this.current); this.current = []; // Not budgeted: each non-empty paragraph already holds >= 1 budgeted inline node. if (children.length > 0) this.paragraphs.push({ type: "paragraph", children }); } finish(): RichTextDocument { this.endParagraph(); return { type: "doc", version: RICH_TEXT_VERSION, children: this.paragraphs, truncated: this.truncated }; } } function mergeText(nodes: readonly (TextNode | EmojiNode)[]): (TextNode | EmojiNode)[] { const out: (TextNode | EmojiNode)[] = []; for (const node of nodes) { const prev = out[out.length - 1]; if (node.type === "text" && prev?.type === "text") out[out.length - 1] = { type: "text", text: prev.text + node.text }; else out.push(node); } return out; } function trimParagraph(nodes: InlineNode[]): InlineNode[] { let start = 0; let end = nodes.length; const blank = (n: InlineNode | undefined) => n?.type === "linebreak" || (n?.type === "text" && n.text.trim() === ""); while (start < end && blank(nodes[start])) start++; while (end > start && blank(nodes[end - 1])) end--; return nodes.slice(start, end); }