@museumwnf/viewer-core 1.14.0 → 1.15.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.
Files changed (43) hide show
  1. package/LICENSE.md +44 -44
  2. package/README.md +658 -635
  3. package/package.json +50 -50
  4. package/src/AppRoot.vue +58 -58
  5. package/src/catalogue/dates.js +209 -209
  6. package/src/catalogue/facets.js +61 -61
  7. package/src/catalogue/keywordIndex.js +289 -289
  8. package/src/catalogue/listQuery.js +100 -100
  9. package/src/catalogue/pagination.js +57 -57
  10. package/src/catalogue/searchLanguage.js +32 -32
  11. package/src/catalogue/timeline.js +220 -220
  12. package/src/composables/useCatalogueData.js +141 -141
  13. package/src/composables/useDataPackage.js +98 -98
  14. package/src/composables/useEntities.js +100 -100
  15. package/src/composables/useRecordLanguage.js +71 -71
  16. package/src/conventions.js +252 -148
  17. package/src/createViewer.js +29 -29
  18. package/src/i18n/I18nText.vue +17 -17
  19. package/src/i18n/I18nTextInline.vue +17 -17
  20. package/src/i18n/index.js +88 -88
  21. package/src/i18n/language.js +127 -127
  22. package/src/i18n/markdown.js +265 -265
  23. package/src/index.js +51 -50
  24. package/src/injectionKeys.js +1 -1
  25. package/src/languages.js +49 -49
  26. package/src/legacy/index.js +58 -58
  27. package/src/record/collectionTree.js +307 -307
  28. package/src/record/derivations.js +154 -154
  29. package/src/record/partners.js +87 -87
  30. package/src/record/recordSheet.js +231 -231
  31. package/src/rights.js +53 -53
  32. package/src/router/index.js +138 -138
  33. package/src/siteConfig.js +37 -37
  34. package/src/styles/base.css +49 -49
  35. package/src/testing/checkOfferedLanguages.js +67 -67
  36. package/src/testing/index.js +3 -3
  37. package/src/testing/smoke.js +103 -103
  38. package/src/testing/viteConfig.js +90 -90
  39. package/src/views/DetailView.vue +53 -53
  40. package/src/views/HomeView.vue +20 -20
  41. package/src/views/ListView.vue +61 -61
  42. package/src/views/NotFoundView.vue +8 -8
  43. package/src/vite.js +8 -8
@@ -1,289 +1,289 @@
1
- import { computed, toValue } from 'vue'
2
- import { renderPlain } from '../i18n/markdown.js'
3
- import { useDataPackage } from '../composables/useDataPackage.js'
4
- import { entityRef } from '../composables/useEntities.js'
5
- import { sortChronological } from './pagination.js'
6
-
7
- // Two search engines, one interface. The standalone sites search nine named
8
- // fields of the translation record with up to four keyword rows joined by
9
- // AND/OR — `database.php`'s form. The DXA sites run MySQL's boolean
10
- // full-text grammar (`+word -word word* "a phrase"` and the relevance
11
- // operators) over a haystack of the English sheet, which is why their
12
- // "how to search" page is an essay about operators. They are different
13
- // products and each site copied its engine unchanged; here each is written
14
- // once and a site names the one it is.
15
- //
16
- // Both read Markdown fields as plain text, through the pipeline every text
17
- // goes through, so a `*` of emphasis is not searched for and a term written
18
- // in a heading is found.
19
-
20
- const plain = (value) => (value == null ? '' : renderPlain(String(value)).toLowerCase())
21
-
22
- function strings(value) {
23
- if (value == null) return []
24
- if (Array.isArray(value)) return value.flatMap(strings)
25
- return [String(value)]
26
- }
27
-
28
- // ── The field grammar ──────────────────────────────────────────────────────
29
-
30
- /**
31
- * `expand(keyword, lang)` (when given) names alternative spellings of the
32
- * same keyword — a glossary term's other spellings, a country's other names
33
- * — searched in the same haystack as the keyword itself, so a row matches on
34
- * any of them without the site's field map knowing expansion happened.
35
- */
36
- function fieldMatches(getters, field, keyword, item, text, helpers, expand, lang) {
37
- const needle = String(keyword ?? '').trim().toLowerCase()
38
- if (!needle) return true
39
- const getter = getters[field] ?? getters.keyword ?? Object.values(getters)[0]
40
- if (!getter) return false
41
- const hay = strings(getter(item, text, helpers)).map(plain)
42
- if (hay.some((s) => s.includes(needle))) return true
43
- if (!expand) return false
44
- return (expand(String(keyword ?? '').trim(), lang) ?? []).some((alt) => {
45
- const altNeedle = String(alt ?? '').trim().toLowerCase()
46
- return altNeedle !== '' && hay.some((s) => s.includes(altNeedle))
47
- })
48
- }
49
-
50
- /**
51
- * The AND/OR fold of the keyword rows: the first row stands alone, each
52
- * following row is joined to the result so far by its own `cond`. Rows with
53
- * no keyword are skipped. No row at all matches everything.
54
- *
55
- * `rank: 'hits'` (Decision D3, legacy's `ORDER BY nn DESC, pkdate ASC`)
56
- * reorders the matches by how many of the active rows each one matched, most
57
- * first, then chronologically — `sortChronological`'s own rule, undated
58
- * last, rather than a second date comparator written here. Left unranked
59
- * (the default) a site keeps its own order, page-size and all — this option
60
- * exists for a site that wants the legacy order back, not to replace it.
61
- */
62
- function fieldSearch(getters, items, rows, textOf, helpers, { rank, expand, lang } = {}) {
63
- const active = (rows ?? []).filter((row) => String(row?.keyword ?? '').trim() !== '')
64
- if (active.length === 0) return [...items]
65
- const matched = []
66
- for (const item of items) {
67
- const text = textOf(item)
68
- let result = null
69
- let hits = 0
70
- for (const row of active) {
71
- const hit = fieldMatches(getters, row.field, row.keyword, item, text, helpers, expand, lang)
72
- if (hit) hits += 1
73
- if (result === null) result = hit
74
- else if (String(row.cond).toUpperCase() === 'OR') result = result || hit
75
- else result = result && hit
76
- }
77
- if (result === true) matched.push({ item, hits })
78
- }
79
- if (rank === 'hits') {
80
- const chronological = sortChronological(matched.map((m) => m.item))
81
- const order = new Map(chronological.map((item, i) => [item, i]))
82
- matched.sort((a, b) => order.get(a.item) - order.get(b.item))
83
- matched.sort((a, b) => b.hits - a.hits)
84
- }
85
- return matched.map((m) => m.item)
86
- }
87
-
88
- // ── The boolean grammar ────────────────────────────────────────────────────
89
-
90
- /** `+word -word word* "a phrase" ~word >word <word` → terms. */
91
- export function parseBooleanQuery(input) {
92
- const terms = []
93
- const re = /([+\-~<>]?)(?:"([^"]+)"|(\S+))/g
94
- let match
95
- while ((match = re.exec(input ?? '')) !== null) {
96
- const [, op, phrase, word] = match
97
- let text = (phrase ?? word ?? '').toLowerCase()
98
- if (!text) continue
99
- let prefix = false
100
- if (!phrase && text.endsWith('*')) {
101
- prefix = true
102
- text = text.slice(0, -1)
103
- }
104
- if (!text) continue
105
- terms.push({ op: op || '', text, prefix, phrase: Boolean(phrase) })
106
- }
107
- return terms
108
- }
109
-
110
- const REGEXP_SPECIAL = /[.*+?^${}()|[\]\\]/g
111
-
112
- function termMatches(hay, term) {
113
- if (term.prefix) {
114
- return new RegExp(`(?:^|[^\\p{L}\\p{N}_])${term.text.replace(REGEXP_SPECIAL, '\\$&')}`, 'u').test(hay)
115
- }
116
- return hay.includes(term.text)
117
- }
118
-
119
- /** Rank `items` against a boolean query; `hayOf(item)` is the lower-cased haystack. */
120
- function booleanSearch(items, input, hayOf, labelOf) {
121
- const terms = parseBooleanQuery(input)
122
- if (terms.length === 0) return []
123
- const required = terms.filter((t) => t.op === '+')
124
- const excluded = terms.filter((t) => t.op === '-')
125
- const optional = terms.filter((t) => t.op !== '+' && t.op !== '-')
126
-
127
- const scored = []
128
- for (const item of items) {
129
- const hay = hayOf(item)
130
- if (!required.every((t) => termMatches(hay, t))) continue
131
- if (excluded.some((t) => termMatches(hay, t))) continue
132
- let score = required.length * 2
133
- let anyOptional = false
134
- for (const t of optional) {
135
- if (!termMatches(hay, t)) continue
136
- anyOptional = true
137
- // `~` counts against, `>` for more, `<` for less — legacy's relevance
138
- // operators, as simple weights.
139
- score += t.op === '~' ? -1 : t.op === '>' ? 2 : t.op === '<' ? 0.5 : 1
140
- }
141
- if (!required.length && !anyOptional) continue
142
- scored.push({ item, score })
143
- }
144
- scored.sort((a, b) => b.score - a.score || labelOf(a.item).localeCompare(labelOf(b.item)))
145
- return scored.map((s) => s.item)
146
- }
147
-
148
- // ── The composable ─────────────────────────────────────────────────────────
149
-
150
- /**
151
- * A keyword search over one entity, in one of two grammars.
152
- *
153
- * `grammar: 'fields'` — `fields` maps a field name to `(record, text) =>
154
- * string | string[]`, the strings that field searches (`text` is the
155
- * record's translation in `language`); `search(rows)` takes
156
- * `[{ field, keyword, cond }]` and folds them with AND/OR. Which fields exist
157
- * and what each reads is the site's: the nine of `database.php` are Islamic
158
- * Art's, Baroque Art's differ. `rank: 'hits'` and `expand` (below) apply to
159
- * this grammar only — the DXA boolean grammar already ranks by score and is
160
- * unaffected by either.
161
- *
162
- * `grammar: 'boolean'` — `haystack(record, text)` returns the strings the
163
- * record is searched in; `search(query)` ranks by the boolean grammar.
164
- *
165
- * `language` (a ref, getter or string) is the translation language the text
166
- * is read in — English on every DXA site, the visitor's search language on a
167
- * standalone one. The haystacks are computed once per record and language
168
- * and dropped when that language's translations load or change, which is
169
- * what a site used to do by hand with a `resetSearchIndex()` after
170
- * `loadEnglish()`.
171
- *
172
- * `rank: 'hits'` — off by default, so an existing site's own order (or lack
173
- * of one) is untouched. On, `search()` returns matches ordered by how many
174
- * of the query's rows they matched, then chronologically — legacy's
175
- * `ORDER BY nn DESC, pkdate ASC`. A results page composing this into
176
- * `viewer-layout`'s list view passes `sort: false` there so the order this
177
- * function already computed is not sorted a second time.
178
- *
179
- * `expand(keyword, language) => string[]` — alternative spellings of a
180
- * keyword, searched alongside it in whichever field the row names. Off by
181
- * default. `glossaryExpansion` and `countryExpansion` below are the two
182
- * legacy rules (`database_results.php:41,113-136,181-186`); `combineExpansions`
183
- * runs both.
184
- */
185
- export function useKeywordIndex(entity, { grammar, fields = {}, haystack, language = 'en', label, rank, expand } = {}) {
186
- if (grammar !== 'fields' && grammar !== 'boolean') {
187
- throw new Error(`useKeywordIndex: grammar must be "fields" or "boolean", got "${grammar}"`)
188
- }
189
- const { tr, translations } = useDataPackage()
190
- const records = entityRef(entity)
191
- const labelOf = label ?? ((record) => plain(record?.name ?? record?.title ?? record?.internal_name ?? record?.id))
192
-
193
- const lang = computed(() => toValue(language) || 'en')
194
- const textOf = (record) => tr(entity, record.id, lang.value, 'en')
195
-
196
- // Keyed on the translation map itself: `translations()` hands out one
197
- // object per loaded file, so a new object means the file arrived and every
198
- // haystack built without it is stale.
199
- let cacheFor = null
200
- let cache = new Map()
201
- function hayOf(record) {
202
- const current = translations(entity, lang.value)
203
- if (current !== cacheFor) {
204
- cacheFor = current
205
- cache = new Map()
206
- }
207
- let hay = cache.get(record.id)
208
- if (hay === undefined) {
209
- hay = strings(haystack ? haystack(record, textOf(record)) : Object.values(textOf(record)))
210
- .map(plain)
211
- .join(' \n ')
212
- cache.set(record.id, hay)
213
- }
214
- return hay
215
- }
216
-
217
- function search(input) {
218
- const items = records.value ?? []
219
- if (grammar === 'fields') {
220
- return fieldSearch(fields, items, input, textOf, { tr, translations }, { rank, expand, lang: lang.value })
221
- }
222
- return booleanSearch(items, input, hayOf, labelOf)
223
- }
224
-
225
- function reset() {
226
- cacheFor = null
227
- cache = new Map()
228
- }
229
-
230
- return { search, reset, records, language: lang }
231
- }
232
-
233
- // ── Expansion hooks ─────────────────────────────────────────────────────────
234
- //
235
- // Both read a term-lookup entity the same way `searchGlossary` does: the
236
- // translation in `language` falling back to English, never the base record
237
- // alone, because a spelling or a name is what the visitor typed in and the
238
- // base record carries neither.
239
-
240
- /**
241
- * A term equal to one of a glossary entry's spellings, in `language`
242
- * (falling back to English), expands to every spelling of that same entry
243
- * in that language — legacy's `gl_spellings`/`glossary` lookup
244
- * (`database_results.php:104-131`): typing the term used in one spelling
245
- * finds the record written with another.
246
- */
247
- export function glossaryExpansion({ entity = 'glossary' } = {}) {
248
- return (term, language) => {
249
- const needle = String(term ?? '').trim().toLowerCase()
250
- if (!needle) return []
251
- const { tr } = useDataPackage()
252
- for (const record of entityRef(entity).value ?? []) {
253
- const t = tr(entity, record.id, language)
254
- const spellings = t.spellings?.length ? t.spellings : [record.word]
255
- if (spellings.some((s) => String(s ?? '').trim().toLowerCase() === needle)) {
256
- return spellings.map((s) => String(s).trim())
257
- }
258
- }
259
- return []
260
- }
261
- }
262
-
263
- /**
264
- * A term equal to a country's name, in `language` (falling back to
265
- * `internal_name`), expands to that country's id — legacy's
266
- * `getKeywordCountry` (`database_results.php:27-51,223-224,291-293`): typing
267
- * "Egypt" also finds the records held in Egypt. Applies to whichever field a
268
- * row names; the field's own getter decides whether a record's country is
269
- * part of what it searches — the `keyword` and `location` fields on the
270
- * standalone form did, in legacy.
271
- */
272
- export function countryExpansion({ entity = 'countries' } = {}) {
273
- return (term, language) => {
274
- const needle = String(term ?? '').trim().toLowerCase()
275
- if (!needle) return []
276
- const { tr } = useDataPackage()
277
- const out = []
278
- for (const record of entityRef(entity).value ?? []) {
279
- const name = tr(entity, record.id, language).name ?? record.internal_name ?? record.id
280
- if (String(name).trim().toLowerCase() === needle) out.push(record.id)
281
- }
282
- return out
283
- }
284
- }
285
-
286
- /** Runs every expansion given and concatenates what each returns. */
287
- export function combineExpansions(...expansions) {
288
- return (term, language) => expansions.flatMap((expand) => expand(term, language) ?? [])
289
- }
1
+ import { computed, toValue } from 'vue'
2
+ import { renderPlain } from '../i18n/markdown.js'
3
+ import { useDataPackage } from '../composables/useDataPackage.js'
4
+ import { entityRef } from '../composables/useEntities.js'
5
+ import { sortChronological } from './pagination.js'
6
+
7
+ // Two search engines, one interface. The standalone sites search nine named
8
+ // fields of the translation record with up to four keyword rows joined by
9
+ // AND/OR — `database.php`'s form. The DXA sites run MySQL's boolean
10
+ // full-text grammar (`+word -word word* "a phrase"` and the relevance
11
+ // operators) over a haystack of the English sheet, which is why their
12
+ // "how to search" page is an essay about operators. They are different
13
+ // products and each site copied its engine unchanged; here each is written
14
+ // once and a site names the one it is.
15
+ //
16
+ // Both read Markdown fields as plain text, through the pipeline every text
17
+ // goes through, so a `*` of emphasis is not searched for and a term written
18
+ // in a heading is found.
19
+
20
+ const plain = (value) => (value == null ? '' : renderPlain(String(value)).toLowerCase())
21
+
22
+ function strings(value) {
23
+ if (value == null) return []
24
+ if (Array.isArray(value)) return value.flatMap(strings)
25
+ return [String(value)]
26
+ }
27
+
28
+ // ── The field grammar ──────────────────────────────────────────────────────
29
+
30
+ /**
31
+ * `expand(keyword, lang)` (when given) names alternative spellings of the
32
+ * same keyword — a glossary term's other spellings, a country's other names
33
+ * — searched in the same haystack as the keyword itself, so a row matches on
34
+ * any of them without the site's field map knowing expansion happened.
35
+ */
36
+ function fieldMatches(getters, field, keyword, item, text, helpers, expand, lang) {
37
+ const needle = String(keyword ?? '').trim().toLowerCase()
38
+ if (!needle) return true
39
+ const getter = getters[field] ?? getters.keyword ?? Object.values(getters)[0]
40
+ if (!getter) return false
41
+ const hay = strings(getter(item, text, helpers)).map(plain)
42
+ if (hay.some((s) => s.includes(needle))) return true
43
+ if (!expand) return false
44
+ return (expand(String(keyword ?? '').trim(), lang) ?? []).some((alt) => {
45
+ const altNeedle = String(alt ?? '').trim().toLowerCase()
46
+ return altNeedle !== '' && hay.some((s) => s.includes(altNeedle))
47
+ })
48
+ }
49
+
50
+ /**
51
+ * The AND/OR fold of the keyword rows: the first row stands alone, each
52
+ * following row is joined to the result so far by its own `cond`. Rows with
53
+ * no keyword are skipped. No row at all matches everything.
54
+ *
55
+ * `rank: 'hits'` (Decision D3, legacy's `ORDER BY nn DESC, pkdate ASC`)
56
+ * reorders the matches by how many of the active rows each one matched, most
57
+ * first, then chronologically — `sortChronological`'s own rule, undated
58
+ * last, rather than a second date comparator written here. Left unranked
59
+ * (the default) a site keeps its own order, page-size and all — this option
60
+ * exists for a site that wants the legacy order back, not to replace it.
61
+ */
62
+ function fieldSearch(getters, items, rows, textOf, helpers, { rank, expand, lang } = {}) {
63
+ const active = (rows ?? []).filter((row) => String(row?.keyword ?? '').trim() !== '')
64
+ if (active.length === 0) return [...items]
65
+ const matched = []
66
+ for (const item of items) {
67
+ const text = textOf(item)
68
+ let result = null
69
+ let hits = 0
70
+ for (const row of active) {
71
+ const hit = fieldMatches(getters, row.field, row.keyword, item, text, helpers, expand, lang)
72
+ if (hit) hits += 1
73
+ if (result === null) result = hit
74
+ else if (String(row.cond).toUpperCase() === 'OR') result = result || hit
75
+ else result = result && hit
76
+ }
77
+ if (result === true) matched.push({ item, hits })
78
+ }
79
+ if (rank === 'hits') {
80
+ const chronological = sortChronological(matched.map((m) => m.item))
81
+ const order = new Map(chronological.map((item, i) => [item, i]))
82
+ matched.sort((a, b) => order.get(a.item) - order.get(b.item))
83
+ matched.sort((a, b) => b.hits - a.hits)
84
+ }
85
+ return matched.map((m) => m.item)
86
+ }
87
+
88
+ // ── The boolean grammar ────────────────────────────────────────────────────
89
+
90
+ /** `+word -word word* "a phrase" ~word >word <word` → terms. */
91
+ export function parseBooleanQuery(input) {
92
+ const terms = []
93
+ const re = /([+\-~<>]?)(?:"([^"]+)"|(\S+))/g
94
+ let match
95
+ while ((match = re.exec(input ?? '')) !== null) {
96
+ const [, op, phrase, word] = match
97
+ let text = (phrase ?? word ?? '').toLowerCase()
98
+ if (!text) continue
99
+ let prefix = false
100
+ if (!phrase && text.endsWith('*')) {
101
+ prefix = true
102
+ text = text.slice(0, -1)
103
+ }
104
+ if (!text) continue
105
+ terms.push({ op: op || '', text, prefix, phrase: Boolean(phrase) })
106
+ }
107
+ return terms
108
+ }
109
+
110
+ const REGEXP_SPECIAL = /[.*+?^${}()|[\]\\]/g
111
+
112
+ function termMatches(hay, term) {
113
+ if (term.prefix) {
114
+ return new RegExp(`(?:^|[^\\p{L}\\p{N}_])${term.text.replace(REGEXP_SPECIAL, '\\$&')}`, 'u').test(hay)
115
+ }
116
+ return hay.includes(term.text)
117
+ }
118
+
119
+ /** Rank `items` against a boolean query; `hayOf(item)` is the lower-cased haystack. */
120
+ function booleanSearch(items, input, hayOf, labelOf) {
121
+ const terms = parseBooleanQuery(input)
122
+ if (terms.length === 0) return []
123
+ const required = terms.filter((t) => t.op === '+')
124
+ const excluded = terms.filter((t) => t.op === '-')
125
+ const optional = terms.filter((t) => t.op !== '+' && t.op !== '-')
126
+
127
+ const scored = []
128
+ for (const item of items) {
129
+ const hay = hayOf(item)
130
+ if (!required.every((t) => termMatches(hay, t))) continue
131
+ if (excluded.some((t) => termMatches(hay, t))) continue
132
+ let score = required.length * 2
133
+ let anyOptional = false
134
+ for (const t of optional) {
135
+ if (!termMatches(hay, t)) continue
136
+ anyOptional = true
137
+ // `~` counts against, `>` for more, `<` for less — legacy's relevance
138
+ // operators, as simple weights.
139
+ score += t.op === '~' ? -1 : t.op === '>' ? 2 : t.op === '<' ? 0.5 : 1
140
+ }
141
+ if (!required.length && !anyOptional) continue
142
+ scored.push({ item, score })
143
+ }
144
+ scored.sort((a, b) => b.score - a.score || labelOf(a.item).localeCompare(labelOf(b.item)))
145
+ return scored.map((s) => s.item)
146
+ }
147
+
148
+ // ── The composable ─────────────────────────────────────────────────────────
149
+
150
+ /**
151
+ * A keyword search over one entity, in one of two grammars.
152
+ *
153
+ * `grammar: 'fields'` — `fields` maps a field name to `(record, text) =>
154
+ * string | string[]`, the strings that field searches (`text` is the
155
+ * record's translation in `language`); `search(rows)` takes
156
+ * `[{ field, keyword, cond }]` and folds them with AND/OR. Which fields exist
157
+ * and what each reads is the site's: the nine of `database.php` are Islamic
158
+ * Art's, Baroque Art's differ. `rank: 'hits'` and `expand` (below) apply to
159
+ * this grammar only — the DXA boolean grammar already ranks by score and is
160
+ * unaffected by either.
161
+ *
162
+ * `grammar: 'boolean'` — `haystack(record, text)` returns the strings the
163
+ * record is searched in; `search(query)` ranks by the boolean grammar.
164
+ *
165
+ * `language` (a ref, getter or string) is the translation language the text
166
+ * is read in — English on every DXA site, the visitor's search language on a
167
+ * standalone one. The haystacks are computed once per record and language
168
+ * and dropped when that language's translations load or change, which is
169
+ * what a site used to do by hand with a `resetSearchIndex()` after
170
+ * `loadEnglish()`.
171
+ *
172
+ * `rank: 'hits'` — off by default, so an existing site's own order (or lack
173
+ * of one) is untouched. On, `search()` returns matches ordered by how many
174
+ * of the query's rows they matched, then chronologically — legacy's
175
+ * `ORDER BY nn DESC, pkdate ASC`. A results page composing this into
176
+ * `viewer-layout`'s list view passes `sort: false` there so the order this
177
+ * function already computed is not sorted a second time.
178
+ *
179
+ * `expand(keyword, language) => string[]` — alternative spellings of a
180
+ * keyword, searched alongside it in whichever field the row names. Off by
181
+ * default. `glossaryExpansion` and `countryExpansion` below are the two
182
+ * legacy rules (`database_results.php:41,113-136,181-186`); `combineExpansions`
183
+ * runs both.
184
+ */
185
+ export function useKeywordIndex(entity, { grammar, fields = {}, haystack, language = 'en', label, rank, expand } = {}) {
186
+ if (grammar !== 'fields' && grammar !== 'boolean') {
187
+ throw new Error(`useKeywordIndex: grammar must be "fields" or "boolean", got "${grammar}"`)
188
+ }
189
+ const { tr, translations } = useDataPackage()
190
+ const records = entityRef(entity)
191
+ const labelOf = label ?? ((record) => plain(record?.name ?? record?.title ?? record?.internal_name ?? record?.id))
192
+
193
+ const lang = computed(() => toValue(language) || 'en')
194
+ const textOf = (record) => tr(entity, record.id, lang.value, 'en')
195
+
196
+ // Keyed on the translation map itself: `translations()` hands out one
197
+ // object per loaded file, so a new object means the file arrived and every
198
+ // haystack built without it is stale.
199
+ let cacheFor = null
200
+ let cache = new Map()
201
+ function hayOf(record) {
202
+ const current = translations(entity, lang.value)
203
+ if (current !== cacheFor) {
204
+ cacheFor = current
205
+ cache = new Map()
206
+ }
207
+ let hay = cache.get(record.id)
208
+ if (hay === undefined) {
209
+ hay = strings(haystack ? haystack(record, textOf(record)) : Object.values(textOf(record)))
210
+ .map(plain)
211
+ .join(' \n ')
212
+ cache.set(record.id, hay)
213
+ }
214
+ return hay
215
+ }
216
+
217
+ function search(input) {
218
+ const items = records.value ?? []
219
+ if (grammar === 'fields') {
220
+ return fieldSearch(fields, items, input, textOf, { tr, translations }, { rank, expand, lang: lang.value })
221
+ }
222
+ return booleanSearch(items, input, hayOf, labelOf)
223
+ }
224
+
225
+ function reset() {
226
+ cacheFor = null
227
+ cache = new Map()
228
+ }
229
+
230
+ return { search, reset, records, language: lang }
231
+ }
232
+
233
+ // ── Expansion hooks ─────────────────────────────────────────────────────────
234
+ //
235
+ // Both read a term-lookup entity the same way `searchGlossary` does: the
236
+ // translation in `language` falling back to English, never the base record
237
+ // alone, because a spelling or a name is what the visitor typed in and the
238
+ // base record carries neither.
239
+
240
+ /**
241
+ * A term equal to one of a glossary entry's spellings, in `language`
242
+ * (falling back to English), expands to every spelling of that same entry
243
+ * in that language — legacy's `gl_spellings`/`glossary` lookup
244
+ * (`database_results.php:104-131`): typing the term used in one spelling
245
+ * finds the record written with another.
246
+ */
247
+ export function glossaryExpansion({ entity = 'glossary' } = {}) {
248
+ return (term, language) => {
249
+ const needle = String(term ?? '').trim().toLowerCase()
250
+ if (!needle) return []
251
+ const { tr } = useDataPackage()
252
+ for (const record of entityRef(entity).value ?? []) {
253
+ const t = tr(entity, record.id, language)
254
+ const spellings = t.spellings?.length ? t.spellings : [record.word]
255
+ if (spellings.some((s) => String(s ?? '').trim().toLowerCase() === needle)) {
256
+ return spellings.map((s) => String(s).trim())
257
+ }
258
+ }
259
+ return []
260
+ }
261
+ }
262
+
263
+ /**
264
+ * A term equal to a country's name, in `language` (falling back to
265
+ * `internal_name`), expands to that country's id — legacy's
266
+ * `getKeywordCountry` (`database_results.php:27-51,223-224,291-293`): typing
267
+ * "Egypt" also finds the records held in Egypt. Applies to whichever field a
268
+ * row names; the field's own getter decides whether a record's country is
269
+ * part of what it searches — the `keyword` and `location` fields on the
270
+ * standalone form did, in legacy.
271
+ */
272
+ export function countryExpansion({ entity = 'countries' } = {}) {
273
+ return (term, language) => {
274
+ const needle = String(term ?? '').trim().toLowerCase()
275
+ if (!needle) return []
276
+ const { tr } = useDataPackage()
277
+ const out = []
278
+ for (const record of entityRef(entity).value ?? []) {
279
+ const name = tr(entity, record.id, language).name ?? record.internal_name ?? record.id
280
+ if (String(name).trim().toLowerCase() === needle) out.push(record.id)
281
+ }
282
+ return out
283
+ }
284
+ }
285
+
286
+ /** Runs every expansion given and concatenates what each returns. */
287
+ export function combineExpansions(...expansions) {
288
+ return (term, language) => expansions.flatMap((expand) => expand(term, language) ?? [])
289
+ }