@silicajs/search 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.js +161 -97
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -2,6 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
Server-side SQLite full-text search helpers for Silica.
|
|
4
4
|
|
|
5
|
-
The build step
|
|
5
|
+
The build step adds an FTS5 `search_index` table to `.silica/vault.db`. The generated Next.js `/api/search` route queries that shared vault database and returns ranked results with excerpts.
|
|
6
6
|
|
|
7
7
|
Includes a benchmark helper for cold/warm search latency checks.
|
package/dist/index.d.ts
CHANGED
|
@@ -46,6 +46,7 @@ declare function benchmarkSearchIndex(databasePath: string, { query, warmRuns, .
|
|
|
46
46
|
|
|
47
47
|
declare const SEARCH_DATABASE_FILENAME = "search.db";
|
|
48
48
|
declare function buildSearchDatabase(records: SearchRecord[], databasePath: string): Promise<SearchDatabaseMetadata>;
|
|
49
|
+
declare function buildSearchTables(db: Database.Database, records: SearchRecord[]): string;
|
|
49
50
|
|
|
50
51
|
declare function normalizeSearchText(value: string): string;
|
|
51
52
|
declare function makeExcerpt(content: string, query: string, maxLength?: number): string;
|
|
@@ -59,4 +60,4 @@ declare function loadSearchIndex(databasePath: string): Promise<LoadedSearchInde
|
|
|
59
60
|
|
|
60
61
|
declare function querySearchIndex(loaded: LoadedSearchIndex, query: string, options?: SearchQueryOptions): SearchResult[];
|
|
61
62
|
|
|
62
|
-
export { type LoadedSearchIndex, SEARCH_DATABASE_FILENAME, type SearchBenchmarkOptions, type SearchBenchmarkResult, type SearchDatabaseMetadata, type SearchQueryOptions, type SearchRecord, type SearchResult, benchmarkSearchIndex, buildSearchDatabase, loadSearchIndex, makeExcerpt, normalizeSearchText, querySearchIndex };
|
|
63
|
+
export { type LoadedSearchIndex, SEARCH_DATABASE_FILENAME, type SearchBenchmarkOptions, type SearchBenchmarkResult, type SearchDatabaseMetadata, type SearchQueryOptions, type SearchRecord, type SearchResult, benchmarkSearchIndex, buildSearchDatabase, buildSearchTables, loadSearchIndex, makeExcerpt, normalizeSearchText, querySearchIndex };
|
package/dist/index.js
CHANGED
|
@@ -62,18 +62,18 @@ function querySearchIndex(loaded, query, options = {}) {
|
|
|
62
62
|
const rows = loaded.db.prepare(
|
|
63
63
|
`
|
|
64
64
|
SELECT
|
|
65
|
-
|
|
66
|
-
|
|
65
|
+
n.slug,
|
|
66
|
+
n.title,
|
|
67
67
|
highlight(search_index, 0, char(57344), char(57345)) AS highlighted_title,
|
|
68
|
-
|
|
69
|
-
|
|
68
|
+
n.description,
|
|
69
|
+
n.tags_json,
|
|
70
70
|
snippet(search_index, -1, char(57344), char(57345), '\u2026', 24) AS highlighted_excerpt,
|
|
71
71
|
bm25(search_index, 8.0, 1.0, 3.0) AS score
|
|
72
72
|
FROM search_index
|
|
73
|
-
JOIN
|
|
73
|
+
JOIN notes n ON n.rowid = search_index.rowid
|
|
74
74
|
WHERE search_index MATCH ?
|
|
75
75
|
${tagClause.sql}
|
|
76
|
-
ORDER BY score ASC,
|
|
76
|
+
ORDER BY score ASC, n.title COLLATE NOCASE ASC
|
|
77
77
|
LIMIT ?
|
|
78
78
|
`
|
|
79
79
|
).all(ftsQuery, ...tagClause.params, limit);
|
|
@@ -88,17 +88,17 @@ function queryByTags(loaded, tags, limit) {
|
|
|
88
88
|
const rows = loaded.db.prepare(
|
|
89
89
|
`
|
|
90
90
|
SELECT
|
|
91
|
-
|
|
92
|
-
|
|
91
|
+
n.slug,
|
|
92
|
+
n.title,
|
|
93
93
|
NULL AS highlighted_title,
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
94
|
+
n.description,
|
|
95
|
+
n.tags_json,
|
|
96
|
+
n.search_excerpt AS excerpt,
|
|
97
97
|
0 AS score
|
|
98
|
-
FROM
|
|
98
|
+
FROM notes n
|
|
99
99
|
WHERE 1 = 1
|
|
100
100
|
${tagClause.sql}
|
|
101
|
-
ORDER BY
|
|
101
|
+
ORDER BY n.title COLLATE NOCASE ASC
|
|
102
102
|
LIMIT ?
|
|
103
103
|
`
|
|
104
104
|
).all(...tagClause.params, limit);
|
|
@@ -110,9 +110,9 @@ function makeTagClause(tags) {
|
|
|
110
110
|
sql: `
|
|
111
111
|
AND EXISTS (
|
|
112
112
|
SELECT 1
|
|
113
|
-
FROM
|
|
114
|
-
WHERE
|
|
115
|
-
AND
|
|
113
|
+
FROM note_tags nt
|
|
114
|
+
WHERE nt.slug = n.slug
|
|
115
|
+
AND nt.tag IN (${tags.map(() => "?").join(", ")})
|
|
116
116
|
)
|
|
117
117
|
`,
|
|
118
118
|
params: tags
|
|
@@ -131,8 +131,7 @@ function toResult(row) {
|
|
|
131
131
|
};
|
|
132
132
|
}
|
|
133
133
|
function parseTags(value) {
|
|
134
|
-
|
|
135
|
-
return Array.isArray(parsed) ? parsed.filter((tag) => typeof tag === "string") : [];
|
|
134
|
+
return JSON.parse(value);
|
|
136
135
|
}
|
|
137
136
|
function toFtsQuery(query) {
|
|
138
137
|
const terms = query.match(/[\p{L}\p{N}_]+/gu) ?? [];
|
|
@@ -201,85 +200,8 @@ async function buildSearchDatabase(records, databasePath) {
|
|
|
201
200
|
try {
|
|
202
201
|
db.pragma("journal_mode = DELETE");
|
|
203
202
|
db.pragma("synchronous = OFF");
|
|
204
|
-
db
|
|
205
|
-
|
|
206
|
-
key TEXT PRIMARY KEY,
|
|
207
|
-
value TEXT NOT NULL
|
|
208
|
-
);
|
|
209
|
-
|
|
210
|
-
CREATE TABLE documents (
|
|
211
|
-
rowid INTEGER PRIMARY KEY,
|
|
212
|
-
id TEXT NOT NULL UNIQUE,
|
|
213
|
-
slug TEXT NOT NULL,
|
|
214
|
-
title TEXT NOT NULL,
|
|
215
|
-
description TEXT,
|
|
216
|
-
tags_json TEXT NOT NULL,
|
|
217
|
-
excerpt TEXT NOT NULL
|
|
218
|
-
);
|
|
219
|
-
|
|
220
|
-
CREATE TABLE document_tags (
|
|
221
|
-
document_rowid INTEGER NOT NULL,
|
|
222
|
-
tag TEXT NOT NULL,
|
|
223
|
-
PRIMARY KEY (document_rowid, tag),
|
|
224
|
-
FOREIGN KEY (document_rowid) REFERENCES documents(rowid) ON DELETE CASCADE
|
|
225
|
-
);
|
|
226
|
-
|
|
227
|
-
CREATE INDEX document_tags_tag_idx ON document_tags(tag);
|
|
228
|
-
|
|
229
|
-
CREATE VIRTUAL TABLE search_index USING fts5(
|
|
230
|
-
title,
|
|
231
|
-
content,
|
|
232
|
-
tags,
|
|
233
|
-
tokenize='porter unicode61',
|
|
234
|
-
prefix='3'
|
|
235
|
-
);
|
|
236
|
-
`);
|
|
237
|
-
const builtAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
238
|
-
const insertMetadata = db.prepare(
|
|
239
|
-
"INSERT INTO metadata (key, value) VALUES (?, ?)"
|
|
240
|
-
);
|
|
241
|
-
const insertDocument = db.prepare(`
|
|
242
|
-
INSERT INTO documents (id, slug, title, description, tags_json, excerpt)
|
|
243
|
-
VALUES (@id, @slug, @title, @description, @tagsJson, @excerpt)
|
|
244
|
-
`);
|
|
245
|
-
const insertSearch = db.prepare(`
|
|
246
|
-
INSERT INTO search_index (rowid, title, content, tags)
|
|
247
|
-
VALUES (?, ?, ?, ?)
|
|
248
|
-
`);
|
|
249
|
-
const insertTag = db.prepare(`
|
|
250
|
-
INSERT OR IGNORE INTO document_tags (document_rowid, tag)
|
|
251
|
-
VALUES (?, ?)
|
|
252
|
-
`);
|
|
253
|
-
const insertRecords = db.transaction((items) => {
|
|
254
|
-
for (const record of items) {
|
|
255
|
-
const tags = record.tags.map(normalizeTag2).filter(Boolean);
|
|
256
|
-
const result = insertDocument.run({
|
|
257
|
-
id: record.id,
|
|
258
|
-
slug: record.slug,
|
|
259
|
-
title: record.title,
|
|
260
|
-
description: record.description,
|
|
261
|
-
tagsJson: JSON.stringify(record.tags),
|
|
262
|
-
excerpt: makeExcerpt(
|
|
263
|
-
record.content,
|
|
264
|
-
record.description ?? record.title
|
|
265
|
-
)
|
|
266
|
-
});
|
|
267
|
-
const rowid = Number(result.lastInsertRowid);
|
|
268
|
-
insertSearch.run(rowid, record.title, record.content, tags.join(" "));
|
|
269
|
-
for (const tag of tags) {
|
|
270
|
-
for (const hierarchyTag of tagHierarchy(tag)) {
|
|
271
|
-
insertTag.run(rowid, hierarchyTag);
|
|
272
|
-
}
|
|
273
|
-
}
|
|
274
|
-
}
|
|
275
|
-
insertMetadata.run("version", "1");
|
|
276
|
-
insertMetadata.run("builtAt", builtAt);
|
|
277
|
-
insertMetadata.run("recordCount", String(items.length));
|
|
278
|
-
});
|
|
279
|
-
insertRecords(records);
|
|
280
|
-
db.prepare("INSERT INTO search_index(search_index) VALUES(?)").run(
|
|
281
|
-
"optimize"
|
|
282
|
-
);
|
|
203
|
+
createStandaloneNotesSchema(db);
|
|
204
|
+
const builtAt = buildSearchTables(db, records);
|
|
283
205
|
db.exec("VACUUM");
|
|
284
206
|
return {
|
|
285
207
|
version: 1,
|
|
@@ -291,6 +213,110 @@ async function buildSearchDatabase(records, databasePath) {
|
|
|
291
213
|
db.close();
|
|
292
214
|
}
|
|
293
215
|
}
|
|
216
|
+
function buildSearchTables(db, records) {
|
|
217
|
+
db.exec(`
|
|
218
|
+
DROP TABLE IF EXISTS search_index;
|
|
219
|
+
CREATE VIRTUAL TABLE search_index USING fts5(
|
|
220
|
+
title,
|
|
221
|
+
content,
|
|
222
|
+
tags,
|
|
223
|
+
tokenize='porter unicode61',
|
|
224
|
+
prefix='3'
|
|
225
|
+
);
|
|
226
|
+
`);
|
|
227
|
+
const builtAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
228
|
+
const upsertMetadata = db.prepare(`
|
|
229
|
+
INSERT INTO vault_metadata (key, value) VALUES (?, ?)
|
|
230
|
+
ON CONFLICT(key) DO UPDATE SET value = excluded.value
|
|
231
|
+
`);
|
|
232
|
+
const insertNote = db.prepare(`
|
|
233
|
+
INSERT INTO notes (
|
|
234
|
+
slug,
|
|
235
|
+
file,
|
|
236
|
+
relative_file,
|
|
237
|
+
title,
|
|
238
|
+
menu_label,
|
|
239
|
+
description,
|
|
240
|
+
generated_description,
|
|
241
|
+
frontmatter_json,
|
|
242
|
+
tags_json,
|
|
243
|
+
search_excerpt,
|
|
244
|
+
listed,
|
|
245
|
+
content_hash,
|
|
246
|
+
render_hash,
|
|
247
|
+
prerender
|
|
248
|
+
)
|
|
249
|
+
VALUES (
|
|
250
|
+
@slug,
|
|
251
|
+
@file,
|
|
252
|
+
@relativeFile,
|
|
253
|
+
@title,
|
|
254
|
+
@menuLabel,
|
|
255
|
+
@description,
|
|
256
|
+
NULL,
|
|
257
|
+
'{}',
|
|
258
|
+
@tagsJson,
|
|
259
|
+
@excerpt,
|
|
260
|
+
1,
|
|
261
|
+
@contentHash,
|
|
262
|
+
@renderHash,
|
|
263
|
+
1
|
|
264
|
+
)
|
|
265
|
+
ON CONFLICT(slug) DO UPDATE SET
|
|
266
|
+
title = excluded.title,
|
|
267
|
+
menu_label = excluded.menu_label,
|
|
268
|
+
description = excluded.description,
|
|
269
|
+
tags_json = excluded.tags_json,
|
|
270
|
+
search_excerpt = excluded.search_excerpt
|
|
271
|
+
`);
|
|
272
|
+
const selectNoteRowid = db.prepare(
|
|
273
|
+
"SELECT rowid AS rowid FROM notes WHERE slug = ?"
|
|
274
|
+
);
|
|
275
|
+
const insertSearch = db.prepare(`
|
|
276
|
+
INSERT INTO search_index (rowid, title, content, tags)
|
|
277
|
+
VALUES (?, ?, ?, ?)
|
|
278
|
+
`);
|
|
279
|
+
const insertTag = db.prepare(`
|
|
280
|
+
INSERT OR IGNORE INTO note_tags (slug, tag)
|
|
281
|
+
VALUES (?, ?)
|
|
282
|
+
`);
|
|
283
|
+
const insertRecords = db.transaction((items) => {
|
|
284
|
+
for (const record of items) {
|
|
285
|
+
const tags = record.tags.map(normalizeTag2).filter(Boolean);
|
|
286
|
+
const excerpt = makeExcerpt(
|
|
287
|
+
record.content,
|
|
288
|
+
record.description ?? record.title
|
|
289
|
+
);
|
|
290
|
+
insertNote.run({
|
|
291
|
+
slug: record.slug,
|
|
292
|
+
file: "",
|
|
293
|
+
relativeFile: "",
|
|
294
|
+
title: record.title,
|
|
295
|
+
menuLabel: record.title,
|
|
296
|
+
description: record.description,
|
|
297
|
+
tagsJson: JSON.stringify(record.tags),
|
|
298
|
+
excerpt,
|
|
299
|
+
contentHash: record.id,
|
|
300
|
+
renderHash: record.id
|
|
301
|
+
});
|
|
302
|
+
const row = selectNoteRowid.get(record.slug);
|
|
303
|
+
insertSearch.run(row.rowid, record.title, record.content, tags.join(" "));
|
|
304
|
+
for (const tag of tags) {
|
|
305
|
+
for (const hierarchyTag of tagHierarchy(tag)) {
|
|
306
|
+
insertTag.run(record.slug, hierarchyTag);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
upsertMetadata.run("searchVersion", "1");
|
|
311
|
+
upsertMetadata.run("searchBuiltAt", builtAt);
|
|
312
|
+
upsertMetadata.run("searchRecordCount", String(items.length));
|
|
313
|
+
});
|
|
314
|
+
insertRecords(records);
|
|
315
|
+
db.prepare("INSERT INTO search_index(search_index) VALUES(?)").run(
|
|
316
|
+
"optimize"
|
|
317
|
+
);
|
|
318
|
+
return builtAt;
|
|
319
|
+
}
|
|
294
320
|
async function removeDatabaseFiles(databasePath) {
|
|
295
321
|
await Promise.all(
|
|
296
322
|
["", "-journal", "-shm", "-wal"].map(
|
|
@@ -305,10 +331,48 @@ function tagHierarchy(tag) {
|
|
|
305
331
|
const segments = tag.split("/").filter(Boolean);
|
|
306
332
|
return segments.map((_, index) => segments.slice(0, index + 1).join("/"));
|
|
307
333
|
}
|
|
334
|
+
function createStandaloneNotesSchema(db) {
|
|
335
|
+
db.exec(`
|
|
336
|
+
CREATE TABLE vault_metadata (
|
|
337
|
+
key TEXT PRIMARY KEY,
|
|
338
|
+
value TEXT NOT NULL
|
|
339
|
+
);
|
|
340
|
+
|
|
341
|
+
CREATE TABLE notes (
|
|
342
|
+
slug TEXT PRIMARY KEY,
|
|
343
|
+
file TEXT NOT NULL,
|
|
344
|
+
relative_file TEXT NOT NULL,
|
|
345
|
+
title TEXT NOT NULL,
|
|
346
|
+
menu_label TEXT NOT NULL,
|
|
347
|
+
description TEXT,
|
|
348
|
+
generated_description TEXT,
|
|
349
|
+
frontmatter_json TEXT NOT NULL,
|
|
350
|
+
tags_json TEXT NOT NULL,
|
|
351
|
+
search_excerpt TEXT NOT NULL DEFAULT '',
|
|
352
|
+
created TEXT,
|
|
353
|
+
modified TEXT,
|
|
354
|
+
sort_key TEXT,
|
|
355
|
+
listed INTEGER NOT NULL,
|
|
356
|
+
content_hash TEXT NOT NULL,
|
|
357
|
+
render_hash TEXT NOT NULL,
|
|
358
|
+
prerender INTEGER NOT NULL
|
|
359
|
+
);
|
|
360
|
+
|
|
361
|
+
CREATE TABLE note_tags (
|
|
362
|
+
slug TEXT NOT NULL,
|
|
363
|
+
tag TEXT NOT NULL,
|
|
364
|
+
PRIMARY KEY (slug, tag),
|
|
365
|
+
FOREIGN KEY (slug) REFERENCES notes(slug) ON DELETE CASCADE
|
|
366
|
+
);
|
|
367
|
+
|
|
368
|
+
CREATE INDEX note_tags_tag_idx ON note_tags(tag, slug);
|
|
369
|
+
`);
|
|
370
|
+
}
|
|
308
371
|
export {
|
|
309
372
|
SEARCH_DATABASE_FILENAME,
|
|
310
373
|
benchmarkSearchIndex,
|
|
311
374
|
buildSearchDatabase,
|
|
375
|
+
buildSearchTables,
|
|
312
376
|
loadSearchIndex,
|
|
313
377
|
makeExcerpt,
|
|
314
378
|
normalizeSearchText,
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/benchmark.ts","../src/load.ts","../src/excerpt.ts","../src/query.ts","../src/build.ts"],"sourcesContent":["import { performance } from \"node:perf_hooks\";\nimport { loadSearchIndex } from \"./load.js\";\nimport { querySearchIndex } from \"./query.js\";\nimport type { SearchQueryOptions } from \"./types.js\";\n\nexport type SearchBenchmarkOptions = SearchQueryOptions & {\n query: string;\n warmRuns?: number;\n};\n\nexport type SearchBenchmarkResult = {\n coldMs: number;\n warmMs: number;\n warmRuns: number;\n resultCount: number;\n};\n\nexport async function benchmarkSearchIndex(\n databasePath: string,\n { query, warmRuns = 10, ...queryOptions }: SearchBenchmarkOptions,\n): Promise<SearchBenchmarkResult> {\n const coldStart = performance.now();\n const loaded = await loadSearchIndex(databasePath);\n const coldResults = querySearchIndex(loaded, query, queryOptions);\n const coldMs = performance.now() - coldStart;\n\n const runs = Math.max(1, warmRuns);\n const warmStart = performance.now();\n for (let index = 0; index < runs; index += 1) {\n querySearchIndex(loaded, query, queryOptions);\n }\n const warmMs = (performance.now() - warmStart) / runs;\n\n try {\n return {\n coldMs: round(coldMs),\n warmMs: round(warmMs),\n warmRuns: runs,\n resultCount: coldResults.length,\n };\n } finally {\n loaded.close();\n }\n}\n\nfunction round(value: number): number {\n return Math.round(value * 100) / 100;\n}\n","import Database from \"better-sqlite3\";\n\nexport type LoadedSearchIndex = {\n databasePath: string;\n db: Database.Database;\n close: () => void;\n};\n\nconst globalCache = globalThis as typeof globalThis & {\n __silicaSearchIndexes?: Map<string, LoadedSearchIndex>;\n};\n\nexport async function loadSearchIndex(\n databasePath: string,\n): Promise<LoadedSearchIndex> {\n const cache = (globalCache.__silicaSearchIndexes ??= new Map());\n const cached = cache.get(databasePath);\n if (cached) return cached;\n\n const db = new Database(databasePath, {\n fileMustExist: true,\n readonly: true,\n });\n db.pragma(\"query_only = ON\");\n\n const loaded: LoadedSearchIndex = {\n databasePath,\n db,\n close: () => {\n db.close();\n cache.delete(databasePath);\n },\n };\n cache.set(databasePath, loaded);\n return loaded;\n}\n","const WORD_BOUNDARY = /\\s+/g;\n\nexport function normalizeSearchText(value: string): string {\n return value.replace(/\\s+/g, \" \").trim();\n}\n\nexport function makeExcerpt(\n content: string,\n query: string,\n maxLength = 180,\n): string {\n const normalized = normalizeSearchText(content);\n if (normalized.length <= maxLength) return normalized;\n\n const firstTerm = query\n .toLowerCase()\n .split(WORD_BOUNDARY)\n .find((term) => term.length > 1);\n\n const matchIndex = firstTerm\n ? normalized.toLowerCase().indexOf(firstTerm)\n : -1;\n const center = matchIndex >= 0 ? matchIndex : 0;\n const half = Math.floor(maxLength / 2);\n const start = Math.max(0, center - half);\n const end = Math.min(normalized.length, start + maxLength);\n const excerpt = normalized.slice(start, end).trim();\n\n return `${start > 0 ? \"…\" : \"\"}${excerpt}${end < normalized.length ? \"…\" : \"\"}`;\n}\n","import { normalizeSearchText } from \"./excerpt.js\";\nimport type { LoadedSearchIndex } from \"./load.js\";\nimport type {\n SearchHighlightPart,\n SearchQueryOptions,\n SearchResult,\n} from \"./types.js\";\n\nconst HIGHLIGHT_START = \"\\uE000\";\nconst HIGHLIGHT_END = \"\\uE001\";\n\ntype SearchRow = {\n slug: string;\n title: string;\n highlighted_title: string | null;\n description: string | null;\n tags_json: string;\n highlighted_excerpt: string | null;\n score: number;\n};\n\ntype TagOnlyRow = Omit<\n SearchRow,\n \"highlighted_title\" | \"highlighted_excerpt\" | \"score\"\n> & {\n highlighted_title: null;\n excerpt: string;\n score: 0;\n};\n\nexport function querySearchIndex(\n loaded: LoadedSearchIndex,\n query: string,\n options: SearchQueryOptions = {},\n): SearchResult[] {\n const normalized = normalizeSearchText(query);\n const limit = options.limit ?? 10;\n const tagFilter = [\n ...new Set((options.tags ?? []).map(normalizeTag).filter(Boolean)),\n ];\n if (!normalized && tagFilter.length === 0) return [];\n\n if (!normalized) return queryByTags(loaded, tagFilter, limit);\n\n const ftsQuery = toFtsQuery(normalized);\n if (!ftsQuery) {\n return tagFilter.length > 0 ? queryByTags(loaded, tagFilter, limit) : [];\n }\n\n const tagClause = makeTagClause(tagFilter);\n const rows = loaded.db\n .prepare(\n `\n SELECT\n d.slug,\n d.title,\n highlight(search_index, 0, char(57344), char(57345)) AS highlighted_title,\n d.description,\n d.tags_json,\n snippet(search_index, -1, char(57344), char(57345), '…', 24) AS highlighted_excerpt,\n bm25(search_index, 8.0, 1.0, 3.0) AS score\n FROM search_index\n JOIN documents d ON d.rowid = search_index.rowid\n WHERE search_index MATCH ?\n ${tagClause.sql}\n ORDER BY score ASC, d.title COLLATE NOCASE ASC\n LIMIT ?\n `,\n )\n .all(ftsQuery, ...tagClause.params, limit) as SearchRow[];\n\n return rows.map(toResult);\n}\n\nfunction normalizeTag(tag: string): string {\n return tag.trim().replace(/^#/, \"\").toLowerCase();\n}\n\nfunction queryByTags(\n loaded: LoadedSearchIndex,\n tags: string[],\n limit: number,\n): SearchResult[] {\n const tagClause = makeTagClause(tags);\n if (!tagClause.sql) return [];\n\n const rows = loaded.db\n .prepare(\n `\n SELECT\n d.slug,\n d.title,\n NULL AS highlighted_title,\n d.description,\n d.tags_json,\n d.excerpt,\n 0 AS score\n FROM documents d\n WHERE 1 = 1\n ${tagClause.sql}\n ORDER BY d.title COLLATE NOCASE ASC\n LIMIT ?\n `,\n )\n .all(...tagClause.params, limit) as TagOnlyRow[];\n\n return rows.map(toResult);\n}\n\nfunction makeTagClause(tags: string[]): { sql: string; params: string[] } {\n if (tags.length === 0) return { sql: \"\", params: [] };\n return {\n sql: `\n AND EXISTS (\n SELECT 1\n FROM document_tags dt\n WHERE dt.document_rowid = d.rowid\n AND dt.tag IN (${tags.map(() => \"?\").join(\", \")})\n )\n `,\n params: tags,\n };\n}\n\nfunction toResult(row: SearchRow | TagOnlyRow): SearchResult {\n const excerpt =\n \"highlighted_excerpt\" in row\n ? (row.highlighted_excerpt ?? \"\")\n : row.excerpt;\n return {\n slug: row.slug,\n title: row.title,\n titleParts: toHighlightParts(row.highlighted_title ?? row.title),\n description: row.description ?? undefined,\n tags: parseTags(row.tags_json),\n excerptParts: toHighlightParts(excerpt),\n score: -row.score,\n };\n}\n\nfunction parseTags(value: string): string[] {\n const parsed = JSON.parse(value) as unknown;\n return Array.isArray(parsed)\n ? parsed.filter((tag): tag is string => typeof tag === \"string\")\n : [];\n}\n\nfunction toFtsQuery(query: string): string | undefined {\n const terms = query.match(/[\\p{L}\\p{N}_]+/gu) ?? [];\n const normalizedTerms = terms\n .map((term) => term.toLocaleLowerCase())\n .filter(Boolean);\n if (normalizedTerms.length === 0) return;\n\n return normalizedTerms\n .map((term) => (term.length >= 3 ? `${term}*` : term))\n .join(\" \");\n}\n\nfunction toHighlightParts(value: string): SearchHighlightPart[] {\n const normalized = normalizeSearchText(value);\n if (!normalized) return [];\n\n const parts: SearchHighlightPart[] = [];\n let highlighted = false;\n for (const part of normalized.split(\n new RegExp(`(${HIGHLIGHT_START}|${HIGHLIGHT_END})`),\n )) {\n if (!part) continue;\n if (part === HIGHLIGHT_START) {\n highlighted = true;\n continue;\n }\n if (part === HIGHLIGHT_END) {\n highlighted = false;\n continue;\n }\n parts.push({ text: part, highlighted });\n }\n return parts;\n}\n","import fs from \"node:fs/promises\";\nimport path from \"node:path\";\nimport Database from \"better-sqlite3\";\nimport { makeExcerpt } from \"./excerpt.js\";\nimport type { SearchDatabaseMetadata, SearchRecord } from \"./types.js\";\n\nexport const SEARCH_DATABASE_FILENAME = \"search.db\";\n\nexport async function buildSearchDatabase(\n records: SearchRecord[],\n databasePath: string,\n): Promise<SearchDatabaseMetadata> {\n await fs.mkdir(path.dirname(databasePath), { recursive: true });\n await removeDatabaseFiles(databasePath);\n\n const db = new Database(databasePath);\n try {\n db.pragma(\"journal_mode = DELETE\");\n db.pragma(\"synchronous = OFF\");\n db.exec(`\n CREATE TABLE metadata (\n key TEXT PRIMARY KEY,\n value TEXT NOT NULL\n );\n\n CREATE TABLE documents (\n rowid INTEGER PRIMARY KEY,\n id TEXT NOT NULL UNIQUE,\n slug TEXT NOT NULL,\n title TEXT NOT NULL,\n description TEXT,\n tags_json TEXT NOT NULL,\n excerpt TEXT NOT NULL\n );\n\n CREATE TABLE document_tags (\n document_rowid INTEGER NOT NULL,\n tag TEXT NOT NULL,\n PRIMARY KEY (document_rowid, tag),\n FOREIGN KEY (document_rowid) REFERENCES documents(rowid) ON DELETE CASCADE\n );\n\n CREATE INDEX document_tags_tag_idx ON document_tags(tag);\n\n CREATE VIRTUAL TABLE search_index USING fts5(\n title,\n content,\n tags,\n tokenize='porter unicode61',\n prefix='3'\n );\n `);\n\n const builtAt = new Date().toISOString();\n const insertMetadata = db.prepare(\n \"INSERT INTO metadata (key, value) VALUES (?, ?)\",\n );\n const insertDocument = db.prepare(`\n INSERT INTO documents (id, slug, title, description, tags_json, excerpt)\n VALUES (@id, @slug, @title, @description, @tagsJson, @excerpt)\n `);\n const insertSearch = db.prepare(`\n INSERT INTO search_index (rowid, title, content, tags)\n VALUES (?, ?, ?, ?)\n `);\n const insertTag = db.prepare(`\n INSERT OR IGNORE INTO document_tags (document_rowid, tag)\n VALUES (?, ?)\n `);\n\n const insertRecords = db.transaction((items: SearchRecord[]) => {\n for (const record of items) {\n const tags = record.tags.map(normalizeTag).filter(Boolean);\n const result = insertDocument.run({\n id: record.id,\n slug: record.slug,\n title: record.title,\n description: record.description,\n tagsJson: JSON.stringify(record.tags),\n excerpt: makeExcerpt(\n record.content,\n record.description ?? record.title,\n ),\n });\n const rowid = Number(result.lastInsertRowid);\n insertSearch.run(rowid, record.title, record.content, tags.join(\" \"));\n for (const tag of tags) {\n for (const hierarchyTag of tagHierarchy(tag)) {\n insertTag.run(rowid, hierarchyTag);\n }\n }\n }\n\n insertMetadata.run(\"version\", \"1\");\n insertMetadata.run(\"builtAt\", builtAt);\n insertMetadata.run(\"recordCount\", String(items.length));\n });\n insertRecords(records);\n\n db.prepare(\"INSERT INTO search_index(search_index) VALUES(?)\").run(\n \"optimize\",\n );\n db.exec(\"VACUUM\");\n\n return {\n version: 1,\n databasePath,\n recordCount: records.length,\n builtAt,\n };\n } finally {\n db.close();\n }\n}\n\nasync function removeDatabaseFiles(databasePath: string): Promise<void> {\n await Promise.all(\n [\"\", \"-journal\", \"-shm\", \"-wal\"].map((suffix) =>\n fs.rm(`${databasePath}${suffix}`, { force: true }),\n ),\n );\n}\n\nfunction normalizeTag(tag: string): string {\n return tag.trim().replace(/^#/, \"\").toLowerCase();\n}\n\nfunction tagHierarchy(tag: string): string[] {\n const segments = tag.split(\"/\").filter(Boolean);\n return segments.map((_, index) => segments.slice(0, index + 1).join(\"/\"));\n}\n"],"mappings":";AAAA,SAAS,mBAAmB;;;ACA5B,OAAO,cAAc;AAQrB,IAAM,cAAc;AAIpB,eAAsB,gBACpB,cAC4B;AAC5B,QAAM,QAAS,YAAY,0BAA0B,oBAAI,IAAI;AAC7D,QAAM,SAAS,MAAM,IAAI,YAAY;AACrC,MAAI,OAAQ,QAAO;AAEnB,QAAM,KAAK,IAAI,SAAS,cAAc;AAAA,IACpC,eAAe;AAAA,IACf,UAAU;AAAA,EACZ,CAAC;AACD,KAAG,OAAO,iBAAiB;AAE3B,QAAM,SAA4B;AAAA,IAChC;AAAA,IACA;AAAA,IACA,OAAO,MAAM;AACX,SAAG,MAAM;AACT,YAAM,OAAO,YAAY;AAAA,IAC3B;AAAA,EACF;AACA,QAAM,IAAI,cAAc,MAAM;AAC9B,SAAO;AACT;;;ACnCA,IAAM,gBAAgB;AAEf,SAAS,oBAAoB,OAAuB;AACzD,SAAO,MAAM,QAAQ,QAAQ,GAAG,EAAE,KAAK;AACzC;AAEO,SAAS,YACd,SACA,OACA,YAAY,KACJ;AACR,QAAM,aAAa,oBAAoB,OAAO;AAC9C,MAAI,WAAW,UAAU,UAAW,QAAO;AAE3C,QAAM,YAAY,MACf,YAAY,EACZ,MAAM,aAAa,EACnB,KAAK,CAAC,SAAS,KAAK,SAAS,CAAC;AAEjC,QAAM,aAAa,YACf,WAAW,YAAY,EAAE,QAAQ,SAAS,IAC1C;AACJ,QAAM,SAAS,cAAc,IAAI,aAAa;AAC9C,QAAM,OAAO,KAAK,MAAM,YAAY,CAAC;AACrC,QAAM,QAAQ,KAAK,IAAI,GAAG,SAAS,IAAI;AACvC,QAAM,MAAM,KAAK,IAAI,WAAW,QAAQ,QAAQ,SAAS;AACzD,QAAM,UAAU,WAAW,MAAM,OAAO,GAAG,EAAE,KAAK;AAElD,SAAO,GAAG,QAAQ,IAAI,WAAM,EAAE,GAAG,OAAO,GAAG,MAAM,WAAW,SAAS,WAAM,EAAE;AAC/E;;;ACrBA,IAAM,kBAAkB;AACxB,IAAM,gBAAgB;AAqBf,SAAS,iBACd,QACA,OACA,UAA8B,CAAC,GACf;AAChB,QAAM,aAAa,oBAAoB,KAAK;AAC5C,QAAM,QAAQ,QAAQ,SAAS;AAC/B,QAAM,YAAY;AAAA,IAChB,GAAG,IAAI,KAAK,QAAQ,QAAQ,CAAC,GAAG,IAAI,YAAY,EAAE,OAAO,OAAO,CAAC;AAAA,EACnE;AACA,MAAI,CAAC,cAAc,UAAU,WAAW,EAAG,QAAO,CAAC;AAEnD,MAAI,CAAC,WAAY,QAAO,YAAY,QAAQ,WAAW,KAAK;AAE5D,QAAM,WAAW,WAAW,UAAU;AACtC,MAAI,CAAC,UAAU;AACb,WAAO,UAAU,SAAS,IAAI,YAAY,QAAQ,WAAW,KAAK,IAAI,CAAC;AAAA,EACzE;AAEA,QAAM,YAAY,cAAc,SAAS;AACzC,QAAM,OAAO,OAAO,GACjB;AAAA,IACC;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,QAYE,UAAU,GAAG;AAAA;AAAA;AAAA;AAAA,EAIjB,EACC,IAAI,UAAU,GAAG,UAAU,QAAQ,KAAK;AAE3C,SAAO,KAAK,IAAI,QAAQ;AAC1B;AAEA,SAAS,aAAa,KAAqB;AACzC,SAAO,IAAI,KAAK,EAAE,QAAQ,MAAM,EAAE,EAAE,YAAY;AAClD;AAEA,SAAS,YACP,QACA,MACA,OACgB;AAChB,QAAM,YAAY,cAAc,IAAI;AACpC,MAAI,CAAC,UAAU,IAAK,QAAO,CAAC;AAE5B,QAAM,OAAO,OAAO,GACjB;AAAA,IACC;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,QAWE,UAAU,GAAG;AAAA;AAAA;AAAA;AAAA,EAIjB,EACC,IAAI,GAAG,UAAU,QAAQ,KAAK;AAEjC,SAAO,KAAK,IAAI,QAAQ;AAC1B;AAEA,SAAS,cAAc,MAAmD;AACxE,MAAI,KAAK,WAAW,EAAG,QAAO,EAAE,KAAK,IAAI,QAAQ,CAAC,EAAE;AACpD,SAAO;AAAA,IACL,KAAK;AAAA;AAAA;AAAA;AAAA;AAAA,2BAKkB,KAAK,IAAI,MAAM,GAAG,EAAE,KAAK,IAAI,CAAC;AAAA;AAAA;AAAA,IAGrD,QAAQ;AAAA,EACV;AACF;AAEA,SAAS,SAAS,KAA2C;AAC3D,QAAM,UACJ,yBAAyB,MACpB,IAAI,uBAAuB,KAC5B,IAAI;AACV,SAAO;AAAA,IACL,MAAM,IAAI;AAAA,IACV,OAAO,IAAI;AAAA,IACX,YAAY,iBAAiB,IAAI,qBAAqB,IAAI,KAAK;AAAA,IAC/D,aAAa,IAAI,eAAe;AAAA,IAChC,MAAM,UAAU,IAAI,SAAS;AAAA,IAC7B,cAAc,iBAAiB,OAAO;AAAA,IACtC,OAAO,CAAC,IAAI;AAAA,EACd;AACF;AAEA,SAAS,UAAU,OAAyB;AAC1C,QAAM,SAAS,KAAK,MAAM,KAAK;AAC/B,SAAO,MAAM,QAAQ,MAAM,IACvB,OAAO,OAAO,CAAC,QAAuB,OAAO,QAAQ,QAAQ,IAC7D,CAAC;AACP;AAEA,SAAS,WAAW,OAAmC;AACrD,QAAM,QAAQ,MAAM,MAAM,kBAAkB,KAAK,CAAC;AAClD,QAAM,kBAAkB,MACrB,IAAI,CAAC,SAAS,KAAK,kBAAkB,CAAC,EACtC,OAAO,OAAO;AACjB,MAAI,gBAAgB,WAAW,EAAG;AAElC,SAAO,gBACJ,IAAI,CAAC,SAAU,KAAK,UAAU,IAAI,GAAG,IAAI,MAAM,IAAK,EACpD,KAAK,GAAG;AACb;AAEA,SAAS,iBAAiB,OAAsC;AAC9D,QAAM,aAAa,oBAAoB,KAAK;AAC5C,MAAI,CAAC,WAAY,QAAO,CAAC;AAEzB,QAAM,QAA+B,CAAC;AACtC,MAAI,cAAc;AAClB,aAAW,QAAQ,WAAW;AAAA,IAC5B,IAAI,OAAO,IAAI,eAAe,IAAI,aAAa,GAAG;AAAA,EACpD,GAAG;AACD,QAAI,CAAC,KAAM;AACX,QAAI,SAAS,iBAAiB;AAC5B,oBAAc;AACd;AAAA,IACF;AACA,QAAI,SAAS,eAAe;AAC1B,oBAAc;AACd;AAAA,IACF;AACA,UAAM,KAAK,EAAE,MAAM,MAAM,YAAY,CAAC;AAAA,EACxC;AACA,SAAO;AACT;;;AHnKA,eAAsB,qBACpB,cACA,EAAE,OAAO,WAAW,IAAI,GAAG,aAAa,GACR;AAChC,QAAM,YAAY,YAAY,IAAI;AAClC,QAAM,SAAS,MAAM,gBAAgB,YAAY;AACjD,QAAM,cAAc,iBAAiB,QAAQ,OAAO,YAAY;AAChE,QAAM,SAAS,YAAY,IAAI,IAAI;AAEnC,QAAM,OAAO,KAAK,IAAI,GAAG,QAAQ;AACjC,QAAM,YAAY,YAAY,IAAI;AAClC,WAAS,QAAQ,GAAG,QAAQ,MAAM,SAAS,GAAG;AAC5C,qBAAiB,QAAQ,OAAO,YAAY;AAAA,EAC9C;AACA,QAAM,UAAU,YAAY,IAAI,IAAI,aAAa;AAEjD,MAAI;AACF,WAAO;AAAA,MACL,QAAQ,MAAM,MAAM;AAAA,MACpB,QAAQ,MAAM,MAAM;AAAA,MACpB,UAAU;AAAA,MACV,aAAa,YAAY;AAAA,IAC3B;AAAA,EACF,UAAE;AACA,WAAO,MAAM;AAAA,EACf;AACF;AAEA,SAAS,MAAM,OAAuB;AACpC,SAAO,KAAK,MAAM,QAAQ,GAAG,IAAI;AACnC;;;AI/CA,OAAO,QAAQ;AACf,OAAO,UAAU;AACjB,OAAOA,eAAc;AAId,IAAM,2BAA2B;AAExC,eAAsB,oBACpB,SACA,cACiC;AACjC,QAAM,GAAG,MAAM,KAAK,QAAQ,YAAY,GAAG,EAAE,WAAW,KAAK,CAAC;AAC9D,QAAM,oBAAoB,YAAY;AAEtC,QAAM,KAAK,IAAIC,UAAS,YAAY;AACpC,MAAI;AACF,OAAG,OAAO,uBAAuB;AACjC,OAAG,OAAO,mBAAmB;AAC7B,OAAG,KAAK;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,KAgCP;AAED,UAAM,WAAU,oBAAI,KAAK,GAAE,YAAY;AACvC,UAAM,iBAAiB,GAAG;AAAA,MACxB;AAAA,IACF;AACA,UAAM,iBAAiB,GAAG,QAAQ;AAAA;AAAA;AAAA,KAGjC;AACD,UAAM,eAAe,GAAG,QAAQ;AAAA;AAAA;AAAA,KAG/B;AACD,UAAM,YAAY,GAAG,QAAQ;AAAA;AAAA;AAAA,KAG5B;AAED,UAAM,gBAAgB,GAAG,YAAY,CAAC,UAA0B;AAC9D,iBAAW,UAAU,OAAO;AAC1B,cAAM,OAAO,OAAO,KAAK,IAAIC,aAAY,EAAE,OAAO,OAAO;AACzD,cAAM,SAAS,eAAe,IAAI;AAAA,UAChC,IAAI,OAAO;AAAA,UACX,MAAM,OAAO;AAAA,UACb,OAAO,OAAO;AAAA,UACd,aAAa,OAAO;AAAA,UACpB,UAAU,KAAK,UAAU,OAAO,IAAI;AAAA,UACpC,SAAS;AAAA,YACP,OAAO;AAAA,YACP,OAAO,eAAe,OAAO;AAAA,UAC/B;AAAA,QACF,CAAC;AACD,cAAM,QAAQ,OAAO,OAAO,eAAe;AAC3C,qBAAa,IAAI,OAAO,OAAO,OAAO,OAAO,SAAS,KAAK,KAAK,GAAG,CAAC;AACpE,mBAAW,OAAO,MAAM;AACtB,qBAAW,gBAAgB,aAAa,GAAG,GAAG;AAC5C,sBAAU,IAAI,OAAO,YAAY;AAAA,UACnC;AAAA,QACF;AAAA,MACF;AAEA,qBAAe,IAAI,WAAW,GAAG;AACjC,qBAAe,IAAI,WAAW,OAAO;AACrC,qBAAe,IAAI,eAAe,OAAO,MAAM,MAAM,CAAC;AAAA,IACxD,CAAC;AACD,kBAAc,OAAO;AAErB,OAAG,QAAQ,kDAAkD,EAAE;AAAA,MAC7D;AAAA,IACF;AACA,OAAG,KAAK,QAAQ;AAEhB,WAAO;AAAA,MACL,SAAS;AAAA,MACT;AAAA,MACA,aAAa,QAAQ;AAAA,MACrB;AAAA,IACF;AAAA,EACF,UAAE;AACA,OAAG,MAAM;AAAA,EACX;AACF;AAEA,eAAe,oBAAoB,cAAqC;AACtE,QAAM,QAAQ;AAAA,IACZ,CAAC,IAAI,YAAY,QAAQ,MAAM,EAAE;AAAA,MAAI,CAAC,WACpC,GAAG,GAAG,GAAG,YAAY,GAAG,MAAM,IAAI,EAAE,OAAO,KAAK,CAAC;AAAA,IACnD;AAAA,EACF;AACF;AAEA,SAASA,cAAa,KAAqB;AACzC,SAAO,IAAI,KAAK,EAAE,QAAQ,MAAM,EAAE,EAAE,YAAY;AAClD;AAEA,SAAS,aAAa,KAAuB;AAC3C,QAAM,WAAW,IAAI,MAAM,GAAG,EAAE,OAAO,OAAO;AAC9C,SAAO,SAAS,IAAI,CAAC,GAAG,UAAU,SAAS,MAAM,GAAG,QAAQ,CAAC,EAAE,KAAK,GAAG,CAAC;AAC1E;","names":["Database","Database","normalizeTag"]}
|
|
1
|
+
{"version":3,"sources":["../src/benchmark.ts","../src/load.ts","../src/excerpt.ts","../src/query.ts","../src/build.ts"],"sourcesContent":["import { performance } from \"node:perf_hooks\";\nimport { loadSearchIndex } from \"./load.js\";\nimport { querySearchIndex } from \"./query.js\";\nimport type { SearchQueryOptions } from \"./types.js\";\n\nexport type SearchBenchmarkOptions = SearchQueryOptions & {\n query: string;\n warmRuns?: number;\n};\n\nexport type SearchBenchmarkResult = {\n coldMs: number;\n warmMs: number;\n warmRuns: number;\n resultCount: number;\n};\n\nexport async function benchmarkSearchIndex(\n databasePath: string,\n { query, warmRuns = 10, ...queryOptions }: SearchBenchmarkOptions,\n): Promise<SearchBenchmarkResult> {\n const coldStart = performance.now();\n const loaded = await loadSearchIndex(databasePath);\n const coldResults = querySearchIndex(loaded, query, queryOptions);\n const coldMs = performance.now() - coldStart;\n\n const runs = Math.max(1, warmRuns);\n const warmStart = performance.now();\n for (let index = 0; index < runs; index += 1) {\n querySearchIndex(loaded, query, queryOptions);\n }\n const warmMs = (performance.now() - warmStart) / runs;\n\n try {\n return {\n coldMs: round(coldMs),\n warmMs: round(warmMs),\n warmRuns: runs,\n resultCount: coldResults.length,\n };\n } finally {\n loaded.close();\n }\n}\n\nfunction round(value: number): number {\n return Math.round(value * 100) / 100;\n}\n","import Database from \"better-sqlite3\";\n\nexport type LoadedSearchIndex = {\n databasePath: string;\n db: Database.Database;\n close: () => void;\n};\n\nconst globalCache = globalThis as typeof globalThis & {\n __silicaSearchIndexes?: Map<string, LoadedSearchIndex>;\n};\n\nexport async function loadSearchIndex(\n databasePath: string,\n): Promise<LoadedSearchIndex> {\n const cache = (globalCache.__silicaSearchIndexes ??= new Map());\n const cached = cache.get(databasePath);\n if (cached) return cached;\n\n const db = new Database(databasePath, {\n fileMustExist: true,\n readonly: true,\n });\n db.pragma(\"query_only = ON\");\n\n const loaded: LoadedSearchIndex = {\n databasePath,\n db,\n close: () => {\n db.close();\n cache.delete(databasePath);\n },\n };\n cache.set(databasePath, loaded);\n return loaded;\n}\n","const WORD_BOUNDARY = /\\s+/g;\n\nexport function normalizeSearchText(value: string): string {\n return value.replace(/\\s+/g, \" \").trim();\n}\n\nexport function makeExcerpt(\n content: string,\n query: string,\n maxLength = 180,\n): string {\n const normalized = normalizeSearchText(content);\n if (normalized.length <= maxLength) return normalized;\n\n const firstTerm = query\n .toLowerCase()\n .split(WORD_BOUNDARY)\n .find((term) => term.length > 1);\n\n const matchIndex = firstTerm\n ? normalized.toLowerCase().indexOf(firstTerm)\n : -1;\n const center = matchIndex >= 0 ? matchIndex : 0;\n const half = Math.floor(maxLength / 2);\n const start = Math.max(0, center - half);\n const end = Math.min(normalized.length, start + maxLength);\n const excerpt = normalized.slice(start, end).trim();\n\n return `${start > 0 ? \"…\" : \"\"}${excerpt}${end < normalized.length ? \"…\" : \"\"}`;\n}\n","import { normalizeSearchText } from \"./excerpt.js\";\nimport type { LoadedSearchIndex } from \"./load.js\";\nimport type {\n SearchHighlightPart,\n SearchQueryOptions,\n SearchResult,\n} from \"./types.js\";\n\nconst HIGHLIGHT_START = \"\\uE000\";\nconst HIGHLIGHT_END = \"\\uE001\";\n\ntype SearchRow = {\n slug: string;\n title: string;\n highlighted_title: string | null;\n description: string | null;\n tags_json: string;\n highlighted_excerpt: string | null;\n score: number;\n};\n\ntype TagOnlyRow = Omit<\n SearchRow,\n \"highlighted_title\" | \"highlighted_excerpt\" | \"score\"\n> & {\n highlighted_title: null;\n excerpt: string;\n score: 0;\n};\n\nexport function querySearchIndex(\n loaded: LoadedSearchIndex,\n query: string,\n options: SearchQueryOptions = {},\n): SearchResult[] {\n const normalized = normalizeSearchText(query);\n const limit = options.limit ?? 10;\n const tagFilter = [\n ...new Set((options.tags ?? []).map(normalizeTag).filter(Boolean)),\n ];\n if (!normalized && tagFilter.length === 0) return [];\n\n if (!normalized) return queryByTags(loaded, tagFilter, limit);\n\n const ftsQuery = toFtsQuery(normalized);\n if (!ftsQuery) {\n return tagFilter.length > 0 ? queryByTags(loaded, tagFilter, limit) : [];\n }\n\n const tagClause = makeTagClause(tagFilter);\n const rows = loaded.db\n .prepare(\n `\n SELECT\n n.slug,\n n.title,\n highlight(search_index, 0, char(57344), char(57345)) AS highlighted_title,\n n.description,\n n.tags_json,\n snippet(search_index, -1, char(57344), char(57345), '…', 24) AS highlighted_excerpt,\n bm25(search_index, 8.0, 1.0, 3.0) AS score\n FROM search_index\n JOIN notes n ON n.rowid = search_index.rowid\n WHERE search_index MATCH ?\n ${tagClause.sql}\n ORDER BY score ASC, n.title COLLATE NOCASE ASC\n LIMIT ?\n `,\n )\n .all(ftsQuery, ...tagClause.params, limit) as SearchRow[];\n\n return rows.map(toResult);\n}\n\nfunction normalizeTag(tag: string): string {\n return tag.trim().replace(/^#/, \"\").toLowerCase();\n}\n\nfunction queryByTags(\n loaded: LoadedSearchIndex,\n tags: string[],\n limit: number,\n): SearchResult[] {\n const tagClause = makeTagClause(tags);\n if (!tagClause.sql) return [];\n\n const rows = loaded.db\n .prepare(\n `\n SELECT\n n.slug,\n n.title,\n NULL AS highlighted_title,\n n.description,\n n.tags_json,\n n.search_excerpt AS excerpt,\n 0 AS score\n FROM notes n\n WHERE 1 = 1\n ${tagClause.sql}\n ORDER BY n.title COLLATE NOCASE ASC\n LIMIT ?\n `,\n )\n .all(...tagClause.params, limit) as TagOnlyRow[];\n\n return rows.map(toResult);\n}\n\nfunction makeTagClause(tags: string[]): { sql: string; params: string[] } {\n if (tags.length === 0) return { sql: \"\", params: [] };\n return {\n sql: `\n AND EXISTS (\n SELECT 1\n FROM note_tags nt\n WHERE nt.slug = n.slug\n AND nt.tag IN (${tags.map(() => \"?\").join(\", \")})\n )\n `,\n params: tags,\n };\n}\n\nfunction toResult(row: SearchRow | TagOnlyRow): SearchResult {\n const excerpt =\n \"highlighted_excerpt\" in row\n ? (row.highlighted_excerpt ?? \"\")\n : row.excerpt;\n return {\n slug: row.slug,\n title: row.title,\n titleParts: toHighlightParts(row.highlighted_title ?? row.title),\n description: row.description ?? undefined,\n tags: parseTags(row.tags_json),\n excerptParts: toHighlightParts(excerpt),\n score: -row.score,\n };\n}\n\nfunction parseTags(value: string): string[] {\n return JSON.parse(value) as string[];\n}\n\nfunction toFtsQuery(query: string): string | undefined {\n const terms = query.match(/[\\p{L}\\p{N}_]+/gu) ?? [];\n const normalizedTerms = terms\n .map((term) => term.toLocaleLowerCase())\n .filter(Boolean);\n if (normalizedTerms.length === 0) return;\n\n return normalizedTerms\n .map((term) => (term.length >= 3 ? `${term}*` : term))\n .join(\" \");\n}\n\nfunction toHighlightParts(value: string): SearchHighlightPart[] {\n const normalized = normalizeSearchText(value);\n if (!normalized) return [];\n\n const parts: SearchHighlightPart[] = [];\n let highlighted = false;\n for (const part of normalized.split(\n new RegExp(`(${HIGHLIGHT_START}|${HIGHLIGHT_END})`),\n )) {\n if (!part) continue;\n if (part === HIGHLIGHT_START) {\n highlighted = true;\n continue;\n }\n if (part === HIGHLIGHT_END) {\n highlighted = false;\n continue;\n }\n parts.push({ text: part, highlighted });\n }\n return parts;\n}\n","import fs from \"node:fs/promises\";\nimport path from \"node:path\";\nimport Database from \"better-sqlite3\";\nimport { makeExcerpt } from \"./excerpt.js\";\nimport type { SearchDatabaseMetadata, SearchRecord } from \"./types.js\";\n\nexport const SEARCH_DATABASE_FILENAME = \"search.db\";\n\nexport async function buildSearchDatabase(\n records: SearchRecord[],\n databasePath: string,\n): Promise<SearchDatabaseMetadata> {\n await fs.mkdir(path.dirname(databasePath), { recursive: true });\n await removeDatabaseFiles(databasePath);\n\n const db = new Database(databasePath);\n try {\n db.pragma(\"journal_mode = DELETE\");\n db.pragma(\"synchronous = OFF\");\n createStandaloneNotesSchema(db);\n const builtAt = buildSearchTables(db, records);\n db.exec(\"VACUUM\");\n\n return {\n version: 1,\n databasePath,\n recordCount: records.length,\n builtAt,\n };\n } finally {\n db.close();\n }\n}\n\nexport function buildSearchTables(\n db: Database.Database,\n records: SearchRecord[],\n): string {\n db.exec(`\n DROP TABLE IF EXISTS search_index;\n CREATE VIRTUAL TABLE search_index USING fts5(\n title,\n content,\n tags,\n tokenize='porter unicode61',\n prefix='3'\n );\n `);\n\n const builtAt = new Date().toISOString();\n const upsertMetadata = db.prepare(`\n INSERT INTO vault_metadata (key, value) VALUES (?, ?)\n ON CONFLICT(key) DO UPDATE SET value = excluded.value\n `);\n const insertNote = db.prepare(`\n INSERT INTO notes (\n slug,\n file,\n relative_file,\n title,\n menu_label,\n description,\n generated_description,\n frontmatter_json,\n tags_json,\n search_excerpt,\n listed,\n content_hash,\n render_hash,\n prerender\n )\n VALUES (\n @slug,\n @file,\n @relativeFile,\n @title,\n @menuLabel,\n @description,\n NULL,\n '{}',\n @tagsJson,\n @excerpt,\n 1,\n @contentHash,\n @renderHash,\n 1\n )\n ON CONFLICT(slug) DO UPDATE SET\n title = excluded.title,\n menu_label = excluded.menu_label,\n description = excluded.description,\n tags_json = excluded.tags_json,\n search_excerpt = excluded.search_excerpt\n `);\n const selectNoteRowid = db.prepare(\n \"SELECT rowid AS rowid FROM notes WHERE slug = ?\",\n );\n const insertSearch = db.prepare(`\n INSERT INTO search_index (rowid, title, content, tags)\n VALUES (?, ?, ?, ?)\n `);\n const insertTag = db.prepare(`\n INSERT OR IGNORE INTO note_tags (slug, tag)\n VALUES (?, ?)\n `);\n\n const insertRecords = db.transaction((items: SearchRecord[]) => {\n for (const record of items) {\n const tags = record.tags.map(normalizeTag).filter(Boolean);\n const excerpt = makeExcerpt(\n record.content,\n record.description ?? record.title,\n );\n insertNote.run({\n slug: record.slug,\n file: \"\",\n relativeFile: \"\",\n title: record.title,\n menuLabel: record.title,\n description: record.description,\n tagsJson: JSON.stringify(record.tags),\n excerpt,\n contentHash: record.id,\n renderHash: record.id,\n });\n const row = selectNoteRowid.get(record.slug) as { rowid: number };\n insertSearch.run(row.rowid, record.title, record.content, tags.join(\" \"));\n for (const tag of tags) {\n for (const hierarchyTag of tagHierarchy(tag)) {\n insertTag.run(record.slug, hierarchyTag);\n }\n }\n }\n\n upsertMetadata.run(\"searchVersion\", \"1\");\n upsertMetadata.run(\"searchBuiltAt\", builtAt);\n upsertMetadata.run(\"searchRecordCount\", String(items.length));\n });\n insertRecords(records);\n\n db.prepare(\"INSERT INTO search_index(search_index) VALUES(?)\").run(\n \"optimize\",\n );\n return builtAt;\n}\n\nasync function removeDatabaseFiles(databasePath: string): Promise<void> {\n await Promise.all(\n [\"\", \"-journal\", \"-shm\", \"-wal\"].map((suffix) =>\n fs.rm(`${databasePath}${suffix}`, { force: true }),\n ),\n );\n}\n\nfunction normalizeTag(tag: string): string {\n return tag.trim().replace(/^#/, \"\").toLowerCase();\n}\n\nfunction tagHierarchy(tag: string): string[] {\n const segments = tag.split(\"/\").filter(Boolean);\n return segments.map((_, index) => segments.slice(0, index + 1).join(\"/\"));\n}\n\nfunction createStandaloneNotesSchema(db: Database.Database): void {\n db.exec(`\n CREATE TABLE vault_metadata (\n key TEXT PRIMARY KEY,\n value TEXT NOT NULL\n );\n\n CREATE TABLE notes (\n slug TEXT PRIMARY KEY,\n file TEXT NOT NULL,\n relative_file TEXT NOT NULL,\n title TEXT NOT NULL,\n menu_label TEXT NOT NULL,\n description TEXT,\n generated_description TEXT,\n frontmatter_json TEXT NOT NULL,\n tags_json TEXT NOT NULL,\n search_excerpt TEXT NOT NULL DEFAULT '',\n created TEXT,\n modified TEXT,\n sort_key TEXT,\n listed INTEGER NOT NULL,\n content_hash TEXT NOT NULL,\n render_hash TEXT NOT NULL,\n prerender INTEGER NOT NULL\n );\n\n CREATE TABLE note_tags (\n slug TEXT NOT NULL,\n tag TEXT NOT NULL,\n PRIMARY KEY (slug, tag),\n FOREIGN KEY (slug) REFERENCES notes(slug) ON DELETE CASCADE\n );\n\n CREATE INDEX note_tags_tag_idx ON note_tags(tag, slug);\n `);\n}\n"],"mappings":"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","names":["Database","Database","normalizeTag"]}
|