{"version":3,"file":"search-5VqtLzMs.mjs","names":[],"sources":["../src/search/query.ts","../src/search/text-extraction.ts","../src/search/index.ts"],"sourcesContent":["/**\n * Search Query Functions\n *\n * Programmatic API for searching content using FTS5.\n */\n\nimport type { Kysely } from \"kysely\";\nimport { sql } from \"kysely\";\n\nimport { encodeCursor, decodeCursor, InvalidCursorError } from \"../database/repositories/types.js\";\nimport type { Database } from \"../database/types.js\";\nimport { validateIdentifier } from \"../database/validate.js\";\nimport { resolveConfiguredLocale } from \"../i18n/config.js\";\nimport { getDb } from \"../loader.js\";\nimport { FTSManager } from \"./fts-manager.js\";\nimport type {\n\tSearchOptions,\n\tCollectionSearchOptions,\n\tSearchResult,\n\tSearchResponse,\n\tSuggestOptions,\n\tSuggestion,\n\tSearchStats,\n} from \"./types.js\";\n\n/**\n * Marker stored in the `id` slot of a search pagination cursor.\n *\n * Search results are merged from per-collection FTS queries and re-sorted by\n * score, so there is no single stable keyset column to encode (the way\n * `getEmDashCollection` encodes `(orderValue, id)`). We page over the merged,\n * score-sorted set by offset instead, carried as the cursor's `orderValue`.\n * Reusing `encodeCursor`/`decodeCursor` keeps the opaque base64-JSON shape and\n * the `InvalidCursorError` handling consistent with the rest of the API.\n */\nconst SEARCH_CURSOR_MARKER = \"search\";\n\n/** Encode the next-page offset into an opaque search cursor. */\nfunction encodeSearchCursor(offset: number): string {\n\treturn encodeCursor(String(offset), SEARCH_CURSOR_MARKER);\n}\n\n/**\n * Decode a search cursor back to its offset. Throws `InvalidCursorError` for a\n * malformed cursor or one that doesn't carry a non-negative integer offset, so\n * a client pagination bug surfaces immediately rather than silently restarting\n * from the first page.\n */\n/** Upper bound on a decoded search offset, to cap the per-collection row fetch a forged cursor can trigger. */\nconst MAX_SEARCH_OFFSET = 10_000;\n\nfunction decodeSearchOffset(cursor: string): number {\n\tconst { orderValue, id } = decodeCursor(cursor);\n\tif (id !== SEARCH_CURSOR_MARKER) {\n\t\tthrow new InvalidCursorError(cursor);\n\t}\n\tconst offset = Number(orderValue);\n\tif (!Number.isInteger(offset) || offset < 0 || offset > MAX_SEARCH_OFFSET) {\n\t\tthrow new InvalidCursorError(cursor);\n\t}\n\treturn offset;\n}\n\n/** Pattern to split on whitespace for query term extraction */\nconst WHITESPACE_SPLIT_PATTERN = /\\s+/;\nconst FTS_OPERATORS_PATTERN = /\\b(AND|OR|NOT|NEAR)\\b/i;\nconst DOUBLE_QUOTE_PATTERN = /\"/g;\n\n/**\n * Detect FTS5 query syntax errors. Match specifically on the SQLite FTS5\n * error fingerprints rather than a broad \"fts5\" / \"syntax error\" filter\n * (which would also swallow internal table-corruption errors). The two\n * fingerprints we care about are:\n *\n *  - \"fts5: syntax error near …\" — unbalanced quotes, stray operators,\n *    other malformed user input\n *  - \"unknown special query: …\" — bare special tokens like `^*` that\n *    parse but don't resolve to a real FTS5 directive\n */\nfunction isFts5SyntaxError(error: unknown): boolean {\n\tif (!(error instanceof Error)) return false;\n\tconst message = error.message.toLowerCase();\n\treturn message.includes(\"fts5: syntax error\") || message.includes(\"unknown special query\");\n}\n\n/**\n * Search across multiple collections\n *\n * Public API that auto-injects the database.\n *\n * @param query - Search query (FTS5 syntax supported)\n * @param options - Search options\n * @returns Search results with pagination\n *\n * @example\n * ```typescript\n * import { search } from \"@premium-cms/emdash\";\n *\n * const results = await search(\"hello world\", {\n *   collections: [\"posts\", \"pages\"],\n *   limit: 20\n * });\n * ```\n */\nexport async function search(query: string, options: SearchOptions = {}): Promise<SearchResponse> {\n\tconst db = await getDb();\n\treturn searchWithDb(db, query, options);\n}\n\n/**\n * Search across multiple collections (with explicit db)\n *\n * @internal Use `search()` in templates. This variant is for admin routes\n * that already have a database handle.\n *\n * @param db - Kysely database instance\n * @param query - Search query (FTS5 syntax supported)\n * @param options - Search options\n * @returns Search results with pagination\n */\nexport async function searchWithDb(\n\tdb: Kysely<Database>,\n\tquery: string,\n\toptions: SearchOptions = {},\n): Promise<SearchResponse> {\n\tconst ftsManager = new FTSManager(db);\n\tconst limit = options.limit ?? 20;\n\tconst status = options.status ?? \"published\";\n\tconst offset = options.cursor ? decodeSearchOffset(options.cursor) : 0;\n\n\t// Get searchable collections\n\tlet collections = options.collections;\n\tif (!collections || collections.length === 0) {\n\t\tcollections = await getSearchableCollections(db);\n\t}\n\n\tif (collections.length === 0) {\n\t\treturn { items: [] };\n\t}\n\n\t// To rank the merged window [offset, offset + limit) correctly, each\n\t// collection must contribute its own top (offset + limit) rows — in the\n\t// worst case the entire window comes from one collection. The extra +1\n\t// row detects whether a further page exists.\n\tconst perCollectionLimit = offset + limit + 1;\n\n\t// Search each collection and merge results\n\tconst allResults: SearchResult[] = [];\n\tconst titleColumns = await ftsManager.getCollectionsWithTitleColumn(collections);\n\n\tfor (const collection of collections) {\n\t\tconst config = await ftsManager.getSearchConfig(collection);\n\t\tif (!config?.enabled) {\n\t\t\tcontinue;\n\t\t}\n\n\t\tconst collectionResults = await searchSingleCollection(\n\t\t\tdb,\n\t\t\tcollection,\n\t\t\tquery,\n\t\t\t{\n\t\t\t\tstatus,\n\t\t\t\tlocale: options.locale,\n\t\t\t\tlimit: perCollectionLimit,\n\t\t\t},\n\t\t\tconfig.weights,\n\t\t\ttitleColumns.has(collection),\n\t\t\tconfig.titleField,\n\t\t);\n\n\t\tallResults.push(...collectionResults);\n\t}\n\n\t// Sort by score descending\n\tallResults.sort((a, b) => b.score - a.score);\n\n\t// Page the merged set by offset. A nextCursor is issued only when at least\n\t// one result exists past this page.\n\tconst items = allResults.slice(offset, offset + limit);\n\tconst hasMore = allResults.length > offset + limit;\n\tconst nextCursor = hasMore ? encodeSearchCursor(offset + limit) : undefined;\n\n\treturn { items, nextCursor };\n}\n\n/**\n * Search within a single collection\n *\n * @param db - Kysely database instance\n * @param collection - Collection slug\n * @param query - Search query (FTS5 syntax supported)\n * @param options - Search options\n * @returns Search results with pagination\n *\n * @example\n * ```typescript\n * const results = await searchCollection(db, \"posts\", \"hello world\", {\n *   limit: 10\n * });\n * ```\n */\nexport async function searchCollection(\n\tdb: Kysely<Database>,\n\tcollection: string,\n\tquery: string,\n\toptions: CollectionSearchOptions = {},\n): Promise<SearchResponse> {\n\tconst ftsManager = new FTSManager(db);\n\tconst config = await ftsManager.getSearchConfig(collection);\n\n\tif (!config?.enabled) {\n\t\treturn { items: [] };\n\t}\n\n\tconst limit = options.limit ?? 20;\n\tconst offset = options.cursor ? decodeSearchOffset(options.cursor) : 0;\n\n\t// Over-fetch the [offset, offset + limit) window plus one row to detect a\n\t// further page, then slice. Keeps the FTS SQL (LIMIT-only) unchanged and\n\t// the cursor contract identical to the cross-collection path.\n\tconst fetched = await searchSingleCollection(\n\t\tdb,\n\t\tcollection,\n\t\tquery,\n\t\t{ status: options.status, locale: options.locale, limit: offset + limit + 1 },\n\t\tconfig.weights,\n\t\tundefined,\n\t\tconfig.titleField,\n\t);\n\n\tconst items = fetched.slice(offset, offset + limit);\n\tconst hasMore = fetched.length > offset + limit;\n\tconst nextCursor = hasMore ? encodeSearchCursor(offset + limit) : undefined;\n\n\treturn { items, nextCursor };\n}\n\n/**\n * Internal function to search a single collection\n */\nasync function searchSingleCollection(\n\tdb: Kysely<Database>,\n\tcollection: string,\n\tquery: string,\n\toptions: CollectionSearchOptions,\n\tweights?: Record<string, number>,\n\thasTitle?: boolean,\n\ttitleField?: string,\n): Promise<SearchResult[]> {\n\t// Validate before any raw SQL interpolation\n\tvalidateIdentifier(collection, \"collection slug\");\n\n\tconst ftsManager = new FTSManager(db);\n\tconst ftsTable = ftsManager.getFtsTableName(collection);\n\tconst contentTable = ftsManager.getContentTableName(collection);\n\tconst limit = options.limit ?? 20;\n\tconst status = options.status ?? \"published\";\n\tconst locale = options.locale ? resolveConfiguredLocale(options.locale) : undefined;\n\n\t// Check if FTS table exists\n\tif (!(await ftsManager.ftsTableExists(collection))) {\n\t\treturn [];\n\t}\n\n\t// Escape the query for FTS5\n\tconst escapedQuery = escapeQuery(query);\n\tif (!escapedQuery) {\n\t\treturn [];\n\t}\n\n\t// Get searchable fields for snippet generation\n\tconst searchableFields = await ftsManager.getSearchableFields(collection);\n\n\t// `title` is an optional user-defined field, not a system column. Only\n\t// select it when the collection actually has one; otherwise the query\n\t// errors with \"no such column: c.title\". Multi-collection callers\n\t// precompute this in bulk and pass it in; single-collection callers fall\n\t// back to the per-collection check.\n\t// A configured titleField drives the result title so search shows\n\t// the same value as the content list. It's a validated existing field, so\n\t// the column exists; otherwise fall back to the optional `title` column.\n\tlet titleExpr;\n\tif (titleField) {\n\t\tvalidateIdentifier(titleField, \"title field\");\n\t\ttitleExpr = sql`c.${sql.ref(titleField)}`;\n\t} else {\n\t\tconst collectionHasTitle = hasTitle ?? (await ftsManager.hasTitleColumn(collection));\n\t\ttitleExpr = collectionHasTitle ? sql`c.title` : sql`NULL`;\n\t}\n\n\t// Build weight string for bm25 if weights provided\n\t// Format: bm25(table, weight1, weight2, ...)\n\t// First two weights are for 'id' and 'locale' columns (UNINDEXED, so 0)\n\tlet bm25Args = \"\";\n\tif (weights && searchableFields.length > 0) {\n\t\tconst weightValues = [\"0\", \"0\"]; // id column, locale column\n\t\tfor (const field of searchableFields) {\n\t\t\tweightValues.push(String(weights[field] ?? 1));\n\t\t}\n\t\tbm25Args = weightValues.join(\", \");\n\t}\n\n\t// Build and execute the search query\n\t// Using raw SQL because Kysely doesn't have FTS5 support\n\tconst bm25Expr = bm25Args ? `bm25(\"${ftsTable}\", ${bm25Args})` : `bm25(\"${ftsTable}\")`;\n\n\t// Snippet column index is 2 (after id=0, locale=1, first searchable field=2)\n\tlet results;\n\ttry {\n\t\tresults = await sql<{\n\t\t\tid: string;\n\t\t\tslug: string | null;\n\t\t\tlocale: string;\n\t\t\ttitle: string | null;\n\t\t\tsnippet: string | null;\n\t\t\tscore: number;\n\t\t}>`\n\t\tSELECT\n\t\t\tc.id,\n\t\t\tc.slug,\n\t\t\tc.locale,\n\t\t\t${titleExpr} as title,\n\t\t\tsnippet(\"${sql.raw(ftsTable)}\", 2, '<mark>', '</mark>', '...', 32) as snippet,\n\t\t\t${sql.raw(bm25Expr)} as score\n\t\tFROM \"${sql.raw(ftsTable)}\" f\n\t\tJOIN \"${sql.raw(contentTable)}\" c ON f.id = c.id\n\t\tWHERE \"${sql.raw(ftsTable)}\" MATCH ${escapedQuery}\n\t\tAND c.status = ${status}\n\t\tAND c.deleted_at IS NULL\n\t\t${locale ? sql`AND c.locale = ${locale}` : sql``}\n\t\tORDER BY score\n\t\tLIMIT ${limit}\n\t`.execute(db);\n\t} catch (error) {\n\t\t// FTS5 returns syntax errors for queries with unbalanced quotes,\n\t\t// stray operators, or other malformed input. Treat these as\n\t\t// \"no matches\" so the user gets an empty result rather than an\n\t\t// internals-leaking error. Other errors (table missing, IO) still\n\t\t// propagate. Intentionally not logged: any anonymous client can\n\t\t// trigger this path, and the underlying error message embeds the\n\t\t// raw query, so logging would be both noisy and a log-injection\n\t\t// vector.\n\t\tif (isFts5SyntaxError(error)) {\n\t\t\treturn [];\n\t\t}\n\t\tthrow error;\n\t}\n\n\treturn results.rows.map((row) => ({\n\t\tcollection,\n\t\tid: row.id,\n\t\tslug: row.slug,\n\t\tlocale: row.locale,\n\t\ttitle: row.title ?? undefined,\n\t\t// SQLite's snippet() returns NULL when the targeted column is\n\t\t// NULL for that row — even if the row matched via a different\n\t\t// searchable column. Skip sanitization in that case so we don't\n\t\t// throw on `null.replace`. The SearchResult.snippet field is\n\t\t// already optional, so omitting it is the documented contract.\n\t\tsnippet: row.snippet === null ? undefined : sanitizeSnippet(row.snippet),\n\t\tscore: Math.abs(row.score), // bm25 returns negative scores\n\t}));\n}\n\n// Module-scope regexes so the engine doesn't recompile per call —\n// snippet sanitization runs on every search result.\nconst SNIPPET_AMP_RE = /&/g;\nconst SNIPPET_LT_RE = /</g;\nconst SNIPPET_GT_RE = />/g;\nconst SNIPPET_QUOT_RE = /\"/g;\nconst SNIPPET_APOS_RE = /'/g;\n\n/**\n * Make an FTS5 snippet safe to render with `set:html` / `innerHTML`.\n *\n * SQLite's `snippet()` function splices literal `<mark>` and `</mark>`\n * markers around matched terms but does not escape the surrounding\n * source text. Posts that legitimately contain `<`, `>`, `&`, `\"` or\n * `'` would render as broken markup, and a `<script>` literal in a\n * title (or any other indexed field) would execute when displayed.\n *\n * The fix: HTML-escape the whole string, which turns the markers into\n * `&lt;mark&gt;` / `&lt;/mark&gt;`. Then restore those two patterns to\n * their original tag form. The result is \"the indexed text with all\n * HTML metacharacters escaped, plus a small set of literal `<mark>`\n * highlight tags around matched terms\" — which matches the API's\n * documented contract.\n */\nfunction sanitizeSnippet(snippet: string): string {\n\treturn snippet\n\t\t.replace(SNIPPET_AMP_RE, \"&amp;\")\n\t\t.replace(SNIPPET_LT_RE, \"&lt;\")\n\t\t.replace(SNIPPET_GT_RE, \"&gt;\")\n\t\t.replace(SNIPPET_QUOT_RE, \"&quot;\")\n\t\t.replace(SNIPPET_APOS_RE, \"&#39;\")\n\t\t.replaceAll(\"&lt;mark&gt;\", \"<mark>\")\n\t\t.replaceAll(\"&lt;/mark&gt;\", \"</mark>\");\n}\n\n/**\n * Get search suggestions for autocomplete\n *\n * @param db - Kysely database instance\n * @param query - Partial search query\n * @param options - Suggestion options\n * @returns Array of suggestions\n */\nexport async function getSuggestions(\n\tdb: Kysely<Database>,\n\tquery: string,\n\toptions: SuggestOptions = {},\n): Promise<Suggestion[]> {\n\tconst limit = options.limit ?? 5;\n\tconst locale = options.locale ? resolveConfiguredLocale(options.locale) : undefined;\n\n\t// Get searchable collections\n\tlet collections = options.collections;\n\tif (!collections || collections.length === 0) {\n\t\tcollections = await getSearchableCollections(db);\n\t}\n\n\tif (collections.length === 0) {\n\t\treturn [];\n\t}\n\n\tconst suggestions: Suggestion[] = [];\n\tconst ftsManager = new FTSManager(db);\n\tconst titleColumns = await ftsManager.getCollectionsWithTitleColumn(collections);\n\n\tfor (const collection of collections) {\n\t\tconst config = await ftsManager.getSearchConfig(collection);\n\t\tif (!config?.enabled) {\n\t\t\tcontinue;\n\t\t}\n\n\t\t// Suggestions are title-based (Suggestion.title is required). A configured\n\t\t// titleField supplies it -- a validated existing column -- so it\n\t\t// takes precedence and keeps autocomplete consistent with search results.\n\t\t// Otherwise fall back to the optional `title` field; collections with\n\t\t// neither can't produce a suggestion and are skipped (selecting a missing\n\t\t// column would error).\n\t\tlet titleExpr;\n\t\tif (config.titleField) {\n\t\t\tvalidateIdentifier(config.titleField, \"title field\");\n\t\t\ttitleExpr = sql`c.${sql.ref(config.titleField)}`;\n\t\t} else if (titleColumns.has(collection)) {\n\t\t\ttitleExpr = sql`c.title`;\n\t\t} else {\n\t\t\tcontinue;\n\t\t}\n\n\t\t// Validate before raw SQL interpolation\n\t\tvalidateIdentifier(collection, \"collection slug\");\n\n\t\tconst ftsTable = ftsManager.getFtsTableName(collection);\n\t\tconst contentTable = ftsManager.getContentTableName(collection);\n\n\t\t// Use prefix search for autocomplete. `escapeQuery` already appends `*`\n\t\t// to each term for prefix matching, so we must not append another one.\n\t\tconst prefixQuery = escapeQuery(query);\n\t\tif (!prefixQuery) {\n\t\t\tcontinue;\n\t\t}\n\n\t\tlet results;\n\t\ttry {\n\t\t\tresults = await sql<{\n\t\t\t\tid: string;\n\t\t\t\tslug: string | null;\n\t\t\t\ttitle: string;\n\t\t\t}>`\n\t\t\t\tSELECT\n\t\t\t\t\tc.id,\n\t\t\t\t\tc.slug,\n\t\t\t\t\t${titleExpr} AS title\n\t\t\t\tFROM \"${sql.raw(ftsTable)}\" f\n\t\t\t\tJOIN \"${sql.raw(contentTable)}\" c ON f.id = c.id\n\t\t\t\tWHERE \"${sql.raw(ftsTable)}\" MATCH ${prefixQuery}\n\t\t\t\tAND c.status = 'published'\n\t\t\t\tAND c.deleted_at IS NULL\n\t\t\t\tAND ${titleExpr} IS NOT NULL\n\t\t\t\t${locale ? sql`AND c.locale = ${locale}` : sql``}\n\t\t\t\tORDER BY bm25(\"${sql.raw(ftsTable)}\")\n\t\t\t\tLIMIT ${limit}\n\t\t\t`.execute(db);\n\t\t} catch (error) {\n\t\t\t// Same swallow as searchSingleCollection: malformed prefix\n\t\t\t// queries should yield no suggestions, not surface DB errors.\n\t\t\t// Intentionally not logged (anonymous-triggerable, echoes\n\t\t\t// user input -- see searchSingleCollection for rationale).\n\t\t\tif (isFts5SyntaxError(error)) {\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\tthrow error;\n\t\t}\n\n\t\tfor (const row of results.rows) {\n\t\t\tsuggestions.push({\n\t\t\t\tcollection,\n\t\t\t\tid: row.id,\n\t\t\t\tslug: row.slug,\n\t\t\t\ttitle: row.title,\n\t\t\t});\n\t\t}\n\t}\n\n\treturn suggestions.slice(0, limit);\n}\n\n/**\n * Get search statistics for all collections\n */\nexport async function getSearchStats(db: Kysely<Database>): Promise<SearchStats> {\n\tconst ftsManager = new FTSManager(db);\n\tconst collections = await getSearchableCollections(db);\n\tconst stats: SearchStats = { collections: {} };\n\n\tfor (const collection of collections) {\n\t\tconst collectionStats = await ftsManager.getIndexStats(collection);\n\t\tif (collectionStats) {\n\t\t\tstats.collections[collection] = collectionStats;\n\t\t}\n\t}\n\n\treturn stats;\n}\n\n/**\n * Get list of collections with search enabled\n */\nasync function getSearchableCollections(db: Kysely<Database>): Promise<string[]> {\n\tconst results = await db\n\t\t.selectFrom(\"_emdash_collections\")\n\t\t.select([\"slug\", \"search_config\"])\n\t\t.execute();\n\n\treturn results\n\t\t.filter((r) => {\n\t\t\tif (!r.search_config) return false;\n\t\t\ttry {\n\t\t\t\tconst config = JSON.parse(r.search_config);\n\t\t\t\treturn config.enabled === true;\n\t\t\t} catch {\n\t\t\t\treturn false;\n\t\t\t}\n\t\t})\n\t\t.map((r) => r.slug);\n}\n\n/**\n * Escape a query string for FTS5\n *\n * Handles special characters and prevents injection.\n */\nfunction escapeQuery(query: string): string {\n\tif (!query || typeof query !== \"string\") {\n\t\treturn \"\";\n\t}\n\n\t// Trim whitespace\n\tquery = query.trim();\n\n\tif (query.length === 0) {\n\t\treturn \"\";\n\t}\n\n\t// If already a quoted phrase, escape only interior quotes and preserve phrase syntax\n\tif (query.startsWith('\"') && query.endsWith('\"') && query.length >= 2) {\n\t\tconst inner = query.slice(1, -1);\n\t\treturn `\"${inner.replace(DOUBLE_QUOTE_PATTERN, '\"\"')}\"`;\n\t}\n\n\t// Escape any existing quotes\n\tconst escaped = query.replace(DOUBLE_QUOTE_PATTERN, '\"\"');\n\n\t// If the query contains FTS5 operators (AND, OR, NOT, NEAR),\n\t// pass through with quotes escaped but operators preserved\n\tif (FTS_OPERATORS_PATTERN.test(query)) {\n\t\treturn escaped;\n\t}\n\n\t// For simple queries, wrap each word to handle special chars\n\tconst terms = escaped.split(WHITESPACE_SPLIT_PATTERN).filter((t) => t.length > 0);\n\tif (terms.length === 0) {\n\t\treturn \"\";\n\t}\n\n\t// Join with implicit AND, add prefix matching (*) to all terms\n\t// This allows \"hel wor\" to match \"hello world\"\n\treturn terms.map((t) => `\"${t}\"*`).join(\" \");\n}\n","/**\n * Text Extraction\n *\n * Extracts plain text from Portable Text blocks for FTS indexing.\n * Uses @portabletext/toolkit as base with extensions for custom block types.\n */\n\nimport { toPlainText } from \"@portabletext/toolkit\";\n\nimport type { PortableTextBlock } from \"../content/converters/types.js\";\n\n/**\n * Validate that a value looks like a Portable Text block array.\n * Each element must have at least a `_type` string property.\n */\nfunction isPortableTextArray(value: unknown[]): value is PortableTextBlock[] {\n\treturn value.every(\n\t\t(item) =>\n\t\t\ttypeof item === \"object\" &&\n\t\t\titem !== null &&\n\t\t\t\"_type\" in item &&\n\t\t\ttypeof item._type === \"string\",\n\t);\n}\n\n/**\n * Extract additional text from custom block types that toPlainText doesn't handle\n */\nfunction extractCustomBlockText(block: PortableTextBlock): string {\n\t// Code blocks - include the code content\n\tif (block._type === \"code\" && \"code\" in block && typeof block.code === \"string\") {\n\t\treturn block.code;\n\t}\n\n\t// Image blocks - include alt text and caption\n\tif (block._type === \"image\") {\n\t\tconst parts: string[] = [];\n\t\tif (\"alt\" in block && typeof block.alt === \"string\" && block.alt) {\n\t\t\tparts.push(block.alt);\n\t\t}\n\t\tif (\"caption\" in block && typeof block.caption === \"string\" && block.caption) {\n\t\t\tparts.push(block.caption);\n\t\t}\n\t\treturn parts.join(\" \");\n\t}\n\n\treturn \"\";\n}\n\n/**\n * Extract plain text from Portable Text blocks\n *\n * Uses @portabletext/toolkit's toPlainText for standard blocks,\n * plus extracts text from custom block types (code, images with alt/caption).\n *\n * @param blocks - Array of Portable Text blocks (or a JSON string)\n * @returns Plain text content\n *\n * @example\n * ```typescript\n * const text = extractPlainText([\n *   {\n *     _type: \"block\",\n *     _key: \"abc\",\n *     children: [{ _type: \"span\", _key: \"s1\", text: \"Hello World\" }]\n *   }\n * ]);\n * // Returns: \"Hello World\"\n * ```\n */\nexport function extractPlainText(blocks: PortableTextBlock[] | string | null | undefined): string {\n\tif (!blocks) {\n\t\treturn \"\";\n\t}\n\n\t// Handle JSON string input\n\tlet parsedBlocks: PortableTextBlock[];\n\tif (typeof blocks === \"string\") {\n\t\ttry {\n\t\t\tparsedBlocks = JSON.parse(blocks);\n\t\t} catch {\n\t\t\t// If it's not valid JSON, treat as plain text\n\t\t\treturn blocks;\n\t\t}\n\t} else {\n\t\tparsedBlocks = blocks;\n\t}\n\n\tif (!Array.isArray(parsedBlocks)) {\n\t\treturn \"\";\n\t}\n\n\t// Use official toPlainText for standard blocks.\n\t// toPlainText expects `{ _type: string; [key: string]: any }[]` but our blocks use\n\t// `unknown` index sigs. They're structurally compatible at runtime — spread each block\n\t// to satisfy the wider index signature without an unsafe cast.\n\tconst toolkitBlocks = parsedBlocks.map((b) => {\n\t\tconst obj: Record<string, unknown> & { _type: string } = { _type: b._type };\n\t\tfor (const [key, val] of Object.entries(b)) {\n\t\t\tobj[key] = val;\n\t\t}\n\t\treturn obj;\n\t});\n\tconst standardText = toPlainText(toolkitBlocks);\n\n\t// Extract text from custom block types that toPlainText doesn't handle\n\tconst customTexts = parsedBlocks.map(extractCustomBlockText).filter((text) => text.length > 0);\n\n\t// Combine both\n\tconst allTexts = [standardText, ...customTexts].filter((t) => t.length > 0);\n\treturn allTexts.join(\"\\n\");\n}\n\n/**\n * Extract searchable text from a content entry\n *\n * Extracts text from specified fields, handling both plain text and Portable Text.\n *\n * @param entry - Content entry data\n * @param fields - Field names to extract text from\n * @returns Object mapping field names to extracted text\n */\nexport function extractSearchableFields(\n\tentry: Record<string, unknown>,\n\tfields: string[],\n): Record<string, string> {\n\tconst result: Record<string, string> = {};\n\n\tfor (const field of fields) {\n\t\tconst value = entry[field];\n\n\t\tif (value === null || value === undefined) {\n\t\t\tresult[field] = \"\";\n\t\t\tcontinue;\n\t\t}\n\n\t\tif (typeof value === \"string\") {\n\t\t\t// Could be plain text or JSON Portable Text\n\t\t\tif (value.startsWith(\"[\")) {\n\t\t\t\tresult[field] = extractPlainText(value);\n\t\t\t} else {\n\t\t\t\tresult[field] = value;\n\t\t\t}\n\t\t} else if (Array.isArray(value)) {\n\t\t\t// Validate the array looks like Portable Text before treating it as such\n\t\t\tif (isPortableTextArray(value)) {\n\t\t\t\tresult[field] = extractPlainText(value);\n\t\t\t} else {\n\t\t\t\tresult[field] = JSON.stringify(value);\n\t\t\t}\n\t\t} else if (typeof value === \"object\") {\n\t\t\t// Object — serialize to JSON for searchable text\n\t\t\tresult[field] = JSON.stringify(value);\n\t\t} else if (typeof value === \"number\" || typeof value === \"boolean\") {\n\t\t\tresult[field] = `${value}`;\n\t\t} else {\n\t\t\tresult[field] = \"\";\n\t\t}\n\t}\n\n\treturn result;\n}\n","/**\n * Search Module\n *\n * Full-text search for EmDash using SQLite FTS5.\n */\n\n// Types\nexport type {\n\tSearchConfig,\n\tSearchTokenizer,\n\tSearchOptions,\n\tCollectionSearchOptions,\n\tSearchResult,\n\tSearchResponse,\n\tSuggestOptions,\n\tSuggestion,\n\tSearchStats,\n} from \"./types.js\";\nexport { SEARCH_TOKENIZERS } from \"./types.js\";\n\n// FTS Manager\nexport { FTSManager } from \"./fts-manager.js\";\n\n// Query functions (public API uses getDb() internally)\nexport { search, searchWithDb, searchCollection, getSuggestions, getSearchStats } from \"./query.js\";\n\n// Text extraction\nexport { extractPlainText, extractSearchableFields } from \"./text-extraction.js\";\n"],"mappings":"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"}