@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.
Files changed (43) hide show
  1. package/LICENSE.md +44 -0
  2. package/README.md +635 -0
  3. package/package.json +50 -0
  4. package/src/AppRoot.vue +58 -0
  5. package/src/catalogue/dates.js +209 -0
  6. package/src/catalogue/facets.js +61 -0
  7. package/src/catalogue/keywordIndex.js +289 -0
  8. package/src/catalogue/listQuery.js +100 -0
  9. package/src/catalogue/pagination.js +57 -0
  10. package/src/catalogue/searchLanguage.js +32 -0
  11. package/src/catalogue/timeline.js +220 -0
  12. package/src/composables/useCatalogueData.js +141 -0
  13. package/src/composables/useDataPackage.js +98 -0
  14. package/src/composables/useEntities.js +100 -0
  15. package/src/composables/useRecordLanguage.js +71 -0
  16. package/src/conventions.js +148 -0
  17. package/src/createViewer.js +29 -0
  18. package/src/i18n/I18nText.vue +17 -0
  19. package/src/i18n/I18nTextInline.vue +17 -0
  20. package/src/i18n/index.js +88 -0
  21. package/src/i18n/language.js +127 -0
  22. package/src/i18n/markdown.js +265 -0
  23. package/src/index.js +50 -0
  24. package/src/injectionKeys.js +1 -0
  25. package/src/languages.js +49 -0
  26. package/src/legacy/index.js +58 -0
  27. package/src/record/collectionTree.js +307 -0
  28. package/src/record/derivations.js +154 -0
  29. package/src/record/partners.js +87 -0
  30. package/src/record/recordSheet.js +231 -0
  31. package/src/rights.js +53 -0
  32. package/src/router/index.js +138 -0
  33. package/src/siteConfig.js +37 -0
  34. package/src/styles/base.css +49 -0
  35. package/src/testing/checkOfferedLanguages.js +67 -0
  36. package/src/testing/index.js +3 -0
  37. package/src/testing/smoke.js +103 -0
  38. package/src/testing/viteConfig.js +90 -0
  39. package/src/views/DetailView.vue +53 -0
  40. package/src/views/HomeView.vue +20 -0
  41. package/src/views/ListView.vue +61 -0
  42. package/src/views/NotFoundView.vue +8 -0
  43. package/src/vite.js +8 -0
@@ -0,0 +1,100 @@
1
+ import { computed, reactive, watch } from 'vue'
2
+ import { useRoute, useRouter } from 'vue-router'
3
+
4
+ // The state of a list page lives in the URL, and the URL is what a visitor
5
+ // shares, bookmarks and comes back to through history. Every results page of
6
+ // every website therefore does the same four things: read the filters and the
7
+ // page out of `route.query`, keep them in step when the route changes under
8
+ // it, write them back when the visitor acts, and start again from page one
9
+ // when a filter changes. Eleven views wrote those four things by hand before
10
+ // this existed, each with its own idiom and its own small differences.
11
+ //
12
+ // The convention this encodes is rule R8 of the alignment pass: filters and
13
+ // the page travel in the query, never in the path; `page` is dropped when it
14
+ // is 1; a filter change is a new place in history (`push`), a page change
15
+ // replaces the current one (`replace`), and either navigates on the route the
16
+ // page is already on, by name, so no view writes a path.
17
+
18
+ const PAGE = 'page'
19
+
20
+ function firstValue(value) {
21
+ return Array.isArray(value) ? (value[0] ?? '') : value ?? ''
22
+ }
23
+
24
+ function pageOf(query) {
25
+ const parsed = Number.parseInt(firstValue(query[PAGE]), 10)
26
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : 1
27
+ }
28
+
29
+ /**
30
+ * Filters and page bound to the route query.
31
+ *
32
+ * `keys` names the filters this page knows. `filters` is a reactive object
33
+ * with one string per key — bind a control to it, then `apply()` to navigate
34
+ * with what it holds (or `apply(patch)` to change some and navigate at once,
35
+ * for a control that navigates on change). `reset()` clears every filter and
36
+ * navigates. `goToPage(n)` changes only the page. `page` is the current page,
37
+ * read from the query. `active` says whether any filter is set.
38
+ *
39
+ * Query parameters the page does not own — `lang`, a parameter of another
40
+ * component — are carried across every navigation untouched.
41
+ */
42
+ export function useListQuery({ keys = [], page = true } = {}) {
43
+ const route = useRoute()
44
+ const router = useRouter()
45
+
46
+ const filters = reactive(Object.fromEntries(keys.map((key) => [key, ''])))
47
+
48
+ function readFromRoute() {
49
+ for (const key of keys) filters[key] = String(firstValue(route.query[key]))
50
+ }
51
+ readFromRoute()
52
+ // The route changing under the page — back, forward, a link to the same
53
+ // page with other filters — is the URL speaking; the controls follow it.
54
+ watch(() => route.query, readFromRoute)
55
+
56
+ const current = computed(() => (page ? pageOf(route.query) : 1))
57
+ const active = computed(() => keys.some((key) => filters[key] !== ''))
58
+
59
+ function foreignQuery() {
60
+ const out = {}
61
+ for (const [key, value] of Object.entries(route.query)) {
62
+ if (!keys.includes(key) && key !== PAGE) out[key] = value
63
+ }
64
+ return out
65
+ }
66
+
67
+ function ownQuery() {
68
+ const out = {}
69
+ for (const key of keys) if (filters[key] !== '') out[key] = filters[key]
70
+ return out
71
+ }
72
+
73
+ function location(query) {
74
+ return { name: route.name, params: route.params, query }
75
+ }
76
+
77
+ /** Navigate with the filters as they are, or with `patch` applied first. Starts at page 1. */
78
+ function apply(patch = {}) {
79
+ for (const [key, value] of Object.entries(patch)) {
80
+ if (keys.includes(key)) filters[key] = value == null ? '' : String(value)
81
+ }
82
+ return router.push(location({ ...foreignQuery(), ...ownQuery() }))
83
+ }
84
+
85
+ /** Clear every filter and navigate. */
86
+ function reset() {
87
+ for (const key of keys) filters[key] = ''
88
+ return router.push(location(foreignQuery()))
89
+ }
90
+
91
+ /** Another page of the same results: the same place in history, so back leaves the list. */
92
+ function goToPage(n) {
93
+ const target = Math.max(1, Number.parseInt(n, 10) || 1)
94
+ const query = { ...foreignQuery(), ...ownQuery() }
95
+ if (target > 1) query[PAGE] = String(target)
96
+ return router.replace(location(query))
97
+ }
98
+
99
+ return { filters, page: current, active, apply, reset, goToPage }
100
+ }
@@ -0,0 +1,57 @@
1
+ import { computed, toValue } from 'vue'
2
+
3
+ // One page arithmetic for every list. Five implementations existed — this
4
+ // package's own ListView, a block inlined into six standalone views, a
5
+ // component copied into four DXA sites, and two idioms for the count — and
6
+ // they disagreed on nothing but shape. This is the DXA shape, which answers
7
+ // everything a page needs to render itself: where it is, how far it goes,
8
+ // and which rows it holds.
9
+
10
+ const DEFAULT_SIZE = 20
11
+
12
+ /**
13
+ * `{ total, lastPage, currentPage, from, to, rows }` for one page of `list`.
14
+ * A page past the end clamps to the last one; a page below one is the first;
15
+ * `from`/`to` are 1-based and both 0 for an empty list. The size is the
16
+ * caller's: twenty on the standalone sites and nine on the DXA sites are
17
+ * both legacy facts, and neither is this function's to decide.
18
+ */
19
+ export function paginate(list, page, size = DEFAULT_SIZE) {
20
+ const rows = list ?? []
21
+ const total = rows.length
22
+ const perPage = Math.max(1, Number(size) || DEFAULT_SIZE)
23
+ const lastPage = Math.max(1, Math.ceil(total / perPage))
24
+ const requested = Number.parseInt(page, 10)
25
+ const currentPage = Math.min(Math.max(1, Number.isFinite(requested) ? requested : 1), lastPage)
26
+ const start = (currentPage - 1) * perPage
27
+ return {
28
+ total,
29
+ lastPage,
30
+ currentPage,
31
+ from: total === 0 ? 0 : start + 1,
32
+ to: Math.min(start + perPage, total),
33
+ rows: rows.slice(start, start + perPage),
34
+ }
35
+ }
36
+
37
+ /**
38
+ * `paginate` over reactive sources: `list` and `page` may each be a ref, a
39
+ * computed, a getter or a plain value — a `useListQuery().page`, typically.
40
+ */
41
+ export function usePagination(list, { page = 1, size = DEFAULT_SIZE } = {}) {
42
+ return computed(() => paginate(toValue(list), toValue(page), toValue(size)))
43
+ }
44
+
45
+ /**
46
+ * Chronological order by `key` (a record's `start_date`), undated records
47
+ * last — or first, with `undated: 'first'`. Stable: two records of the same
48
+ * year keep the order they arrived in. Returns a new array.
49
+ */
50
+ export function sortChronological(list, { key = 'start_date', undated = 'last' } = {}) {
51
+ const missing = undated === 'first' ? -Infinity : Infinity
52
+ const year = (record) => {
53
+ const value = record?.[key]
54
+ return typeof value === 'number' && Number.isFinite(value) ? value : missing
55
+ }
56
+ return [...(list ?? [])].sort((a, b) => year(a) - year(b))
57
+ }
@@ -0,0 +1,32 @@
1
+ import { computed, ref, watch } from 'vue'
2
+ import { useDataPackage } from '../composables/useDataPackage.js'
3
+
4
+ /**
5
+ * The search-language rule: a sorted list of languages the entity's
6
+ * translations exist in, and a watcher that loads them when the selection
7
+ * changes. Three Database pages carry this watch locally, reading three
8
+ * different entity names.
9
+ */
10
+ export function useSearchLanguage(entity) {
11
+ const { availableLanguages, loadTranslations } = useDataPackage()
12
+
13
+ // The languages the entity actually carries translations in, sorted.
14
+ const languages = computed(() => {
15
+ const available = availableLanguages(entity)
16
+ return available.slice().sort()
17
+ })
18
+
19
+ // The user's current selection.
20
+ const language = ref('')
21
+
22
+ // Load translations when the selection changes; the watch runs immediately
23
+ // so the reference language (English, the index language) is always there.
24
+ watch(() => language.value, (lang) => {
25
+ if (lang) loadTranslations(entity, lang)
26
+ }, { immediate: true })
27
+
28
+ return {
29
+ languages,
30
+ language,
31
+ }
32
+ }
@@ -0,0 +1,220 @@
1
+ import { computed } from 'vue'
2
+ import { entityRef } from '../composables/useEntities.js'
3
+ import { eraLabel, yearBucketsFromRange } from './dates.js'
4
+
5
+ // The country chronology (`hcr` in legacy) is one merged, year-ordered list
6
+ // per country, served by legacy `/v2/events` and reproduced identically on
7
+ // every gallery and standalone site. An exhibition can instead carry its own
8
+ // narrative chronology (`source: 'thg_local'` — legacy's `/thg/timeline`),
9
+ // which has no country of its own and is never interleaved with the merge.
10
+ // Sharing History's Timeline additionally splits the merge by the exhibition
11
+ // each event belongs to (`collection_id` on its timeline row, `null` for the
12
+ // Permanent Collection). Every one of the seven websites' Timeline pages is
13
+ // one of these three axes over the same event shape — this is the one place
14
+ // that shape is read.
15
+ //
16
+ // The event overlap rule was triplicated verbatim (islamicart, baroqueart,
17
+ // sharinghistory) and mirrors legacy `class.hcr.inc.php`'s SQL; carpets and
18
+ // water-in-islam wrote a related but distinct copy for the DXA sites. Both
19
+ // are ported here rather than merged into a fourth variant, because the two
20
+ // already ship identical answers on the cases that matter (see the tests):
21
+ // the only real divergence was which of the three label rules a site reached
22
+ // for, which is now one function trying all three in the order the data
23
+ // disambiguates them.
24
+ //
25
+ // What stays out, by design: the DXA legacy 2-letter country code
26
+ // (`legacyCodeOf`/`nameFor` in carpets and water-in-islam) — parsing
27
+ // `backward_compatibility` and falling back to `Intl.DisplayNames` is a
28
+ // concern of the sites still keying their Timeline URLs on that code, not of
29
+ // the merge itself. Those sites pass `countryIdForCode` to translate their
30
+ // code back to the id this module filters on.
31
+
32
+ /** `year_to === 0` mirrors the legacy convention for an open-ended period. */
33
+ export function effectiveYearTo(event) {
34
+ return event?.year_to && event.year_to !== 0 ? event.year_to : null
35
+ }
36
+
37
+ /**
38
+ * Whether `event` falls in `[begin, end]`, either bound optional — legacy's
39
+ * `class.hcr.inc.php` rule: an event with a known end overlaps once that end
40
+ * is past `begin` and its start is before `end`; an open-ended event (no
41
+ * known end) overlaps on its start alone. An event with no `year_from` at
42
+ * all never overlaps a bounded search — there is nothing to compare — but
43
+ * still matches a search with no bound, the same as every dated event does.
44
+ */
45
+ export function overlapsRange(event, begin, end) {
46
+ const yf = event?.year_from
47
+ const yt = effectiveYearTo(event)
48
+ if (begin == null && end == null) return true
49
+ if (!Number.isFinite(yf)) return false
50
+ if (begin != null && end != null) {
51
+ return yt !== null ? yt > begin && yf < end : yf > begin && yf < end
52
+ }
53
+ if (begin != null) return yt !== null ? yt > begin : yf > begin
54
+ return yf <= end
55
+ }
56
+
57
+ /**
58
+ * The label under an event, trying the three rules the sites share in the
59
+ * order the data disambiguates them: a curated description pair
60
+ * (`date_from_description`/`date_to_description`, for an event whose date
61
+ * is imprecise in a way a bare year cannot say — every package's named event
62
+ * carries a date description too, so the title is not a date), a narrative
63
+ * chronology's own period name (`text.name`, a sort key, only tried when an
64
+ * event has no date description at all), and failing both, the derived year
65
+ * range through `eraLabel` — the one reached for by the worldwide country
66
+ * merge, which has neither.
67
+ */
68
+ export function eventDateLabel(event, text, t) {
69
+ if (text?.date_from_description) {
70
+ // Legacy printed the pair only when the two differ; the era branch
71
+ // collapses an equal year_from/year_to the same way. Trim before
72
+ // comparing so that whitespace doesn't bloat the display.
73
+ if (text.date_to_description?.trim() === text.date_from_description.trim()) {
74
+ return text.date_from_description
75
+ }
76
+ return text.date_to_description
77
+ ? `${text.date_from_description} – ${text.date_to_description}`
78
+ : text.date_from_description
79
+ }
80
+ if (text?.name) return text.name
81
+ if (!Number.isFinite(event?.year_from)) return ''
82
+ const from = eraLabel(event.year_from, t)
83
+ const yt = effectiveYearTo(event)
84
+ return yt !== null ? `${from} – ${eraLabel(yt, t)}` : `${from} –`
85
+ }
86
+
87
+ /**
88
+ * The engine of a Timeline results page, one implementation for the three
89
+ * axes legacy split across separate endpoints:
90
+ *
91
+ * - `'country'` — the worldwide country merge (galleries, and the standalone
92
+ * sites that offer only this axis);
93
+ * - `'local'` — an exhibition's own narrative chronology in place of the
94
+ * merge (the `thg_local` timeline row), no country picker;
95
+ * - `'collection'` — the merge again, plus filtering by which exhibition an
96
+ * event's timeline is bound to (`null` = the Permanent Collection), for
97
+ * Sharing History's second axis.
98
+ *
99
+ * `countryLabel(countryId)` names a country for the picker and is the
100
+ * site's own — a country reads as its name, which this module has no way to
101
+ * know. `countryIdForCode`, if given, resolves a `findEvents({ country })`
102
+ * value through the site's own legacy-code table before filtering; omit it
103
+ * and `country` is already the id. `tr(id)` returns an event's translated
104
+ * fields already resolved for entity and language — the site's own `tr`,
105
+ * bound the way every data composable already binds it. `timelinesEntity`
106
+ * / `eventsEntity` name the data package files, for a site that calls its
107
+ * own something else.
108
+ */
109
+ export function useTimelineEvents({
110
+ scope = 'country',
111
+ countryLabel,
112
+ countryIdForCode,
113
+ tr,
114
+ timelinesEntity = 'timelines',
115
+ eventsEntity = 'timeline_events',
116
+ } = {}) {
117
+ const timelines = entityRef(timelinesEntity)
118
+ const events = entityRef(eventsEntity)
119
+
120
+ // The narrative chronology has no country of its own, so it must be kept
121
+ // out of the merge by `source` rather than by the country filter — on "all
122
+ // countries" it would otherwise interleave into the worldwide list.
123
+ const localTimeline = computed(
124
+ () => (timelines.value ?? []).find((row) => row.source === 'thg_local') ?? null,
125
+ )
126
+
127
+ const timelineById = computed(() => new Map((timelines.value ?? []).map((row) => [row.id, row])))
128
+
129
+ /** The events this scope works over — one chronology, never both. */
130
+ const eventPool = computed(() => {
131
+ const all = events.value ?? []
132
+ const local = localTimeline.value
133
+ if (scope === 'local') return local ? all.filter((event) => event.timeline_id === local.id) : []
134
+ return local ? all.filter((event) => event.timeline_id !== local.id) : all
135
+ })
136
+
137
+ const hasTimeline = computed(() => eventPool.value.length > 0)
138
+ const usesLocalTimeline = computed(() => scope === 'local' && Boolean(localTimeline.value))
139
+
140
+ /**
141
+ * Countries that actually have a chronology, alphabetized by label, with
142
+ * an "all" marker first — the marker carries no label of its own, so the
143
+ * picker renders it through its own catalogue entry. No picker at all for
144
+ * `'local'`: legacy hides the country select once the section is the
145
+ * exhibition's own chronology, because there is nothing to filter by.
146
+ */
147
+ const countries = computed(() => {
148
+ if (scope === 'local') return []
149
+ const seen = new Map()
150
+ for (const row of timelines.value ?? []) {
151
+ if (!row.country_id || seen.has(row.country_id)) continue
152
+ seen.set(row.country_id, {
153
+ value: row.country_id,
154
+ label: countryLabel ? countryLabel(row.country_id) : row.country_id,
155
+ })
156
+ }
157
+ const rows = [...seen.values()].sort((a, b) => String(a.label).localeCompare(String(b.label)))
158
+ return [{ value: 'all', label: null }, ...rows]
159
+ })
160
+
161
+ /** Every event year present in this scope, as the year picker's source range. */
162
+ const yearRange = computed(() => {
163
+ const years = eventPool.value
164
+ .map((event) => event.year_from)
165
+ .filter((value) => Number.isFinite(value) && value !== 0)
166
+ if (!years.length) return [null, null]
167
+ return [Math.min(...years), Math.max(...years)]
168
+ })
169
+
170
+ /** The legacy era buckets over this scope's year range. */
171
+ function yearBuckets(t) {
172
+ const [min, max] = yearRange.value
173
+ return yearBucketsFromRange(min, max, t)
174
+ }
175
+
176
+ function resolveCountryId(country) {
177
+ if (country == null || country === 'all') return null
178
+ return countryIdForCode ? countryIdForCode(country) : country
179
+ }
180
+
181
+ // `null` is Sharing History's Permanent Collection sentinel — a timeline
182
+ // bound to no exhibition — so it must be told apart from "no filter",
183
+ // which `collection: undefined` spells instead.
184
+ function matchesCollection(event, collection) {
185
+ const collectionId = timelineById.value.get(event.timeline_id)?.collection_id ?? null
186
+ return collection === null ? collectionId === null : collectionId === collection
187
+ }
188
+
189
+ /**
190
+ * Legacy's `/events?ic[]=&ya=&yo=` (and Sharing History's exhibition
191
+ * filter alongside it) — this scope's events in `[begin, end]`, for the
192
+ * given country and/or collection, chronological. `display_order` breaks a
193
+ * tie on `year_from`, the order the exhibition itself gave two events of
194
+ * the same year; an event with no year at all has no place in that order
195
+ * and sorts after every dated one.
196
+ */
197
+ function findEvents({ country, collection, begin, end } = {}) {
198
+ const countryId = resolveCountryId(country)
199
+ const from = begin === '' || begin == null ? null : Number(begin)
200
+ const to = end === '' || end == null ? null : Number(end)
201
+
202
+ return eventPool.value
203
+ .filter((event) => {
204
+ // A narrative chronology's events carry no country_id at all — the
205
+ // filter has nothing to apply itself to, the same reason `countries`
206
+ // renders no picker for this scope.
207
+ if (scope !== 'local' && countryId && event.country_id !== countryId) return false
208
+ if (collection !== undefined && !matchesCollection(event, collection)) return false
209
+ return overlapsRange(event, from, to)
210
+ })
211
+ .map((event) => ({ ...event, text: tr ? tr(event.id) : {} }))
212
+ .sort((a, b) => {
213
+ const ya = Number.isFinite(a.year_from) ? a.year_from : Infinity
214
+ const yb = Number.isFinite(b.year_from) ? b.year_from : Infinity
215
+ return ya - yb || (a.display_order ?? 0) - (b.display_order ?? 0)
216
+ })
217
+ }
218
+
219
+ return { countries, yearRange, yearBuckets, hasTimeline, usesLocalTimeline, findEvents }
220
+ }
@@ -0,0 +1,141 @@
1
+ import { computed } from 'vue'
2
+ import { useDataPackage } from './useDataPackage.js'
3
+ import { byId as coreById, entityRef } from './useEntities.js'
4
+ import { md as coreMd, mdInline as coreMdInline, mdStrip as coreMdStrip } from '../i18n/markdown.js'
5
+ import { glossaryEntries } from '../record/recordSheet.js'
6
+
7
+ // The wrapper half of seven sites' data composables (2,376 lines across
8
+ // them), written once. Every one of them re-wrapped the same `tr`, the same
9
+ // memoised `loadEnglish`, the same one-shape label helper
10
+ // (`mdStrip(tr(entity, id).name ?? record.internal_name ?? id)`), and the
11
+ // same `md`/`mdInline`/`mdStrip` re-export, around whichever entities and
12
+ // visibility rules were its own — a per-build language filter, a hidden-
13
+ // partner rule, `display_status` — coded rather than declared. This is that
14
+ // wrapper. What survives in a site's own composable is what genuinely
15
+ // differs: routes, legacy key mappings, chrome images, sibling lists.
16
+ //
17
+ // Called once, at the top of a site's data composable module, and its
18
+ // return value re-exported from there — the way `entityRef`/`byId` are
19
+ // already read at module scope, so importing the module loads nothing and a
20
+ // page pays only for the entity it declares. `entity`/`index` read the same
21
+ // shared `entityRef`/`byId` this file does, so a second call would only
22
+ // duplicate the memoisation `loadEnglish` and the glossary binding carry —
23
+ // one instance per site, not one per component.
24
+ const BASE_LANGUAGE = 'en'
25
+
26
+ // A term's spellings and definition in one language, falling back to
27
+ // English, then to the term's own headword — the same row shape
28
+ // `useRecordSheet`'s `termRow` builds, but for the whole glossary in
29
+ // `defaultLanguage` rather than one record's `glossary_ids` in the record's
30
+ // own language, which is why it is not reused: a catalogue list or a label
31
+ // is always shown in `defaultLanguage`, a record sheet's text is not.
32
+ function glossaryRows(language, translations) {
33
+ const rows = translations('glossary', language)
34
+ const fallback = translations('glossary', BASE_LANGUAGE)
35
+ const terms = []
36
+ for (const term of entityRef('glossary').value ?? []) {
37
+ const t = rows[term.id] ?? fallback[term.id] ?? {}
38
+ const spellings = (t.spellings ?? []).map((s) => String(s).trim()).filter(Boolean)
39
+ const word = term.word ?? spellings[0] ?? ''
40
+ if (!word && spellings.length === 0) continue
41
+ terms.push({ id: term.id, word, definition: t.definition ?? '', spellings: spellings.length ? spellings : [word] })
42
+ }
43
+ return terms
44
+ }
45
+
46
+ /**
47
+ * The wrapper half of a website's data composable: `tr`, `md`/`mdInline`/
48
+ * `mdStrip` bound to the site's glossary, `loadEnglish`, `labelOf`, the
49
+ * visible form of an entity and its index, and the package re-exports.
50
+ *
51
+ * `eager` names the entities `loadEnglish` loads — every entity the package
52
+ * carries by default. `defaultLanguage` is the language `tr`, the labels and
53
+ * the glossary binding read (`'en'` for every site so far). `visible` names,
54
+ * per entity, the predicate `entity()`/`index()` apply — a per-build
55
+ * language filter, a hidden-partner rule, a display-status check are all
56
+ * "which records this build shows", declared here once instead of coded
57
+ * into every page that lists that entity. `glossary` turns off the default
58
+ * binding on `md`/`mdInline` (a caller may still pass its own `glossary` to
59
+ * either, on or off).
60
+ */
61
+ export function useCatalogueData({ eager, defaultLanguage = BASE_LANGUAGE, visible = {}, glossary = true } = {}) {
62
+ const pkg = useDataPackage()
63
+ const { availableLanguages, loadTranslations, translations } = pkg
64
+ const eagerEntities = eager ?? pkg.entityNames
65
+
66
+ /** One record's translated fields, falling back to `defaultLanguage` then `{}`. */
67
+ function tr(entity, id, lang = defaultLanguage) {
68
+ return pkg.tr(entity, id, lang, defaultLanguage)
69
+ }
70
+
71
+ const defaultGlossary = computed(() =>
72
+ glossary ? glossaryEntries(glossaryRows(defaultLanguage, translations)) : [],
73
+ )
74
+
75
+ // A caller's own `glossary` — a record's own terms, not every term in the
76
+ // package — always wins, whether the default binding is on or off; `md`
77
+ // and `mdInline` differ only in which core renderer they wrap, `mdStrip`
78
+ // has nothing to bind, since plain text carries no highlighting.
79
+ function bindGlossary(renderer) {
80
+ return (text, opts = {}) =>
81
+ renderer(text, { ...opts, glossary: 'glossary' in opts ? opts.glossary : defaultGlossary.value })
82
+ }
83
+ const md = bindGlossary(coreMd)
84
+ const mdInline = bindGlossary(coreMdInline)
85
+ const mdStrip = coreMdStrip
86
+
87
+ /** `mdStrip(tr(entity, id).name ?? record[fallback] ?? id)` — the one label shape every site wrote. */
88
+ function labelOf(entity, id, { fallback = 'internal_name' } = {}) {
89
+ if (!id) return ''
90
+ const name = tr(entity, id).name
91
+ if (name) return mdStrip(name)
92
+ const record = coreById(entity).value.get(id)
93
+ return mdStrip(record?.[fallback] ?? id)
94
+ }
95
+
96
+ let englishReady = null
97
+ /** Loads `defaultLanguage` for every entity in `eager`, once. */
98
+ function loadEnglish() {
99
+ if (!englishReady) {
100
+ englishReady = Promise.all(eagerEntities.map((name) => loadTranslations(name, defaultLanguage)))
101
+ }
102
+ return englishReady
103
+ }
104
+
105
+ // Cached the way `byId` is: a site reads `entity('items')`/`index('items')`
106
+ // from more than one place, and each should see the same ref rather than
107
+ // a fresh filter pass and a fresh Map per call.
108
+ const entityCache = new Map()
109
+ /** `entityRef(name)`'s records, narrowed by `visible[name]` when one is declared; `null` until loaded. */
110
+ function entity(name) {
111
+ let ref = entityCache.get(name)
112
+ if (!ref) {
113
+ const source = entityRef(name)
114
+ const predicate = visible[name]
115
+ ref = computed(() => {
116
+ if (source.value === null) return null
117
+ return predicate ? source.value.filter(predicate) : source.value
118
+ })
119
+ entityCache.set(name, ref)
120
+ }
121
+ return ref
122
+ }
123
+
124
+ const indexCache = new Map()
125
+ /** A `Map` of `entity(name)`'s records by `key` — the visible ones, unlike `byId`. */
126
+ function index(name, key = 'id') {
127
+ const cacheKey = `${name}.${key}`
128
+ let ref = indexCache.get(cacheKey)
129
+ if (!ref) {
130
+ const list = entity(name)
131
+ ref = computed(() => new Map((list.value ?? []).map((record) => [record[key], record])))
132
+ indexCache.set(cacheKey, ref)
133
+ }
134
+ return ref
135
+ }
136
+
137
+ return {
138
+ tr, md, mdInline, mdStrip, loadEnglish, labelOf, entity, index,
139
+ availableLanguages, loadTranslations, translations,
140
+ }
141
+ }
@@ -0,0 +1,98 @@
1
+ import { ref } from 'vue'
2
+
3
+ // package.json is npm metadata, not an entity — exclude it from the glob.
4
+ const entityModules = import.meta.glob(['@inventory-data/*.json', '!**/package.json'])
5
+ const manifestModules = import.meta.glob('@inventory-data/manifest.json', { eager: true })
6
+
7
+ // One file per entity per language; a file is simply absent when that entity
8
+ // has no translation in that language. Left lazy (unlike entityModules'
9
+ // eager-in-spirit-but-actually-per-call loaders, this one also needs a
10
+ // reactive cache — see below) so a build only bundles what a page visit
11
+ // actually requests, not every language of every entity.
12
+ const translationModules = import.meta.glob('@inventory-data/translations/*.json')
13
+
14
+ const manifest = Object.values(manifestModules)[0]?.default ?? {}
15
+
16
+ const entityLoaders = {}
17
+ for (const [path, load] of Object.entries(entityModules)) {
18
+ const name = path.split('/').pop().replace(/\.json$/, '')
19
+ if (name !== 'manifest') {
20
+ entityLoaders[name] = load
21
+ }
22
+ }
23
+
24
+ function translationLoader(entity, lang) {
25
+ const suffix = `/translations/${entity}.${lang}.json`
26
+ const path = Object.keys(translationModules).find((k) => k.endsWith(suffix))
27
+ return path ? translationModules[path] : null
28
+ }
29
+
30
+ // `${entity}.${lang}` → record map, or `{}` once a miss has been resolved.
31
+ // A plain `await import()` would already be deduped by the module system;
32
+ // this cache exists so a component reading through `translations`/`tr` -
33
+ // synchronous, not awaited - re-renders once a lazy `loadTranslations` call
34
+ // elsewhere resolves.
35
+ const translationCache = ref({})
36
+
37
+ export function useDataPackage() {
38
+ return {
39
+ manifest,
40
+ languages: manifest.languages ?? ['en'],
41
+ entityNames: Object.keys(entityLoaders).sort(),
42
+ async loadEntity(name) {
43
+ const load = entityLoaders[name]
44
+ if (!load) {
45
+ throw new Error(`Unknown entity "${name}" in the data package`)
46
+ }
47
+ const module = await load()
48
+ return module.default ?? module
49
+ },
50
+
51
+ /** Languages `translations/<entity>.<lang>.json` actually exists for. */
52
+ availableLanguages(entity) {
53
+ const prefix = `/translations/${entity}.`
54
+ return Object.keys(translationModules)
55
+ .filter((k) => k.includes(prefix))
56
+ .map((k) => k.slice(k.lastIndexOf(prefix) + prefix.length, -'.json'.length))
57
+ },
58
+
59
+ /**
60
+ * Lazy-loads `translations/<entity>.<lang>.json` (a map of record id to
61
+ * that record's translated fields); `{}` when the file does not exist.
62
+ * Cached, so calling it again for the same pair is free.
63
+ */
64
+ async loadTranslations(entity, lang) {
65
+ const key = `${entity}.${lang}`
66
+ if (translationCache.value[key]) return translationCache.value[key]
67
+ const load = translationLoader(entity, lang)
68
+ let data = {}
69
+ if (load) {
70
+ try {
71
+ data = (await load()).default ?? {}
72
+ } catch {
73
+ data = {}
74
+ }
75
+ }
76
+ translationCache.value = { ...translationCache.value, [key]: data }
77
+ return data
78
+ },
79
+
80
+ /** Already-loaded translations for one entity/lang; `{}` if not loaded yet (or absent). */
81
+ translations(entity, lang) {
82
+ return translationCache.value[`${entity}.${lang}`] ?? {}
83
+ },
84
+
85
+ /**
86
+ * One record's translated fields, falling back to `fallbackLang` (default
87
+ * `'en'`) then to `{}`. Reads only what is already loaded — call
88
+ * `loadTranslations` first for each language a page may need.
89
+ */
90
+ tr(entity, id, lang, fallbackLang = 'en') {
91
+ return (
92
+ translationCache.value[`${entity}.${lang}`]?.[id] ??
93
+ translationCache.value[`${entity}.${fallbackLang}`]?.[id] ??
94
+ {}
95
+ )
96
+ },
97
+ }
98
+ }