/** * Helpers over serialized Lexical richText JSON (Payload's editor state). * A serialized tree is `{ root: { children: [...] } }`; text lives on * `{ text }` nodes, nesting happens via `{ children }`. */ export type Node = { type?: string text?: string children?: Node[] tag?: string url?: string listType?: string code?: string headerState?: number } /** Root `children` of a serialized tree, narrowed at runtime. */ function rootChildren(data: unknown): Node[] | null { if (data === null || typeof data !== 'object' || !('root' in data)) return null const root: unknown = data.root // unknown via `in` narrowing — no shape assumed if (root === null || typeof root !== 'object' || !('children' in root)) return null const children: unknown = root.children if (!Array.isArray(children)) return null // Boundary: the array is the editor's serialized node list; every access // below goes through Node's all-optional contract, so nothing unchecked. const nodes: Node[] = children return nodes } /** Concatenated text of every text node (secret scanning, llms extraction). */ export function lexicalToText(content: unknown): string { let out = '' const walk = (node: unknown): void => { if (node === null || typeof node !== 'object') return if ('text' in node && typeof node.text === 'string') out += node.text + '\n' if ('children' in node && Array.isArray(node.children)) { for (const child of node.children) walk(child) } } // Serialized trees wrap nodes in `root` — walk it, not the wrapper. if (content !== null && typeof content === 'object' && 'root' in content) { walk((content as { root: unknown }).root) } else { walk(content) } return out } /** Count whitespace-delimited words across all text nodes of a Lexical tree. */ export function countLexicalWords(content: unknown): number { let words = 0 const walk = (node: unknown): void => { if (node === null || typeof node !== 'object') return if ('text' in node && typeof node.text === 'string') { words += node.text.trim().split(/\s+/).filter(Boolean).length } if ('children' in node && Array.isArray(node.children)) { for (const child of node.children) walk(child) } } // Same root unwrap as lexicalToText. if (content !== null && typeof content === 'object' && 'root' in content) { walk((content as { root: unknown }).root) } else { walk(content) } return words } export function innerText(nodes?: Node[]): string { return (nodes || []).map(n => (n.type === 'text' ? n.text || '' : innerText(n.children))).join('') } /** H2 headings — TOC and the paid-mode paywall preview. */ export function extractH2s(data: unknown): string[] { const children = rootChildren(data) if (!children) return [] return children .filter(n => n.type === 'heading' && (n.tag || 'h2') === 'h2') .map(n => innerText(n.children)) } function tagLevel(tag?: string): number { const n = Number.parseInt((tag ?? 'h2').replace('h', ''), 10) return Number.isFinite(n) && n >= 1 && n <= 6 ? n : 2 } /** * Plain-text rendering for llms-full.txt: headings → `## `, paragraphs and * list items → lines, code → fenced blocks, table rows → `| a | b |`. */ export function lexicalToPlain(data: unknown): string { const children = rootChildren(data) if (!children) return '' const lines: string[] = [] for (const n of children) { switch (n.type) { case 'heading': lines.push(`${'#'.repeat(tagLevel(n.tag))} ${innerText(n.children)}`.trim()) break case 'paragraph': lines.push(innerText(n.children).trim()) break case 'list': for (const item of n.children || []) lines.push(`- ${innerText(item.children).trim()}`) break case 'listitem': lines.push(`- ${innerText(n.children).trim()}`) break case 'code': lines.push('```\n' + (n.code ?? innerText(n.children)) + '\n```') break case 'table': for (const row of n.children || []) { const cells = (row.children || []).map(cell => innerText(cell.children).trim().replace(/\|/g, '\\|')) lines.push(`| ${cells.join(' | ')} |`) } break case 'quote': lines.push(`> ${innerText(n.children).trim()}`) break case 'horizontalrule': lines.push('---') break default: { const text = innerText(n.children).trim() if (text) lines.push(text) } } } return lines.filter(line => line !== '').join('\n\n') }