type LexicalNode = { text?: string; children?: LexicalNode[] }; function collectText(node: LexicalNode, out: string[]): void { if (typeof node.text === "string" && node.text) out.push(node.text); if (Array.isArray(node.children)) { for (const child of node.children) collectText(child, out); } } /** Walks a Lexical richText field's JSON (any node shape) and extracts plain text, regardless of formatting/links/lists used. */ export function plainTextFromRichText(data: unknown): string { if (!data || typeof data !== "object") return ""; const root = (data as { root?: LexicalNode }).root; if (!root) return ""; const parts: string[] = []; collectText(root, parts); return parts.join(" ").replace(/\s+/g, " ").trim(); } /** Truncates at a word boundary rather than mid-word, for a natural-reading meta description fallback. */ export function excerpt(text: string, maxLength = 160): string { const clean = text.replace(/\s+/g, " ").trim(); if (clean.length <= maxLength) return clean; const truncated = clean.slice(0, maxLength); const lastSpace = truncated.lastIndexOf(" "); const safe = lastSpace > maxLength * 0.6 ? truncated.slice(0, lastSpace) : truncated; return `${safe.trim()}…`; } export function excerptFromRichText(data: unknown, maxLength = 160): string { return excerpt(plainTextFromRichText(data), maxLength); }