@museumwnf/viewer-core 2.0.0 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +49 -14
- package/package.json +1 -1
- package/src/dxa/exhibition/useExhibitionSheet.js +6 -2
- package/src/dxa/gallery/useGallerySheet.js +6 -2
- package/src/index.js +1 -1
- package/src/record/partners.js +142 -0
package/README.md
CHANGED
|
@@ -11,6 +11,29 @@ Texts are handled here, without an i18n library: see [Texts](#texts). Nothing in
|
|
|
11
11
|
the platform depends on `vue-i18n` any more, and this package no longer asks for
|
|
12
12
|
it.
|
|
13
13
|
|
|
14
|
+
## Entry points and layers
|
|
15
|
+
|
|
16
|
+
Each entry point belongs to one layer of the platform's
|
|
17
|
+
[architecture reference](https://github.com/museumwithnofrontiers/inventory-app/issues/1510), which says what goes where across
|
|
18
|
+
all the packages:
|
|
19
|
+
|
|
20
|
+
| Entry | Layer | Holds |
|
|
21
|
+
| --- | --- | --- |
|
|
22
|
+
| `@museumwnf/viewer-core` | engine and data layer | the application factory, the router and the texts runtime; the **data layer** — how a site reads its data package ([records](#records-useentities-the-standard-way-a-website-reads-them), [a website's data composable](#a-websites-data-composable), [`useDataPackage`](#usedatapackage--data-access-the-only-allowed-way-to-read-the-data-package)); the engines of list pages, timelines and record pages; the view-model builders the blocks of `@museumwnf/viewer-layout` are fed with ([record pages](#record-pages), [collection trees](#collection-trees), [partners](#partners)) |
|
|
23
|
+
| `@museumwnf/viewer-core/dxa` | DXA family layer | the data composables of the gallery and the exhibition families ([below](#museumwnfviewer-coredxa)) |
|
|
24
|
+
| `@museumwnf/viewer-core/legacy` | legacy address mapping | pure functions decoding `backward_compatibility` for legacy URL shapes |
|
|
25
|
+
| `@museumwnf/viewer-core/testing` | a website's tests | the shared smoke-test kit ([Testing a website](#testing-a-website)) |
|
|
26
|
+
| `@museumwnf/viewer-core/vite` | a website's build | `defineViewerConfig` |
|
|
27
|
+
|
|
28
|
+
The root is generic: every kind of website reads its data through it — the
|
|
29
|
+
standalone products, the scaffold of `website-template`, and the DXA
|
|
30
|
+
family's own composables. A composable only the galleries or the
|
|
31
|
+
exhibitions use belongs under `/dxa`, never in the root. The dependency runs
|
|
32
|
+
one way: the root, `/legacy`, `/testing` and `/vite` never import from
|
|
33
|
+
`/dxa`, and `/dxa` builds only on what the root and `/legacy` publish. A
|
|
34
|
+
function that moves between entries keeps its old export as an alias until
|
|
35
|
+
the websites have moved; a major release then removes it.
|
|
36
|
+
|
|
14
37
|
## Install
|
|
15
38
|
|
|
16
39
|
Published to npmjs (`registry.npmjs.org`), publicly — no authentication needed
|
|
@@ -447,6 +470,7 @@ under the main partner it belongs to.
|
|
|
447
470
|
| --- | --- |
|
|
448
471
|
| `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. |
|
|
449
472
|
| `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. |
|
|
473
|
+
| `partnerView(partner, text, ctx)` | The view-model every rendering of one partner reads — `@museumwnf/viewer-layout`'s `PartnerPanel` in each of its variants: `{ id, type, name (inline HTML), plainName, city, country, location, logos: [{ url, alt, type }], pictures: [{ url, alt, caption, photographer, copyright }], description (block HTML), contact: { address (block HTML), phone, fax, email, website: { url, label } \| null, links: [{ url, label }] }, persons: [{ title, name, phone, fax, email }], hasContact, map: { latitude, longitude, zoom, label } \| null, itemCount, hidden, route, objectsRoute }`. `text` is the partner's translation in the language shown. `ctx` carries only what differs by family: `countryLabel(id)`, the renderers (`md`, `mdInline`, `mdStrip`, the package's by default), `route(partner)`, `objectsRoute(partner)` and `hidden(partner)` (a hidden partner keeps its name and loses both links). Contact persons come from `contact_persons`, in order, keeping those with a name or a title. A picture's caption is its `alt_text`. A website or link with no scheme gets `https://`; any scheme other than `http(s)` is dropped. |
|
|
450
474
|
|
|
451
475
|
### Conventions
|
|
452
476
|
|
|
@@ -494,16 +518,21 @@ needs it; the standalone sites never import it.
|
|
|
494
518
|
|
|
495
519
|
### `@museumwnf/viewer-core/dxa`
|
|
496
520
|
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
521
|
+
This entry is the data half of the DXA family layer; the family's pages are
|
|
522
|
+
in `@museumwnf/viewer-layout/dxa`. The galleries are one site with different
|
|
523
|
+
data, and so are the exhibitions — the legacy served each family from one
|
|
524
|
+
application — so what is the same on every site of a family lives here
|
|
525
|
+
once instead of in each site.
|
|
526
|
+
|
|
527
|
+
What may go here: a composable or a constant that only the gallery or the
|
|
528
|
+
exhibition family uses, and that is the same on every site of that family,
|
|
529
|
+
including the data instance each site of the family builds from these
|
|
530
|
+
composables. Each family keeps its own composables, never one that papers
|
|
531
|
+
over the two: the difference between the families is real behaviour (the
|
|
532
|
+
exhibition's per-language-build 404 gate, its own local-vs-country timeline
|
|
533
|
+
switch, its manifest-driven citation line). A difference between two sites
|
|
534
|
+
of one family is a `config` value, not a copy. Nothing outside `/dxa`
|
|
535
|
+
imports from it; it builds only on what the root and `/legacy` publish.
|
|
507
536
|
|
|
508
537
|
A site composes its own data/catalogue/timeline/partner/sheet module from
|
|
509
538
|
these, threading the `data` object one family's
|
|
@@ -724,12 +753,12 @@ The package exports shared smoke-test helpers at @museumwnf/viewer-core/testing:
|
|
|
724
753
|
| 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. |
|
|
725
754
|
| 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. |
|
|
726
755
|
| 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). |
|
|
727
|
-
| defineViewerConfig({ dataPackage, inline, plugins, root }) | Return
|
|
756
|
+
| defineViewerConfig({ dataPackage, inline, plugins, root }) | Return the Vite config object every website shares: 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. |
|
|
728
757
|
| 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. |
|
|
729
758
|
|
|
730
759
|
@museumwnf/viewer-core/testing is not imported by websites, only by their tests. A website test might be:
|
|
731
760
|
|
|
732
|
-
|
|
761
|
+
```js
|
|
733
762
|
import { checkOfferedLanguages, checkRoutes, checkSectionMeta, mountSite } from '@museumwnf/viewer-core/testing'
|
|
734
763
|
import config from '../src/dataset.config.js'
|
|
735
764
|
import { mergeMessages } from '@museumwnf/viewer-core'
|
|
@@ -747,7 +776,8 @@ describe('smoke test', () => {
|
|
|
747
776
|
app.unmount()
|
|
748
777
|
})
|
|
749
778
|
})
|
|
750
|
-
|
|
779
|
+
```
|
|
780
|
+
|
|
751
781
|
## Licence
|
|
752
782
|
|
|
753
783
|
This package is Content of the MWNF Website under the [MWNF legal
|
|
@@ -759,5 +789,10 @@ notice text also ships in this package as `LICENSE.md`.
|
|
|
759
789
|
## Release procedure
|
|
760
790
|
|
|
761
791
|
1. Merge to `main` via PR (CI: tests + a downstream build of every website).
|
|
762
|
-
2. Create a GitHub release with tag `vX.Y.Z` — CI publishes to
|
|
792
|
+
2. Create a GitHub release with tag `vX.Y.Z` — CI publishes to npmjs through
|
|
793
|
+
trusted publishing; never publish from a laptop.
|
|
763
794
|
3. Semver rules: **patch** = fix; **minor** = backward-compatible addition; **major** = breaking change (breaking = any change requiring an edit in a consuming website).
|
|
795
|
+
|
|
796
|
+
Websites receive a release through the platform's propagation run — see
|
|
797
|
+
[MAINTENANCE.md](https://github.com/museumwithnofrontiers/viewer-workflows/blob/main/MAINTENANCE.md)
|
|
798
|
+
in `viewer-workflows`.
|
package/package.json
CHANGED
|
@@ -45,8 +45,12 @@ export function useExhibitionSheet(data, config = {}) {
|
|
|
45
45
|
{ key: 'name', label: 'sheet.field.name', value: 'name' },
|
|
46
46
|
{ key: 'aka', label: 'sheet.field.alsoKnownAs', value: 'alternate_name' },
|
|
47
47
|
{ key: 'location', label: 'sheet.field.location', value: (c) => [c.text.location, labelOf('countries', c.record.country_id)].filter(Boolean).join(', ') },
|
|
48
|
-
//
|
|
49
|
-
|
|
48
|
+
// The holding museum, rendered by the wrapper's `museum` slot: the item's
|
|
49
|
+
// holder text, then the partner it refers to (decision D3,
|
|
50
|
+
// inventory-app#2015). The value is the partner's id when the package
|
|
51
|
+
// carries the partner, otherwise the holder text, so the row stays for an
|
|
52
|
+
// item whose holder has no partner page.
|
|
53
|
+
{ key: 'museum', label: 'sheet.field.holdingMuseum', value: (c) => (partnerById.value.get(c.record.partner_id) ? c.record.partner_id : (c.text.holder ?? '')), render: 'custom' },
|
|
50
54
|
{ key: 'originalOwner', label: 'sheet.field.originalOwner', value: 'initial_owner' },
|
|
51
55
|
{ key: 'currentOwner', label: 'sheet.field.currentOwner', value: 'owner' },
|
|
52
56
|
{ key: 'date', label: 'sheet.field.date', value: 'dates' },
|
|
@@ -45,8 +45,12 @@ export function useGallerySheet(data, config = {}) {
|
|
|
45
45
|
{ key: 'name', label: 'sheet.field.name', value: 'name' },
|
|
46
46
|
{ key: 'aka', label: 'sheet.field.alsoKnownAs', value: 'alternate_name' },
|
|
47
47
|
{ key: 'location', label: 'sheet.field.location', value: (c) => [c.text.location, labelOf('countries', c.record.country_id)].filter(Boolean).join(', ') },
|
|
48
|
-
//
|
|
49
|
-
|
|
48
|
+
// The holding museum, rendered by the wrapper's `museum` slot: the item's
|
|
49
|
+
// holder text, then the partner it refers to (decision D3,
|
|
50
|
+
// inventory-app#2015). The value is the partner's id when the package
|
|
51
|
+
// carries the partner, otherwise the holder text, so the row stays for an
|
|
52
|
+
// item whose holder has no partner page.
|
|
53
|
+
{ key: 'museum', label: 'sheet.field.holdingMuseum', value: (c) => (partnerById.value.get(c.record.partner_id) ? c.record.partner_id : (c.text.holder ?? '')), render: 'custom' },
|
|
50
54
|
{ key: 'originalOwner', label: 'sheet.field.originalOwner', value: 'initial_owner' },
|
|
51
55
|
{ key: 'currentOwner', label: 'sheet.field.currentOwner', value: 'owner' },
|
|
52
56
|
{ key: 'date', label: 'sheet.field.date', value: 'dates' },
|
package/src/index.js
CHANGED
|
@@ -35,7 +35,7 @@ export {
|
|
|
35
35
|
citation, relatedRecords, searchGlossary, timelineLinkFor, useGlossaryPopup, useRelatedRecords,
|
|
36
36
|
} from './record/derivations.js'
|
|
37
37
|
export { buildCollectionTree, collectionTreeFromThemes, useCollectionTree } from './record/collectionTree.js'
|
|
38
|
-
export { groupByCountry, partnerHierarchy } from './record/partners.js'
|
|
38
|
+
export { groupByCountry, partnerHierarchy, partnerView } from './record/partners.js'
|
|
39
39
|
|
|
40
40
|
// Conventions every website had written for itself.
|
|
41
41
|
export {
|
package/src/record/partners.js
CHANGED
|
@@ -10,6 +10,15 @@
|
|
|
10
10
|
// nowhere: legacy nested an associated partner under the main partner it
|
|
11
11
|
// belongs to, and the port flattened that into the country column instead.
|
|
12
12
|
// `partnerHierarchy` is that relationship, read once.
|
|
13
|
+
//
|
|
14
|
+
// `partnerView` is what every rendering of one partner reads — a list line,
|
|
15
|
+
// the summary under an item's holder, the partner page — built once here so
|
|
16
|
+
// the contact block, the contact persons and the picture captions are read
|
|
17
|
+
// the same way on every site. A partner is one type in every exporter family
|
|
18
|
+
// (decision D4 of inventory-app#1699); what differs by family is only what
|
|
19
|
+
// `ctx` carries.
|
|
20
|
+
|
|
21
|
+
import { md as mdBlock, mdInline as mdInlineDefault, mdStrip as mdStripDefault } from '../i18n/markdown.js'
|
|
13
22
|
|
|
14
23
|
/**
|
|
15
24
|
* Whether `record` counts as a main (rather than associated) partner.
|
|
@@ -85,3 +94,136 @@ export function partnerHierarchy(partners) {
|
|
|
85
94
|
roots,
|
|
86
95
|
}
|
|
87
96
|
}
|
|
97
|
+
|
|
98
|
+
// ── One partner, for rendering ───────────────────────────────────────────────
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* A partner's website or extra link, as an address a link can use. A value
|
|
102
|
+
* with no scheme (`www.example.org`, about a quarter of the packages' links)
|
|
103
|
+
* gets `https://` (decided 2026-09-25, inventory-app#2031); `http://` and
|
|
104
|
+
* `https://` are kept as they are. Any other scheme (`javascript:`, `data:`,
|
|
105
|
+
* `mailto:`) is not a website and yields `''`, so it never reaches an `href`.
|
|
106
|
+
* A port after a bare host (`host:8080`) is not a scheme.
|
|
107
|
+
*/
|
|
108
|
+
function websiteUrl(value) {
|
|
109
|
+
const url = String(value ?? '').trim()
|
|
110
|
+
if (!url) return ''
|
|
111
|
+
if (/^https?:\/\//i.test(url)) return url
|
|
112
|
+
if (url.startsWith('//')) return `https:${url}`
|
|
113
|
+
if (/^[a-z][a-z0-9+.-]*:(?!\d)/i.test(url)) return ''
|
|
114
|
+
return `https://${url}`
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const byDisplayOrder = (a, b) => (a?.display_order ?? 0) - (b?.display_order ?? 0)
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The contact persons in legacy order (person 1 first), keeping only those
|
|
121
|
+
* with a name or a title — legacy printed a person block only then. Reads
|
|
122
|
+
* `contact_persons`; a package built before that list existed carries
|
|
123
|
+
* `contact_person_1`/`_2` instead (inventory-app#2007 removes them).
|
|
124
|
+
*/
|
|
125
|
+
function contactPersons(partner) {
|
|
126
|
+
const list = Array.isArray(partner?.contact_persons)
|
|
127
|
+
? partner.contact_persons
|
|
128
|
+
: [partner?.contact_person_1, partner?.contact_person_2]
|
|
129
|
+
return list
|
|
130
|
+
.filter((person) => person && (person.name || person.title))
|
|
131
|
+
.map((person) => ({
|
|
132
|
+
title: person.title ?? '',
|
|
133
|
+
name: person.name ?? '',
|
|
134
|
+
phone: person.phone ?? '',
|
|
135
|
+
fax: person.fax ?? '',
|
|
136
|
+
email: person.email ?? '',
|
|
137
|
+
}))
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* The view-model of one partner: what viewer-layout's `PartnerPanel` renders
|
|
142
|
+
* in each of its variants, and what any other rendering of a partner reads.
|
|
143
|
+
*
|
|
144
|
+
* `partner` is a `partners.json` record; `text` its translation in the
|
|
145
|
+
* language shown (`{ name, description, city, address, phone, fax, email,
|
|
146
|
+
* website }`). `ctx` carries what differs by family, nothing else:
|
|
147
|
+
*
|
|
148
|
+
* - `countryLabel(countryId)` — the country's name;
|
|
149
|
+
* - `md`, `mdInline`, `mdStrip` — the renderers, when the site binds its
|
|
150
|
+
* own (a glossary); the package's by default;
|
|
151
|
+
* - `route(partner)` — the partner's own page;
|
|
152
|
+
* - `objectsRoute(partner)` — the page of the items it holds;
|
|
153
|
+
* - `hidden(partner)` — a partner with no page of its own (the exhibitions'
|
|
154
|
+
* rule): it keeps its name, and loses both links.
|
|
155
|
+
*
|
|
156
|
+
* Text is rendered once here: `name` and `description` are HTML (inline and
|
|
157
|
+
* block), `address` is block HTML; every other field is plain text.
|
|
158
|
+
*/
|
|
159
|
+
export function partnerView(partner, text = {}, ctx = {}) {
|
|
160
|
+
if (!partner) return null
|
|
161
|
+
const t = text ?? {}
|
|
162
|
+
const {
|
|
163
|
+
countryLabel = (id) => (id ?? ''),
|
|
164
|
+
md = mdBlock,
|
|
165
|
+
mdInline = mdInlineDefault,
|
|
166
|
+
mdStrip = mdStripDefault,
|
|
167
|
+
route = null,
|
|
168
|
+
objectsRoute = null,
|
|
169
|
+
hidden = null,
|
|
170
|
+
} = ctx
|
|
171
|
+
|
|
172
|
+
const source = String(t.name ?? partner.internal_name ?? partner.id)
|
|
173
|
+
const plainName = mdStrip(source)
|
|
174
|
+
const city = t.city ? String(t.city) : ''
|
|
175
|
+
const country = partner.country_id ? (countryLabel(partner.country_id) ?? '') : ''
|
|
176
|
+
const isHidden = hidden ? Boolean(hidden(partner)) : false
|
|
177
|
+
const itemCount = partner.item_count ?? 0
|
|
178
|
+
const website = websiteUrl(t.website)
|
|
179
|
+
const links = (partner.additional_urls ?? [])
|
|
180
|
+
.map((link) => ({ url: websiteUrl(link?.url), label: link?.title || link?.url || '' }))
|
|
181
|
+
.filter((link) => link.url)
|
|
182
|
+
const hasMap = Number.isFinite(partner.latitude) && Number.isFinite(partner.longitude)
|
|
183
|
+
|
|
184
|
+
const view = {
|
|
185
|
+
id: partner.id,
|
|
186
|
+
type: partner.type ?? '',
|
|
187
|
+
name: mdInline(source),
|
|
188
|
+
plainName,
|
|
189
|
+
city,
|
|
190
|
+
country,
|
|
191
|
+
location: [city, country].filter(Boolean).join(', '),
|
|
192
|
+
logos: [...(partner.logos ?? [])].sort(byDisplayOrder).map((logo) => ({
|
|
193
|
+
url: logo.url,
|
|
194
|
+
alt: logo.alt_text || plainName,
|
|
195
|
+
type: logo.logo_type ?? '',
|
|
196
|
+
})),
|
|
197
|
+
// A partner's pictures carry `alt_text`, not the per-language
|
|
198
|
+
// `captions` an item's images do: the alt text is the caption.
|
|
199
|
+
pictures: [...(partner.images ?? [])].sort(byDisplayOrder).map((picture) => ({
|
|
200
|
+
url: picture.url,
|
|
201
|
+
alt: picture.alt_text || plainName,
|
|
202
|
+
caption: picture.alt_text ?? '',
|
|
203
|
+
photographer: picture.photographer ?? '',
|
|
204
|
+
copyright: picture.copyright ?? '',
|
|
205
|
+
})),
|
|
206
|
+
description: md(t.description ?? ''),
|
|
207
|
+
contact: {
|
|
208
|
+
address: md(t.address ?? ''),
|
|
209
|
+
phone: t.phone ?? '',
|
|
210
|
+
fax: t.fax ?? '',
|
|
211
|
+
email: t.email ?? '',
|
|
212
|
+
website: website ? { url: website, label: String(t.website).trim() } : null,
|
|
213
|
+
links,
|
|
214
|
+
},
|
|
215
|
+
persons: contactPersons(partner),
|
|
216
|
+
map: hasMap
|
|
217
|
+
? { latitude: partner.latitude, longitude: partner.longitude, zoom: partner.map_zoom ?? 15, label: plainName }
|
|
218
|
+
: null,
|
|
219
|
+
itemCount,
|
|
220
|
+
hidden: isHidden,
|
|
221
|
+
route: !isHidden && route ? (route(partner) ?? null) : null,
|
|
222
|
+
objectsRoute: !isHidden && itemCount && objectsRoute ? (objectsRoute(partner) ?? null) : null,
|
|
223
|
+
}
|
|
224
|
+
view.hasContact = Boolean(
|
|
225
|
+
view.contact.address || view.contact.phone || view.contact.fax || view.contact.email
|
|
226
|
+
|| view.contact.website || view.contact.links.length || view.persons.length,
|
|
227
|
+
)
|
|
228
|
+
return view
|
|
229
|
+
}
|