@museumwnf/viewer-core 1.14.0 → 1.15.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.md +44 -44
- package/README.md +658 -635
- package/package.json +50 -50
- package/src/AppRoot.vue +58 -58
- package/src/catalogue/dates.js +209 -209
- package/src/catalogue/facets.js +61 -61
- package/src/catalogue/keywordIndex.js +289 -289
- package/src/catalogue/listQuery.js +100 -100
- package/src/catalogue/pagination.js +57 -57
- package/src/catalogue/searchLanguage.js +32 -32
- package/src/catalogue/timeline.js +220 -220
- package/src/composables/useCatalogueData.js +141 -141
- package/src/composables/useDataPackage.js +98 -98
- package/src/composables/useEntities.js +100 -100
- package/src/composables/useRecordLanguage.js +71 -71
- package/src/conventions.js +252 -148
- package/src/createViewer.js +29 -29
- package/src/i18n/I18nText.vue +17 -17
- package/src/i18n/I18nTextInline.vue +17 -17
- package/src/i18n/index.js +88 -88
- package/src/i18n/language.js +127 -127
- package/src/i18n/markdown.js +265 -265
- package/src/index.js +51 -50
- package/src/injectionKeys.js +1 -1
- package/src/languages.js +49 -49
- package/src/legacy/index.js +58 -58
- package/src/record/collectionTree.js +307 -307
- package/src/record/derivations.js +154 -154
- package/src/record/partners.js +87 -87
- package/src/record/recordSheet.js +231 -231
- package/src/rights.js +53 -53
- package/src/router/index.js +138 -138
- package/src/siteConfig.js +37 -37
- package/src/styles/base.css +49 -49
- package/src/testing/checkOfferedLanguages.js +67 -67
- package/src/testing/index.js +3 -3
- package/src/testing/smoke.js +103 -103
- package/src/testing/viteConfig.js +72 -90
- package/src/views/DetailView.vue +53 -53
- package/src/views/HomeView.vue +20 -20
- package/src/views/ListView.vue +61 -61
- package/src/views/NotFoundView.vue +8 -8
- 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
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
|
416
|
-
|
|
|
417
|
-
|
|
|
418
|
-
|
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
### `
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
|
431
|
-
|
|
|
432
|
-
| `
|
|
433
|
-
| `
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
`
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
|
595
|
-
|
|
|
596
|
-
|
|
|
597
|
-
|
|
|
598
|
-
|
|
|
599
|
-
|
|
|
600
|
-
|
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
})
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
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).
|