@museumwnf/viewer-core 1.14.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/LICENSE.md +44 -0
- package/README.md +635 -0
- package/package.json +50 -0
- package/src/AppRoot.vue +58 -0
- package/src/catalogue/dates.js +209 -0
- package/src/catalogue/facets.js +61 -0
- package/src/catalogue/keywordIndex.js +289 -0
- package/src/catalogue/listQuery.js +100 -0
- package/src/catalogue/pagination.js +57 -0
- package/src/catalogue/searchLanguage.js +32 -0
- package/src/catalogue/timeline.js +220 -0
- package/src/composables/useCatalogueData.js +141 -0
- package/src/composables/useDataPackage.js +98 -0
- package/src/composables/useEntities.js +100 -0
- package/src/composables/useRecordLanguage.js +71 -0
- package/src/conventions.js +148 -0
- package/src/createViewer.js +29 -0
- package/src/i18n/I18nText.vue +17 -0
- package/src/i18n/I18nTextInline.vue +17 -0
- package/src/i18n/index.js +88 -0
- package/src/i18n/language.js +127 -0
- package/src/i18n/markdown.js +265 -0
- package/src/index.js +50 -0
- package/src/injectionKeys.js +1 -0
- package/src/languages.js +49 -0
- package/src/legacy/index.js +58 -0
- package/src/record/collectionTree.js +307 -0
- package/src/record/derivations.js +154 -0
- package/src/record/partners.js +87 -0
- package/src/record/recordSheet.js +231 -0
- package/src/rights.js +53 -0
- package/src/router/index.js +138 -0
- package/src/siteConfig.js +37 -0
- package/src/styles/base.css +49 -0
- package/src/testing/checkOfferedLanguages.js +67 -0
- package/src/testing/index.js +3 -0
- package/src/testing/smoke.js +103 -0
- package/src/testing/viteConfig.js +90 -0
- package/src/views/DetailView.vue +53 -0
- package/src/views/HomeView.vue +20 -0
- package/src/views/ListView.vue +61 -0
- package/src/views/NotFoundView.vue +8 -0
- package/src/vite.js +8 -0
package/package.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@museumwnf/viewer-core",
|
|
3
|
+
"version": "1.14.0",
|
|
4
|
+
"description": "Application engine for MWNF websites: turns a dataset.config.js into a mounted Vue application.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "SEE LICENSE IN LICENSE.md",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/metanull/viewer-core.git"
|
|
10
|
+
},
|
|
11
|
+
"publishConfig": {
|
|
12
|
+
"access": "public",
|
|
13
|
+
"registry": "https://registry.npmjs.org/"
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"src"
|
|
17
|
+
],
|
|
18
|
+
"main": "./src/index.js",
|
|
19
|
+
"module": "./src/index.js",
|
|
20
|
+
"exports": {
|
|
21
|
+
".": "./src/index.js",
|
|
22
|
+
"./i18n": "./src/i18n/index.js",
|
|
23
|
+
"./legacy": "./src/legacy/index.js",
|
|
24
|
+
"./testing": "./src/testing/index.js",
|
|
25
|
+
"./vite": "./src/vite.js",
|
|
26
|
+
"./styles/base.css": "./src/styles/base.css"
|
|
27
|
+
},
|
|
28
|
+
"sideEffects": [
|
|
29
|
+
"**/*.css"
|
|
30
|
+
],
|
|
31
|
+
"scripts": {
|
|
32
|
+
"test": "vitest run"
|
|
33
|
+
},
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"marked": "^18.0.11"
|
|
36
|
+
},
|
|
37
|
+
"peerDependencies": {
|
|
38
|
+
"vue": "^3.5.0",
|
|
39
|
+
"vue-router": "^4.5.0 || ^5.0.0"
|
|
40
|
+
},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"@vitejs/plugin-vue": "^6.0.8",
|
|
43
|
+
"@vue/test-utils": "^2.4.11",
|
|
44
|
+
"jsdom": "^30.0.1",
|
|
45
|
+
"vite": "^8.2.2",
|
|
46
|
+
"vitest": "^4.1.11",
|
|
47
|
+
"vue": "^3.5.0",
|
|
48
|
+
"vue-router": "^5.0.0"
|
|
49
|
+
}
|
|
50
|
+
}
|
package/src/AppRoot.vue
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { computed, inject, ref } from 'vue'
|
|
3
|
+
import { useRoute, useRouter } from 'vue-router'
|
|
4
|
+
import { useI18n } from './i18n/index.js'
|
|
5
|
+
import { negotiateLanguage } from './i18n/language.js'
|
|
6
|
+
import { VIEWER_CONFIG } from './injectionKeys.js'
|
|
7
|
+
|
|
8
|
+
const config = inject(VIEWER_CONFIG, {})
|
|
9
|
+
const { locale } = useI18n()
|
|
10
|
+
const route = useRoute()
|
|
11
|
+
const router = useRouter()
|
|
12
|
+
|
|
13
|
+
// The shell receives the resolved language list, everything the website put in
|
|
14
|
+
// config.navigation, and the current locale — in that order, so `language`
|
|
15
|
+
// always reflects the live locale.
|
|
16
|
+
const shellProps = computed(() => ({
|
|
17
|
+
languages: config.languages,
|
|
18
|
+
...(config.navigation ?? {}),
|
|
19
|
+
language: locale.value,
|
|
20
|
+
}))
|
|
21
|
+
|
|
22
|
+
// The shell is the website's own component, so what it emits is checked here
|
|
23
|
+
// rather than trusted: the active language is always one the website offers.
|
|
24
|
+
function setLanguage(language) {
|
|
25
|
+
locale.value = negotiateLanguage(config.languages, { requested: language })
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Until the first navigation has resolved — which includes loading the
|
|
29
|
+
// entities the route declares — there is no view to show, and the page says
|
|
30
|
+
// so rather than staying blank.
|
|
31
|
+
const navigating = ref(true)
|
|
32
|
+
router.beforeEach(() => {
|
|
33
|
+
navigating.value = true
|
|
34
|
+
})
|
|
35
|
+
router.afterEach(() => {
|
|
36
|
+
navigating.value = false
|
|
37
|
+
})
|
|
38
|
+
router.onError(() => {
|
|
39
|
+
navigating.value = false
|
|
40
|
+
})
|
|
41
|
+
const loading = computed(() => navigating.value && route.matched.length === 0)
|
|
42
|
+
</script>
|
|
43
|
+
|
|
44
|
+
<template>
|
|
45
|
+
<component
|
|
46
|
+
:is="config.shell"
|
|
47
|
+
v-if="config.shell"
|
|
48
|
+
v-bind="shellProps"
|
|
49
|
+
@update:language="setLanguage"
|
|
50
|
+
>
|
|
51
|
+
<p v-if="loading" class="vc-loading">{{ $t('core.status.loading') }}</p>
|
|
52
|
+
<router-view v-else />
|
|
53
|
+
</component>
|
|
54
|
+
<template v-else>
|
|
55
|
+
<p v-if="loading" class="vc-loading">{{ $t('core.status.loading') }}</p>
|
|
56
|
+
<router-view v-else />
|
|
57
|
+
</template>
|
|
58
|
+
</template>
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
// Two date rules, both legacy, never merged.
|
|
2
|
+
//
|
|
3
|
+
// The standalone sites (Islamic Art, Baroque Art, Sharing History) test
|
|
4
|
+
// overlap, tolerating a record that carries one date only: "from 800" keeps
|
|
5
|
+
// a record whose end is at or after 800, or — with no end — whose start is.
|
|
6
|
+
// The DXA sites (the galleries and exhibitions) test containment, the
|
|
7
|
+
// predicate of legacy's `Objects.blade.php`: `na <= start_date` and
|
|
8
|
+
// `nz >= coalesce(end_date, start_date)`, so an undated record is out the
|
|
9
|
+
// moment a bound is set. They give different answers on the same data, and
|
|
10
|
+
// each website has published its answer for years. A site names its rule
|
|
11
|
+
// once, in its catalogue spec; this file only makes sure the two cannot be
|
|
12
|
+
// confused for one.
|
|
13
|
+
|
|
14
|
+
const number = (value) => {
|
|
15
|
+
if (value === '' || value == null) return null
|
|
16
|
+
const n = Number(value)
|
|
17
|
+
return Number.isFinite(n) ? n : null
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const year = (record, field) => {
|
|
21
|
+
const value = record?.[field]
|
|
22
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : null
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Whether `record` falls in `[begin, end]` under `mode`: `'overlap'` (the
|
|
27
|
+
* standalone rule) or `'contain'` (the DXA rule). A bound that is empty or
|
|
28
|
+
* absent is not applied. `fields` names the record's start and end properties.
|
|
29
|
+
*/
|
|
30
|
+
export function inDateRange(record, { begin, end, mode = 'overlap', fields = {} } = {}) {
|
|
31
|
+
const from = number(begin)
|
|
32
|
+
const to = number(end)
|
|
33
|
+
const start = year(record, fields.start ?? 'start_date')
|
|
34
|
+
const last = year(record, fields.end ?? 'end_date')
|
|
35
|
+
|
|
36
|
+
if (mode === 'contain') {
|
|
37
|
+
if (from != null && (start == null || from > start)) return false
|
|
38
|
+
if (to != null) {
|
|
39
|
+
const final = last ?? start
|
|
40
|
+
if (final == null || to < final) return false
|
|
41
|
+
}
|
|
42
|
+
return true
|
|
43
|
+
}
|
|
44
|
+
if (mode !== 'overlap') throw new Error(`Unknown date mode "${mode}": use "overlap" or "contain"`)
|
|
45
|
+
|
|
46
|
+
if (from != null) {
|
|
47
|
+
if (last != null) {
|
|
48
|
+
if (last < from) return false
|
|
49
|
+
} else if (start != null && start < from) return false
|
|
50
|
+
}
|
|
51
|
+
if (to != null) {
|
|
52
|
+
if (start != null) {
|
|
53
|
+
if (start > to) return false
|
|
54
|
+
} else if (last != null && last > to) return false
|
|
55
|
+
}
|
|
56
|
+
return true
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The records of `list` that `inDateRange` keeps. */
|
|
60
|
+
export function dateRange(list, options = {}) {
|
|
61
|
+
return (list ?? []).filter((record) => inDateRange(record, options))
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ── Year buckets ───────────────────────────────────────────────────────────
|
|
65
|
+
//
|
|
66
|
+
// A verbatim port of `loadYears` in the legacy DXA client's collection.js
|
|
67
|
+
// mixin, carried here from four identical copies. The API returned only a
|
|
68
|
+
// min and a max; the client built the era buckets itself, with coarser steps
|
|
69
|
+
// the further back you go (500 → 250 → 100 → 50), an explicit "Before 1000
|
|
70
|
+
// BC" bucket, and an "After …" label when the final step is short. The
|
|
71
|
+
// algorithm is reproduced rather than improved because its values end up in
|
|
72
|
+
// shareable URLs, and a nicer bucket is a broken link.
|
|
73
|
+
|
|
74
|
+
const nextMultiple = (n, m) => Math.ceil(n / m) * m
|
|
75
|
+
const previousMultiple = (n, m) => Math.floor(n / m) * m
|
|
76
|
+
|
|
77
|
+
function pushIncrements(from, maxRounded, incrementMax, increment, array) {
|
|
78
|
+
for (let i = from; i <= incrementMax; i += increment) {
|
|
79
|
+
if (i > maxRounded) return
|
|
80
|
+
array.push(i)
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The era buckets between `min` and `max`, as `[{ value, label }]`. The era
|
|
86
|
+
* words on the labels are texts (`catalogue.era.bc`, `.ad`, `.before`,
|
|
87
|
+
* `.after`) and the years are not, which is why `t` comes in from the caller
|
|
88
|
+
* and the two are joined here rather than written into an entry with a hole
|
|
89
|
+
* in it.
|
|
90
|
+
*/
|
|
91
|
+
export function yearBucketsFromRange(min, max, t) {
|
|
92
|
+
if (!Number.isFinite(min) || !Number.isFinite(max)) return []
|
|
93
|
+
|
|
94
|
+
const increments = [500, 250, 100, 50]
|
|
95
|
+
let smallestIncrement
|
|
96
|
+
const array = [min]
|
|
97
|
+
let minRounded, maxRounded
|
|
98
|
+
|
|
99
|
+
if (max >= 1800) maxRounded = previousMultiple(max, increments[3])
|
|
100
|
+
else if (max >= 1000) maxRounded = previousMultiple(max, increments[2])
|
|
101
|
+
else if (max >= 0) maxRounded = previousMultiple(max, increments[1])
|
|
102
|
+
else maxRounded = previousMultiple(max, increments[0])
|
|
103
|
+
|
|
104
|
+
if (min <= 0) minRounded = nextMultiple(min, increments[0])
|
|
105
|
+
else if (min <= 1000) minRounded = nextMultiple(min, increments[1])
|
|
106
|
+
else if (min <= 1800) minRounded = nextMultiple(min, increments[2])
|
|
107
|
+
else minRounded = nextMultiple(min, increments[3])
|
|
108
|
+
|
|
109
|
+
let guard = 0
|
|
110
|
+
do {
|
|
111
|
+
if (minRounded < 0) {
|
|
112
|
+
pushIncrements(minRounded, maxRounded, 0, increments[0], array)
|
|
113
|
+
smallestIncrement = increments[0]
|
|
114
|
+
} else if (minRounded < 1000) {
|
|
115
|
+
pushIncrements(minRounded, maxRounded, 1000, increments[1], array)
|
|
116
|
+
smallestIncrement = increments[1]
|
|
117
|
+
} else if (minRounded < 1800) {
|
|
118
|
+
pushIncrements(minRounded, maxRounded, 1800, increments[2], array)
|
|
119
|
+
smallestIncrement = increments[2]
|
|
120
|
+
} else {
|
|
121
|
+
pushIncrements(minRounded, maxRounded, maxRounded, increments[3], array)
|
|
122
|
+
smallestIncrement = increments[3]
|
|
123
|
+
}
|
|
124
|
+
const last = array[array.length - 1]
|
|
125
|
+
// The legacy loop relies on the array growing; if a step adds nothing it
|
|
126
|
+
// would spin forever. Legacy never hit that because its data always did.
|
|
127
|
+
if (last === minRounded && guard++ > 0) break
|
|
128
|
+
minRounded = last
|
|
129
|
+
} while (array[array.length - 1] < maxRounded && guard++ < 64)
|
|
130
|
+
|
|
131
|
+
array.push(max)
|
|
132
|
+
const unique = [...new Set(array)]
|
|
133
|
+
|
|
134
|
+
const labelled = []
|
|
135
|
+
for (const value of unique) {
|
|
136
|
+
if (value < 0) labelled.push({ value, label: `${Math.abs(value)} ${t('catalogue.era.bc')}` })
|
|
137
|
+
else if (value > 0) labelled.push({ value, label: `${value} ${t('catalogue.era.ad')}` })
|
|
138
|
+
}
|
|
139
|
+
if (!labelled.length) return []
|
|
140
|
+
|
|
141
|
+
let display = labelled
|
|
142
|
+
if (labelled[0].value < -1000) {
|
|
143
|
+
display = labelled.filter(({ value }) => value >= -1000)
|
|
144
|
+
display.unshift({ value: min, label: `${t('catalogue.era.before')} 1000 ${t('catalogue.era.bc')}` })
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (display.length > 1) {
|
|
148
|
+
const lastGap = display[display.length - 1].value - display[display.length - 2].value
|
|
149
|
+
if (lastGap < smallestIncrement) {
|
|
150
|
+
const previous = display[display.length - 2]
|
|
151
|
+
display[display.length - 1] = {
|
|
152
|
+
value: display[display.length - 1].value,
|
|
153
|
+
label: `${t('catalogue.era.after')} ${previous.label}`,
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return display
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** The buckets spanning the dates of `records` (`start_date` to `end_date ?? start_date`). */
|
|
161
|
+
export function yearBuckets(records, t, { fields = {} } = {}) {
|
|
162
|
+
const startField = fields.start ?? 'start_date'
|
|
163
|
+
const endField = fields.end ?? 'end_date'
|
|
164
|
+
const starts = []
|
|
165
|
+
const ends = []
|
|
166
|
+
for (const record of records ?? []) {
|
|
167
|
+
const start = year(record, startField)
|
|
168
|
+
const end = year(record, endField) ?? start
|
|
169
|
+
if (start != null) starts.push(start)
|
|
170
|
+
if (end != null) ends.push(end)
|
|
171
|
+
}
|
|
172
|
+
if (!starts.length || !ends.length) return []
|
|
173
|
+
return yearBucketsFromRange(Math.min(...starts), Math.max(...ends), t)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* "1193 AD" / "502 BC" — legacy's era suffix rule, for a year on its own.
|
|
178
|
+
* Nothing for a year that is missing or zero.
|
|
179
|
+
*/
|
|
180
|
+
export function eraLabel(year, t) {
|
|
181
|
+
if (!Number.isFinite(year) || year === 0) return ''
|
|
182
|
+
return year < 0 ? `${Math.abs(year)} ${t('catalogue.era.bc')}` : `${year} ${t('catalogue.era.ad')}`
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* A record's dates rounded outward to the century, the window legacy's
|
|
187
|
+
* "Timeline for this item" opens, so a single-year object still lands on a
|
|
188
|
+
* readable stretch of chronology. `[from, to]`, either null when unknown.
|
|
189
|
+
*/
|
|
190
|
+
export function roundOutward(start, end) {
|
|
191
|
+
const from = Number.isFinite(start) ? Math.floor(start / 100) * 100 : null
|
|
192
|
+
const last = Number.isFinite(end) ? end : start
|
|
193
|
+
const to = Number.isFinite(last) ? Math.ceil(last / 100) * 100 : null
|
|
194
|
+
return [from, to]
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* The century boundaries that three Database forms build locally, reproducing
|
|
199
|
+
* legacy `database.php:88–124`. The asymmetry between the two lists is preserved.
|
|
200
|
+
* The default range (501–2000) is the same as the legacy form's initial state.
|
|
201
|
+
*/
|
|
202
|
+
export function centuryPresets({ from = 501, to = 2000 } = {}) {
|
|
203
|
+
const fromOptions = Array.from({ length: 16 }, (_, i) => 501 + i * 100)
|
|
204
|
+
const toOptions = Array.from({ length: 15 }, (_, i) => 600 + i * 100)
|
|
205
|
+
return {
|
|
206
|
+
from: fromOptions,
|
|
207
|
+
to: toOptions,
|
|
208
|
+
}
|
|
209
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { computed, toValue } from 'vue'
|
|
2
|
+
|
|
3
|
+
// The options a filter offers are the values the records in front of it
|
|
4
|
+
// actually carry, labelled, sorted, and nothing else: a country no item is
|
|
5
|
+
// from is not offered. Every results page derives that from its records —
|
|
6
|
+
// the standalone sites copied the derivation into the entrance and the
|
|
7
|
+
// results view of each site, the DXA sites wrote it over tags — and the one
|
|
8
|
+
// real difference between them is which records they pass: all of them
|
|
9
|
+
// (standalone), or the ones that survive the current filters (DXA, where
|
|
10
|
+
// picking a country shrinks the type list). Both are legacy behaviours, and
|
|
11
|
+
// both stay the caller's choice: this reads whatever records it is handed.
|
|
12
|
+
|
|
13
|
+
const compareLabels = (a, b) => a.label.localeCompare(b.label)
|
|
14
|
+
|
|
15
|
+
function valuesOf(record, spec) {
|
|
16
|
+
if (typeof spec.values === 'function') return [spec.values(record)].flat()
|
|
17
|
+
const raw = record?.[spec.field]
|
|
18
|
+
return Array.isArray(raw) ? raw : [raw]
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function capitalize(text) {
|
|
22
|
+
const label = String(text ?? '')
|
|
23
|
+
return label.charAt(0).toUpperCase() + label.slice(1)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* `{ [facet]: [{ value, label }] }` from `records`, one entry per facet of
|
|
28
|
+
* `spec`. A facet is `{ field }` (the record property holding the value or
|
|
29
|
+
* the values) or `{ values(record) }`, plus `label(value)` — the site's own
|
|
30
|
+
* label helper, so a country reads as its name — and optionally
|
|
31
|
+
* `include(value)` to keep some values only (a tag of one category),
|
|
32
|
+
* `sort` (`'label'`, the default; a comparator over `{ value, label }`; or
|
|
33
|
+
* `'none'` for the order met), and `capitalize` for the legacy upper-cased
|
|
34
|
+
* first letter of a tag label.
|
|
35
|
+
*/
|
|
36
|
+
export function facetOptions(records, spec) {
|
|
37
|
+
const out = {}
|
|
38
|
+
for (const [facet, definition] of Object.entries(spec ?? {})) {
|
|
39
|
+
const seen = new Map()
|
|
40
|
+
for (const record of records ?? []) {
|
|
41
|
+
for (const value of valuesOf(record, definition)) {
|
|
42
|
+
if (value == null || value === '' || seen.has(value)) continue
|
|
43
|
+
if (definition.include && !definition.include(value, record)) continue
|
|
44
|
+
let label = definition.label ? definition.label(value, record) : String(value)
|
|
45
|
+
if (definition.capitalize) label = capitalize(label)
|
|
46
|
+
seen.set(value, { value, label: String(label ?? value) })
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
const options = [...seen.values()]
|
|
50
|
+
const sort = definition.sort ?? 'label'
|
|
51
|
+
if (sort === 'label') options.sort(compareLabels)
|
|
52
|
+
else if (typeof sort === 'function') options.sort(sort)
|
|
53
|
+
out[facet] = options
|
|
54
|
+
}
|
|
55
|
+
return out
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** `facetOptions` over a reactive record source. */
|
|
59
|
+
export function useFacets(records, spec) {
|
|
60
|
+
return computed(() => facetOptions(toValue(records), toValue(spec)))
|
|
61
|
+
}
|
|
@@ -0,0 +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
|
+
}
|