@museumwnf/viewer-core 1.14.0 → 1.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/LICENSE.md +44 -44
  2. package/README.md +658 -635
  3. package/package.json +50 -50
  4. package/src/AppRoot.vue +58 -58
  5. package/src/catalogue/dates.js +209 -209
  6. package/src/catalogue/facets.js +61 -61
  7. package/src/catalogue/keywordIndex.js +289 -289
  8. package/src/catalogue/listQuery.js +100 -100
  9. package/src/catalogue/pagination.js +57 -57
  10. package/src/catalogue/searchLanguage.js +32 -32
  11. package/src/catalogue/timeline.js +220 -220
  12. package/src/composables/useCatalogueData.js +141 -141
  13. package/src/composables/useDataPackage.js +98 -98
  14. package/src/composables/useEntities.js +100 -100
  15. package/src/composables/useRecordLanguage.js +71 -71
  16. package/src/conventions.js +252 -148
  17. package/src/createViewer.js +29 -29
  18. package/src/i18n/I18nText.vue +17 -17
  19. package/src/i18n/I18nTextInline.vue +17 -17
  20. package/src/i18n/index.js +88 -88
  21. package/src/i18n/language.js +127 -127
  22. package/src/i18n/markdown.js +265 -265
  23. package/src/index.js +51 -50
  24. package/src/injectionKeys.js +1 -1
  25. package/src/languages.js +49 -49
  26. package/src/legacy/index.js +58 -58
  27. package/src/record/collectionTree.js +307 -307
  28. package/src/record/derivations.js +154 -154
  29. package/src/record/partners.js +87 -87
  30. package/src/record/recordSheet.js +231 -231
  31. package/src/rights.js +53 -53
  32. package/src/router/index.js +138 -138
  33. package/src/siteConfig.js +37 -37
  34. package/src/styles/base.css +49 -49
  35. package/src/testing/checkOfferedLanguages.js +67 -67
  36. package/src/testing/index.js +3 -3
  37. package/src/testing/smoke.js +103 -103
  38. package/src/testing/viteConfig.js +90 -90
  39. package/src/views/DetailView.vue +53 -53
  40. package/src/views/HomeView.vue +20 -20
  41. package/src/views/ListView.vue +61 -61
  42. package/src/views/NotFoundView.vue +8 -8
  43. package/src/vite.js +8 -8
package/README.md CHANGED
@@ -1,635 +1,658 @@
1
- # @museumwnf/viewer-core
2
-
3
- Application engine for MWNF websites. A website repo is a thin shell: it provides a
4
- `dataset.config.js`, its data package and its locale files — this package turns that
5
- into a mounted Vue application (router, i18n, data access, shared views).
6
-
7
- Stack: Vue 3, Vue Router (hash history), Vite, Vitest. Distributed as raw
8
- ESM source; the website's Vite build compiles it.
9
-
10
- Texts are handled here, without an i18n library: see [Texts](#texts). Nothing in
11
- the platform depends on `vue-i18n` any more, and this package no longer asks for
12
- it.
13
-
14
- ## Install
15
-
16
- Published to npmjs (`registry.npmjs.org`), publicly — no authentication needed
17
- to install. (Versions up to 1.13.2, published as `@metanull/viewer-core`,
18
- remain available on GitHub Packages, frozen; no new version is published
19
- there.)
20
-
21
- ```bash
22
- npm install @museumwnf/viewer-core vue vue-router
23
- ```
24
-
25
- Required website-side Vite configuration (`vite.config.js`):
26
-
27
- ```js
28
- import { defineConfig } from 'vite'
29
- import vue from '@vitejs/plugin-vue'
30
- import { defineViewerConfig } from '@museumwnf/viewer-core/vite'
31
-
32
- export default defineConfig({
33
- ...defineViewerConfig({ dataPackage: '@museumwnf/<dataset>-data', plugins: [vue()] }),
34
- })
35
- ```
36
-
37
- The `defineViewerConfig` helper returns a Vite config object with the shared settings all websites use:
38
-
39
- | Setting | Value | Why |
40
- | --- | --- | --- |
41
- | `resolve.alias['@inventory-data']` | path to the installed `@museumwnf/<dataset>-data` | `useDataPackage` reads all JSON (entities and `translations/`) through this alias |
42
- | `optimizeDeps.exclude` | `['@museumwnf/viewer-core']` | the package ships `.vue` source; esbuild pre-bundling cannot parse it |
43
- | plugin | supplied via `plugins` parameter | compiles the shipped `.vue` views |
44
-
45
- ## Usage
46
-
47
- `main.js` (this is the whole website entry point):
48
-
49
- ```js
50
- import { createViewer } from '@museumwnf/viewer-core'
51
- import config from './dataset.config.js'
52
- createViewer(config).mount('#app')
53
- ```
54
-
55
- ## API
56
-
57
- ### `createViewer(config)` → Vue app (caller mounts it)
58
-
59
- `config` is the website's `dataset.config.js` object:
60
-
61
- | Key | Type | Required | Meaning |
62
- | --- | --- | --- | --- |
63
- | `datasetPackage` | string | yes | name of the data package, e.g. `@museumwnf/<dataset>-data` (informational) |
64
- | `siteName` | string | yes | shown on the Home view |
65
- | `languages` | string[] | no | the languages the website offers, from [`offeredLanguages()`](#which-languages-a-website-offers); defaults to the manifest's `languages` |
66
- | `features.entities` | string[] | no | entity names getting list + detail routes (`/<entity>`, `/<entity>/:id`) |
67
- | `views` | `{ home?, list?, detail? }` | no | the components that render the three slots — `/` and the routes `features.entities` publishes — in place of the generic views (see [Views](#views)) |
68
- | `extraViews` | RouteRecord[] | no | website-specific routes appended to the router (see [Routing](#routing)) |
69
- | `routes` | RouteRecord[] | no | raw vue-router records appended after `extraViews` |
70
- | `legacyRoutes` | `[{ path, resolve }]` | no | legacy URL shapes, redirect-only (see [Routing](#routing)) |
71
- | `notFound` | Vue component or `false` | no | the not-found view; `false` leaves the catch-all out |
72
- | `scrollBehavior` | function | no | overrides the router's default scroll rule |
73
- | `shell` | Vue component | no | page-shell component rendered around the active view (see below) |
74
- | `navigation` | object | no | passed through untouched as props of the `shell` component |
75
- | `messages` | `{ [lang]: { [key]: text } }` | no | the website's effective catalogue: the `@museumwnf/viewer-i18n` bundle it receives, merged with its own `locales/` files (see [Texts](#texts)) |
76
- | `media` | `{ legacyHost }` | no | the host of the legacy media server, for [`mediaUrl()`](#the-declaration-outside-a-component) |
77
- | `links` | object | no | the addresses a website links out to: `portal`, `galleries`, `myCollection`, `about`, `contact`, `legalNotice`, `credits`, `cookies` |
78
- | `site.origin` | string | no | the absolute origin this build is deployed at, base path included when there is one (`https://metanull.github.io/islamicart`, no trailing slash) — declared once here rather than baked into the package, since the package cannot know where it will be served; read by [`sourceUrl()`](#the-declaration-outside-a-component) |
79
-
80
- `dataset.config.js` is the whole declaration of a website. Before it mounts,
81
- a website reads nothing from its data package but `manifest.json`; it reads
82
- no environment variable; it carries no address outside `links`, `media` and `site`.
83
-
84
- ### Page shell (`config.shell`)
85
-
86
- When `config.shell` is set, the root component renders it around `<router-view/>`
87
- instead of the bare view. The shell is any component honouring this contract:
88
-
89
- - receives, via `v-bind`: `languages` (the resolved language list), every key of
90
- `config.navigation` (untouched), and `language` (the current locale, reactive);
91
- - may emit `update:language` — `createViewer` sets the active language, after
92
- checking that the website offers it;
93
- - renders its default slot as the page content (that slot is the active view).
94
-
95
- `PageShell` from `@museumwnf/viewer-layout` honours this contract; a website enables
96
- it entirely from `dataset.config.js`:
97
-
98
- ```js
99
- import { PageShell } from '@museumwnf/viewer-layout'
100
-
101
- export default {
102
- // …
103
- shell: PageShell,
104
- navigation: {
105
- headerTitle: 'My Museum',
106
- navLinks: [{ label: 'Home', href: '#/' }],
107
- footerText: '© My Museum',
108
- },
109
- }
110
- ```
111
-
112
- (Navigation links are plain `href`s — with the hash router, `#/things` navigates
113
- without any router coupling in the layout.)
114
-
115
- ## Texts
116
-
117
- A text is a key and a Markdown text. Nothing else: no placeholders, no
118
- interpolation, no pluralisation, no HTML. A value produced at run time — a
119
- number, a date, a count — is rendered by the component *next to* the text,
120
- never inside it, which is what lets a text be translated freely.
121
-
122
- Shared texts come from [`@museumwnf/viewer-i18n`](https://github.com/metanull/viewer-i18n);
123
- a website's own texts come from its `locales/<lang>.json`. The website merges
124
- them and passes the result as `config.messages` — local wins, and that is the
125
- only merge rule:
126
-
127
- ```js
128
- import { createViewer, mergeMessages } from '@museumwnf/viewer-core'
129
- import { catalogues } from '@museumwnf/viewer-i18n/gallery'
130
- import config from './dataset.config.js'
131
-
132
- const local = {}
133
- for (const [path, module] of Object.entries(import.meta.glob('../locales/*.json', { eager: true }))) {
134
- local[path.split('/').pop().replace(/\.json$/, '')] = module.default
135
- }
136
-
137
- createViewer({ ...config, messages: mergeMessages(catalogues, local) }).mount('#app')
138
- ```
139
-
140
- | Export | Use |
141
- | --- | --- |
142
- | `$t(key)` | in a template |
143
- | `useI18n()` → `{ t, locale }` | in `<script setup>` |
144
- | `useLocale()` | the active language, read-only |
145
- | `<I18nText keypath tag="div">` | a text rendered as Markdown (block) |
146
- | `<I18nTextInline keypath tag="span">` | the same, without the surrounding paragraph |
147
- | `mergeMessages(...catalogues)` | the merge rule above |
148
- | `negotiateLanguage`, `isRtl` | the language rules, for a website that needs them directly |
149
- | `renderBlock(text, { breaks, glossary })`, `renderInline(text, { breaks, glossary })`, `renderPlain(text)` | the Markdown pipeline `I18nText`/`I18nTextInline` render through, for a website that needs it directly (a data-package field, not a text) |
150
- | `md(text, { glossary, breaks = true })`, `mdInline(text, { glossary })`, `mdStrip(text)` | the same three, under the record-field convention: `''` for a missing text, line breaks kept by default (a text in the dictionary is prose; a record's field is typed with breaks on purpose) |
151
-
152
- The three renderers are the only ones. `renderBlock` gives paragraphs,
153
- `renderInline` a heading or a cell, `renderPlain` plain text for an `alt`,
154
- a `title`, an option label or a sort key: an image becomes its alt text, a
155
- break a space, raw HTML nothing. All three read the same parser and apply
156
- the same escaping, so a website never imports `marked` and never writes a
157
- rendering rule of its own — a field that renders wrongly is fixed in the
158
- importer, where the data is made, not in the site. `md`/`mdInline`/`mdStrip`
159
- are the same pipeline behind the one signature every site otherwise wrote
160
- for itself (`md(text, glossary)` on some, `md(text, { glossary })` on
161
- others) — a website reaches for these over `renderBlock`/`renderInline`/
162
- `renderPlain` unless it specifically wants the pipeline's own `breaks: false`
163
- default.
164
-
165
- `renderBlock`/`renderInline`'s optional `glossary` — `[{ id, spelling }]`, one
166
- entry per spelling of each of a record's glossary words in the active
167
- language — highlights every occurrence as
168
- `<span class="gloss-term" data-gid="…">`, case-insensitively, whole words
169
- only, longest spelling winning when one contains another. It is a token the
170
- parser produces, not HTML in the source: the escaping below still applies to
171
- everything else in the text, code spans and link destinations are left alone,
172
- and an empty or omitted `glossary` costs nothing extra.
173
-
174
- A package that needs only the text runtime — `@museumwnf/viewer-layout` is the
175
- one — imports it from `@museumwnf/viewer-core/i18n` instead of the package root.
176
- Both resolve to the same module, so there is one set of texts in the
177
- application; the subpath just leaves the router and the data package out of
178
- that build.
179
-
180
- `t(key)` returns the text in the active language, falls back to English, and
181
- returns the key itself if neither has it. **Keys at call sites must be written
182
- out in full**, never assembled — that is what lets CI verify that every text a
183
- page asks for exists.
184
-
185
- Attributes (`aria-label`, `placeholder`, `title`, `alt`) take `t()`, not the
186
- components.
187
-
188
- The scope of this is frozen: lookup, fallback, reactivity. A future need for
189
- pluralisation or interpolation is a different system, not an extension of this
190
- one.
191
-
192
- ### Language
193
-
194
- `createViewer` resolves the language once: `?lang=` in the URL, then the
195
- visitor's remembered choice, then their browser's preferences, then English —
196
- skipping at every step anything the website does not offer. On a website
197
- offering more than one language the URL carries `?lang=`, so a page can be
198
- linked and shared in the language it was read in; a single-language website
199
- never gets the parameter. `lang` and `dir` are set on `<html>`, so a
200
- right-to-left language renders right to left.
201
-
202
- ### Which languages a website offers
203
-
204
- One rule for every website: the package declares the site's languages in
205
- `manifest.site.languages`, in switcher order, each with its native label;
206
- the website offers those that its items actually have content in.
207
-
208
- ```js
209
- import { languageLabels, offeredLanguages } from '@museumwnf/viewer-core'
210
-
211
- const languages = offeredLanguages()
212
- export default {
213
- languages,
214
- navigation: { languages: languageLabels(languages) },
215
- }
216
- ```
217
-
218
- | Export | Meaning |
219
- | --- | --- |
220
- | `offeredLanguages({ declared?, entity = 'items' })` | `declared ∩ availableLanguages(entity)`, in the declared order; `declared` defaults to `manifest.site.languages`, and a website's own `declared` can only narrow that list, never widen it. A package that declares none offers every language its items have content in, alphabetically. |
221
- | `languageLabels(codes)` | `[{ code, label }]` for the switcher, the label from the manifest and the code in capitals where there is none |
222
-
223
- The rule is tested once, by the helper every website runs in its own suite:
224
-
225
- ```js
226
- import { checkOfferedLanguages } from '@museumwnf/viewer-core/testing'
227
- expect(checkOfferedLanguages(config)).toEqual([])
228
- ```
229
-
230
- It reports every offered language without item content, an order that is
231
- not the declared one, a switcher that lists something else, and a language
232
- without a label. A website that narrows the package's declaration passes the
233
- same list to the check — `checkOfferedLanguages(config, { declared })` — so
234
- that it is held to the rule it actually applies.
235
-
236
- ### Record language
237
-
238
- The site language and the language a record is read in are two different
239
- things. The site language is negotiated once and holds for the whole visit;
240
- a record may carry languages the site does not offer. A record renders in
241
- the site language when it carries it, in English when it does not, in its
242
- first language when it has neither — and the visitor may toggle the record
243
- they are reading into any language it carries. That toggle is view state:
244
- not in the URL, never changing the site language, forgotten when the site
245
- language changes or another record is shown.
246
-
247
- ```js
248
- import { useRecordLanguage } from '@museumwnf/viewer-core'
249
- const { language, languages, dir, select, reset } = useRecordLanguage(item)
250
- ```
251
-
252
- `item` is a ref, a computed or a getter; `item.languages` says what it
253
- carries (`{ entity: 'items' }` reads the entity's translation files when a
254
- record says nothing; `{ languages: () => [...] }` overrides both). `language`
255
- is the resolved language, `dir` its direction for a `dir` attribute,
256
- `select(code)` the visitor's toggle, `reset()` its undo.
257
-
258
- ### Records: `useEntities()` (the standard way a website reads them)
259
-
260
- Every entity is one hashed chunk, fetched the first time a page asks for it
261
- and shared by every page after. A website never imports a package file
262
- itself — `import … from '@inventory-data/…'` in site code is forbidden — and
263
- keeps no copy of the records: it reads them through here.
264
-
265
- ```js
266
- import { computed } from 'vue'
267
- import { entityRef } from '@museumwnf/viewer-core'
268
-
269
- export const items = entityRef('items')
270
- export const partners = entityRef('partners')
271
- export const itemById = computed(() => new Map((items.value ?? []).map((i) => [i.id, i])))
272
- ```
273
-
274
- | Member | Meaning |
275
- | --- | --- |
276
- | `<name>` | a shared ref per requested entity: `null` until its chunk has arrived, the records after |
277
- | `ready` | a promise for all of them |
278
- | `loaded` | the same, as a boolean |
279
- | `byId(name, key = 'id')` | a cached `computed` Map of the records by `key` |
280
- | `loadEntities(names)` | the load itself, for a route guard or a resolver |
281
- | `entityRef(name)` | the shared ref alone, loading nothing — what a data composable exports at module level, so that importing it costs nothing and the routes that read an entity are the ones that declare it |
282
-
283
- A route that declares `meta: { entities: ['items'] }` has them loaded by the
284
- router before its view is created, so the view never sees `null`; until the
285
- first route has resolved, the page shows `core.status.loading`.
286
-
287
- ### List pages
288
-
289
- The engine of a results page — what every website's browse and search
290
- pages do before they render a row. Behaviour only; the controls and rows are
291
- viewer-layout's, and which filters exist, in what order, with what labels,
292
- is the site's.
293
-
294
- | Export | Meaning |
295
- | --- | --- |
296
- | `useListQuery({ keys, page = true })` | `{ filters, page, active, apply(patch), reset(), goToPage(n) }` — filters and page bound to the route query. Encodes the routing convention once: filters in the query, `page` dropped when 1, `push` for a filter change (a new place in history), `replace` for a page change, page back to 1 when a filter changes; navigates on the current route by name. Query parameters the page does not own (`lang`) travel untouched. |
297
- | `paginate(list, page, size = 20)` | `{ total, lastPage, currentPage, from, to, rows }`; a page past the end clamps. The size is the site's: twenty on the standalone sites, nine on the DXA sites. |
298
- | `usePagination(list, { page, size })` | the same over reactive sources |
299
- | `sortChronological(list, { key = 'start_date', undated = 'last' })` | a new array, by year, undated last |
300
- | `facetOptions(records, spec)` / `useFacets(records, spec)` | `{ [facet]: [{ value, label }] }` — the values the records in front of the filter carry, labelled by the site's own helper, sorted. A facet is `{ field }` or `{ values(record) }`, plus `label(value)`, `include(value)`, `sort` (`'label'`, a comparator, `'none'`), `capitalize`. Pass all records for options that never narrow (the standalone rule) or the matching subset for dependent facets (the DXA rule); the helper reads what it is handed. |
301
- | `inDateRange(record, { begin, end, mode })` / `dateRange(list, …)` | the two legacy date rules, named and never merged: `'overlap'` (Islamic Art, Baroque Art, Sharing History — a record whose span touches the window, tolerating one date) and `'contain'` (the DXA sites — a record whose span lies inside it; an undated record is out). A site names its mode once. |
302
- | `yearBuckets(records, t)` / `yearBucketsFromRange(min, max, t)` | the legacy era buckets (`[{ value, label }]`, coarser the further back), whose values are in shareable URLs and therefore reproduced rather than improved; `t` supplies `catalogue.era.*` |
303
- | `eraLabel(year, t)`, `roundOutward(start, end)` | "1193 AD" / "502 BC"; a record's dates rounded outward to the century |
304
- | `useKeywordIndex(entity, { grammar, fields, haystack, language, rank, expand })` | `{ search, reset }` — the two search engines under one interface. `grammar: 'fields'`: `fields` maps a field name to `(record, text) => string \| string[]`, `search([{ field, keyword, cond }])` folds the rows with AND/OR (the `database.php` form). `grammar: 'boolean'`: `haystack(record, text)` returns the strings searched, `search('+word -word word* "a phrase"')` ranks by the legacy full-text grammar. `language` is the translation the text is read in. Every value is read as plain text through the pipeline; haystacks are cached per record and language and dropped when that language's translations change. `rank: 'hits'` (fields grammar only, off by default): the matches ordered by how many rows of the query each matched, then chronologically, undated last (legacy `ORDER BY nn DESC, pkdate ASC`) — pass `sort: false` to a composed results page so it does not sort again. `expand(keyword, language) => string[]` (fields grammar only, off by default): alternative spellings of a keyword, searched alongside it. |
305
- | `glossaryExpansion({ entity = 'glossary' })` | an `expand` hook: a term equal to one of a glossary entry's spellings (in `language`, falling back to English) expands to every spelling of that entry in that language — legacy's keyword-to-glossary lookup. |
306
- | `countryExpansion({ entity = 'countries' })` | an `expand` hook: a term equal to a country's name (in `language`, falling back to `internal_name`) expands to that country's id — legacy's `keyword`/`location` country rule; whether a record's country is part of what a field searches is that field's own getter. |
307
- | `combineExpansions(...expand)` | one `expand` hook that runs every one given and concatenates the results — `useKeywordIndex`'s `expand` takes one function. |
308
-
309
- ### Timelines
310
-
311
- Legacy's country chronology (`hcr`) is one merged, year-ordered list per
312
- country, and an exhibition can carry its own narrative chronology instead
313
- (`source: 'thg_local'` on its `timelines.json` row) or split the merge by
314
- which exhibition each event belongs to (Sharing History). Seven websites'
315
- Timeline pages are all one of these three axes over the same event shape.
316
-
317
- | Export | Meaning |
318
- | --- | --- |
319
- | `useTimelineEvents({ scope, countryLabel, countryIdForCode, tr, timelinesEntity, eventsEntity })` | `{ countries, yearRange, yearBuckets(t), hasTimeline, usesLocalTimeline, findEvents({ country, collection, begin, end }) }`. `scope` is `'country'` (the worldwide merge), `'local'` (an exhibition's own `thg_local` chronology in place of the merge — `countries` is empty, there is nothing to filter by) or `'collection'` (the merge again, plus a `collection` filter on the exhibition a timeline is bound to, `null` meaning the Permanent Collection). `countries` is one entry per country with a chronology, `{ value, label }`, alphabetized, with an `{ value: 'all', label: null }` marker first (never, for `'local'`) — the marker carries no label of its own so a picker names it through its own catalogue entry. `countryLabel(id)` labels a country; the DXA legacy 2-letter code table stays out of this package, so a site passing one instead resolves it through `countryIdForCode(code)`. `tr(id)` returns an event's translated fields, already resolved for entity and language, the same way a site's own data composable already binds it. `findEvents` returns the scope's events in `[begin, end]`, chronological, `display_order` breaking a tie on `year_from`, an event with no year at all last. |
320
- | `overlapsRange(event, begin, end)` | legacy `class.hcr.inc.php`'s overlap rule, standalone: a dated event overlaps once its known end is past `begin` and its start is before `end`; an open-ended event (no known end) overlaps on its start alone; an undated event matches nothing but an unbounded search. |
321
- | `effectiveYearTo(event)` | `event.year_to`, or `null` when it is `0` or absent — legacy's convention for an open-ended period. |
322
- | `eventDateLabel(event, text, t)` | the label under an event, trying in order: a narrative chronology's own period name (`text.name` — a sort key there, not a label), a curated description pair (`text.date_from_description` / `text.date_to_description`), and the derived year range through `eraLabel`, for the worldwide merge which has neither. |
323
-
324
- ### Record pages
325
-
326
- The engine of a record page, on top of `useRecordLanguage`. The field list
327
- is the site's — `sheetRows` turns a spec into rows — and the components are
328
- viewer-layout's.
329
-
330
- | Export | Meaning |
331
- | --- | --- |
332
- | `useRecordSheet(record, { entity, translations, attribution })` | what `useRecordLanguage` returns, plus `text` (the record's translation in the active language, falling back to English), `ready` (the entity and every named related entity loaded, in the active language and in English), `terms` (`[{ id, word, definition, spellings }]` for the record's `glossary_ids`, in the active language), `glossary` (the `[{ id, spelling }]` list the renderers take) and `attribution` — when `attribution: ['author', 'copy_editor']` names fields that are proper names, the first other language of the record that has them supplies them (`{ from, author, … }`), because the importer files some records' credits on one language only. |
333
- | `sheetRows(spec, ctx)` | `[{ key, label, render, value, html }]` from `[{ key, label, value, render, when, join }]`: `value` a function of `ctx` or the name of a field of `ctx.text`; `render` one of `inline`, `block`, `plain` (through the pipeline, with `ctx.glossary`), `link`, `custom` (handed back for a slot); `when(ctx)` gates the row; empty values are dropped. "Materials/techniques" on one site and "Type" on another for the same field are both legacy facts, and both are a spec entry. |
334
- | `useGlossaryPopup(entries)` | `{ active, onClick, close }` — one delegated click on the sheet's container answers the term rendered as `.gloss-term` |
335
- | `searchGlossary(input, language)` | the terms whose spelling starts with the input, for a glossary search box |
336
- | `glossaryTermsForText(text, language, { entity })` | `glossaryTermsFor`'s counterpart for a text with no `glossary_ids` column — a dynasty history, a theme essay: `[{ id, word, definition, spellings }]` for every glossary term whose spelling occurs in `text`, matched the same word-bounded way `md`/`renderBlock` highlight it. Pass the result through `glossaryEntries` for the `glossary` option the renderers take, the same as `terms`/`glossary` from `useRecordSheet` |
337
- | `citation({ author, name, project, publisher, year, permalink, inWord })` | the sentence under a sheet, assembled from parts in order rather than written into a text with holes; `permalink` is the page's own address, e.g. from [`sourceUrl()`](#the-declaration-outside-a-component) |
338
- | `relatedRecords(record, { entity, language })` / `useRelatedRecords` | `{ inPackage: [{ reference, record, justification }], outside: [reference] }` — a reference the package does not hold stays a reference; nothing is dropped and nothing invented |
339
- | `timelineLinkFor(record, { name, keys, country, round })` | the route location of the timeline results for the record's country and dates, or null |
340
-
341
- ### Collection trees
342
-
343
- `useCollectionTree` — a collection tree rooted at a purpose marker, over a
344
- flat `collections.json`-shaped array (`{ id, parent_id, display_order, type,
345
- purpose, items }`). Four sites each walked this tree from a purpose marker
346
- by hand — three by `parent_id`/`display_order` over `collections.json`
347
- (islamicart's Artistic Introduction and Exhibitions, baroqueart, sharing
348
- history's Exhibitions/themes/chapters), one with the equivalent tree
349
- pre-built as `themes.json` (the DXA sites) — and each wrote the same reverse
350
- "collections containing this item" scan by hand. This is that walk, written
351
- once.
352
-
353
- ```js
354
- import { useCollectionTree } from '@museumwnf/viewer-core'
355
-
356
- const exhibitions = useCollectionTree({ purpose: 'exhibitions-root', entity: 'collections' })
357
- const { root, children, breadcrumb, itemsUnder, containing, previous, next } = exhibitions
358
- ```
359
-
360
- | Export | Meaning |
361
- | --- | --- |
362
- | `useCollectionTree({ purpose \| rootId, childType?, entity = 'collections', order = 'display_order' })` | the reactive form, over `entityRef(entity)`: empty (not an error) until that entity's chunk has arrived. `purpose` finds the root by its `purpose` field; `rootId` takes a node's id directly (for a site that resolves a marker's single child as the real root itself, as islamicart's Artistic Introduction does). `childType` filters a node's children by `type`, in one of three forms: a string holds every level to that one type — not only the root's — for a tree whose marker also parents siblings that are not real tree nodes (sharing history's country-specific "National Context" collections, which sit next to the real themes); an array, indexed by depth below the root, where `childType[0]` filters the root's children, `childType[1]` filters their children, and so on — a depth past the end of the array is unfiltered (Sharing History's exhibitions → themes → chapters uses `['exhibition', 'theme', 'subtheme']`; a tree rooted at one exhibition uses `['theme', 'subtheme']` instead); a function `(node, depth, parent) => boolean` for anything else. |
363
- | `root`, `byId` | reactive: the root node (`null` if the purpose/id is not found), and a `Map` of every node in the tree by id |
364
- | `children(id)` | a node's children, in `order` |
365
- | `parents(id)` | the ancestor chain, root-first, node itself excluded |
366
- | `breadcrumb(id)` | `parents(id)` plus the node itself |
367
- | `itemsUnder(id)` | the item ids under a node — its own plus every descendant's, depth-first, each once |
368
- | `containing(itemId)` | the nodes an item is directly attached to (not recursive — a page's items do not make its theme "contain" them; walk up with `parents` from what this returns for that) |
369
- | `walk()` | the tree flattened depth-first, root excluded |
370
- | `previous(id)` / `next(id)` | over `walk()`, by index — which is what crosses a branch boundary for free, and is `null` at either end or for an id the tree does not carry |
371
- | `buildCollectionTree(collections, { purpose \| rootId, childType?, order? })` | the pure function `useCollectionTree` reads through `entityRef` for; usable directly outside a component. `childType` takes the same string / array / function forms as above. |
372
- | `collectionTreeFromThemes(themes, { childType?, order? })` | the same interface from a `themes.json` shape (`sub_themes[]` nested, `pictures[].picture_item_id` for content) instead of a flat, `parent_id`-linked array. `root` is always `null` — a `themes.json` package is already the whole tree, an ordered array of top-level themes, with no marker record to key a root on. Also reachable reactively as `useCollectionTree({ source: 'themes', entity: 'themes' })`, where `purpose`/`rootId` do not apply. |
373
-
374
- ### Partners
375
-
376
- Seven partner lists group by country seven ways — the standalone sites
377
- bucket a country's partners into "main" and "associated" on a `level`
378
- field and render an accordion; the DXA sites group the same way with an
379
- A–Z / Z–A toggle and no tiers. `parent_id` is carried on a third of the
380
- standalone partners and read nowhere: legacy nested an associated partner
381
- under the main partner it belongs to.
382
-
383
- | Export | Meaning |
384
- | --- | --- |
385
- | `groupByCountry(records, { label, tier?, order = 'asc' })` | `[{ country, label, main, associated }]`, one entry per distinct `country_id`, sorted by `label`. `label(country)` names a group's country, the same shape as a facet's `label(value)`. `tier` names the field that marks a main partner (the converged shape's `level`); a record is associated only when its tier value is a non-empty value other than `'partner'` (legacy values: `'associated_partner'`, `'minor_contributor'`); `null`, `undefined`, `''`, or `'partner'` mean main. Without a `tier` every record is main, which is the DXA lists' plain grouping. Partners keep the order `records` was given in within each tier. |
386
- | `partnerHierarchy(partners)` | `{ children(id), parentOf(id), roots }` from `parent_id`: `children(id)` a partner's associated partners, `parentOf(id)` the main partner it belongs to (or null), `roots` every partner with no parent in the list or whose `parent_id` points outside it. |
387
-
388
- ### Conventions
389
-
390
- | Export | Meaning |
391
- | --- | --- |
392
- | `projectName(key, t)` / `useProjectName()` / `PROJECT_ENTRIES` | a project's name by its legacy key (`ISL`, `EPM`, `DBA`, `BAR`, `AWE`, `DCA`, `DGA`, `EXTHE`, `GALLERIES`), through `core.project.*`; an unknown key reads as itself |
393
- | `projectFamily(key)` / `PROJECT_FAMILIES` | a project's colour family by the same legacy key (ISL and EPM share `ISLandEPM`, every exhibition shares `EXH`, …) — legacy's own class names, so a site's CSS reads as the stylesheet it was copied from; an unknown key reads as itself |
394
- | `useSection()` | the `meta.section` of the current route — see Routing |
395
- | `useFeaturedRecord(entity, { withImage = true, seed })` | one record at random for a landing page's spotlight, among those with an image; null until the entity is loaded; `seed` pins the pick |
396
- | `sectionMeta(chrome = [])` | returns `meta(section, ...entities) => ({ section, entities: [...chrome, ...entities] })` — a route's `meta` in one call, `chrome` being the entities every page of the site loads regardless of which one it is |
397
- | `mwnfLinks` | the portal and sibling-site addresses the four DXA configs each repeat under `links` — frozen, spread into a site's own `links` rather than retyped |
398
-
399
- ### `@museumwnf/viewer-core/legacy`
400
-
401
- The DXA sites' legacy URL mappings, decoded from `backward_compatibility` —
402
- pure functions over plain lists, no Vue, so a site's own composable stays
403
- the reactive wrapper around its `partners` / `countries` / `items` refs. A
404
- separate entry point because only a site built against these legacy shapes
405
- needs it; the standalone sites never import it.
406
-
407
- | Export | Meaning |
408
- | --- | --- |
409
- | `partnerKey(partner, countries = [])` | `{ legacyId, countryCode }` from a partner's `backward_compatibility` — `mwnf3:museums:Mus21:ua` on the DXA sites, `mwnf3_sharing_history:sh_partners:at_01_d` on Sharing History, whose key carries the country as its own prefix rather than a fourth segment. `countries` is the fallback for a partner with no `backward_compatibility` at all: its `country_id` resolved to the country's own `code`. |
410
- | `partnerFromKey(partners, countries, countryCode, legacyId)` | the partner a legacy `/partner/:country/:id` route names, or null — `partnerKey` in reverse |
411
- | `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) |
412
-
413
- ### The declaration outside a component
414
-
415
- | Export | Meaning |
416
- | --- | --- |
417
- | `useSiteConfig()` | the configuration `createViewer` received, injected inside a component and read from the application outside one |
418
- | `mediaUrl(path, size = 'hi_res')` | `${config.media.legacyHost}/${size}/${path}` for a legacy media path the package carries; an address is returned as it is |
419
- | `useSiteRights()` | `{ holder, termsUrl, attribution }` from the data package's `manifest.rights`; a package with no such block offers no holder or attribution, and `termsUrl` falls back to `mwnfLinks.legalNotice` |
420
- | `sourceUrl(route)` | `${config.site.origin}/#${path}` for `route` — a route already resolved (its `fullPath` read as it is) or a raw location the installed router resolves; null when the website declares no `site.origin` |
421
-
422
- ### `useDataPackage()` → data access (the only allowed way to read the data package)
423
-
424
- | Member | Type | Meaning |
425
- | --- | --- | --- |
426
- | `manifest` | object | parsed `manifest.json` |
427
- | `languages` | string[] | `manifest.languages`, default `['en']` |
428
- | `entityNames` | string[] | one entry per `<entity>.json` file |
429
- | `loadEntity(name)` | `Promise<Array>` | lazy-loads and returns the records of one entity |
430
- | `availableLanguages(entity)` | string[] | languages `translations/<entity>.<lang>.json` actually exists for |
431
- | `loadTranslations(entity, lang)` | `Promise<object>` | lazy-loads `translations/<entity>.<lang>.json` (id → translated fields); `{}` if the file is absent. Cached. |
432
- | `translations(entity, lang)` | object | the already-loaded map for that pair; `{}` if not loaded yet |
433
- | `tr(entity, id, lang, fallbackLang = 'en')` | object | one record's translated fields, falling back to `fallbackLang` then `{}` — reads only what's already loaded |
434
-
435
- Always resolve a per-language file by name through `loadTranslations`/`translations`/`tr` —
436
- never `import(\`...${lang}...\`)`. A dynamic import with an interpolated
437
- specifier can't be resolved statically, so the bundler falls back to bundling
438
- every language of that entity eagerly instead of Vite's dedicated glob-import
439
- path; for a dataset with a large or many-language translation set this can
440
- turn a several-second build into a build that hangs for hours in CI — this is
441
- what made islamicart's production build unable to finish.
442
-
443
- ### A website's data composable
444
-
445
- `useCatalogueData` is the wrapper half of what every site's own data
446
- composable had written for itself: the same three-line `tr`, the same
447
- memoised `loadEnglish`, the same one-shape label helper, the same
448
- `md`/`mdInline`/`mdStrip` re-export, around whichever entities and
449
- visibility rules were the site's own. Call it once, at the top of the
450
- module, and re-export what it returns beside what is genuinely the site's —
451
- routes, legacy key mappings, chrome images, sibling lists.
452
-
453
- ```js
454
- import { useCatalogueData } from '@museumwnf/viewer-core'
455
-
456
- const catalogue = useCatalogueData({
457
- eager: ['items', 'partners', 'countries', 'glossary', 'dynasties'],
458
- visible: {
459
- // A per-build language filter, a hidden-partner rule, a display-status
460
- // check are all "which records this build shows" — declared here once,
461
- // instead of coded into every page that lists that entity.
462
- items: (i) => !i.languages?.length || i.languages.includes('en'),
463
- partners: (p) => !hiddenPartnerIds.has(p.id),
464
- },
465
- })
466
-
467
- export const items = catalogue.entity('items')
468
- export const itemById = catalogue.index('items')
469
- export const { tr, md, mdInline, mdStrip, labelOf, loadEnglish } = catalogue
470
- ```
471
-
472
- | Export | Meaning |
473
- | --- | --- |
474
- | `useCatalogueData({ eager, defaultLanguage = 'en', visible = {}, glossary = true })` | `eager` names the entities `loadEnglish` loads — every entity the package carries, by default. `defaultLanguage` is what `tr`, `labelOf` and the glossary binding read. `visible[name]` is a predicate `(record) => boolean`, applied by `entity(name)`/`index(name)` when one is declared. `glossary` turns off the default binding on `md`/`mdInline`; either still takes a caller's own `glossary` regardless. |
475
- | `tr(entity, id, lang = defaultLanguage)` | one record's translated fields, falling back to `defaultLanguage` then `{}` |
476
- | `md(text, opts)` / `mdInline(text, opts)` | `md`/`mdInline` from the package, with the site's own glossary (every term in `defaultLanguage`) bound as the default `glossary` — a caller passing its own (a record's own terms, not every term in the package) is still honoured |
477
- | `mdStrip(text)` | the package's own, unchanged — plain text carries no highlighting to bind |
478
- | `loadEnglish()` | loads `defaultLanguage` for every entity in `eager`, once, memoised |
479
- | `labelOf(entity, id, { fallback = 'internal_name' })` | `mdStrip(tr(entity, id).name ?? record[fallback] ?? id)` — the one label shape every site wrote, for a list, a dropdown, an alt text |
480
- | `entity(name)` | `entityRef(name)`'s records, narrowed by `visible[name]` when one is declared; `null` until that entity's chunk has arrived, the same as `entityRef` |
481
- | `index(name, key = 'id')` | a `Map` of `entity(name)`'s records by `key` — the visible ones, unlike `byId` |
482
- | `availableLanguages`, `loadTranslations`, `translations` | the package's own, re-exported so a site reads everything through the one object |
483
-
484
- ### Routing
485
-
486
- The convention, applied by the router every website shares:
487
-
488
- - hash history; every route named, in kebab-case;
489
- - a section is `/<section>`, a record `/<section>/:id` with the package id
490
- (`/item/:id`, `/partner/:id`, `/theme/:id`); a sub-list is a path under
491
- its parent (`/partner/:id/objects`);
492
- - the language, the page and every filter travel in the query, never in the
493
- path: `?lang=fr`, `?page=2`, `?country=…`;
494
- - a legacy URL shape is a `legacyRoutes` entry, which only ever redirects:
495
- `{ path, resolve(params, route) }`, `resolve` answering the canonical
496
- location (`{ name, params }` or a path) or nothing, which is the not-found
497
- page. It may be async, so a website can load the entity it maps a legacy
498
- key through `backward_compatibility` with;
499
- - a route says what section it belongs to — `meta: { section: 'collection' }`
500
- — and a shell reads it with `useSection()` for the banner title or the
501
- active menu entry, instead of deriving it from the path;
502
- - a `/:pathMatch(.*)*` catch-all lands on `NotFoundView` (`core.notFound.page`),
503
- or on the component `config.notFound` names;
504
- - the router owns scrolling: back to where the visitor was when they come
505
- back through history, to the anchor the URL names, where it is when only
506
- the query of the same page changed, to the top otherwise. No shell and no
507
- view scrolls the window.
508
-
509
- ```js
510
- export default {
511
- extraViews: [
512
- { path: '/item/:id', name: 'item', component: () => import('./views/ItemSheet.vue'), meta: { entities: ['items'] } },
513
- ],
514
- legacyRoutes: [
515
- {
516
- path: '/database-item/:uid(.*)/:language',
517
- async resolve({ uid }) {
518
- await loadEntities(['items'])
519
- const item = itemByUid.value.get(uid)
520
- return item ? { name: 'item', params: { id: item.id } } : null
521
- },
522
- },
523
- ],
524
- }
525
- ```
526
-
527
- ### Views
528
-
529
- Every website has three slots — the home page, a list page, a record page —
530
- and this package renders each with a generic view unless the website names
531
- another:
532
-
533
- | Slot | Route | Generic view | Props | Renders |
534
- | --- | --- | --- | --- | --- |
535
- | `home` | `/` | `HomeView` | — | `siteName` + links to each configured entity list |
536
- | `list` | `/<entity>` | `ListView` | `entity`, `pageSize` (default 20) | paginated record list (`?page=N`), links to details |
537
- | `detail` | `/<entity>/:id` | `DetailView` | `entity`, `id` | one record; string fields rendered as markdown |
538
- | — | `/:pathMatch(.*)*` | `NotFoundView` | — | `core.notFound.page` and a link home |
539
-
540
- The generic views exist to look at a new dataset: they expose the data
541
- package's shape, not the website's. A real website names its pages in
542
- `config.views`, and the same route names — `home`, `<entity>-list`,
543
- `<entity>-detail` — then render them. The composed views of
544
- `@museumwnf/viewer-layout/views` (`HomeView`, `CatalogueResultsView`,
545
- `RecordView`) are what a website names there: a landing page, a results
546
- page and a record page built from the components of that package on the
547
- composables of this one, driven by a spec the website declares. They live
548
- in the layout package because they are made of its components, and this
549
- package does not depend on it.
550
-
551
- ```js
552
- import { CatalogueResultsView, HomeView, RecordView } from '@museumwnf/viewer-layout/views'
553
-
554
- export default {
555
- features: { entities: ['items'] },
556
- views: { home: HomeView, list: CatalogueResultsView, detail: RecordView },
557
- home: { cards: [...], featured: { entity: 'items' } },
558
- }
559
- ```
560
-
561
- A website that registers its own component on the `home` name in
562
- `extraViews`, or its own results and record routes, keeps it: `views` only
563
- fills the slots the website leaves to the package. `resolveViews(config)`
564
- answers the three components a configuration resolves to.
565
-
566
- ### Styles
567
-
568
- `@museumwnf/viewer-core/styles/base.css` (auto-imported by `createViewer`): reset and
569
- structural rules only. It consumes CSS custom properties and never defines brand values:
570
-
571
- | Property | Default |
572
- | --- | --- |
573
- | `--vc-font-body` | `system-ui, sans-serif` |
574
- | `--vc-line-height` | `1.5` |
575
- | `--vc-color-background` | `transparent` |
576
- | `--vc-color-text` | `inherit` |
577
- | `--vc-content-width` | `60rem` |
578
- | `--vc-content-padding` | `1.5rem` |
579
- | `--vc-weight-label` | `600` |
580
-
581
- ## Data package contract
582
-
583
- | File | Content |
584
- | --- | --- |
585
- | `manifest.json` | at least `{ "languages": ["en", ...] }` — every language any record carries. `site.languages`: the languages the website offers, in switcher order, `[{ "code": "fr", "label": "Français" }, …]`. `site.names`: the site's name per language, `{ "en": "…", "fr": "…" }`. A website reads nothing else from its package before it mounts. |
586
- | `<entity>.json` | array of records; each record has an `id`, optional `title`/`name`, other fields free-form (strings may contain markdown) |
587
- | `translations/<entity>.<lang>.json` | optional; object keyed by record `id`, each value the record's translated fields for `<lang>`. A file is simply absent when that entity has no translation in that language. |
588
-
589
- ## Testing a website
590
-
591
- The package exports shared smoke-test helpers at @museumwnf/viewer-core/testing: mount a website, check its configuration, and verify that texts are rendered.
592
-
593
- | Export | Use |
594
- | --- | --- |
595
- | mountSite(config, messages, hash) | Mount a website on a detached DOM node at a given hash (default '#/'); returns { app, host, router }. The router is ready and the app is mounted before the promise resolves. |
596
- | checkRoutes(config, { names, legacyPaths }) | Verify every route has a name, no route is a catch-all, and expected names and legacy paths are present. Returns the list of problems found. |
597
- | checkSectionMeta(config) | Verify every route has a meta.section string (used by the menu to highlight the current page). Returns the list of problems found. |
598
- | checkTextsRendered(host, { namespaces }) | Match text keys in the rendered host against a regex like /\b(ns1\|ns2)\.[a-z]/i. Returns the list of namespaces found (empty if none). |
599
- | defineViewerConfig({ dataPackage, inline, plugins, root }) | Return a Vite config object the seven websites share: the @inventory-data alias, optimizeDeps to inline viewer packages, testTimeout of 60s, and the test environment. Exported from `@museumwnf/viewer-core/vite` for use in `vite.config.js` (separate entry because Vite's config loader runs under Node.js, which cannot parse `.vue` files; the `/testing` barrel reaches a `.vue` file). dataPackage is the npm package name; inline is an optional array of extra packages; plugins is the Vite plugins array (e.g. @vitejs/plugin-vue); root is the project root path (default `process.cwd()`), used to resolve the data package from the project root. |
600
- | checkOfferedLanguages(config) | Check that the website offers only languages with content, in the declared order, with labels on the switcher. Returns the list of problems found. |
601
-
602
- @museumwnf/viewer-core/testing is not imported by websites, only by their tests. A website test might be:
603
-
604
- \\\js
605
- import { checkOfferedLanguages, checkRoutes, checkSectionMeta, mountSite } from '@museumwnf/viewer-core/testing'
606
- import config from '../src/dataset.config.js'
607
- import { mergeMessages } from '@museumwnf/viewer-core'
608
- import { catalogues } from '@museumwnf/viewer-i18n/gallery'
609
- import ownTexts from '../locales/en.json'
610
-
611
- const messages = mergeMessages(catalogues, { en: ownTexts })
612
-
613
- describe('smoke test', () => {
614
- it('mounts and declares every route by name', async () => {
615
- const { app } = await mountSite(config, messages)
616
- expect(checkRoutes(config)).toEqual([])
617
- expect(checkSectionMeta(config)).toEqual([])
618
- expect(checkOfferedLanguages(config)).toEqual([])
619
- app.unmount()
620
- })
621
- })
622
- \\\
623
- ## Licence
624
-
625
- This package is Content of the MWNF Website under the [MWNF legal
626
- notice](https://www.museumwnf.org/about/legal-notice), which governs its use
627
- (non-commercial, personal, educational and scientific use is permitted, with
628
- attribution and mandatory reporting — see the notice for the full terms). The
629
- notice text also ships in this package as `LICENSE.md`.
630
-
631
- ## Release procedure
632
-
633
- 1. Merge to `main` via PR (CI: tests + a downstream build of every website).
634
- 2. Create a GitHub release with tag `vX.Y.Z` — CI publishes to GitHub Packages; never publish from a laptop.
635
- 3. Semver rules: **patch** = fix; **minor** = backward-compatible addition; **major** = breaking change (breaking = any change requiring an edit in a consuming website).
1
+ # @museumwnf/viewer-core
2
+
3
+ Application engine for MWNF websites. A website repo is a thin shell: it provides a
4
+ `dataset.config.js`, its data package and its locale files — this package turns that
5
+ into a mounted Vue application (router, i18n, data access, shared views).
6
+
7
+ Stack: Vue 3, Vue Router (hash history), Vite, Vitest. Distributed as raw
8
+ ESM source; the website's Vite build compiles it.
9
+
10
+ Texts are handled here, without an i18n library: see [Texts](#texts). Nothing in
11
+ the platform depends on `vue-i18n` any more, and this package no longer asks for
12
+ it.
13
+
14
+ ## Install
15
+
16
+ Published to npmjs (`registry.npmjs.org`), publicly — no authentication needed
17
+ to install. (Versions up to 1.13.2, published as `@metanull/viewer-core`,
18
+ remain available on GitHub Packages, frozen; no new version is published
19
+ there.)
20
+
21
+ ```bash
22
+ npm install @museumwnf/viewer-core vue vue-router
23
+ ```
24
+
25
+ Required website-side Vite configuration (`vite.config.js`):
26
+
27
+ ```js
28
+ import { defineConfig } from 'vite'
29
+ import vue from '@vitejs/plugin-vue'
30
+ import { defineViewerConfig } from '@museumwnf/viewer-core/vite'
31
+
32
+ export default defineConfig({
33
+ ...defineViewerConfig({ dataPackage: '@museumwnf/<dataset>-data', plugins: [vue()] }),
34
+ })
35
+ ```
36
+
37
+ The `defineViewerConfig` helper returns a Vite config object with the shared settings all websites use:
38
+
39
+ | Setting | Value | Why |
40
+ | --- | --- | --- |
41
+ | `resolve.alias['@inventory-data']` | path to the installed `@museumwnf/<dataset>-data` | `useDataPackage` reads all JSON (entities and `translations/`) through this alias |
42
+ | `optimizeDeps.exclude` | `['@museumwnf/viewer-core']` | the package ships `.vue` source; esbuild pre-bundling cannot parse it |
43
+ | plugin | supplied via `plugins` parameter | compiles the shipped `.vue` views |
44
+
45
+ ## Usage
46
+
47
+ `main.js` (this is the whole website entry point):
48
+
49
+ ```js
50
+ import { createViewer } from '@museumwnf/viewer-core'
51
+ import config from './dataset.config.js'
52
+ createViewer(config).mount('#app')
53
+ ```
54
+
55
+ ## API
56
+
57
+ ### `createViewer(config)` → Vue app (caller mounts it)
58
+
59
+ `config` is the website's `dataset.config.js` object:
60
+
61
+ | Key | Type | Required | Meaning |
62
+ | --- | --- | --- | --- |
63
+ | `datasetPackage` | string | yes | name of the data package, e.g. `@museumwnf/<dataset>-data` (informational) |
64
+ | `siteName` | string | yes | shown on the Home view |
65
+ | `languages` | string[] | no | the languages the website offers, from [`offeredLanguages()`](#which-languages-a-website-offers); defaults to the manifest's `languages` |
66
+ | `features.entities` | string[] | no | entity names getting list + detail routes (`/<entity>`, `/<entity>/:id`) |
67
+ | `views` | `{ home?, list?, detail? }` | no | the components that render the three slots — `/` and the routes `features.entities` publishes — in place of the generic views (see [Views](#views)) |
68
+ | `extraViews` | RouteRecord[] | no | website-specific routes appended to the router (see [Routing](#routing)) |
69
+ | `routes` | RouteRecord[] | no | raw vue-router records appended after `extraViews` |
70
+ | `legacyRoutes` | `[{ path, resolve }]` | no | legacy URL shapes, redirect-only (see [Routing](#routing)) |
71
+ | `notFound` | Vue component or `false` | no | the not-found view; `false` leaves the catch-all out |
72
+ | `scrollBehavior` | function | no | overrides the router's default scroll rule |
73
+ | `shell` | Vue component | no | page-shell component rendered around the active view (see below) |
74
+ | `navigation` | object | no | passed through untouched as props of the `shell` component |
75
+ | `messages` | `{ [lang]: { [key]: text } }` | no | the website's effective catalogue: the `@museumwnf/viewer-i18n` bundle it receives, merged with its own `locales/` files (see [Texts](#texts)) |
76
+ | `media` | `{ legacyHost }` | no | the host of the legacy media server, for [`mediaUrl()`](#the-declaration-outside-a-component) |
77
+ | `links` | object | no | the addresses a website links out to: `portal`, `galleries`, `myCollection`, `about`, `contact`, `legalNotice`, `credits`, `cookies` |
78
+ | `site.origin` | string | no | the absolute origin this build is deployed at, base path included when there is one (`https://metanull.github.io/islamicart`, no trailing slash) — declared once here rather than baked into the package, since the package cannot know where it will be served; read by [`sourceUrl()`](#the-declaration-outside-a-component) |
79
+
80
+ `dataset.config.js` is the whole declaration of a website. Before it mounts,
81
+ a website reads nothing from its data package but `manifest.json`; it reads
82
+ no environment variable; it carries no address outside `links`, `media` and `site`.
83
+
84
+ ### Page shell (`config.shell`)
85
+
86
+ When `config.shell` is set, the root component renders it around `<router-view/>`
87
+ instead of the bare view. The shell is any component honouring this contract:
88
+
89
+ - receives, via `v-bind`: `languages` (the resolved language list), every key of
90
+ `config.navigation` (untouched), and `language` (the current locale, reactive);
91
+ - may emit `update:language` — `createViewer` sets the active language, after
92
+ checking that the website offers it;
93
+ - renders its default slot as the page content (that slot is the active view).
94
+
95
+ `PageShell` from `@museumwnf/viewer-layout` honours this contract; a website enables
96
+ it entirely from `dataset.config.js`:
97
+
98
+ ```js
99
+ import { PageShell } from '@museumwnf/viewer-layout'
100
+
101
+ export default {
102
+ // …
103
+ shell: PageShell,
104
+ navigation: {
105
+ headerTitle: 'My Museum',
106
+ navLinks: [{ label: 'Home', href: '#/' }],
107
+ footerText: '© My Museum',
108
+ },
109
+ }
110
+ ```
111
+
112
+ (Navigation links are plain `href`s — with the hash router, `#/things` navigates
113
+ without any router coupling in the layout.)
114
+
115
+ ## Texts
116
+
117
+ A text is a key and a Markdown text. Nothing else: no placeholders, no
118
+ interpolation, no pluralisation, no HTML. A value produced at run time — a
119
+ number, a date, a count — is rendered by the component *next to* the text,
120
+ never inside it, which is what lets a text be translated freely.
121
+
122
+ Shared texts come from [`@museumwnf/viewer-i18n`](https://github.com/metanull/viewer-i18n);
123
+ a website's own texts come from its `locales/<lang>.json`. The website merges
124
+ them and passes the result as `config.messages` — local wins, and that is the
125
+ only merge rule:
126
+
127
+ ```js
128
+ import { createViewer, mergeMessages } from '@museumwnf/viewer-core'
129
+ import { catalogues } from '@museumwnf/viewer-i18n/gallery'
130
+ import config from './dataset.config.js'
131
+
132
+ const local = {}
133
+ for (const [path, module] of Object.entries(import.meta.glob('../locales/*.json', { eager: true }))) {
134
+ local[path.split('/').pop().replace(/\.json$/, '')] = module.default
135
+ }
136
+
137
+ createViewer({ ...config, messages: mergeMessages(catalogues, local) }).mount('#app')
138
+ ```
139
+
140
+ | Export | Use |
141
+ | --- | --- |
142
+ | `$t(key)` | in a template |
143
+ | `useI18n()` → `{ t, locale }` | in `<script setup>` |
144
+ | `useLocale()` | the active language, read-only |
145
+ | `<I18nText keypath tag="div">` | a text rendered as Markdown (block) |
146
+ | `<I18nTextInline keypath tag="span">` | the same, without the surrounding paragraph |
147
+ | `mergeMessages(...catalogues)` | the merge rule above |
148
+ | `negotiateLanguage`, `isRtl` | the language rules, for a website that needs them directly |
149
+ | `renderBlock(text, { breaks, glossary })`, `renderInline(text, { breaks, glossary })`, `renderPlain(text)` | the Markdown pipeline `I18nText`/`I18nTextInline` render through, for a website that needs it directly (a data-package field, not a text) |
150
+ | `md(text, { glossary, breaks = true })`, `mdInline(text, { glossary })`, `mdStrip(text)` | the same three, under the record-field convention: `''` for a missing text, line breaks kept by default (a text in the dictionary is prose; a record's field is typed with breaks on purpose) |
151
+
152
+ The three renderers are the only ones. `renderBlock` gives paragraphs,
153
+ `renderInline` a heading or a cell, `renderPlain` plain text for an `alt`,
154
+ a `title`, an option label or a sort key: an image becomes its alt text, a
155
+ break a space, raw HTML nothing. All three read the same parser and apply
156
+ the same escaping, so a website never imports `marked` and never writes a
157
+ rendering rule of its own — a field that renders wrongly is fixed in the
158
+ importer, where the data is made, not in the site. `md`/`mdInline`/`mdStrip`
159
+ are the same pipeline behind the one signature every site otherwise wrote
160
+ for itself (`md(text, glossary)` on some, `md(text, { glossary })` on
161
+ others) — a website reaches for these over `renderBlock`/`renderInline`/
162
+ `renderPlain` unless it specifically wants the pipeline's own `breaks: false`
163
+ default.
164
+
165
+ `renderBlock`/`renderInline`'s optional `glossary` — `[{ id, spelling }]`, one
166
+ entry per spelling of each of a record's glossary words in the active
167
+ language — highlights every occurrence as
168
+ `<span class="gloss-term" data-gid="…">`, case-insensitively, whole words
169
+ only, longest spelling winning when one contains another. It is a token the
170
+ parser produces, not HTML in the source: the escaping below still applies to
171
+ everything else in the text, code spans and link destinations are left alone,
172
+ and an empty or omitted `glossary` costs nothing extra.
173
+
174
+ A package that needs only the text runtime — `@museumwnf/viewer-layout` is the
175
+ one — imports it from `@museumwnf/viewer-core/i18n` instead of the package root.
176
+ Both resolve to the same module, so there is one set of texts in the
177
+ application; the subpath just leaves the router and the data package out of
178
+ that build.
179
+
180
+ `t(key)` returns the text in the active language, falls back to English, and
181
+ returns the key itself if neither has it. **Keys at call sites must be written
182
+ out in full**, never assembled — that is what lets CI verify that every text a
183
+ page asks for exists.
184
+
185
+ Attributes (`aria-label`, `placeholder`, `title`, `alt`) take `t()`, not the
186
+ components.
187
+
188
+ The scope of this is frozen: lookup, fallback, reactivity. A future need for
189
+ pluralisation or interpolation is a different system, not an extension of this
190
+ one.
191
+
192
+ ### Language
193
+
194
+ `createViewer` resolves the language once: `?lang=` in the URL, then the
195
+ visitor's remembered choice, then their browser's preferences, then English —
196
+ skipping at every step anything the website does not offer. On a website
197
+ offering more than one language the URL carries `?lang=`, so a page can be
198
+ linked and shared in the language it was read in; a single-language website
199
+ never gets the parameter. `lang` and `dir` are set on `<html>`, so a
200
+ right-to-left language renders right to left.
201
+
202
+ ### Which languages a website offers
203
+
204
+ One rule for every website: the package declares the site's languages in
205
+ `manifest.site.languages`, in switcher order, each with its native label;
206
+ the website offers those that its items actually have content in.
207
+
208
+ ```js
209
+ import { languageLabels, offeredLanguages } from '@museumwnf/viewer-core'
210
+
211
+ const languages = offeredLanguages()
212
+ export default {
213
+ languages,
214
+ navigation: { languages: languageLabels(languages) },
215
+ }
216
+ ```
217
+
218
+ | Export | Meaning |
219
+ | --- | --- |
220
+ | `offeredLanguages({ declared?, entity = 'items' })` | `declared ∩ availableLanguages(entity)`, in the declared order; `declared` defaults to `manifest.site.languages`, and a website's own `declared` can only narrow that list, never widen it. A package that declares none offers every language its items have content in, alphabetically. |
221
+ | `languageLabels(codes)` | `[{ code, label }]` for the switcher, the label from the manifest and the code in capitals where there is none |
222
+
223
+ The rule is tested once, by the helper every website runs in its own suite:
224
+
225
+ ```js
226
+ import { checkOfferedLanguages } from '@museumwnf/viewer-core/testing'
227
+ expect(checkOfferedLanguages(config)).toEqual([])
228
+ ```
229
+
230
+ It reports every offered language without item content, an order that is
231
+ not the declared one, a switcher that lists something else, and a language
232
+ without a label. A website that narrows the package's declaration passes the
233
+ same list to the check — `checkOfferedLanguages(config, { declared })` — so
234
+ that it is held to the rule it actually applies.
235
+
236
+ ### Record language
237
+
238
+ The site language and the language a record is read in are two different
239
+ things. The site language is negotiated once and holds for the whole visit;
240
+ a record may carry languages the site does not offer. A record renders in
241
+ the site language when it carries it, in English when it does not, in its
242
+ first language when it has neither — and the visitor may toggle the record
243
+ they are reading into any language it carries. That toggle is view state:
244
+ not in the URL, never changing the site language, forgotten when the site
245
+ language changes or another record is shown.
246
+
247
+ ```js
248
+ import { useRecordLanguage } from '@museumwnf/viewer-core'
249
+ const { language, languages, dir, select, reset } = useRecordLanguage(item)
250
+ ```
251
+
252
+ `item` is a ref, a computed or a getter; `item.languages` says what it
253
+ carries (`{ entity: 'items' }` reads the entity's translation files when a
254
+ record says nothing; `{ languages: () => [...] }` overrides both). `language`
255
+ is the resolved language, `dir` its direction for a `dir` attribute,
256
+ `select(code)` the visitor's toggle, `reset()` its undo.
257
+
258
+ ### Records: `useEntities()` (the standard way a website reads them)
259
+
260
+ Every entity is one hashed chunk, fetched the first time a page asks for it
261
+ and shared by every page after. A website never imports a package file
262
+ itself — `import … from '@inventory-data/…'` in site code is forbidden — and
263
+ keeps no copy of the records: it reads them through here.
264
+
265
+ ```js
266
+ import { computed } from 'vue'
267
+ import { entityRef } from '@museumwnf/viewer-core'
268
+
269
+ export const items = entityRef('items')
270
+ export const partners = entityRef('partners')
271
+ export const itemById = computed(() => new Map((items.value ?? []).map((i) => [i.id, i])))
272
+ ```
273
+
274
+ | Member | Meaning |
275
+ | --- | --- |
276
+ | `<name>` | a shared ref per requested entity: `null` until its chunk has arrived, the records after |
277
+ | `ready` | a promise for all of them |
278
+ | `loaded` | the same, as a boolean |
279
+ | `byId(name, key = 'id')` | a cached `computed` Map of the records by `key` |
280
+ | `loadEntities(names)` | the load itself, for a route guard or a resolver |
281
+ | `entityRef(name)` | the shared ref alone, loading nothing — what a data composable exports at module level, so that importing it costs nothing and the routes that read an entity are the ones that declare it |
282
+
283
+ A route that declares `meta: { entities: ['items'] }` has them loaded by the
284
+ router before its view is created, so the view never sees `null`; until the
285
+ first route has resolved, the page shows `core.status.loading`.
286
+
287
+ ### List pages
288
+
289
+ The engine of a results page — what every website's browse and search
290
+ pages do before they render a row. Behaviour only; the controls and rows are
291
+ viewer-layout's, and which filters exist, in what order, with what labels,
292
+ is the site's.
293
+
294
+ | Export | Meaning |
295
+ | --- | --- |
296
+ | `useListQuery({ keys, page = true })` | `{ filters, page, active, apply(patch), reset(), goToPage(n) }` — filters and page bound to the route query. Encodes the routing convention once: filters in the query, `page` dropped when 1, `push` for a filter change (a new place in history), `replace` for a page change, page back to 1 when a filter changes; navigates on the current route by name. Query parameters the page does not own (`lang`) travel untouched. |
297
+ | `paginate(list, page, size = 20)` | `{ total, lastPage, currentPage, from, to, rows }`; a page past the end clamps. The size is the site's: twenty on the standalone sites, nine on the DXA sites. |
298
+ | `usePagination(list, { page, size })` | the same over reactive sources |
299
+ | `sortChronological(list, { key = 'start_date', undated = 'last' })` | a new array, by year, undated last |
300
+ | `facetOptions(records, spec)` / `useFacets(records, spec)` | `{ [facet]: [{ value, label }] }` — the values the records in front of the filter carry, labelled by the site's own helper, sorted. A facet is `{ field }` or `{ values(record) }`, plus `label(value)`, `include(value)`, `sort` (`'label'`, a comparator, `'none'`), `capitalize`. Pass all records for options that never narrow (the standalone rule) or the matching subset for dependent facets (the DXA rule); the helper reads what it is handed. |
301
+ | `inDateRange(record, { begin, end, mode })` / `dateRange(list, …)` | the two legacy date rules, named and never merged: `'overlap'` (Islamic Art, Baroque Art, Sharing History — a record whose span touches the window, tolerating one date) and `'contain'` (the DXA sites — a record whose span lies inside it; an undated record is out). A site names its mode once. |
302
+ | `yearBuckets(records, t)` / `yearBucketsFromRange(min, max, t)` | the legacy era buckets (`[{ value, label }]`, coarser the further back), whose values are in shareable URLs and therefore reproduced rather than improved; `t` supplies `catalogue.era.*` |
303
+ | `eraLabel(year, t)`, `roundOutward(start, end)` | "1193 AD" / "502 BC"; a record's dates rounded outward to the century |
304
+ | `useKeywordIndex(entity, { grammar, fields, haystack, language, rank, expand })` | `{ search, reset }` — the two search engines under one interface. `grammar: 'fields'`: `fields` maps a field name to `(record, text) => string \| string[]`, `search([{ field, keyword, cond }])` folds the rows with AND/OR (the `database.php` form). `grammar: 'boolean'`: `haystack(record, text)` returns the strings searched, `search('+word -word word* "a phrase"')` ranks by the legacy full-text grammar. `language` is the translation the text is read in. Every value is read as plain text through the pipeline; haystacks are cached per record and language and dropped when that language's translations change. `rank: 'hits'` (fields grammar only, off by default): the matches ordered by how many rows of the query each matched, then chronologically, undated last (legacy `ORDER BY nn DESC, pkdate ASC`) — pass `sort: false` to a composed results page so it does not sort again. `expand(keyword, language) => string[]` (fields grammar only, off by default): alternative spellings of a keyword, searched alongside it. |
305
+ | `glossaryExpansion({ entity = 'glossary' })` | an `expand` hook: a term equal to one of a glossary entry's spellings (in `language`, falling back to English) expands to every spelling of that entry in that language — legacy's keyword-to-glossary lookup. |
306
+ | `countryExpansion({ entity = 'countries' })` | an `expand` hook: a term equal to a country's name (in `language`, falling back to `internal_name`) expands to that country's id — legacy's `keyword`/`location` country rule; whether a record's country is part of what a field searches is that field's own getter. |
307
+ | `combineExpansions(...expand)` | one `expand` hook that runs every one given and concatenates the results — `useKeywordIndex`'s `expand` takes one function. |
308
+
309
+ ### Timelines
310
+
311
+ Legacy's country chronology (`hcr`) is one merged, year-ordered list per
312
+ country, and an exhibition can carry its own narrative chronology instead
313
+ (`source: 'thg_local'` on its `timelines.json` row) or split the merge by
314
+ which exhibition each event belongs to (Sharing History). Seven websites'
315
+ Timeline pages are all one of these three axes over the same event shape.
316
+
317
+ | Export | Meaning |
318
+ | --- | --- |
319
+ | `useTimelineEvents({ scope, countryLabel, countryIdForCode, tr, timelinesEntity, eventsEntity })` | `{ countries, yearRange, yearBuckets(t), hasTimeline, usesLocalTimeline, findEvents({ country, collection, begin, end }) }`. `scope` is `'country'` (the worldwide merge), `'local'` (an exhibition's own `thg_local` chronology in place of the merge — `countries` is empty, there is nothing to filter by) or `'collection'` (the merge again, plus a `collection` filter on the exhibition a timeline is bound to, `null` meaning the Permanent Collection). `countries` is one entry per country with a chronology, `{ value, label }`, alphabetized, with an `{ value: 'all', label: null }` marker first (never, for `'local'`) — the marker carries no label of its own so a picker names it through its own catalogue entry. `countryLabel(id)` labels a country; the DXA legacy 2-letter code table stays out of this package, so a site passing one instead resolves it through `countryIdForCode(code)`. `tr(id)` returns an event's translated fields, already resolved for entity and language, the same way a site's own data composable already binds it. `findEvents` returns the scope's events in `[begin, end]`, chronological, `display_order` breaking a tie on `year_from`, an event with no year at all last. |
320
+ | `overlapsRange(event, begin, end)` | legacy `class.hcr.inc.php`'s overlap rule, standalone: a dated event overlaps once its known end is past `begin` and its start is before `end`; an open-ended event (no known end) overlaps on its start alone; an undated event matches nothing but an unbounded search. |
321
+ | `effectiveYearTo(event)` | `event.year_to`, or `null` when it is `0` or absent — legacy's convention for an open-ended period. |
322
+ | `eventDateLabel(event, text, t)` | the label under an event, trying in order: a narrative chronology's own period name (`text.name` — a sort key there, not a label), a curated description pair (`text.date_from_description` / `text.date_to_description`), and the derived year range through `eraLabel`, for the worldwide merge which has neither. |
323
+
324
+ ### Record pages
325
+
326
+ The engine of a record page, on top of `useRecordLanguage`. The field list
327
+ is the site's — `sheetRows` turns a spec into rows — and the components are
328
+ viewer-layout's.
329
+
330
+ | Export | Meaning |
331
+ | --- | --- |
332
+ | `useRecordSheet(record, { entity, translations, attribution })` | what `useRecordLanguage` returns, plus `text` (the record's translation in the active language, falling back to English), `ready` (the entity and every named related entity loaded, in the active language and in English), `terms` (`[{ id, word, definition, spellings }]` for the record's `glossary_ids`, in the active language), `glossary` (the `[{ id, spelling }]` list the renderers take) and `attribution` — when `attribution: ['author', 'copy_editor']` names fields that are proper names, the first other language of the record that has them supplies them (`{ from, author, … }`), because the importer files some records' credits on one language only. |
333
+ | `sheetRows(spec, ctx)` | `[{ key, label, render, value, html }]` from `[{ key, label, value, render, when, join }]`: `value` a function of `ctx` or the name of a field of `ctx.text`; `render` one of `inline`, `block`, `plain` (through the pipeline, with `ctx.glossary`), `link`, `custom` (handed back for a slot); `when(ctx)` gates the row; empty values are dropped. "Materials/techniques" on one site and "Type" on another for the same field are both legacy facts, and both are a spec entry. |
334
+ | `useGlossaryPopup(entries)` | `{ active, onClick, close }` — one delegated click on the sheet's container answers the term rendered as `.gloss-term` |
335
+ | `searchGlossary(input, language)` | the terms whose spelling starts with the input, for a glossary search box |
336
+ | `glossaryTermsForText(text, language, { entity })` | `glossaryTermsFor`'s counterpart for a text with no `glossary_ids` column — a dynasty history, a theme essay: `[{ id, word, definition, spellings }]` for every glossary term whose spelling occurs in `text`, matched the same word-bounded way `md`/`renderBlock` highlight it. Pass the result through `glossaryEntries` for the `glossary` option the renderers take, the same as `terms`/`glossary` from `useRecordSheet` |
337
+ | `citation({ author, name, project, publisher, year, permalink, inWord })` | the sentence under a sheet, assembled from parts in order rather than written into a text with holes; `permalink` is the page's own address, e.g. from [`sourceUrl()`](#the-declaration-outside-a-component) |
338
+ | `relatedRecords(record, { entity, language })` / `useRelatedRecords` | `{ inPackage: [{ reference, record, justification }], outside: [reference] }` — a reference the package does not hold stays a reference; nothing is dropped and nothing invented |
339
+ | `timelineLinkFor(record, { name, keys, country, round })` | the route location of the timeline results for the record's country and dates, or null |
340
+
341
+ ### Collection trees
342
+
343
+ `useCollectionTree` — a collection tree rooted at a purpose marker, over a
344
+ flat `collections.json`-shaped array (`{ id, parent_id, display_order, type,
345
+ purpose, items }`). Four sites each walked this tree from a purpose marker
346
+ by hand — three by `parent_id`/`display_order` over `collections.json`
347
+ (islamicart's Artistic Introduction and Exhibitions, baroqueart, sharing
348
+ history's Exhibitions/themes/chapters), one with the equivalent tree
349
+ pre-built as `themes.json` (the DXA sites) — and each wrote the same reverse
350
+ "collections containing this item" scan by hand. This is that walk, written
351
+ once.
352
+
353
+ ```js
354
+ import { useCollectionTree } from '@museumwnf/viewer-core'
355
+
356
+ const exhibitions = useCollectionTree({ purpose: 'exhibitions-root', entity: 'collections' })
357
+ const { root, children, breadcrumb, itemsUnder, containing, previous, next } = exhibitions
358
+ ```
359
+
360
+ | Export | Meaning |
361
+ | --- | --- |
362
+ | `useCollectionTree({ purpose \| rootId, childType?, entity = 'collections', order = 'display_order' })` | the reactive form, over `entityRef(entity)`: empty (not an error) until that entity's chunk has arrived. `purpose` finds the root by its `purpose` field; `rootId` takes a node's id directly (for a site that resolves a marker's single child as the real root itself, as islamicart's Artistic Introduction does). `childType` filters a node's children by `type`, in one of three forms: a string holds every level to that one type — not only the root's — for a tree whose marker also parents siblings that are not real tree nodes (sharing history's country-specific "National Context" collections, which sit next to the real themes); an array, indexed by depth below the root, where `childType[0]` filters the root's children, `childType[1]` filters their children, and so on — a depth past the end of the array is unfiltered (Sharing History's exhibitions → themes → chapters uses `['exhibition', 'theme', 'subtheme']`; a tree rooted at one exhibition uses `['theme', 'subtheme']` instead); a function `(node, depth, parent) => boolean` for anything else. |
363
+ | `root`, `byId` | reactive: the root node (`null` if the purpose/id is not found), and a `Map` of every node in the tree by id |
364
+ | `children(id)` | a node's children, in `order` |
365
+ | `parents(id)` | the ancestor chain, root-first, node itself excluded |
366
+ | `breadcrumb(id)` | `parents(id)` plus the node itself |
367
+ | `itemsUnder(id)` | the item ids under a node — its own plus every descendant's, depth-first, each once |
368
+ | `containing(itemId)` | the nodes an item is directly attached to (not recursive — a page's items do not make its theme "contain" them; walk up with `parents` from what this returns for that) |
369
+ | `walk()` | the tree flattened depth-first, root excluded |
370
+ | `previous(id)` / `next(id)` | over `walk()`, by index — which is what crosses a branch boundary for free, and is `null` at either end or for an id the tree does not carry |
371
+ | `buildCollectionTree(collections, { purpose \| rootId, childType?, order? })` | the pure function `useCollectionTree` reads through `entityRef` for; usable directly outside a component. `childType` takes the same string / array / function forms as above. |
372
+ | `collectionTreeFromThemes(themes, { childType?, order? })` | the same interface from a `themes.json` shape (`sub_themes[]` nested, `pictures[].picture_item_id` for content) instead of a flat, `parent_id`-linked array. `root` is always `null` — a `themes.json` package is already the whole tree, an ordered array of top-level themes, with no marker record to key a root on. Also reachable reactively as `useCollectionTree({ source: 'themes', entity: 'themes' })`, where `purpose`/`rootId` do not apply. |
373
+
374
+ ### Partners
375
+
376
+ Seven partner lists group by country seven ways — the standalone sites
377
+ bucket a country's partners into "main" and "associated" on a `level`
378
+ field and render an accordion; the DXA sites group the same way with an
379
+ A–Z / Z–A toggle and no tiers. `parent_id` is carried on a third of the
380
+ standalone partners and read nowhere: legacy nested an associated partner
381
+ under the main partner it belongs to.
382
+
383
+ | Export | Meaning |
384
+ | --- | --- |
385
+ | `groupByCountry(records, { label, tier?, order = 'asc' })` | `[{ country, label, main, associated }]`, one entry per distinct `country_id`, sorted by `label`. `label(country)` names a group's country, the same shape as a facet's `label(value)`. `tier` names the field that marks a main partner (the converged shape's `level`); a record is associated only when its tier value is a non-empty value other than `'partner'` (legacy values: `'associated_partner'`, `'minor_contributor'`); `null`, `undefined`, `''`, or `'partner'` mean main. Without a `tier` every record is main, which is the DXA lists' plain grouping. Partners keep the order `records` was given in within each tier. |
386
+ | `partnerHierarchy(partners)` | `{ children(id), parentOf(id), roots }` from `parent_id`: `children(id)` a partner's associated partners, `parentOf(id)` the main partner it belongs to (or null), `roots` every partner with no parent in the list or whose `parent_id` points outside it. |
387
+
388
+ ### Conventions
389
+
390
+ | Export | Meaning |
391
+ | --- | --- |
392
+ | `projectName(key, t)` / `useProjectName()` / `PROJECT_ENTRIES` ⚠️ deprecated | a project's name by its legacy key (`ISL`, `EPM`, `DBA`, `BAR`, `AWE`, `DCA`, `DGA`, `EXTHE`, `GALLERIES`), through `core.project.*`; an unknown key reads as itself |
393
+ | `projectFamily(key)` / `PROJECT_FAMILIES` ⚠️ deprecated | a project's colour family by the same legacy key (ISL and EPM share `ISLandEPM`, every exhibition shares `EXH`, …) — legacy's own class names, so a site's CSS reads as the stylesheet it was copied from; an unknown key reads as itself |
394
+ | `useSection()` | the `meta.section` of the current route — see Routing |
395
+ | `useFeaturedRecord(entity, { withImage = true, seed })` | one record at random for a landing page's spotlight, among those with an image; null until the entity is loaded; `seed` pins the pick |
396
+ | `sectionMeta(chrome = [])` | returns `meta(section, ...entities) => ({ section, entities: [...chrome, ...entities] })` — a route's `meta` in one call, `chrome` being the entities every page of the site loads regardless of which one it is |
397
+ | `mwnfLinks` | the portal and sibling-site addresses the four DXA configs each repeat under `links` — frozen, spread into a site's own `links` rather than retyped |
398
+
399
+ #### Projects, from the data package (epic metanull/inventory-app#1727)
400
+
401
+ `manifest.projects` — a project UUID → `{ name: { <lang>: '…' }, site_url, related_database_url, artistic_introduction_url }` map, emitted by every exporter since #1727 phase 2 — is project data moved out of viewer-core and into the data package a project's own exporter writes. It is additive: a package built before phase 2 simply has no `projects` key, and every function below reads that exactly like an unknown project id — `null`, never a throw.
402
+
403
+ | Export | Meaning |
404
+ | --- | --- |
405
+ | `projectLabel(manifest, projectId, lang)` | `manifest.projects[projectId].name[…]`, with the same language fallback every translated field in this package follows (`resolveRecordLanguage`): `lang`, then this package's base language (`en`), then the entry's first carried language. `null` when the package has no `projects` section, `projectId` isn't a key of it, or the entry names nothing. |
406
+ | `projectLinks(manifest, projectId)` | `{ siteUrl, relatedDatabaseUrl, artisticIntroductionUrl }` from the same entry (camelCased, individually nullable — a project may simply have no value for one). `null` for the whole result when the package predates the section or doesn't carry `projectId`. |
407
+ | `useProjects()` | `{ label(projectId, lang?), links(projectId) }` bound to the installed data package and active language — the manifest-driven analogue of `useProjectName()`. |
408
+
409
+ Migration mapping, from the epic's design comment (§B) — what each of the five usages moves to:
410
+
411
+ | Today (deprecated) | Reads from `manifest.projects` instead |
412
+ | --- | --- |
413
+ | RecordView citation line (`projectName(record.project_key, t)`) | `projectLabel(manifest, record.project_id, lang)` / `useProjects().label(record.project_id)` — tracked on viewer-layout#81 |
414
+ | ItemSheet "Source database" line + `mwnf-chip--<family>` colour | same `projectLabel`, plus a site-config map from project UUID to a CSS token (no more `projectFamily`) |
415
+ | "Search related database" block | `projectLinks(manifest, projectId).relatedDatabaseUrl`, rendered iff non-null |
416
+ | Artistic Introduction link | `projectLinks(manifest, projectId).artisticIntroductionUrl`, iff non-null |
417
+ | Search-scope include-EPM checkbox | unchanged by this phase — stays the site's own `dataset.config.js` scope list (epic decision 5) |
418
+ | Partner Museums/Institutions split | unchanged by this phase — `partner.project_uuids` matched against a site-config list (epic decision 5) |
419
+
420
+ `PROJECT_ENTRIES`, `PROJECT_FAMILIES` and `projectFamily` are deprecated as of this phase and will be removed in the cleanup wave once every site has migrated (phase 4); `projectName()`/`useProjectName()` are unaffected for now — they still resolve a *legacy key* through the installed texts for whatever hasn't migrated yet.
421
+
422
+ ### `@museumwnf/viewer-core/legacy`
423
+
424
+ The DXA sites' legacy URL mappings, decoded from `backward_compatibility` —
425
+ pure functions over plain lists, no Vue, so a site's own composable stays
426
+ the reactive wrapper around its `partners` / `countries` / `items` refs. A
427
+ separate entry point because only a site built against these legacy shapes
428
+ needs it; the standalone sites never import it.
429
+
430
+ | Export | Meaning |
431
+ | --- | --- |
432
+ | `partnerKey(partner, countries = [])` | `{ legacyId, countryCode }` from a partner's `backward_compatibility` — `mwnf3:museums:Mus21:ua` on the DXA sites, `mwnf3_sharing_history:sh_partners:at_01_d` on Sharing History, whose key carries the country as its own prefix rather than a fourth segment. `countries` is the fallback for a partner with no `backward_compatibility` at all: its `country_id` resolved to the country's own `code`. |
433
+ | `partnerFromKey(partners, countries, countryCode, legacyId)` | the partner a legacy `/partner/:country/:id` route names, or null — `partnerKey` in reverse |
434
+ | `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
+
436
+ ### The declaration outside a component
437
+
438
+ | Export | Meaning |
439
+ | --- | --- |
440
+ | `useSiteConfig()` | the configuration `createViewer` received, injected inside a component and read from the application outside one |
441
+ | `mediaUrl(path, size = 'hi_res')` | `${config.media.legacyHost}/${size}/${path}` for a legacy media path the package carries; an address is returned as it is |
442
+ | `useSiteRights()` | `{ holder, termsUrl, attribution }` from the data package's `manifest.rights`; a package with no such block offers no holder or attribution, and `termsUrl` falls back to `mwnfLinks.legalNotice` |
443
+ | `sourceUrl(route)` | `${config.site.origin}/#${path}` for `route` — a route already resolved (its `fullPath` read as it is) or a raw location the installed router resolves; null when the website declares no `site.origin` |
444
+
445
+ ### `useDataPackage()` → data access (the only allowed way to read the data package)
446
+
447
+ | Member | Type | Meaning |
448
+ | --- | --- | --- |
449
+ | `manifest` | object | parsed `manifest.json` |
450
+ | `languages` | string[] | `manifest.languages`, default `['en']` |
451
+ | `entityNames` | string[] | one entry per `<entity>.json` file |
452
+ | `loadEntity(name)` | `Promise<Array>` | lazy-loads and returns the records of one entity |
453
+ | `availableLanguages(entity)` | string[] | languages `translations/<entity>.<lang>.json` actually exists for |
454
+ | `loadTranslations(entity, lang)` | `Promise<object>` | lazy-loads `translations/<entity>.<lang>.json` (id → translated fields); `{}` if the file is absent. Cached. |
455
+ | `translations(entity, lang)` | object | the already-loaded map for that pair; `{}` if not loaded yet |
456
+ | `tr(entity, id, lang, fallbackLang = 'en')` | object | one record's translated fields, falling back to `fallbackLang` then `{}` — reads only what's already loaded |
457
+
458
+ Always resolve a per-language file by name through `loadTranslations`/`translations`/`tr` —
459
+ never `import(\`...${lang}...\`)`. A dynamic import with an interpolated
460
+ specifier can't be resolved statically, so the bundler falls back to bundling
461
+ every language of that entity eagerly instead of Vite's dedicated glob-import
462
+ path; for a dataset with a large or many-language translation set this can
463
+ turn a several-second build into a build that hangs for hours in CI — this is
464
+ what made islamicart's production build unable to finish.
465
+
466
+ ### A website's data composable
467
+
468
+ `useCatalogueData` is the wrapper half of what every site's own data
469
+ composable had written for itself: the same three-line `tr`, the same
470
+ memoised `loadEnglish`, the same one-shape label helper, the same
471
+ `md`/`mdInline`/`mdStrip` re-export, around whichever entities and
472
+ visibility rules were the site's own. Call it once, at the top of the
473
+ module, and re-export what it returns beside what is genuinely the site's —
474
+ routes, legacy key mappings, chrome images, sibling lists.
475
+
476
+ ```js
477
+ import { useCatalogueData } from '@museumwnf/viewer-core'
478
+
479
+ const catalogue = useCatalogueData({
480
+ eager: ['items', 'partners', 'countries', 'glossary', 'dynasties'],
481
+ visible: {
482
+ // A per-build language filter, a hidden-partner rule, a display-status
483
+ // check are all "which records this build shows" — declared here once,
484
+ // instead of coded into every page that lists that entity.
485
+ items: (i) => !i.languages?.length || i.languages.includes('en'),
486
+ partners: (p) => !hiddenPartnerIds.has(p.id),
487
+ },
488
+ })
489
+
490
+ export const items = catalogue.entity('items')
491
+ export const itemById = catalogue.index('items')
492
+ export const { tr, md, mdInline, mdStrip, labelOf, loadEnglish } = catalogue
493
+ ```
494
+
495
+ | Export | Meaning |
496
+ | --- | --- |
497
+ | `useCatalogueData({ eager, defaultLanguage = 'en', visible = {}, glossary = true })` | `eager` names the entities `loadEnglish` loads — every entity the package carries, by default. `defaultLanguage` is what `tr`, `labelOf` and the glossary binding read. `visible[name]` is a predicate `(record) => boolean`, applied by `entity(name)`/`index(name)` when one is declared. `glossary` turns off the default binding on `md`/`mdInline`; either still takes a caller's own `glossary` regardless. |
498
+ | `tr(entity, id, lang = defaultLanguage)` | one record's translated fields, falling back to `defaultLanguage` then `{}` |
499
+ | `md(text, opts)` / `mdInline(text, opts)` | `md`/`mdInline` from the package, with the site's own glossary (every term in `defaultLanguage`) bound as the default `glossary` — a caller passing its own (a record's own terms, not every term in the package) is still honoured |
500
+ | `mdStrip(text)` | the package's own, unchanged — plain text carries no highlighting to bind |
501
+ | `loadEnglish()` | loads `defaultLanguage` for every entity in `eager`, once, memoised |
502
+ | `labelOf(entity, id, { fallback = 'internal_name' })` | `mdStrip(tr(entity, id).name ?? record[fallback] ?? id)` — the one label shape every site wrote, for a list, a dropdown, an alt text |
503
+ | `entity(name)` | `entityRef(name)`'s records, narrowed by `visible[name]` when one is declared; `null` until that entity's chunk has arrived, the same as `entityRef` |
504
+ | `index(name, key = 'id')` | a `Map` of `entity(name)`'s records by `key` — the visible ones, unlike `byId` |
505
+ | `availableLanguages`, `loadTranslations`, `translations` | the package's own, re-exported so a site reads everything through the one object |
506
+
507
+ ### Routing
508
+
509
+ The convention, applied by the router every website shares:
510
+
511
+ - hash history; every route named, in kebab-case;
512
+ - a section is `/<section>`, a record `/<section>/:id` with the package id
513
+ (`/item/:id`, `/partner/:id`, `/theme/:id`); a sub-list is a path under
514
+ its parent (`/partner/:id/objects`);
515
+ - the language, the page and every filter travel in the query, never in the
516
+ path: `?lang=fr`, `?page=2`, `?country=…`;
517
+ - a legacy URL shape is a `legacyRoutes` entry, which only ever redirects:
518
+ `{ path, resolve(params, route) }`, `resolve` answering the canonical
519
+ location (`{ name, params }` or a path) or nothing, which is the not-found
520
+ page. It may be async, so a website can load the entity it maps a legacy
521
+ key through `backward_compatibility` with;
522
+ - a route says what section it belongs to — `meta: { section: 'collection' }`
523
+ — and a shell reads it with `useSection()` for the banner title or the
524
+ active menu entry, instead of deriving it from the path;
525
+ - a `/:pathMatch(.*)*` catch-all lands on `NotFoundView` (`core.notFound.page`),
526
+ or on the component `config.notFound` names;
527
+ - the router owns scrolling: back to where the visitor was when they come
528
+ back through history, to the anchor the URL names, where it is when only
529
+ the query of the same page changed, to the top otherwise. No shell and no
530
+ view scrolls the window.
531
+
532
+ ```js
533
+ export default {
534
+ extraViews: [
535
+ { path: '/item/:id', name: 'item', component: () => import('./views/ItemSheet.vue'), meta: { entities: ['items'] } },
536
+ ],
537
+ legacyRoutes: [
538
+ {
539
+ path: '/database-item/:uid(.*)/:language',
540
+ async resolve({ uid }) {
541
+ await loadEntities(['items'])
542
+ const item = itemByUid.value.get(uid)
543
+ return item ? { name: 'item', params: { id: item.id } } : null
544
+ },
545
+ },
546
+ ],
547
+ }
548
+ ```
549
+
550
+ ### Views
551
+
552
+ Every website has three slots — the home page, a list page, a record page —
553
+ and this package renders each with a generic view unless the website names
554
+ another:
555
+
556
+ | Slot | Route | Generic view | Props | Renders |
557
+ | --- | --- | --- | --- | --- |
558
+ | `home` | `/` | `HomeView` | — | `siteName` + links to each configured entity list |
559
+ | `list` | `/<entity>` | `ListView` | `entity`, `pageSize` (default 20) | paginated record list (`?page=N`), links to details |
560
+ | `detail` | `/<entity>/:id` | `DetailView` | `entity`, `id` | one record; string fields rendered as markdown |
561
+ | — | `/:pathMatch(.*)*` | `NotFoundView` | — | `core.notFound.page` and a link home |
562
+
563
+ The generic views exist to look at a new dataset: they expose the data
564
+ package's shape, not the website's. A real website names its pages in
565
+ `config.views`, and the same route names — `home`, `<entity>-list`,
566
+ `<entity>-detail` — then render them. The composed views of
567
+ `@museumwnf/viewer-layout/views` (`HomeView`, `CatalogueResultsView`,
568
+ `RecordView`) are what a website names there: a landing page, a results
569
+ page and a record page built from the components of that package on the
570
+ composables of this one, driven by a spec the website declares. They live
571
+ in the layout package because they are made of its components, and this
572
+ package does not depend on it.
573
+
574
+ ```js
575
+ import { CatalogueResultsView, HomeView, RecordView } from '@museumwnf/viewer-layout/views'
576
+
577
+ export default {
578
+ features: { entities: ['items'] },
579
+ views: { home: HomeView, list: CatalogueResultsView, detail: RecordView },
580
+ home: { cards: [...], featured: { entity: 'items' } },
581
+ }
582
+ ```
583
+
584
+ A website that registers its own component on the `home` name in
585
+ `extraViews`, or its own results and record routes, keeps it: `views` only
586
+ fills the slots the website leaves to the package. `resolveViews(config)`
587
+ answers the three components a configuration resolves to.
588
+
589
+ ### Styles
590
+
591
+ `@museumwnf/viewer-core/styles/base.css` (auto-imported by `createViewer`): reset and
592
+ structural rules only. It consumes CSS custom properties and never defines brand values:
593
+
594
+ | Property | Default |
595
+ | --- | --- |
596
+ | `--vc-font-body` | `system-ui, sans-serif` |
597
+ | `--vc-line-height` | `1.5` |
598
+ | `--vc-color-background` | `transparent` |
599
+ | `--vc-color-text` | `inherit` |
600
+ | `--vc-content-width` | `60rem` |
601
+ | `--vc-content-padding` | `1.5rem` |
602
+ | `--vc-weight-label` | `600` |
603
+
604
+ ## Data package contract
605
+
606
+ | File | Content |
607
+ | --- | --- |
608
+ | `manifest.json` | at least `{ "languages": ["en", ...] }` — every language any record carries. `site.languages`: the languages the website offers, in switcher order, `[{ "code": "fr", "label": "Français" }, …]`. `site.names`: the site's name per language, `{ "en": "…", "fr": "…" }`. A website reads nothing else from its package before it mounts. |
609
+ | `<entity>.json` | array of records; each record has an `id`, optional `title`/`name`, other fields free-form (strings may contain markdown) |
610
+ | `translations/<entity>.<lang>.json` | optional; object keyed by record `id`, each value the record's translated fields for `<lang>`. A file is simply absent when that entity has no translation in that language. |
611
+
612
+ ## Testing a website
613
+
614
+ The package exports shared smoke-test helpers at @museumwnf/viewer-core/testing: mount a website, check its configuration, and verify that texts are rendered.
615
+
616
+ | Export | Use |
617
+ | --- | --- |
618
+ | mountSite(config, messages, hash) | Mount a website on a detached DOM node at a given hash (default '#/'); returns { app, host, router }. The router is ready and the app is mounted before the promise resolves. |
619
+ | checkRoutes(config, { names, legacyPaths }) | Verify every route has a name, no route is a catch-all, and expected names and legacy paths are present. Returns the list of problems found. |
620
+ | checkSectionMeta(config) | Verify every route has a meta.section string (used by the menu to highlight the current page). Returns the list of problems found. |
621
+ | checkTextsRendered(host, { namespaces }) | Match text keys in the rendered host against a regex like /\b(ns1\|ns2)\.[a-z]/i. Returns the list of namespaces found (empty if none). |
622
+ | defineViewerConfig({ dataPackage, inline, plugins, root }) | Return a Vite config object the seven websites share: the @inventory-data alias, optimizeDeps to inline viewer packages, testTimeout of 60s, and the test environment. Exported from `@museumwnf/viewer-core/vite` for use in `vite.config.js` (separate entry because Vite's config loader runs under Node.js, which cannot parse `.vue` files; the `/testing` barrel reaches a `.vue` file). dataPackage is the npm package name; inline is an optional array of extra packages; plugins is the Vite plugins array (e.g. @vitejs/plugin-vue); root is the project root path (default `process.cwd()`), used to resolve the data package from the project root. |
623
+ | checkOfferedLanguages(config) | Check that the website offers only languages with content, in the declared order, with labels on the switcher. Returns the list of problems found. |
624
+
625
+ @museumwnf/viewer-core/testing is not imported by websites, only by their tests. A website test might be:
626
+
627
+ \\\js
628
+ import { checkOfferedLanguages, checkRoutes, checkSectionMeta, mountSite } from '@museumwnf/viewer-core/testing'
629
+ import config from '../src/dataset.config.js'
630
+ import { mergeMessages } from '@museumwnf/viewer-core'
631
+ import { catalogues } from '@museumwnf/viewer-i18n/gallery'
632
+ import ownTexts from '../locales/en.json'
633
+
634
+ const messages = mergeMessages(catalogues, { en: ownTexts })
635
+
636
+ describe('smoke test', () => {
637
+ it('mounts and declares every route by name', async () => {
638
+ const { app } = await mountSite(config, messages)
639
+ expect(checkRoutes(config)).toEqual([])
640
+ expect(checkSectionMeta(config)).toEqual([])
641
+ expect(checkOfferedLanguages(config)).toEqual([])
642
+ app.unmount()
643
+ })
644
+ })
645
+ \\\
646
+ ## Licence
647
+
648
+ This package is Content of the MWNF Website under the [MWNF legal
649
+ notice](https://www.museumwnf.org/about/legal-notice), which governs its use
650
+ (non-commercial, personal, educational and scientific use is permitted, with
651
+ attribution and mandatory reporting — see the notice for the full terms). The
652
+ notice text also ships in this package as `LICENSE.md`.
653
+
654
+ ## Release procedure
655
+
656
+ 1. Merge to `main` via PR (CI: tests + a downstream build of every website).
657
+ 2. Create a GitHub release with tag `vX.Y.Z` — CI publishes to GitHub Packages; never publish from a laptop.
658
+ 3. Semver rules: **patch** = fix; **minor** = backward-compatible addition; **major** = breaking change (breaking = any change requiring an edit in a consuming website).