@nomideusz/svelte-search 0.1.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/CHANGELOG.md +18 -0
- package/README.md +295 -0
- package/dist/core/engine.d.ts +25 -0
- package/dist/core/engine.js +384 -0
- package/dist/core/geo.d.ts +24 -0
- package/dist/core/geo.js +54 -0
- package/dist/core/indexer.d.ts +60 -0
- package/dist/core/indexer.js +302 -0
- package/dist/core/normalize.d.ts +30 -0
- package/dist/core/normalize.js +128 -0
- package/dist/core/resolver.d.ts +41 -0
- package/dist/core/resolver.js +107 -0
- package/dist/core/track.d.ts +10 -0
- package/dist/core/track.js +40 -0
- package/dist/core/types.d.ts +206 -0
- package/dist/core/types.js +6 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +14 -0
- package/dist/locales/pl.d.ts +11 -0
- package/dist/locales/pl.js +266 -0
- package/package.json +66 -0
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// @nomideusz/svelte-search — Indexer
|
|
3
|
+
// ============================================================
|
|
4
|
+
// Generic trigram indexing and FTS maintenance, driven by SchemaAdapter.
|
|
5
|
+
//
|
|
6
|
+
// Dialect support:
|
|
7
|
+
// - 'sqlite' (default): custom trigram tables, FTS5 rebuild
|
|
8
|
+
// - 'postgres': pg_trgm extension (no custom trigram tables needed),
|
|
9
|
+
// tsvector column update
|
|
10
|
+
import { normalize as normalizeText, trigrams } from './normalize.js';
|
|
11
|
+
// ── Create indexer ─────────────────────────────────────────
|
|
12
|
+
export function createIndexer(config) {
|
|
13
|
+
const { db, adapter, locale, dialect = 'sqlite' } = config;
|
|
14
|
+
const { tables, columns, trigramColumns } = adapter;
|
|
15
|
+
// ── SQLite trigram indexing ─────────────────────────────
|
|
16
|
+
/**
|
|
17
|
+
* Index trigrams for a single entity (SQLite only).
|
|
18
|
+
* For PostgreSQL, pg_trgm handles this automatically via GIN indexes.
|
|
19
|
+
*/
|
|
20
|
+
async function indexTrigrams(entityId, entity) {
|
|
21
|
+
if (dialect === 'postgres')
|
|
22
|
+
return; // pg_trgm handles this
|
|
23
|
+
await db.execute({
|
|
24
|
+
sql: `DELETE FROM ${tables.trigrams} WHERE ${trigramColumns.entityId} = ?`,
|
|
25
|
+
args: [entityId],
|
|
26
|
+
});
|
|
27
|
+
const entries = [];
|
|
28
|
+
for (const { text, field } of adapter.trigramFields(entity)) {
|
|
29
|
+
if (!text)
|
|
30
|
+
continue;
|
|
31
|
+
for (const t of trigrams(text, locale)) {
|
|
32
|
+
entries.push({ trigram: t, field });
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
const BATCH = 100;
|
|
36
|
+
for (let i = 0; i < entries.length; i += BATCH) {
|
|
37
|
+
const chunk = entries.slice(i, i + BATCH);
|
|
38
|
+
const ph = chunk.map(() => '(?,?,?)').join(',');
|
|
39
|
+
const args = chunk.flatMap(e => [e.trigram, entityId, e.field]);
|
|
40
|
+
await db.execute({
|
|
41
|
+
sql: `INSERT OR IGNORE INTO ${tables.trigrams} (${trigramColumns.trigram}, ${trigramColumns.entityId}, ${trigramColumns.field}) VALUES ${ph}`,
|
|
42
|
+
args,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
/** Rebuild all trigrams from scratch (SQLite only). */
|
|
47
|
+
async function reindexAllTrigrams() {
|
|
48
|
+
if (dialect === 'postgres')
|
|
49
|
+
return 0; // pg_trgm handles this
|
|
50
|
+
const result = await db.execute({ sql: `SELECT * FROM ${tables.entities}`, args: [] });
|
|
51
|
+
let count = 0;
|
|
52
|
+
for (const row of result.rows) {
|
|
53
|
+
const id = row[columns.id];
|
|
54
|
+
await indexTrigrams(id, row);
|
|
55
|
+
count++;
|
|
56
|
+
}
|
|
57
|
+
return count;
|
|
58
|
+
}
|
|
59
|
+
/** Rebuild FTS index. */
|
|
60
|
+
async function rebuildFts() {
|
|
61
|
+
if (dialect === 'postgres') {
|
|
62
|
+
// PostgreSQL: no separate FTS rebuild needed — tsvector column is maintained
|
|
63
|
+
// by triggers or updated via updateSearchVector()
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
// SQLite FTS5 rebuild
|
|
67
|
+
await db.execute({
|
|
68
|
+
sql: `INSERT INTO ${tables.fts}(${tables.fts}) VALUES('rebuild')`,
|
|
69
|
+
args: [],
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
/** Check if FTS index is in sync with entities table. */
|
|
73
|
+
async function checkFtsSync() {
|
|
74
|
+
if (dialect === 'postgres') {
|
|
75
|
+
// PostgreSQL: check for entities with NULL search vectors
|
|
76
|
+
const tsCol = tables.fts;
|
|
77
|
+
const [entityCount, missingCount] = await Promise.all([
|
|
78
|
+
db.execute({ sql: `SELECT COUNT(*) as c FROM ${tables.entities}`, args: [] }),
|
|
79
|
+
db.execute({ sql: `SELECT COUNT(*) as c FROM ${tables.entities} WHERE ${tsCol} IS NULL`, args: [] }),
|
|
80
|
+
]);
|
|
81
|
+
const total = Number(entityCount.rows[0].c);
|
|
82
|
+
const missing = Number(missingCount.rows[0].c);
|
|
83
|
+
return {
|
|
84
|
+
inEntities: total,
|
|
85
|
+
inFts: total - missing,
|
|
86
|
+
missingFromFts: missing,
|
|
87
|
+
orphanedInFts: 0,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
// SQLite FTS5 sync check
|
|
91
|
+
const [entityCount, ftsCount, missing, orphaned] = await Promise.all([
|
|
92
|
+
db.execute({ sql: `SELECT COUNT(*) as c FROM ${tables.entities}`, args: [] }),
|
|
93
|
+
db.execute({ sql: `SELECT COUNT(*) as c FROM ${tables.fts}`, args: [] }),
|
|
94
|
+
db.execute({ sql: `SELECT COUNT(*) as c FROM ${tables.entities} s LEFT JOIN ${tables.fts} f ON s.rowid = f.rowid WHERE f.rowid IS NULL`, args: [] }),
|
|
95
|
+
db.execute({ sql: `SELECT COUNT(*) as c FROM ${tables.fts} f LEFT JOIN ${tables.entities} s ON f.rowid = s.rowid WHERE s.rowid IS NULL`, args: [] }),
|
|
96
|
+
]);
|
|
97
|
+
return {
|
|
98
|
+
inEntities: Number(entityCount.rows[0].c),
|
|
99
|
+
inFts: Number(ftsCount.rows[0].c),
|
|
100
|
+
missingFromFts: Number(missing.rows[0].c),
|
|
101
|
+
orphanedInFts: Number(orphaned.rows[0].c),
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Update the tsvector search column for a single entity (PostgreSQL only).
|
|
106
|
+
* Call after inserting/updating an entity.
|
|
107
|
+
*
|
|
108
|
+
* @param entityId - Primary key value
|
|
109
|
+
* @param searchText - Concatenated text to index (name + description + location + categories etc.)
|
|
110
|
+
* @param tsConfig - PostgreSQL text search config (default: 'simple')
|
|
111
|
+
*/
|
|
112
|
+
async function updateSearchVector(entityId, searchText, tsConfig = 'simple') {
|
|
113
|
+
if (dialect !== 'postgres')
|
|
114
|
+
return;
|
|
115
|
+
const tsCol = tables.fts;
|
|
116
|
+
await db.execute({
|
|
117
|
+
sql: `UPDATE ${tables.entities} SET ${tsCol} = to_tsvector($1, $2) WHERE ${columns.id} = $3`,
|
|
118
|
+
args: [tsConfig, searchText, entityId],
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Rebuild all search vectors (PostgreSQL only).
|
|
123
|
+
* Uses the adapter's trigramFields to build search text.
|
|
124
|
+
*/
|
|
125
|
+
async function rebuildAllSearchVectors(tsConfig = 'simple') {
|
|
126
|
+
if (dialect !== 'postgres')
|
|
127
|
+
return 0;
|
|
128
|
+
const result = await db.execute({ sql: `SELECT * FROM ${tables.entities}`, args: [] });
|
|
129
|
+
let count = 0;
|
|
130
|
+
for (const row of result.rows) {
|
|
131
|
+
const id = row[columns.id];
|
|
132
|
+
const textParts = adapter.trigramFields(row).map(f => f.text).filter(Boolean);
|
|
133
|
+
const searchText = textParts.join(' ');
|
|
134
|
+
await updateSearchVector(id, searchText, tsConfig);
|
|
135
|
+
count++;
|
|
136
|
+
}
|
|
137
|
+
return count;
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Re-derive all normalized columns from source data.
|
|
141
|
+
* Call after changing the normalize() function or locale.
|
|
142
|
+
*/
|
|
143
|
+
async function renormalizeAll(updateRow) {
|
|
144
|
+
const result = await db.execute({ sql: `SELECT * FROM ${tables.entities}`, args: [] });
|
|
145
|
+
let count = 0;
|
|
146
|
+
for (const row of result.rows) {
|
|
147
|
+
await updateRow(db, row);
|
|
148
|
+
if (dialect === 'sqlite') {
|
|
149
|
+
const id = row[columns.id];
|
|
150
|
+
await indexTrigrams(id, row);
|
|
151
|
+
}
|
|
152
|
+
count++;
|
|
153
|
+
}
|
|
154
|
+
if (dialect === 'sqlite') {
|
|
155
|
+
await rebuildFts();
|
|
156
|
+
}
|
|
157
|
+
else {
|
|
158
|
+
await rebuildAllSearchVectors();
|
|
159
|
+
}
|
|
160
|
+
return count;
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Set up PostgreSQL extensions and indexes needed for search.
|
|
164
|
+
* Call once during schema setup / migration.
|
|
165
|
+
*/
|
|
166
|
+
async function setupPostgresExtensions() {
|
|
167
|
+
if (dialect !== 'postgres')
|
|
168
|
+
return;
|
|
169
|
+
// Enable pg_trgm extension
|
|
170
|
+
await db.execute({ sql: 'CREATE EXTENSION IF NOT EXISTS pg_trgm', args: [] });
|
|
171
|
+
// Create GIN index on the tsvector column for full-text search
|
|
172
|
+
const tsCol = tables.fts;
|
|
173
|
+
await db.execute({
|
|
174
|
+
sql: `CREATE INDEX IF NOT EXISTS idx_${tables.entities}_fts ON ${tables.entities} USING GIN (${tsCol})`,
|
|
175
|
+
args: [],
|
|
176
|
+
});
|
|
177
|
+
// Create GIN trigram indexes on normalized text columns
|
|
178
|
+
const trgmCols = [columns.nameNormalized];
|
|
179
|
+
if (columns.categoriesNormalized)
|
|
180
|
+
trgmCols.push(columns.categoriesNormalized);
|
|
181
|
+
if (columns.locationNormalized)
|
|
182
|
+
trgmCols.push(columns.locationNormalized);
|
|
183
|
+
for (const col of trgmCols) {
|
|
184
|
+
await db.execute({
|
|
185
|
+
sql: `CREATE INDEX IF NOT EXISTS idx_${tables.entities}_${col}_trgm ON ${tables.entities} USING GIN (${col} gin_trgm_ops)`,
|
|
186
|
+
args: [],
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return {
|
|
191
|
+
indexTrigrams,
|
|
192
|
+
reindexAllTrigrams,
|
|
193
|
+
rebuildFts,
|
|
194
|
+
checkFtsSync,
|
|
195
|
+
updateSearchVector,
|
|
196
|
+
rebuildAllSearchVectors,
|
|
197
|
+
renormalizeAll,
|
|
198
|
+
setupPostgresExtensions,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
export function createLookupsLoader(config) {
|
|
202
|
+
let _cached = null;
|
|
203
|
+
let _cacheTime = 0;
|
|
204
|
+
let _pending = null;
|
|
205
|
+
const ttl = config.cacheTtlMs ?? 5 * 60 * 1000;
|
|
206
|
+
async function load() {
|
|
207
|
+
const now = Date.now();
|
|
208
|
+
if (_cached && (now - _cacheTime) < ttl)
|
|
209
|
+
return _cached;
|
|
210
|
+
if (_pending)
|
|
211
|
+
return _pending;
|
|
212
|
+
_pending = reload();
|
|
213
|
+
try {
|
|
214
|
+
return await _pending;
|
|
215
|
+
}
|
|
216
|
+
finally {
|
|
217
|
+
_pending = null;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
async function reload() {
|
|
221
|
+
const { db, locale } = config;
|
|
222
|
+
// Locations
|
|
223
|
+
const locationMap = new Map();
|
|
224
|
+
const locationEntityCount = new Map();
|
|
225
|
+
const locationGeo = new Map();
|
|
226
|
+
const locRows = await db.execute({ sql: config.locations.sql, args: [] });
|
|
227
|
+
for (const row of locRows.rows) {
|
|
228
|
+
locationMap.set(row[config.locations.nameNormalizedCol], row[config.locations.slugCol]);
|
|
229
|
+
if (config.locations.countCol) {
|
|
230
|
+
locationEntityCount.set(row[config.locations.slugCol], row[config.locations.countCol] ?? 0);
|
|
231
|
+
}
|
|
232
|
+
if (config.locations.latCol && config.locations.lngCol && config.locations.nameCol) {
|
|
233
|
+
const lat = row[config.locations.latCol];
|
|
234
|
+
const lng = row[config.locations.lngCol];
|
|
235
|
+
if (lat != null && lng != null) {
|
|
236
|
+
locationGeo.set(row[config.locations.slugCol], {
|
|
237
|
+
lat, lng, name: row[config.locations.nameCol],
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
// Categories
|
|
243
|
+
const categoryMap = new Map();
|
|
244
|
+
const catRows = await db.execute({ sql: config.categories.sql, args: [] });
|
|
245
|
+
for (const row of catRows.rows) {
|
|
246
|
+
const nameN = row[config.categories.nameNormalizedCol];
|
|
247
|
+
const slug = row[config.categories.slugCol];
|
|
248
|
+
categoryMap.set(nameN, slug);
|
|
249
|
+
for (const word of nameN.split(/\s+/)) {
|
|
250
|
+
if (word && word.length > 2 && !categoryMap.has(word)) {
|
|
251
|
+
categoryMap.set(word, slug);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
if (config.categories.aliasesNormalizedCol) {
|
|
255
|
+
const aliasesN = row[config.categories.aliasesNormalizedCol] || '';
|
|
256
|
+
for (const alias of aliasesN.split(/\s+/)) {
|
|
257
|
+
if (alias)
|
|
258
|
+
categoryMap.set(alias, slug);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
// Synonym expansion
|
|
263
|
+
if (config.synonymsSql) {
|
|
264
|
+
const synRows = await db.execute({ sql: config.synonymsSql, args: [] });
|
|
265
|
+
for (const row of synRows.rows) {
|
|
266
|
+
const alias = normalize(row.alias, locale);
|
|
267
|
+
const canonical = normalize(row.canonical, locale);
|
|
268
|
+
const category = row.category;
|
|
269
|
+
if (category === 'city' || category === 'location') {
|
|
270
|
+
const slug = locationMap.get(canonical);
|
|
271
|
+
if (slug)
|
|
272
|
+
locationMap.set(alias, slug);
|
|
273
|
+
}
|
|
274
|
+
else if (category === 'style' || category === 'category') {
|
|
275
|
+
const slug = categoryMap.get(canonical);
|
|
276
|
+
if (slug)
|
|
277
|
+
categoryMap.set(alias, slug);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
// Areas/districts
|
|
282
|
+
const areaMap = new Map();
|
|
283
|
+
if (config.areas) {
|
|
284
|
+
const areaRows = await db.execute({ sql: config.areas.sql, args: [] });
|
|
285
|
+
for (const row of areaRows.rows) {
|
|
286
|
+
const locSlug = row[config.areas.locationSlugCol];
|
|
287
|
+
const areasJson = row[config.areas.areasJsonCol] || '[]';
|
|
288
|
+
const areas = JSON.parse(areasJson);
|
|
289
|
+
areaMap.set(locSlug, areas.map(a => normalize(a, locale)));
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
_cached = { locationMap, categoryMap, areaMap, locationEntityCount, locationGeo };
|
|
293
|
+
_cacheTime = Date.now();
|
|
294
|
+
return _cached;
|
|
295
|
+
}
|
|
296
|
+
function invalidate() {
|
|
297
|
+
_cached = null;
|
|
298
|
+
_cacheTime = 0;
|
|
299
|
+
}
|
|
300
|
+
return { load, invalidate };
|
|
301
|
+
}
|
|
302
|
+
const normalize = normalizeText;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { SearchLocale } from './types.js';
|
|
2
|
+
/** Strip diacritics using Unicode NFD decomposition (generic, all languages). */
|
|
3
|
+
export declare function stripDiacriticsGeneric(text: string): string;
|
|
4
|
+
/** Full normalization: lowercase + strip diacritics + collapse whitespace. */
|
|
5
|
+
export declare function normalize(text: string, locale?: SearchLocale): string;
|
|
6
|
+
/** Generate trigrams from text. "hatha" → ["hat","ath","tha"] */
|
|
7
|
+
export declare function trigrams(text: string, locale?: SearchLocale): string[];
|
|
8
|
+
/** Trigram similarity (Jaccard coefficient). 0..1, higher = more similar. */
|
|
9
|
+
export declare function trigramSimilarity(a: string, b: string, locale?: SearchLocale): number;
|
|
10
|
+
/** Levenshtein distance between two strings. */
|
|
11
|
+
export declare function levenshtein(a: string, b: string): number;
|
|
12
|
+
/** Normalized Levenshtein similarity. 0..1, higher = more similar. */
|
|
13
|
+
export declare function levenshteinSimilarity(a: string, b: string, locale?: SearchLocale): number;
|
|
14
|
+
/** Detect postcode format: XX-XXX or XXXXX (Polish default, override via locale). */
|
|
15
|
+
export declare function isPostcode(text: string): boolean;
|
|
16
|
+
/** Detect "near me" intent using locale geo patterns. */
|
|
17
|
+
export declare function hasGeoIntent(query: string, locale?: SearchLocale): boolean;
|
|
18
|
+
/** Remove geo-intent phrases from query. */
|
|
19
|
+
export declare function stripGeoIntent(query: string, locale?: SearchLocale): string;
|
|
20
|
+
/**
|
|
21
|
+
* Strip stop words/phrases from a NORMALIZED string.
|
|
22
|
+
* Strips geo intent phrases first, then multi-word stop phrases,
|
|
23
|
+
* then single stop tokens.
|
|
24
|
+
*/
|
|
25
|
+
export declare function stripStopWords(normalized: string, locale?: SearchLocale): string;
|
|
26
|
+
/**
|
|
27
|
+
* Minimum token length for substring/prefix matching.
|
|
28
|
+
* Tokens shorter than this are too ambiguous for client-side matching.
|
|
29
|
+
*/
|
|
30
|
+
export declare const MIN_SEARCH_TOKEN_LENGTH = 3;
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// @nomideusz/svelte-search — Text normalization
|
|
3
|
+
// ============================================================
|
|
4
|
+
// Generic normalization, trigrams, and similarity functions.
|
|
5
|
+
// Locale-specific logic (diacritics, stop words) is injected
|
|
6
|
+
// via SearchLocale.
|
|
7
|
+
// ── Default diacritics (NFD decomposition) ─────────────────
|
|
8
|
+
/** Strip diacritics using Unicode NFD decomposition (generic, all languages). */
|
|
9
|
+
export function stripDiacriticsGeneric(text) {
|
|
10
|
+
return text.normalize('NFD').replace(/[\u0300-\u036f]/g, '');
|
|
11
|
+
}
|
|
12
|
+
/** Full normalization: lowercase + strip diacritics + collapse whitespace. */
|
|
13
|
+
export function normalize(text, locale) {
|
|
14
|
+
if (!text)
|
|
15
|
+
return '';
|
|
16
|
+
const stripped = locale ? locale.stripDiacritics(text) : stripDiacriticsGeneric(text);
|
|
17
|
+
return stripped
|
|
18
|
+
.toLowerCase()
|
|
19
|
+
.replace(/[^a-z0-9\s-]/g, ' ')
|
|
20
|
+
.replace(/\s+/g, ' ')
|
|
21
|
+
.trim();
|
|
22
|
+
}
|
|
23
|
+
// ── Trigrams ───────────────────────────────────────────────
|
|
24
|
+
/** Generate trigrams from text. "hatha" → ["hat","ath","tha"] */
|
|
25
|
+
export function trigrams(text, locale) {
|
|
26
|
+
const n = normalize(text, locale);
|
|
27
|
+
if (n.length < 3)
|
|
28
|
+
return n ? [n] : [];
|
|
29
|
+
const result = [];
|
|
30
|
+
for (const word of n.split(/\s+/)) {
|
|
31
|
+
if (word.length < 3) {
|
|
32
|
+
result.push(word);
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
for (let i = 0; i <= word.length - 3; i++)
|
|
36
|
+
result.push(word.slice(i, i + 3));
|
|
37
|
+
}
|
|
38
|
+
return [...new Set(result)];
|
|
39
|
+
}
|
|
40
|
+
// ── Similarity ─────────────────────────────────────────────
|
|
41
|
+
/** Trigram similarity (Jaccard coefficient). 0..1, higher = more similar. */
|
|
42
|
+
export function trigramSimilarity(a, b, locale) {
|
|
43
|
+
const tA = new Set(trigrams(a, locale));
|
|
44
|
+
const tB = new Set(trigrams(b, locale));
|
|
45
|
+
if (tA.size === 0 && tB.size === 0)
|
|
46
|
+
return 1;
|
|
47
|
+
if (tA.size === 0 || tB.size === 0)
|
|
48
|
+
return 0;
|
|
49
|
+
let intersection = 0;
|
|
50
|
+
for (const t of tA)
|
|
51
|
+
if (tB.has(t))
|
|
52
|
+
intersection++;
|
|
53
|
+
return intersection / (tA.size + tB.size - intersection);
|
|
54
|
+
}
|
|
55
|
+
/** Levenshtein distance between two strings. */
|
|
56
|
+
export function levenshtein(a, b) {
|
|
57
|
+
if (a.length === 0)
|
|
58
|
+
return b.length;
|
|
59
|
+
if (b.length === 0)
|
|
60
|
+
return a.length;
|
|
61
|
+
let prev = Array.from({ length: b.length + 1 }, (_, i) => i);
|
|
62
|
+
let curr = new Array(b.length + 1);
|
|
63
|
+
for (let i = 1; i <= a.length; i++) {
|
|
64
|
+
curr[0] = i;
|
|
65
|
+
for (let j = 1; j <= b.length; j++) {
|
|
66
|
+
curr[j] = Math.min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
67
|
+
}
|
|
68
|
+
[prev, curr] = [curr, prev];
|
|
69
|
+
}
|
|
70
|
+
return prev[b.length];
|
|
71
|
+
}
|
|
72
|
+
/** Normalized Levenshtein similarity. 0..1, higher = more similar. */
|
|
73
|
+
export function levenshteinSimilarity(a, b, locale) {
|
|
74
|
+
const na = normalize(a, locale), nb = normalize(b, locale);
|
|
75
|
+
const max = Math.max(na.length, nb.length);
|
|
76
|
+
return max === 0 ? 1 : 1 - levenshtein(na, nb) / max;
|
|
77
|
+
}
|
|
78
|
+
/** Detect postcode format: XX-XXX or XXXXX (Polish default, override via locale). */
|
|
79
|
+
export function isPostcode(text) {
|
|
80
|
+
return /^\d{2}-?\d{3}$/.test(text.trim());
|
|
81
|
+
}
|
|
82
|
+
// ── Geo intent ─────────────────────────────────────────────
|
|
83
|
+
/** Detect "near me" intent using locale geo patterns. */
|
|
84
|
+
export function hasGeoIntent(query, locale) {
|
|
85
|
+
if (!locale)
|
|
86
|
+
return false;
|
|
87
|
+
const n = locale.stripDiacritics(query).toLowerCase();
|
|
88
|
+
return locale.geoPatterns.some(p => p.test(n));
|
|
89
|
+
}
|
|
90
|
+
/** Remove geo-intent phrases from query. */
|
|
91
|
+
export function stripGeoIntent(query, locale) {
|
|
92
|
+
if (!locale)
|
|
93
|
+
return query;
|
|
94
|
+
let q = locale.stripDiacritics(query).toLowerCase();
|
|
95
|
+
for (const p of locale.geoPatterns)
|
|
96
|
+
q = q.replace(p, '');
|
|
97
|
+
return q.replace(/\s+/g, ' ').trim();
|
|
98
|
+
}
|
|
99
|
+
// ── Stop words ─────────────────────────────────────────────
|
|
100
|
+
/**
|
|
101
|
+
* Strip stop words/phrases from a NORMALIZED string.
|
|
102
|
+
* Strips geo intent phrases first, then multi-word stop phrases,
|
|
103
|
+
* then single stop tokens.
|
|
104
|
+
*/
|
|
105
|
+
export function stripStopWords(normalized, locale) {
|
|
106
|
+
if (!locale)
|
|
107
|
+
return normalized;
|
|
108
|
+
let result = normalized;
|
|
109
|
+
// Geo intent phrases first (before single-token stripping splits them)
|
|
110
|
+
for (const p of locale.geoPatterns)
|
|
111
|
+
result = result.replace(p, ' ');
|
|
112
|
+
// Multi-word stop phrases (longest first to avoid partial stripping)
|
|
113
|
+
const sorted = [...locale.stopPhrases].sort((a, b) => b.length - a.length);
|
|
114
|
+
for (const phrase of sorted) {
|
|
115
|
+
result = result.replace(new RegExp(`\\b${phrase}\\b`, 'g'), ' ');
|
|
116
|
+
}
|
|
117
|
+
// Single-word stop tokens
|
|
118
|
+
result = result
|
|
119
|
+
.split(/\s+/)
|
|
120
|
+
.filter(t => !locale.stopTokens.has(t))
|
|
121
|
+
.join(' ');
|
|
122
|
+
return result.replace(/\s+/g, ' ').trim();
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Minimum token length for substring/prefix matching.
|
|
126
|
+
* Tokens shorter than this are too ambiguous for client-side matching.
|
|
127
|
+
*/
|
|
128
|
+
export const MIN_SEARCH_TOKEN_LENGTH = 3;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { SearchLocale, ResolverLookups } from './types.js';
|
|
2
|
+
interface TokenMatch {
|
|
3
|
+
matched: string;
|
|
4
|
+
slug: string;
|
|
5
|
+
original: string;
|
|
6
|
+
}
|
|
7
|
+
export interface ParsedQuery {
|
|
8
|
+
/** Original normalized query */
|
|
9
|
+
normalized: string;
|
|
10
|
+
/** Query after stop word removal */
|
|
11
|
+
working: string;
|
|
12
|
+
/** Whether geo intent was detected */
|
|
13
|
+
geoIntent: boolean;
|
|
14
|
+
/** Extracted postal code, if any */
|
|
15
|
+
postal: string | undefined;
|
|
16
|
+
/** Matched location token */
|
|
17
|
+
location: TokenMatch | null;
|
|
18
|
+
/** Matched category token */
|
|
19
|
+
category: TokenMatch | null;
|
|
20
|
+
/** Remaining unclassified tokens */
|
|
21
|
+
rest: string[];
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Parse and classify a raw search query.
|
|
25
|
+
* Reusable by any app — the classification is the same,
|
|
26
|
+
* only the dispatch logic differs.
|
|
27
|
+
*/
|
|
28
|
+
export declare function parseQuery(raw: string, lookups: ResolverLookups, locale?: SearchLocale): ParsedQuery;
|
|
29
|
+
/** Check if query matches an area/district in the given location. */
|
|
30
|
+
export declare function findMatchingArea(query: string, locationSlug: string, lookups: ResolverLookups, locale?: SearchLocale): string | null;
|
|
31
|
+
/**
|
|
32
|
+
* Find the nearest location that has entities.
|
|
33
|
+
* Useful for "no results" states.
|
|
34
|
+
*/
|
|
35
|
+
export declare function findNearestLocationWithEntities(lat: number, lng: number, lookups: ResolverLookups, excludeSlug?: string): {
|
|
36
|
+
name: string;
|
|
37
|
+
slug: string;
|
|
38
|
+
distanceKm: number;
|
|
39
|
+
count: number;
|
|
40
|
+
} | null;
|
|
41
|
+
export {};
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// @nomideusz/svelte-search — Resolver framework
|
|
3
|
+
// ============================================================
|
|
4
|
+
// Generic query resolver: classifies tokens (location, category,
|
|
5
|
+
// area, unclassified) and dispatches to page-specific handlers.
|
|
6
|
+
// Apps provide their own dispatch rules via createResolver().
|
|
7
|
+
import { normalize, hasGeoIntent, stripGeoIntent, stripStopWords, isPostcode } from './normalize.js';
|
|
8
|
+
function matchToken(tokens, lookup, locale) {
|
|
9
|
+
// Try bigrams first
|
|
10
|
+
for (let i = 0; i < tokens.length - 1; i++) {
|
|
11
|
+
const bigram = `${tokens[i]} ${tokens[i + 1]}`;
|
|
12
|
+
const slug = lookup.get(bigram);
|
|
13
|
+
if (slug)
|
|
14
|
+
return { matched: bigram, slug, original: bigram };
|
|
15
|
+
}
|
|
16
|
+
// Single tokens (with optional locale stemming)
|
|
17
|
+
for (const t of tokens) {
|
|
18
|
+
const slug = lookup.get(t);
|
|
19
|
+
if (slug)
|
|
20
|
+
return { matched: t, slug, original: t };
|
|
21
|
+
// Try locale-specific stems (e.g. Polish case forms)
|
|
22
|
+
if (locale?.locationStems) {
|
|
23
|
+
for (const stem of locale.locationStems(t)) {
|
|
24
|
+
if (stem === t)
|
|
25
|
+
continue;
|
|
26
|
+
const stemSlug = lookup.get(stem);
|
|
27
|
+
if (stemSlug)
|
|
28
|
+
return { matched: t, slug: stemSlug, original: t };
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
function matchesArea(query, area) {
|
|
35
|
+
if (query === area)
|
|
36
|
+
return true;
|
|
37
|
+
const re = new RegExp(`(?:^|\\s)${area.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?:\\s|$)`);
|
|
38
|
+
return re.test(query);
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Parse and classify a raw search query.
|
|
42
|
+
* Reusable by any app — the classification is the same,
|
|
43
|
+
* only the dispatch logic differs.
|
|
44
|
+
*/
|
|
45
|
+
export function parseQuery(raw, lookups, locale) {
|
|
46
|
+
const normalized = normalize(raw, locale);
|
|
47
|
+
if (!normalized) {
|
|
48
|
+
return { normalized: '', working: '', geoIntent: false, postal: undefined, location: null, category: null, rest: [] };
|
|
49
|
+
}
|
|
50
|
+
let working = stripStopWords(normalized, locale);
|
|
51
|
+
if (!working) {
|
|
52
|
+
return { normalized, working: '', geoIntent: false, postal: undefined, location: null, category: null, rest: [] };
|
|
53
|
+
}
|
|
54
|
+
// Geo intent
|
|
55
|
+
const geoIntent = hasGeoIntent(raw, locale);
|
|
56
|
+
if (geoIntent) {
|
|
57
|
+
working = stripGeoIntent(working, locale);
|
|
58
|
+
}
|
|
59
|
+
// Postal code
|
|
60
|
+
const postalMatch = working.match(/\b(\d{2})-?(\d{3})\b/);
|
|
61
|
+
let postal;
|
|
62
|
+
if (postalMatch) {
|
|
63
|
+
postal = `${postalMatch[1]}-${postalMatch[2]}`;
|
|
64
|
+
working = working.replace(postalMatch[0], '').replace(/\s+/g, ' ').trim();
|
|
65
|
+
}
|
|
66
|
+
if (!working) {
|
|
67
|
+
return { normalized, working: '', geoIntent, postal, location: null, category: null, rest: [] };
|
|
68
|
+
}
|
|
69
|
+
// Tokenize and classify
|
|
70
|
+
const tokens = working.split(/\s+/).filter(Boolean);
|
|
71
|
+
const location = matchToken(tokens, lookups.locationMap, locale);
|
|
72
|
+
const category = matchToken(tokens, lookups.categoryMap, locale);
|
|
73
|
+
const rest = tokens.filter(t => t !== location?.matched && t !== category?.matched);
|
|
74
|
+
return { normalized, working, geoIntent, postal, location, category, rest };
|
|
75
|
+
}
|
|
76
|
+
// ── Dispatch helpers ───────────────────────────────────────
|
|
77
|
+
// Common action patterns used by app-specific resolvers.
|
|
78
|
+
/** Check if query matches an area/district in the given location. */
|
|
79
|
+
export function findMatchingArea(query, locationSlug, lookups, locale) {
|
|
80
|
+
const areas = lookups.areaMap.get(locationSlug) ?? [];
|
|
81
|
+
const normalized = normalize(query, locale);
|
|
82
|
+
return areas.find(a => matchesArea(normalized, a)) ?? null;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Find the nearest location that has entities.
|
|
86
|
+
* Useful for "no results" states.
|
|
87
|
+
*/
|
|
88
|
+
export function findNearestLocationWithEntities(lat, lng, lookups, excludeSlug) {
|
|
89
|
+
if (!lookups.locationEntityCount || !lookups.locationGeo)
|
|
90
|
+
return null;
|
|
91
|
+
let best = null;
|
|
92
|
+
let bestDist = Infinity;
|
|
93
|
+
for (const [slug, geo] of lookups.locationGeo) {
|
|
94
|
+
const count = lookups.locationEntityCount.get(slug) ?? 0;
|
|
95
|
+
if (count === 0 || slug === excludeSlug)
|
|
96
|
+
continue;
|
|
97
|
+
const dlat = (geo.lat - lat) * Math.PI / 180;
|
|
98
|
+
const dlng = (geo.lng - lng) * Math.PI / 180;
|
|
99
|
+
const a = Math.sin(dlat / 2) ** 2 + Math.cos(lat * Math.PI / 180) * Math.cos(geo.lat * Math.PI / 180) * Math.sin(dlng / 2) ** 2;
|
|
100
|
+
const d = 6371 * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
|
101
|
+
if (d < bestDist) {
|
|
102
|
+
bestDist = d;
|
|
103
|
+
best = { slug, name: geo.name, count, distanceKm: Math.round(d) };
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return best;
|
|
107
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { TrackSearchEvent } from './types.js';
|
|
2
|
+
export interface TrackerConfig {
|
|
3
|
+
/** API endpoint to POST events to (default: '/api/search-events') */
|
|
4
|
+
endpoint?: string;
|
|
5
|
+
/** sessionStorage key (default: 'search_sid') */
|
|
6
|
+
sessionKey?: string;
|
|
7
|
+
}
|
|
8
|
+
export declare function createTracker(config?: TrackerConfig): {
|
|
9
|
+
track: (event: TrackSearchEvent) => void;
|
|
10
|
+
};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// @nomideusz/svelte-search — Search event tracker
|
|
3
|
+
// ============================================================
|
|
4
|
+
// Lightweight fire-and-forget search analytics.
|
|
5
|
+
// No PII — sessionId is a random UUID in sessionStorage.
|
|
6
|
+
export function createTracker(config = {}) {
|
|
7
|
+
const endpoint = config.endpoint ?? '/api/search-events';
|
|
8
|
+
const sessionKey = config.sessionKey ?? 'search_sid';
|
|
9
|
+
function getSessionId() {
|
|
10
|
+
if (typeof sessionStorage === 'undefined')
|
|
11
|
+
return '';
|
|
12
|
+
let sid = sessionStorage.getItem(sessionKey);
|
|
13
|
+
if (!sid) {
|
|
14
|
+
sid = crypto.randomUUID();
|
|
15
|
+
sessionStorage.setItem(sessionKey, sid);
|
|
16
|
+
}
|
|
17
|
+
return sid;
|
|
18
|
+
}
|
|
19
|
+
function track(event) {
|
|
20
|
+
try {
|
|
21
|
+
const body = { ...event, sessionId: getSessionId() };
|
|
22
|
+
const payload = JSON.stringify(body);
|
|
23
|
+
if (typeof navigator !== 'undefined' && navigator.sendBeacon) {
|
|
24
|
+
navigator.sendBeacon(endpoint, new Blob([payload], { type: 'application/json' }));
|
|
25
|
+
}
|
|
26
|
+
else if (typeof fetch !== 'undefined') {
|
|
27
|
+
fetch(endpoint, {
|
|
28
|
+
method: 'POST',
|
|
29
|
+
headers: { 'Content-Type': 'application/json' },
|
|
30
|
+
body: payload,
|
|
31
|
+
keepalive: true,
|
|
32
|
+
}).catch(() => { });
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
// Never fail the user experience for tracking
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return { track };
|
|
40
|
+
}
|