@museumwnf/viewer-core 1.15.0 → 1.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -44,7 +44,25 @@ The `defineViewerConfig` helper returns a Vite config object with the shared set
44
44
 
45
45
  ## Usage
46
46
 
47
- `main.js` (this is the whole website entry point):
47
+ `main.js`, once its own CSS imports and locale glob are done (see
48
+ `createStandardViewer` below for exactly what a website still writes):
49
+
50
+ ```js
51
+ import { createStandardViewer } from '@museumwnf/viewer-core'
52
+ import { catalogues } from '@museumwnf/viewer-i18n/gallery'
53
+ import config from './dataset.config.js'
54
+ import SiteShell from './SiteShell.vue'
55
+
56
+ const ownMessages = {}
57
+ for (const [path, module] of Object.entries(import.meta.glob('../locales/*.json', { eager: true }))) {
58
+ ownMessages[path.split('/').pop().replace(/\.json$/, '')] = module.default
59
+ }
60
+
61
+ createStandardViewer(config, SiteShell, { dictionary: catalogues, ownMessages })
62
+ ```
63
+
64
+ A website that wants the lower-level building block on its own — no shell
65
+ wiring, no message merge, caller mounts it — calls `createViewer()` directly:
48
66
 
49
67
  ```js
50
68
  import { createViewer } from '@museumwnf/viewer-core'
@@ -54,6 +72,46 @@ createViewer(config).mount('#app')
54
72
 
55
73
  ## API
56
74
 
75
+ ### `createStandardViewer(config, siteClass, options)` → the mounted Vue app
76
+
77
+ The one-call form of the `main.js` pattern above: merges `options.dictionary`
78
+ (a `@museumwnf/viewer-i18n/{standalone|gallery|exhibition}` catalogue) with
79
+ `options.ownMessages` (a website's own texts, local wins — same rule as
80
+ [`mergeMessages`](#texts)), calls `createViewer()` with `siteClass` as the
81
+ shell (only when `config.shell` isn't already set — an existing
82
+ `dataset.config.js` that sets `shell` itself keeps working unchanged),
83
+ mounts the result at `options.el` (`'#app'` by default) and returns the
84
+ mounted app.
85
+
86
+ | Argument | Type | Required | Meaning |
87
+ | --- | --- | --- | --- |
88
+ | `config` | object | yes | the website's `dataset.config.js` object, exactly as passed to `createViewer` |
89
+ | `siteClass` | Vue component | no | the website's `SiteShell`; used as `config.shell` when `config.shell` is unset |
90
+ | `options.dictionary` | object | no | the family catalogue from `@museumwnf/viewer-i18n` |
91
+ | `options.ownMessages` | object | no | the website's own catalogue (e.g. from the `import.meta.glob` loop); overloads `dictionary`, local wins |
92
+ | `options.el` | string \| Element | no | mount target, defaults to `'#app'` |
93
+
94
+ Without `options.dictionary`, the merge step is skipped and `config.messages`
95
+ (or `options.ownMessages` on its own) is used as-is — a website that has
96
+ already merged its texts, or has none to merge, needs neither.
97
+
98
+ `viewer-core` does not import `@museumwnf/viewer-i18n` itself: its three
99
+ family dictionaries are separate subpath entry points precisely so a
100
+ website's bundle carries only the one it uses, and choosing between them at
101
+ runtime from a string would cost that tree-shaking (or force every consumer
102
+ to depend on the whole package). The website still imports its own family's
103
+ `catalogues` and passes it in.
104
+
105
+ What a website's `main.js` still writes, and why it cannot move here:
106
+
107
+ - its own CSS imports (`@museumwnf/viewer-layout/style.css`, theme and site
108
+ stylesheets) — each is a static, site-relative specifier only the site's
109
+ own module can write;
110
+ - the `import.meta.glob('../locales/*.json', { eager: true })` loop — a
111
+ glob is resolved relative to the file that calls it, so from inside this
112
+ package it would look for `locales/` next to viewer-core, not next to the
113
+ website.
114
+
57
115
  ### `createViewer(config)` → Vue app (caller mounts it)
58
116
 
59
117
  `config` is the website's `dataset.config.js` object:
@@ -119,7 +177,7 @@ interpolation, no pluralisation, no HTML. A value produced at run time — a
119
177
  number, a date, a count — is rendered by the component *next to* the text,
120
178
  never inside it, which is what lets a text be translated freely.
121
179
 
122
- Shared texts come from [`@museumwnf/viewer-i18n`](https://github.com/metanull/viewer-i18n);
180
+ Shared texts come from [`@museumwnf/viewer-i18n`](https://github.com/museumwithnofrontiers/viewer-i18n);
123
181
  a website's own texts come from its `locales/<lang>.json`. The website merges
124
182
  them and passes the result as `config.messages` — local wins, and that is the
125
183
  only merge rule:
@@ -137,6 +195,11 @@ for (const [path, module] of Object.entries(import.meta.glob('../locales/*.json'
137
195
  createViewer({ ...config, messages: mergeMessages(catalogues, local) }).mount('#app')
138
196
  ```
139
197
 
198
+ `createStandardViewer(config, siteClass, { dictionary, ownMessages })` (see
199
+ [above](#createstandardviewerconfig-siteclass-options--the-mounted-vue-app))
200
+ does exactly this merge, plus the shell wiring and the mount — a website
201
+ normally reaches for that instead of calling `mergeMessages` itself.
202
+
140
203
  | Export | Use |
141
204
  | --- | --- |
142
205
  | `$t(key)` | in a template |
@@ -433,6 +496,52 @@ needs it; the standalone sites never import it.
433
496
  | `partnerFromKey(partners, countries, countryCode, legacyId)` | the partner a legacy `/partner/:country/:id` route names, or null — `partnerKey` in reverse |
434
497
  | `itemFromUidPath(items, path)` | the item a legacy dbUid *path* names, or null — `backward_compatibility` with `:` swapped for `/`, matched case-insensitively (Sharing History stores its keys lowercase) |
435
498
 
499
+ ### `@museumwnf/viewer-core/dxa`
500
+
501
+ The gallery/exhibition-pair composables the four live DXA sites each wrote
502
+ for themselves — `useCollection`/`useGalleryData`, the timeline, the
503
+ partner specs and the item sheet — byte-identical within each pair
504
+ (carpets/amulets — a gallery; the-use-of-colours-in-art/water-in-islam — an
505
+ exhibition). Promoted here per family, never as one composable that papers
506
+ over the two: the cross-family diff is real behaviour (the exhibition
507
+ shape's per-language-build 404 gate, its own local-vs-country timeline
508
+ switch, its manifest-driven citation line), confirmed file by file against
509
+ both pairs' `origin/main` (epic metanull/inventory-app#1730). A separate
510
+ entry point because only a gallery/exhibition site needs it.
511
+
512
+ A site composes its own data/catalogue/timeline/partner/sheet module from
513
+ these, threading the `data` object one family's
514
+ `useGalleryData()`/`useExhibitionData()` call returns into the rest — the
515
+ same explicit parameter every function below takes, rather than each
516
+ re-reading a module-level singleton:
517
+
518
+ ```js
519
+ import {
520
+ useGalleryData, useGalleryCollection, useGalleryTimeline, useGalleryPartner, useGallerySheet,
521
+ } from '@museumwnf/viewer-core/dxa'
522
+
523
+ const data = useGalleryData()
524
+ const collection = useGalleryCollection(data)
525
+ const timeline = useGalleryTimeline(data, collection)
526
+ const partner = useGalleryPartner(data, collection)
527
+ const sheet = useGallerySheet(data)
528
+ ```
529
+
530
+ No export below reads a site name, a legacy project key, or a UUID: what a
531
+ family's composables need beyond the data package itself is a `config`
532
+ parameter (currently unused by either family — every DXA site so far
533
+ shares the same entity list and catalogue shape; documented per function
534
+ below for whichever future site needs its own).
535
+
536
+ | Export (gallery / exhibition) | Reads | Returns |
537
+ | --- | --- | --- |
538
+ | `useGalleryData(config)` / `useExhibitionData(config)` | `config.eager` (entity names `loadEnglish` loads eagerly; default the family's own list, the exhibition's also carrying `themes`) | the family's whole data layer: `tr`/`md`/`mdInline`/`mdStrip`/`labelOf`/`loadEnglish`/`translations`, every entity and lookup map (`items`, `partners`, `countries`, `tags`, `dynasties`, `timelines`, `timelineEvents`, …), the routes (`itemRoute`, `partnerRoute`, `partnerObjectsRoute`), the legacy URL wrappers (`itemFromUidPath`, `partnerFromKey` — gallery only), `chromeImage`, `manifest`, `defaultLang`, plus family-specific fields (gallery: `siblingGalleries`/`pickSiblings`; exhibition: `isHiddenPartner`, `isInstitution`, `projectName`, `exhibitionTitle`/`exhibitionSubtitle`/`exhibitionHeadline`/`bannerCaption`, `siblingSites`) |
539
+ | `useGalleryCollection(data, config)` / `useExhibitionCollection(data, config)` | the `data` object above; `config` unused today | `FACETS`, `tile(item, t)`, `collectionResults` (the `CatalogueResultsView` spec), plus the facet-value helpers (`countryIdForCode`, `tagLabelForLegacy`, gallery's own `tagIdForLegacy`, `hasEveryTag`) |
540
+ | `useGalleryTimeline(data, collection, config)` / `useExhibitionTimeline(data, collection, config)` | `data`, the `collection` object above (for its `tile`); `config` unused today | the timeline specs (`timelineResults`/`timelineSpec`, `timelineGallery`/`timelineGallerySpec`) and their helpers (`countryLabel`/`countryIdForCode`, `timelineGalleryItems`; the exhibition shape adds `usesLocalTimeline`, `hasTimeline`, `timelineCountries`, `findEvents` for its own local-vs-country chronology switch) |
541
+ | `useGalleryPartner(data, collection, config)` / `useExhibitionPartner(data, config)` | `data`, and (gallery only) `collection` for its `tile` | `partnerList`/`partnerListSpec`, `partnerSheet`/`partnerSheetSpec`, and (gallery only) `partnerObjects` — the exhibition shape's `partnerSpecs.js` never carried an objects-page spec |
542
+ | `useGallerySheet(data, config)` / `useExhibitionSheet(data, config)` | `data`; `config` unused today | `{ itemSheet }`, the `RecordView` spec — the exhibition shape's `citation.project` reads `data.projectName`, absent from the gallery shape |
543
+ | `PAGE_SIZE`, `DATE_MODE`, `FACET_CATEGORIES`, `FACET_LABEL_KEYS`, `useFacetLabels()` | — | the five THG catalogue facets, shared verbatim by both families (nine tiles a page, containment dates, `type`/`dynasty`/`subject`/`material`/`artist`) |
544
+
436
545
  ### The declaration outside a component
437
546
 
438
547
  | Export | Meaning |
package/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@museumwnf/viewer-core",
3
- "version": "1.15.0",
3
+ "version": "1.16.0",
4
4
  "description": "Application engine for MWNF websites: turns a dataset.config.js into a mounted Vue application.",
5
5
  "type": "module",
6
6
  "license": "SEE LICENSE IN LICENSE.md",
7
7
  "repository": {
8
8
  "type": "git",
9
- "url": "git+https://github.com/metanull/viewer-core.git"
9
+ "url": "git+https://github.com/museumwithnofrontiers/viewer-core.git"
10
10
  },
11
11
  "publishConfig": {
12
12
  "access": "public",
@@ -19,6 +19,7 @@
19
19
  "module": "./src/index.js",
20
20
  "exports": {
21
21
  ".": "./src/index.js",
22
+ "./dxa": "./src/dxa/index.js",
22
23
  "./i18n": "./src/i18n/index.js",
23
24
  "./legacy": "./src/legacy/index.js",
24
25
  "./testing": "./src/testing/index.js",
@@ -0,0 +1,61 @@
1
+ import { createViewer } from './createViewer.js'
2
+ import { mergeMessages } from './i18n/index.js'
3
+
4
+ /**
5
+ * Build, wire up and mount a standard MWNF website in one call.
6
+ *
7
+ * Every website's `main.js` does the same three things after its own CSS
8
+ * and locale imports: merge the shared dictionary its family pulls in
9
+ * (`@museumwnf/viewer-i18n/{standalone|gallery|exhibition}`) under its own
10
+ * `locales/*.json` overloads (local wins), hand the result and the site's
11
+ * shell component to `createViewer()`, and mount the app. This helper does
12
+ * those three steps, so `main.js` is left with only what genuinely cannot
13
+ * move here:
14
+ *
15
+ * - its own CSS imports (`@museumwnf/viewer-layout/style.css`, the theme
16
+ * and site stylesheets) — Vite needs a static, site-relative specifier
17
+ * for each one, which only the site's own module can write;
18
+ * - the `import.meta.glob('../locales/*.json', { eager: true })` loop that
19
+ * loads the site's own texts — a glob is resolved relative to the file
20
+ * that calls it, so run from inside this package it would look for
21
+ * `locales/` next to viewer-core, not next to the website.
22
+ *
23
+ * `viewer-core` does not depend on `@museumwnf/viewer-i18n`: that package's
24
+ * three family dictionaries are independent subpath entry points precisely
25
+ * so a website's bundle carries only the one it uses. Importing one of them
26
+ * from here — or choosing between them at runtime from a string in
27
+ * `config` — would either force every consumer to carry all three or
28
+ * replace a bundler-time choice with one a bundler cannot tree-shake. The
29
+ * website still imports its own family's `catalogues` and passes it as
30
+ * `dictionary`.
31
+ *
32
+ * @param {object} config - the site's `dataset.config.js` object, exactly
33
+ * as passed to {@link createViewer} today
34
+ * @param {object} [siteClass] - the site's `SiteShell` component, used as
35
+ * `config.shell` when `config` does not already set one (a config that
36
+ * still declares its own `shell` keeps taking precedence, so existing
37
+ * `dataset.config.js` files need no change)
38
+ * @param {object} [options]
39
+ * @param {object} [options.dictionary] - the family's shared catalogue,
40
+ * i.e. `catalogues` imported from
41
+ * `@museumwnf/viewer-i18n/{standalone|gallery|exhibition}`
42
+ * @param {object} [options.ownMessages] - the website's own catalogue,
43
+ * already loaded (e.g. via the `import.meta.glob` loop above); merged
44
+ * over `dictionary` with {@link mergeMessages} — local wins. Used on its
45
+ * own, without a `dictionary`, when a website has no shared family text
46
+ * @param {string|Element} [options.el] - the mount target, defaults to
47
+ * `'#app'`, exactly like every website's current `main.js`
48
+ * @returns {import('vue').App} the mounted Vue application (call
49
+ * `.unmount()` on it, e.g. in a test)
50
+ */
51
+ export function createStandardViewer(config, siteClass, options = {}) {
52
+ const { dictionary, ownMessages, el = '#app' } = options
53
+
54
+ const messages = dictionary
55
+ ? mergeMessages(dictionary, ownMessages ?? {})
56
+ : (config.messages ?? ownMessages ?? {})
57
+
58
+ const app = createViewer({ ...config, shell: config.shell ?? siteClass, messages })
59
+ app.mount(el)
60
+ return app
61
+ }
@@ -0,0 +1,176 @@
1
+ import { DATE_MODE, FACET_CATEGORIES, PAGE_SIZE } from '../shared.js'
2
+
3
+ export { DATE_MODE, FACET_CATEGORIES, FACET_LABEL_KEYS, PAGE_SIZE, useFacetLabels } from '../shared.js'
4
+
5
+ // The catalogue spec: what a DXA exhibition's lists filter and search on.
6
+ // The engine — query state, options, dates, pages, the boolean grammar — is
7
+ // viewer-core's own; what is declared here is only what is an exhibition's:
8
+ // the facet *predicates* over its own data, the haystack the search bar
9
+ // reads, and the tile a record becomes.
10
+ // `PAGE_SIZE`/`DATE_MODE`/`FACET_CATEGORIES`/`FACET_LABEL_KEYS`/
11
+ // `useFacetLabels` are shared verbatim with the gallery family — see
12
+ // `../shared.js`.
13
+ //
14
+ // Legacy asked the API for everything: `/items?ic[]=…&id[]=…&na=…&nz=…` for
15
+ // the results, and `/items/countries`, `/items/tags`, `/items/years` *with
16
+ // the same filters applied* for the dropdowns — which is what made the
17
+ // facets dependent (pick a country, and the type list shrinks to the types
18
+ // still reachable). The results page hands the engine the matching subset
19
+ // for that reason; the entrance hands it everything.
20
+
21
+ /**
22
+ * The exhibition's collection spec, over its own data layer's `data` (the
23
+ * return value of {@link import('./useExhibitionData.js').useExhibitionData}).
24
+ *
25
+ * Reads `data.countries`, `data.tags`, `data.countryById`, `data.tagById`,
26
+ * `data.itemById`, `data.labelOf`, `data.itemRoute`, `data.tr`,
27
+ * `data.defaultLang`, `data.mdInline`, `data.projectName`. `config` carries
28
+ * nothing today.
29
+ */
30
+ export function useExhibitionCollection(data, config = {}) {
31
+ void config
32
+ const {
33
+ countries, tags, countryById, tagById, itemById, labelOf, itemRoute, tr, defaultLang, mdInline, projectName,
34
+ } = data
35
+
36
+ // ── What the URL carries ─────────────────────────────────────────────
37
+ //
38
+ // The legacy 2-letter country code for `country`, the legacy tag id for a
39
+ // facet: the values legacy's URLs carried, so a link shared then still
40
+ // resolves now.
41
+
42
+ function countryIdForCode(code) {
43
+ return code ? (countries.value ?? []).find((c) => c.code === code)?.id ?? null : null
44
+ }
45
+
46
+ function tagLabelForLegacy(legacyId) {
47
+ return (tags.value ?? []).find((t) => t.legacy_tag_id === legacyId)?.label ?? legacyId
48
+ }
49
+
50
+ /**
51
+ * The facet spec for viewer-core's `useFacets`: the country by code, each
52
+ * tag category by legacy id, labels upper-cased on the first letter as
53
+ * legacy printed them.
54
+ */
55
+ const FACETS = {
56
+ country: {
57
+ values: (item) => countryById.value.get(item.country_id)?.code ?? null,
58
+ label: (code) => labelOf('countries', countryIdForCode(code)),
59
+ },
60
+ ...Object.fromEntries(
61
+ FACET_CATEGORIES.map((category) => [
62
+ category,
63
+ {
64
+ values: (item) =>
65
+ (item.tag_ids ?? [])
66
+ .map((id) => tagById.value.get(id))
67
+ .filter((tag) => tag && tag.category === category)
68
+ .map((tag) => tag.legacy_tag_id),
69
+ label: tagLabelForLegacy,
70
+ capitalize: true,
71
+ },
72
+ ]),
73
+ ),
74
+ }
75
+
76
+ // ── The search bar ───────────────────────────────────────────────────
77
+ //
78
+ // Legacy ran MySQL boolean full-text search over the English sheet; the
79
+ // haystack is the same set of fields, and the grammar is viewer-core's.
80
+
81
+ function haystack(item, text) {
82
+ return [
83
+ text.name, text.description, text.short_description, text.type, text.holder, text.dates,
84
+ text.location, text.provenance, text.alternate_name, text.place_of_production,
85
+ ...(text.keywords ?? []), ...(text.materials ?? []),
86
+ item.internal_name, item.owner_reference, item.mwnf_reference,
87
+ labelOf('partners', item.partner_id), labelOf('countries', item.country_id),
88
+ ]
89
+ }
90
+
91
+ // ── The tile ──────────────────────────────────────────────────────────
92
+
93
+ /**
94
+ * A record as viewer-layout's grid contract: the thumbnail, the name, the
95
+ * lines legacy's hover card carried (date, holder, place, source
96
+ * project). The project name is the manifest's own, read in the same
97
+ * `defaultLang` every other line of this tile is — nothing for a record
98
+ * legacy left nameless (the Explore case), so the line is dropped, not
99
+ * printed with a hole in it.
100
+ */
101
+ function tile(item, t) {
102
+ const text = tr('items', item.id, defaultLang)
103
+ const project = projectName(item, defaultLang)
104
+ return {
105
+ id: item.id,
106
+ image: item.images?.[0]?.url ?? '',
107
+ imageAlt: labelOf('items', item.id),
108
+ name: mdInline(text.name ?? item.internal_name ?? ''),
109
+ meta: [
110
+ text.dates ?? '',
111
+ labelOf('partners', item.partner_id),
112
+ [text.location, labelOf('countries', item.country_id)].filter(Boolean).join(', '),
113
+ project ? `${t('catalogue.results.forProject')} ${project}` : '',
114
+ ].filter(Boolean),
115
+ to: itemRoute(item),
116
+ }
117
+ }
118
+
119
+ // ── The results page, as a spec ──────────────────────────────────────
120
+ //
121
+ // What viewer-layout's `CatalogueResultsView` renders on
122
+ // `/collection-results`: the facets over the *matching* records (the
123
+ // dependent dropdowns above), the containment date rule, undated first,
124
+ // nine tiles a page, legacy's "Collection | <selections>" summary line.
125
+ // The panel itself is composed by the view's wrapper, in the aside where
126
+ // legacy put it, so no controls are declared here. Every text is an
127
+ // entry name.
128
+
129
+ // `from`/`to`, not `start`/`end`: the entrance's `SearchFormView` spec
130
+ // (`mode: 'facets'`, `dates: 'buckets'`) writes the date bounds under
131
+ // those two fixed keys — the view's own, not something a spec can rename
132
+ // — so the results side has to read the same ones to stay linked to it.
133
+ const KEYS = ['country', ...FACET_CATEGORIES, 'from', 'to']
134
+
135
+ /** The filter summary line legacy printed as "Collection | <selections>". */
136
+ function filterSummary(filters, t) {
137
+ const parts = []
138
+ if (filters.country) parts.push(labelOf('countries', countryIdForCode(filters.country)))
139
+ for (const key of FACET_CATEGORIES) if (filters[key]) parts.push(tagLabelForLegacy(filters[key]))
140
+ if (filters.from) parts.push(`${t('catalogue.filter.from')} ${filters.from}`)
141
+ if (filters.to) parts.push(`${t('catalogue.filter.to')} ${filters.to}`)
142
+ return parts.filter(Boolean).join(' | ')
143
+ }
144
+
145
+ const collectionResults = {
146
+ entity: 'items',
147
+ keys: KEYS,
148
+ facets: FACETS,
149
+ facetScope: 'matching',
150
+ filterMode: 'immediate',
151
+ // The engine reads the raw `items` entity; a per-language build ships
152
+ // every member's `languages` array but not every member's text in this
153
+ // language, and legacy 404s such a record (`itemById`'s own rule, in
154
+ // useExhibitionData.js). The results page must not offer what the sheet
155
+ // would refuse to open, so it is filtered to the same renderable
156
+ // subset.
157
+ scope: (item) => itemById.value.has(item.id),
158
+ dates: { mode: DATE_MODE, begin: 'from', end: 'to' },
159
+ sort: { undated: 'first' },
160
+ pageSize: PAGE_SIZE,
161
+ variant: 'grid',
162
+ recordRoute: 'item',
163
+ actionLabel: 'exhibition.action.seeDatabaseEntry',
164
+ empty: 'catalogue.results.noResults',
165
+ pagination: { jump: true },
166
+
167
+ record: (item, { t }) => tile(item, t),
168
+
169
+ summary: ({ filters, pageInfo, t }) => [
170
+ { label: t('exhibition.section.collection'), value: filterSummary(filters, t) },
171
+ { count: pageInfo.total, value: `${t('catalogue.results.outOf')} ${itemById.value.size} ${t('catalogue.results.objects')}` },
172
+ ],
173
+ }
174
+
175
+ return { FACETS, haystack, tile, collectionResults, countryIdForCode, tagLabelForLegacy }
176
+ }
@@ -0,0 +1,246 @@
1
+ import { computed } from 'vue'
2
+ import { mediaUrl } from '../../siteConfig.js'
3
+ import { useCatalogueData } from '../../composables/useCatalogueData.js'
4
+ import { useDataPackage } from '../../composables/useDataPackage.js'
5
+ import { byId, entityRef } from '../../composables/useEntities.js'
6
+ import { projectLabel } from '../../conventions.js'
7
+
8
+ // The data layer every DXA exhibition site (the-use-of-colours-in-art,
9
+ // water-in-islam, …) wrote for itself: the entities an exhibition reads,
10
+ // the lookup maps over them, its routes, its legacy key mappings and its
11
+ // chrome. Promoted from the byte-identical copies the two live exhibitions
12
+ // carried (epic #1730) — what a future exhibition needs that is genuinely
13
+ // its own belongs in `config`, not in a fork of this file. The themes tree
14
+ // stays in a site's own `composables/themes.js`, on top of viewer-core's
15
+ // `useCollectionTree`.
16
+
17
+ /** Every entity an exhibition page reads English labels or fallbacks for. */
18
+ const DEFAULT_EAGER = ['items', 'partners', 'countries', 'glossary', 'dynasties', 'timeline_events', 'themes']
19
+
20
+ /**
21
+ * The exhibition's data layer, bound to the installed data package. Each
22
+ * entity is a shared ref that stays `null` until a route declaring it in
23
+ * `meta.entities` brings its chunk in, so calling this loads nothing, and a
24
+ * page pays only for what it reads.
25
+ *
26
+ * `config.eager` overrides the entities `loadEnglish` loads eagerly
27
+ * (default: {@link DEFAULT_EAGER}).
28
+ */
29
+ export function useExhibitionData(config = {}) {
30
+ const dataPackage = useDataPackage()
31
+ const manifest = dataPackage.manifest
32
+
33
+ // English is the base language of every catalogue in the platform: every
34
+ // list, label and fallback reads it. A record the visitor reads in
35
+ // another language is resolved on the sheet itself, by
36
+ // `useRecordLanguage`.
37
+ const defaultLang = 'en'
38
+
39
+ const exhibition = entityRef('exhibition')
40
+ const relatedContent = entityRef('related_content')
41
+ const tags = entityRef('tags')
42
+ const countries = entityRef('countries')
43
+ const languages = entityRef('languages')
44
+ const dynasties = entityRef('dynasties')
45
+ const glossary = entityRef('glossary')
46
+ const timelines = entityRef('timelines')
47
+ const timelineEvents = entityRef('timeline_events')
48
+
49
+ // E6: a hidden museum is exported but must not appear on any list or
50
+ // profile page. Its items still render — legacy hides the museum, not the
51
+ // object. Declared here as a `visible` predicate rather than coded into
52
+ // every page that lists partners; `isHiddenPartner` stays a named export
53
+ // because the item sheet's holder line is the one surface that needs the
54
+ // opposite of `visible` — the museum keeps its name and loses only the
55
+ // link, because it has no page to link to.
56
+ const hiddenPartnerIds = computed(() => new Set(exhibition.value?.hidden_partner_ids ?? []))
57
+ function isHiddenPartner(partner) {
58
+ return hiddenPartnerIds.value.has(partner?.id)
59
+ }
60
+
61
+ const catalogue = useCatalogueData({
62
+ eager: config.eager ?? DEFAULT_EAGER,
63
+ defaultLanguage: defaultLang,
64
+ visible: {
65
+ // An item's `languages` is what it has TRANSLATIONS in, so a
66
+ // non-empty array without this build's language means the text
67
+ // exists in some other language and not in this one — legacy's own
68
+ // instance 404s such a record, and this build drops it to match.
69
+ //
70
+ // An EMPTY array is a different case and must not be swept in with
71
+ // it: it means the package has no text in ANY language, which is a
72
+ // gap in the export rather than a fact about the record, and legacy
73
+ // serves those records regardless. They keep their legacy names
74
+ // through `labelOf`'s `internal_name` fallback and lose only their
75
+ // descriptions. Hence the `!i.languages?.length ||` guard, which
76
+ // reads like a redundant null-check and is not.
77
+ items: (i) => !i.languages?.length || i.languages.includes(defaultLang),
78
+ partners: (p) => !hiddenPartnerIds.value.has(p.id),
79
+ },
80
+ })
81
+
82
+ const items = catalogue.entity('items')
83
+ const itemById = catalogue.index('items')
84
+ const visiblePartners = catalogue.entity('partners')
85
+ const visiblePartnerIndex = catalogue.index('partners')
86
+ function visiblePartnerById(id) {
87
+ return visiblePartnerIndex.value.get(id) ?? null
88
+ }
89
+
90
+ const {
91
+ tr, md, mdInline, mdStrip, labelOf, loadEnglish, availableLanguages, loadTranslations, translations,
92
+ } = catalogue
93
+ loadEnglish()
94
+
95
+ /**
96
+ * Exhibition chrome images and the related-content PDFs.
97
+ * `banner_image_path`, `homepage_image_path` and `document_path` were
98
+ * never imported into inventory storage, so the package ships the legacy
99
+ * path and the address is built from the host the site's
100
+ * `dataset.config.js` declares under `media`. `size` ∈ zoom | hi_res |
101
+ * lo_res | small | full.
102
+ */
103
+ function chromeImage(path, size = 'hi_res') {
104
+ return mediaUrl(path, size)
105
+ }
106
+
107
+ // ── Lookup maps ────────────────────────────────────────────────────────
108
+ //
109
+ // Unfiltered, unlike `itemById`/`visiblePartnerById`: a holder line still
110
+ // needs to resolve a hidden museum's name (`isHiddenPartner` above is
111
+ // what suppresses the link), and every other lookup here has no
112
+ // visibility rule to begin with.
113
+ const partnerById = byId('partners')
114
+ const countryById = byId('countries')
115
+ const tagById = byId('tags')
116
+ const dynastyById = byId('dynasties')
117
+ const glossaryById = byId('glossary')
118
+ const languageByCode = byId('languages', 'code')
119
+
120
+ // countries.json is keyed by the inventory id (ISO 3166-1 alpha-3), but
121
+ // the legacy two-letter code is what related_content and the timeline
122
+ // keyspaces carry. `code` is the country's own backward_compatibility, so
123
+ // this is the bridge between the two — and the reason it is a lookup
124
+ // rather than a parse is that several legacy codes are not ISO (`uk`,
125
+ // `pa`, `qt`, `ua`, `sb`).
126
+ const countryByCode = computed(
127
+ () => new Map((countries.value ?? []).filter((c) => c.code).map((c) => [c.code, c])),
128
+ )
129
+
130
+ /** The same label from a legacy two-letter code (`uk` → United Kingdom). */
131
+ function countryLabelFromCode(code) {
132
+ if (!code) return ''
133
+ const country = countryByCode.value.get(code)
134
+ return country ? labelOf('countries', country.id) : code
135
+ }
136
+
137
+ /** The canonical item route: the package id, and no language in the path. */
138
+ function itemRoute(item) {
139
+ return { name: 'item', params: { id: item.id } }
140
+ }
141
+
142
+ // Institutions (monument owners) and museums both live in partners.json —
143
+ // the package ships the union of legacy's /partners and /institutions
144
+ // because a static package has no endpoints to split them across. The
145
+ // viewer routes by `type`, which is what legacy's two page templates
146
+ // keyed off.
147
+ function isInstitution(partner) {
148
+ return partner?.type === 'institution'
149
+ }
150
+
151
+ function partnerRoute(partner) {
152
+ return {
153
+ name: isInstitution(partner) ? 'institution' : 'partner',
154
+ params: { id: partner.id },
155
+ }
156
+ }
157
+
158
+ function partnerObjectsRoute(partner, page = 1) {
159
+ return {
160
+ name: isInstitution(partner) ? 'institution-monuments' : 'partner-objects',
161
+ params: { id: partner.id },
162
+ query: page > 1 ? { page } : {},
163
+ }
164
+ }
165
+
166
+ // ── Source projects ────────────────────────────────────────────────────
167
+ //
168
+ // A member is borrowed from the MWNF project that originally published
169
+ // it. The name is the data package's own `manifest.projects` entry
170
+ // (populated by the importer/exporter), keyed by this record's
171
+ // `project_id` (a UUID) — not viewer-core's deprecated, legacy-key-keyed
172
+ // `projectName()`/`projectFamily()`, and not an exhibition's own
173
+ // project-key comparisons either. The manifest already names an
174
+ // exhibition's own native project under its own uuid (the sibling
175
+ // `Collection` the importer creates alongside every `Project` carries the
176
+ // same title), so there is no special case here for a native member any
177
+ // more; the colour swatch is this exhibition's own editorial choice
178
+ // (`dataset.config.js`'s `projectColors`, read by the site's `ItemSheet`).
179
+
180
+ // Some members have no `project_id` at all: they come from the Explore
181
+ // monuments database rather than from a project, which is why provenance
182
+ // has to be read from the keyspace here instead of from a field. Legacy
183
+ // still coloured them and still printed an empty project name, so its
184
+ // citation read `"…" in , Museum With No Frontiers, …` with a hole in it.
185
+ // The colour is reproduced (the site's own `.mwnf-chip--Explore` rule);
186
+ // the empty name is not, because a label reading "for" with nothing after
187
+ // it is a rendering fault rather than a faithful copy. The line is
188
+ // dropped instead.
189
+ function isExploreRecord(item) {
190
+ return (item?.backward_compatibility ?? '').startsWith('mwnf3_explore:')
191
+ }
192
+
193
+ /** `null` for a record with no project (the Explore case) or whose
194
+ * project the manifest does not name in `lang`. */
195
+ function projectName(item, lang = defaultLang) {
196
+ return projectLabel(manifest, item?.project_id, lang)
197
+ }
198
+
199
+ /** The exhibition's own per-language chrome text. */
200
+ function exhibitionTitle(lang = defaultLang) {
201
+ return exhibition.value?.titles?.[lang] ?? exhibition.value?.titles?.en ?? ''
202
+ }
203
+
204
+ function exhibitionSubtitle(lang = defaultLang) {
205
+ return exhibition.value?.subtitles?.[lang] ?? exhibition.value?.subtitles?.en ?? ''
206
+ }
207
+
208
+ function exhibitionHeadline(lang = defaultLang) {
209
+ return exhibition.value?.headlines?.[lang] ?? exhibition.value?.headlines?.en ?? ''
210
+ }
211
+
212
+ function bannerCaption(lang = defaultLang) {
213
+ return exhibition.value?.banner_captions?.[lang] ?? exhibition.value?.banner_captions?.en ?? ''
214
+ }
215
+
216
+ // ── Sibling sites ──────────────────────────────────────────────────────
217
+ //
218
+ // Decision Q3: these are reference objects, not resolved links. The
219
+ // exporter records identity plus whatever the import carried; where a
220
+ // `legacy_host` came across we can link to it, and where it did not the
221
+ // entry still renders — it just does not become an anchor.
222
+
223
+ const siblingSites = computed(
224
+ () => (exhibition.value?.sibling_sites ?? []).filter((s) => !s.hidden),
225
+ )
226
+
227
+ function siblingUrl(sibling) {
228
+ return sibling?.legacy_host || null
229
+ }
230
+
231
+ return {
232
+ manifest,
233
+ defaultLang,
234
+ exhibition, relatedContent, tags, countries, languages, dynasties, glossary, timelines, timelineEvents,
235
+ isHiddenPartner,
236
+ items, itemById, visiblePartners, visiblePartnerById,
237
+ tr, md, mdInline, mdStrip, labelOf, loadEnglish, availableLanguages, loadTranslations, translations,
238
+ chromeImage,
239
+ partnerById, countryById, tagById, dynastyById, glossaryById, languageByCode,
240
+ countryByCode, countryLabelFromCode,
241
+ itemRoute, isInstitution, partnerRoute, partnerObjectsRoute,
242
+ isExploreRecord, projectName,
243
+ exhibitionTitle, exhibitionSubtitle, exhibitionHeadline, bannerCaption,
244
+ siblingSites, siblingUrl,
245
+ }
246
+ }