@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 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
- The gallery/exhibition-pair composables the four live DXA sites each wrote
498
- for themselves — `useCollection`/`useGalleryData`, the timeline, the
499
- partner specs and the item sheet — byte-identical within each pair
500
- (carpets/amulets — a gallery; the-use-of-colours-in-art/water-in-islam — an
501
- exhibition). Promoted here per family, never as one composable that papers
502
- over the two: the cross-family diff is real behaviour (the exhibition
503
- shape's per-language-build 404 gate, its own local-vs-country timeline
504
- switch, its manifest-driven citation line), confirmed file by file against
505
- both pairs' `origin/main` (epic metanull/inventory-app#1730). A separate
506
- entry point because only a gallery/exhibition site needs it.
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 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. |
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
- \\\js
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 GitHub Packages; never publish from a laptop.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@museumwnf/viewer-core",
3
- "version": "2.0.0",
3
+ "version": "2.1.0",
4
4
  "description": "Application engine for MWNF websites: turns a dataset.config.js into a mounted Vue application.",
5
5
  "type": "module",
6
6
  "license": "SEE LICENSE IN LICENSE.md",
@@ -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
- // A link to the partner's page, rendered by the wrapper's `museum` slot.
49
- { key: 'museum', label: 'sheet.field.holdingMuseum', value: (c) => (partnerById.value.get(c.record.partner_id) ? c.record.partner_id : ''), render: 'custom' },
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
- // A link to the partner's page, rendered by the wrapper's `museum` slot.
49
- { key: 'museum', label: 'sheet.field.holdingMuseum', value: (c) => (partnerById.value.get(c.record.partner_id) ? c.record.partner_id : ''), render: 'custom' },
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 {
@@ -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
+ }