@museumwnf/viewer-core 1.16.1 → 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,13 +470,12 @@ 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
 
453
477
  | Export | Meaning |
454
478
  | --- | --- |
455
- | `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 |
456
- | `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 |
457
479
  | `useSection()` | the `meta.section` of the current route — see Routing |
458
480
  | `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 |
459
481
  | `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 |
@@ -461,26 +483,24 @@ under the main partner it belongs to.
461
483
 
462
484
  #### Projects, from the data package (epic metanull/inventory-app#1727)
463
485
 
464
- `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.
486
+ `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 read from the data package a project's own exporter writes, not carried in viewer-core. 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.
465
487
 
466
488
  | Export | Meaning |
467
489
  | --- | --- |
468
490
  | `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. |
469
491
  | `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`. |
470
- | `useProjects()` | `{ label(projectId, lang?), links(projectId) }` bound to the installed data package and active language — the manifest-driven analogue of `useProjectName()`. |
492
+ | `useProjects()` | `{ label(projectId, lang?), links(projectId) }` bound to the installed data package and active language. |
471
493
 
472
- Migration mapping, from the epic's design comment (§B) — what each of the five usages moves to:
494
+ What each of the sites' former legacy-key usages reads now, from `manifest.projects` (see CHANGELOG.md 2.0.0 for the full removal/migration notes):
473
495
 
474
- | Today (deprecated) | Reads from `manifest.projects` instead |
496
+ | Formerly | Now reads |
475
497
  | --- | --- |
476
- | RecordView citation line (`projectName(record.project_key, t)`) | `projectLabel(manifest, record.project_id, lang)` / `useProjects().label(record.project_id)` — tracked on viewer-layout#81 |
477
- | 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`) |
498
+ | RecordView citation line (`projectName(record.project_key, t)`) | `projectLabel(manifest, record.project_id, lang)` / `useProjects().label(record.project_id)` |
499
+ | ItemSheet "Source database" line + `mwnf-chip--<family>` colour | `projectLabel`, plus a site-config map from project UUID to a CSS token |
478
500
  | "Search related database" block | `projectLinks(manifest, projectId).relatedDatabaseUrl`, rendered iff non-null |
479
501
  | Artistic Introduction link | `projectLinks(manifest, projectId).artisticIntroductionUrl`, iff non-null |
480
- | Search-scope include-EPM checkbox | unchanged by this phase — stays the site's own `dataset.config.js` scope list (epic decision 5) |
481
- | Partner Museums/Institutions split | unchanged by this phase — `partner.project_uuids` matched against a site-config list (epic decision 5) |
482
-
483
- `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.
502
+ | Search-scope include-EPM checkbox | the site's own `dataset.config.js` scope list (epic decision 5) |
503
+ | Partner Museums/Institutions split | `partner.project_uuids` matched against a site-config list (epic decision 5) |
484
504
 
485
505
  ### `@museumwnf/viewer-core/legacy`
486
506
 
@@ -498,16 +518,21 @@ needs it; the standalone sites never import it.
498
518
 
499
519
  ### `@museumwnf/viewer-core/dxa`
500
520
 
501
- The gallery/exhibition-pair composables the four live DXA sites each wrote
502
- for themselves — `useCollection`/`useGalleryData`, the timeline, the
503
- partner specs and the item sheet — byte-identical within each pair
504
- (carpets/amulets — a gallery; the-use-of-colours-in-art/water-in-islam — an
505
- exhibition). Promoted here per family, never as one composable that papers
506
- over the two: the cross-family diff is real behaviour (the exhibition
507
- shape's per-language-build 404 gate, its own local-vs-country timeline
508
- switch, its manifest-driven citation line), confirmed file by file against
509
- both pairs' `origin/main` (epic metanull/inventory-app#1730). A separate
510
- entry point because only a gallery/exhibition site needs it.
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.
511
536
 
512
537
  A site composes its own data/catalogue/timeline/partner/sheet module from
513
538
  these, threading the `data` object one family's
@@ -728,12 +753,12 @@ The package exports shared smoke-test helpers at @museumwnf/viewer-core/testing:
728
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. |
729
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. |
730
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). |
731
- | 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. |
732
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. |
733
758
 
734
759
  @museumwnf/viewer-core/testing is not imported by websites, only by their tests. A website test might be:
735
760
 
736
- \\\js
761
+ ```js
737
762
  import { checkOfferedLanguages, checkRoutes, checkSectionMeta, mountSite } from '@museumwnf/viewer-core/testing'
738
763
  import config from '../src/dataset.config.js'
739
764
  import { mergeMessages } from '@museumwnf/viewer-core'
@@ -751,7 +776,8 @@ describe('smoke test', () => {
751
776
  app.unmount()
752
777
  })
753
778
  })
754
- \\\
779
+ ```
780
+
755
781
  ## Licence
756
782
 
757
783
  This package is Content of the MWNF Website under the [MWNF legal
@@ -763,5 +789,10 @@ notice text also ships in this package as `LICENSE.md`.
763
789
  ## Release procedure
764
790
 
765
791
  1. Merge to `main` via PR (CI: tests + a downstream build of every website).
766
- 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.
767
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": "1.16.1",
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",
@@ -7,88 +7,11 @@ import { resolveRecordLanguage } from './composables/useRecordLanguage.js'
7
7
 
8
8
  // Three small conventions each website had written for itself.
9
9
 
10
- // ── Project names ──────────────────────────────────────────────────────────
11
- //
12
- // `mwnf3.projectnames`, the whole table rather than the projects a website
13
- // happens to borrow from, since that set moves with every reimport. The key
14
- // on the left is data; the entry on the right is a shared text
15
- // (`core.project.*` in viewer-i18n), written out so a reader can see the
16
- // eight this asks for. A key that is not here reads as itself.
17
- //
18
- // @deprecated Project data belongs in the data package, not in viewer-core.
19
- // Read a project's name from `manifest.projects` via `projectLabel()` /
20
- // `useProjects()` instead (epic metanull/inventory-app#1727, phase 3) — see
21
- // the migration table below. Kept only for sites that have not migrated yet
22
- // (phase 4); removed in the cleanup wave.
23
- export const PROJECT_ENTRIES = Object.freeze({
24
- ISL: 'core.project.islamicArt',
25
- EPM: 'core.project.explorePartners',
26
- DBA: 'core.project.baroqueArt',
27
- BAR: 'core.project.baroqueArt',
28
- AWE: 'core.project.sharingHistory',
29
- awe: 'core.project.sharingHistory',
30
- DCA: 'core.project.carpetArt',
31
- DGA: 'core.project.glassArt',
32
- EXTHE: 'core.project.tableIsSet',
33
- GALLERIES: 'core.project.galleries',
34
- })
35
-
36
- /** The name of a project by its legacy key, through `t`; the key itself when unknown. */
37
- export function projectName(key, t) {
38
- const entry = PROJECT_ENTRIES[key]
39
- return entry ? t(entry) : (key ?? '')
40
- }
41
-
42
- /** `projectName` bound to the installed texts, for a component. */
43
- export function useProjectName() {
44
- const { t } = useI18n()
45
- return (key) => projectName(key, t)
46
- }
47
-
48
- // A member is borrowed from the MWNF project that originally published it,
49
- // and legacy names and colours that project on the item sheet and in the
50
- // results grid — two separate mappings, because legacy keeps them separate:
51
- // the name is per project key (`PROJECT_ENTRIES` above), the colour is per
52
- // project *family*, so ISL and EPM share a swatch and every exhibition
53
- // shares another. The two exhibition sites re-hard-coded this table as
54
- // English literals next to their own copy of the name table, which is why a
55
- // project name translates on the galleries and not on the exhibitions.
56
- //
57
- // @deprecated Chip colour is a per-project site-config map keyed by the
58
- // project UUID (epic #1727 decision 1: no family/parent relation is modeled
59
- // anywhere, this table was always a frontend invention). Removed in the
60
- // cleanup wave alongside `PROJECT_ENTRIES`.
61
- export const PROJECT_FAMILIES = Object.freeze({
62
- ISL: 'ISLandEPM',
63
- EPM: 'ISLandEPM',
64
- DBA: 'DBA',
65
- BAR: 'DBA',
66
- AWE: 'AWE',
67
- awe: 'AWE',
68
- DCA: 'DCA',
69
- DGA: 'DGA',
70
- EXTHE: 'EXH',
71
- GALLERIES: 'Galleries',
72
- })
73
-
74
- /**
75
- * A project's family by its legacy key — legacy's own class names, so a
76
- * site's CSS reads as the stylesheet it was copied from. A key with no
77
- * entry falls back to itself, the same rule `projectName` applies.
78
- *
79
- * @deprecated See `PROJECT_FAMILIES`.
80
- */
81
- export function projectFamily(key) {
82
- return PROJECT_FAMILIES[key] ?? (key ?? '')
83
- }
84
-
85
10
  // ── Projects, from the data package ─────────────────────────────────────────
86
11
  //
87
- // `manifest.projects` (epic metanull/inventory-app#1727, phase 2 —
88
- // `scripts/exporters`) is what `PROJECT_ENTRIES`/`PROJECT_FAMILIES` above
89
- // should have been: project data belongs in the data package a project's own
90
- // exporter writes, not in a table viewer-core carries for every website. Its
91
- // shape, one entry per project UUID:
12
+ // `manifest.projects` (epic metanull/inventory-app#1727 — `scripts/exporters`)
13
+ // is project data from the data package a project's own exporter writes.
14
+ // Its shape, one entry per project UUID:
92
15
  //
93
16
  // manifest.projects[projectId] = {
94
17
  // name: { <lang>: '…' }, // per-language title
@@ -99,21 +22,8 @@ export function projectFamily(key) {
99
22
  //
100
23
  // A data package built before this phase simply has no `projects` key at
101
24
  // all; `projectLabel`/`projectLinks` answer `null` for that case exactly as
102
- // they do for an unknown `projectId` — a site notices nothing until it
103
- // migrates. Migration mapping, from the epic's design comment (§B):
104
- //
105
- // RecordView citation line → projectLabel(manifest, record.project_id, lang) (viewer-layout#81)
106
- // ItemSheet "Source database" line → same, plus a site-config colour map keyed by project UUID (no more `projectFamily`)
107
- // "Search related database" block → projectLinks(manifest, id).relatedDatabaseUrl, rendered iff non-null
108
- // Artistic Introduction link → projectLinks(manifest, id).artisticIntroductionUrl, iff non-null
109
- // search-scope include-EPM checkbox → stays the site's own dataset.config.js scope list (not modeled here — decision 5)
110
- // partner Museums/Institutions split → partner.project_uuids matched against a site-config list (decision 5)
111
- //
112
- // `PROJECT_ENTRIES`, `PROJECT_FAMILIES` and `projectFamily` are deprecated by
113
- // this section and removed in the cleanup wave once every site has moved
114
- // (phase 4). `projectName()`/`useProjectName()` are unaffected — they still
115
- // resolve a *legacy key* through the installed texts, for whatever has not
116
- // migrated yet.
25
+ // they do for an unknown `projectId`. See CHANGELOG.md 2.0.0 for the
26
+ // migration notes from this package's former legacy-key conventions.
117
27
 
118
28
  /** One entry of `manifest.projects`, or `null` when the package predates
119
29
  * the section or does not carry `projectId`. */
@@ -156,9 +66,8 @@ export function projectLinks(manifest, projectId) {
156
66
 
157
67
  /**
158
68
  * `projectLabel`/`projectLinks` bound to the installed data package and
159
- * texts, for a component — the manifest-driven analogue of
160
- * `useProjectName()`. `label(projectId)` reads the site's active language;
161
- * pass a language explicitly to read another.
69
+ * texts, for a component. `label(projectId)` reads the site's active
70
+ * language; pass a language explicitly to read another.
162
71
  */
163
72
  export function useProjects() {
164
73
  const { manifest } = useDataPackage()
@@ -168,9 +168,8 @@ export function useExhibitionData(config = {}) {
168
168
  // A member is borrowed from the MWNF project that originally published
169
169
  // it. The name is the data package's own `manifest.projects` entry
170
170
  // (populated by the importer/exporter), keyed by this record's
171
- // `project_id` (a UUID) — not viewer-core's deprecated, legacy-key-keyed
172
- // `projectName()`/`projectFamily()`, and not an exhibition's own
173
- // project-key comparisons either. The manifest already names an
171
+ // `project_id` (a UUID) — not a legacy-key lookup, and not an exhibition's
172
+ // own project-key comparisons either. The manifest already names an
174
173
  // exhibition's own native project under its own uuid (the sibling
175
174
  // `Collection` the importer creates alongside every `Project` carries the
176
175
  // same title), so there is no special case here for a native member any
@@ -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,12 +35,11 @@ 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 {
42
- PROJECT_ENTRIES, PROJECT_FAMILIES, mwnfLinks, projectFamily, projectLabel,
43
- projectLinks, projectName, sectionMeta, useFeaturedRecord, useProjectName,
42
+ mwnfLinks, projectLabel, projectLinks, sectionMeta, useFeaturedRecord,
44
43
  useProjects, useSection,
45
44
  } from './conventions.js'
46
45
 
@@ -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
+ }