import { a as __exportAll } from "./runner-K8XK101B.mjs";
import { t as validateIdentifier } from "./validate-VPnKoIzW.mjs";
import { t as FTSManager } from "./fts-manager-BbFaF_V8.mjs";
import { a as getDb } from "./loader-CB6YHeul.mjs";
import { sql } from "kysely";
import { toPlainText } from "@portabletext/toolkit";

//#region src/search/query.ts
/** Pattern to split on whitespace for query term extraction */
const WHITESPACE_SPLIT_PATTERN = /\s+/;
const FTS_OPERATORS_PATTERN = /\b(AND|OR|NOT|NEAR)\b/i;
const DOUBLE_QUOTE_PATTERN = /"/g;
/**
* Detect FTS5 query syntax errors. Match specifically on the SQLite FTS5
* error fingerprints rather than a broad "fts5" / "syntax error" filter
* (which would also swallow internal table-corruption errors). The two
* fingerprints we care about are:
*
*  - "fts5: syntax error near …" — unbalanced quotes, stray operators,
*    other malformed user input
*  - "unknown special query: …" — bare special tokens like `^*` that
*    parse but don't resolve to a real FTS5 directive
*/
function isFts5SyntaxError(error) {
	if (!(error instanceof Error)) return false;
	const message = error.message.toLowerCase();
	return message.includes("fts5: syntax error") || message.includes("unknown special query");
}
/**
* Search across multiple collections
*
* Public API that auto-injects the database.
*
* @param query - Search query (FTS5 syntax supported)
* @param options - Search options
* @returns Search results with pagination
*
* @example
* ```typescript
* import { search } from "emdash";
*
* const results = await search("hello world", {
*   collections: ["posts", "pages"],
*   limit: 20
* });
* ```
*/
async function search(query, options = {}) {
	return searchWithDb(await getDb(), query, options);
}
/**
* Search across multiple collections (with explicit db)
*
* @internal Use `search()` in templates. This variant is for admin routes
* that already have a database handle.
*
* @param db - Kysely database instance
* @param query - Search query (FTS5 syntax supported)
* @param options - Search options
* @returns Search results with pagination
*/
async function searchWithDb(db, query, options = {}) {
	const ftsManager = new FTSManager(db);
	const limit = options.limit ?? 20;
	const status = options.status ?? "published";
	let collections = options.collections;
	if (!collections || collections.length === 0) collections = await getSearchableCollections(db);
	if (collections.length === 0) return { items: [] };
	const allResults = [];
	for (const collection of collections) {
		const config = await ftsManager.getSearchConfig(collection);
		if (!config?.enabled) continue;
		const collectionResults = await searchSingleCollection(db, collection, query, {
			status,
			locale: options.locale,
			limit: limit * 2
		}, config.weights);
		allResults.push(...collectionResults);
	}
	allResults.sort((a, b) => b.score - a.score);
	return { items: allResults.slice(0, limit) };
}
/**
* Search within a single collection
*
* @param db - Kysely database instance
* @param collection - Collection slug
* @param query - Search query (FTS5 syntax supported)
* @param options - Search options
* @returns Search results with pagination
*
* @example
* ```typescript
* const results = await searchCollection(db, "posts", "hello world", {
*   limit: 10
* });
* ```
*/
async function searchCollection(db, collection, query, options = {}) {
	const config = await new FTSManager(db).getSearchConfig(collection);
	if (!config?.enabled) return { items: [] };
	return { items: await searchSingleCollection(db, collection, query, options, config.weights) };
}
/**
* Internal function to search a single collection
*/
async function searchSingleCollection(db, collection, query, options, weights) {
	validateIdentifier(collection, "collection slug");
	const ftsManager = new FTSManager(db);
	const ftsTable = ftsManager.getFtsTableName(collection);
	const contentTable = ftsManager.getContentTableName(collection);
	const limit = options.limit ?? 20;
	const status = options.status ?? "published";
	const locale = options.locale;
	if (!await ftsManager.ftsTableExists(collection)) return [];
	const escapedQuery = escapeQuery(query);
	if (!escapedQuery) return [];
	const searchableFields = await ftsManager.getSearchableFields(collection);
	let bm25Args = "";
	if (weights && searchableFields.length > 0) {
		const weightValues = ["0", "0"];
		for (const field of searchableFields) weightValues.push(String(weights[field] ?? 1));
		bm25Args = weightValues.join(", ");
	}
	const bm25Expr = bm25Args ? `bm25("${ftsTable}", ${bm25Args})` : `bm25("${ftsTable}")`;
	let results;
	try {
		results = await sql`
		SELECT 
			c.id,
			c.slug,
			c.locale,
			c.title,
			snippet("${sql.raw(ftsTable)}", 2, '<mark>', '</mark>', '...', 32) as snippet,
			${sql.raw(bm25Expr)} as score
		FROM "${sql.raw(ftsTable)}" f
		JOIN "${sql.raw(contentTable)}" c ON f.id = c.id
		WHERE "${sql.raw(ftsTable)}" MATCH ${escapedQuery}
		AND c.status = ${status}
		AND c.deleted_at IS NULL
		${locale ? sql`AND c.locale = ${locale}` : sql``}
		ORDER BY score
		LIMIT ${limit}
	`.execute(db);
	} catch (error) {
		if (isFts5SyntaxError(error)) return [];
		throw error;
	}
	return results.rows.map((row) => ({
		collection,
		id: row.id,
		slug: row.slug,
		locale: row.locale,
		title: row.title ?? void 0,
		snippet: row.snippet === null ? void 0 : sanitizeSnippet(row.snippet),
		score: Math.abs(row.score)
	}));
}
const SNIPPET_AMP_RE = /&/g;
const SNIPPET_LT_RE = /</g;
const SNIPPET_GT_RE = />/g;
const SNIPPET_QUOT_RE = /"/g;
const SNIPPET_APOS_RE = /'/g;
/**
* Make an FTS5 snippet safe to render with `set:html` / `innerHTML`.
*
* SQLite's `snippet()` function splices literal `<mark>` and `</mark>`
* markers around matched terms but does not escape the surrounding
* source text. Posts that legitimately contain `<`, `>`, `&`, `"` or
* `'` would render as broken markup, and a `<script>` literal in a
* title (or any other indexed field) would execute when displayed.
*
* The fix: HTML-escape the whole string, which turns the markers into
* `&lt;mark&gt;` / `&lt;/mark&gt;`. Then restore those two patterns to
* their original tag form. The result is "the indexed text with all
* HTML metacharacters escaped, plus a small set of literal `<mark>`
* highlight tags around matched terms" — which matches the API's
* documented contract.
*/
function sanitizeSnippet(snippet) {
	return snippet.replace(SNIPPET_AMP_RE, "&amp;").replace(SNIPPET_LT_RE, "&lt;").replace(SNIPPET_GT_RE, "&gt;").replace(SNIPPET_QUOT_RE, "&quot;").replace(SNIPPET_APOS_RE, "&#39;").replaceAll("&lt;mark&gt;", "<mark>").replaceAll("&lt;/mark&gt;", "</mark>");
}
/**
* Get search suggestions for autocomplete
*
* @param db - Kysely database instance
* @param query - Partial search query
* @param options - Suggestion options
* @returns Array of suggestions
*/
async function getSuggestions(db, query, options = {}) {
	const limit = options.limit ?? 5;
	const locale = options.locale;
	let collections = options.collections;
	if (!collections || collections.length === 0) collections = await getSearchableCollections(db);
	if (collections.length === 0) return [];
	const suggestions = [];
	for (const collection of collections) {
		const ftsManager = new FTSManager(db);
		if (!(await ftsManager.getSearchConfig(collection))?.enabled) continue;
		validateIdentifier(collection, "collection slug");
		const ftsTable = ftsManager.getFtsTableName(collection);
		const contentTable = ftsManager.getContentTableName(collection);
		const prefixQuery = escapeQuery(query);
		if (!prefixQuery) continue;
		let results;
		try {
			results = await sql`
				SELECT 
					c.id,
					c.slug,
					c.title
				FROM "${sql.raw(ftsTable)}" f
				JOIN "${sql.raw(contentTable)}" c ON f.id = c.id
				WHERE "${sql.raw(ftsTable)}" MATCH ${prefixQuery}
				AND c.status = 'published'
				AND c.deleted_at IS NULL
				AND c.title IS NOT NULL
				${locale ? sql`AND c.locale = ${locale}` : sql``}
				ORDER BY bm25("${sql.raw(ftsTable)}")
				LIMIT ${limit}
			`.execute(db);
		} catch (error) {
			if (isFts5SyntaxError(error)) continue;
			throw error;
		}
		for (const row of results.rows) suggestions.push({
			collection,
			id: row.id,
			slug: row.slug,
			title: row.title
		});
	}
	return suggestions.slice(0, limit);
}
/**
* Get search statistics for all collections
*/
async function getSearchStats(db) {
	const ftsManager = new FTSManager(db);
	const collections = await getSearchableCollections(db);
	const stats = { collections: {} };
	for (const collection of collections) {
		const collectionStats = await ftsManager.getIndexStats(collection);
		if (collectionStats) stats.collections[collection] = collectionStats;
	}
	return stats;
}
/**
* Get list of collections with search enabled
*/
async function getSearchableCollections(db) {
	return (await db.selectFrom("_emdash_collections").select(["slug", "search_config"]).execute()).filter((r) => {
		if (!r.search_config) return false;
		try {
			return JSON.parse(r.search_config).enabled === true;
		} catch {
			return false;
		}
	}).map((r) => r.slug);
}
/**
* Escape a query string for FTS5
*
* Handles special characters and prevents injection.
*/
function escapeQuery(query) {
	if (!query || typeof query !== "string") return "";
	query = query.trim();
	if (query.length === 0) return "";
	if (query.startsWith("\"") && query.endsWith("\"") && query.length >= 2) return `"${query.slice(1, -1).replace(DOUBLE_QUOTE_PATTERN, "\"\"")}"`;
	const escaped = query.replace(DOUBLE_QUOTE_PATTERN, "\"\"");
	if (FTS_OPERATORS_PATTERN.test(query)) return escaped;
	const terms = escaped.split(WHITESPACE_SPLIT_PATTERN).filter((t) => t.length > 0);
	if (terms.length === 0) return "";
	return terms.map((t) => `"${t}"*`).join(" ");
}

//#endregion
//#region src/search/text-extraction.ts
/**
* Text Extraction
*
* Extracts plain text from Portable Text blocks for FTS indexing.
* Uses @portabletext/toolkit as base with extensions for custom block types.
*/
/**
* Validate that a value looks like a Portable Text block array.
* Each element must have at least a `_type` string property.
*/
function isPortableTextArray(value) {
	return value.every((item) => typeof item === "object" && item !== null && "_type" in item && typeof item._type === "string");
}
/**
* Extract additional text from custom block types that toPlainText doesn't handle
*/
function extractCustomBlockText(block) {
	if (block._type === "code" && "code" in block && typeof block.code === "string") return block.code;
	if (block._type === "image") {
		const parts = [];
		if ("alt" in block && typeof block.alt === "string" && block.alt) parts.push(block.alt);
		if ("caption" in block && typeof block.caption === "string" && block.caption) parts.push(block.caption);
		return parts.join(" ");
	}
	return "";
}
/**
* Extract plain text from Portable Text blocks
*
* Uses @portabletext/toolkit's toPlainText for standard blocks,
* plus extracts text from custom block types (code, images with alt/caption).
*
* @param blocks - Array of Portable Text blocks (or a JSON string)
* @returns Plain text content
*
* @example
* ```typescript
* const text = extractPlainText([
*   {
*     _type: "block",
*     _key: "abc",
*     children: [{ _type: "span", _key: "s1", text: "Hello World" }]
*   }
* ]);
* // Returns: "Hello World"
* ```
*/
function extractPlainText(blocks) {
	if (!blocks) return "";
	let parsedBlocks;
	if (typeof blocks === "string") try {
		parsedBlocks = JSON.parse(blocks);
	} catch {
		return blocks;
	}
	else parsedBlocks = blocks;
	if (!Array.isArray(parsedBlocks)) return "";
	return [toPlainText(parsedBlocks.map((b) => {
		const obj = { _type: b._type };
		for (const [key, val] of Object.entries(b)) obj[key] = val;
		return obj;
	})), ...parsedBlocks.map(extractCustomBlockText).filter((text) => text.length > 0)].filter((t) => t.length > 0).join("\n");
}
/**
* Extract searchable text from a content entry
*
* Extracts text from specified fields, handling both plain text and Portable Text.
*
* @param entry - Content entry data
* @param fields - Field names to extract text from
* @returns Object mapping field names to extracted text
*/
function extractSearchableFields(entry, fields) {
	const result = {};
	for (const field of fields) {
		const value = entry[field];
		if (value === null || value === void 0) {
			result[field] = "";
			continue;
		}
		if (typeof value === "string") if (value.startsWith("[")) result[field] = extractPlainText(value);
		else result[field] = value;
		else if (Array.isArray(value)) if (isPortableTextArray(value)) result[field] = extractPlainText(value);
		else result[field] = JSON.stringify(value);
		else if (typeof value === "object") result[field] = JSON.stringify(value);
		else if (typeof value === "number" || typeof value === "boolean") result[field] = `${value}`;
		else result[field] = "";
	}
	return result;
}

//#endregion
//#region src/search/index.ts
var search_exports = /* @__PURE__ */ __exportAll({ searchWithDb: () => searchWithDb });

//#endregion
export { getSuggestions as a, searchWithDb as c, getSearchStats as i, extractPlainText as n, search as o, extractSearchableFields as r, searchCollection as s, search_exports as t };
//# sourceMappingURL=search-DZN2HZ-V.mjs.map