@museumwnf/viewer-core 1.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/LICENSE.md +44 -0
  2. package/README.md +635 -0
  3. package/package.json +50 -0
  4. package/src/AppRoot.vue +58 -0
  5. package/src/catalogue/dates.js +209 -0
  6. package/src/catalogue/facets.js +61 -0
  7. package/src/catalogue/keywordIndex.js +289 -0
  8. package/src/catalogue/listQuery.js +100 -0
  9. package/src/catalogue/pagination.js +57 -0
  10. package/src/catalogue/searchLanguage.js +32 -0
  11. package/src/catalogue/timeline.js +220 -0
  12. package/src/composables/useCatalogueData.js +141 -0
  13. package/src/composables/useDataPackage.js +98 -0
  14. package/src/composables/useEntities.js +100 -0
  15. package/src/composables/useRecordLanguage.js +71 -0
  16. package/src/conventions.js +148 -0
  17. package/src/createViewer.js +29 -0
  18. package/src/i18n/I18nText.vue +17 -0
  19. package/src/i18n/I18nTextInline.vue +17 -0
  20. package/src/i18n/index.js +88 -0
  21. package/src/i18n/language.js +127 -0
  22. package/src/i18n/markdown.js +265 -0
  23. package/src/index.js +50 -0
  24. package/src/injectionKeys.js +1 -0
  25. package/src/languages.js +49 -0
  26. package/src/legacy/index.js +58 -0
  27. package/src/record/collectionTree.js +307 -0
  28. package/src/record/derivations.js +154 -0
  29. package/src/record/partners.js +87 -0
  30. package/src/record/recordSheet.js +231 -0
  31. package/src/rights.js +53 -0
  32. package/src/router/index.js +138 -0
  33. package/src/siteConfig.js +37 -0
  34. package/src/styles/base.css +49 -0
  35. package/src/testing/checkOfferedLanguages.js +67 -0
  36. package/src/testing/index.js +3 -0
  37. package/src/testing/smoke.js +103 -0
  38. package/src/testing/viteConfig.js +90 -0
  39. package/src/views/DetailView.vue +53 -0
  40. package/src/views/HomeView.vue +20 -0
  41. package/src/views/ListView.vue +61 -0
  42. package/src/views/NotFoundView.vue +8 -0
  43. package/src/vite.js +8 -0
@@ -0,0 +1,307 @@
1
+ import { computed } from 'vue'
2
+ import { entityRef } from '../composables/useEntities.js'
3
+
4
+ // Three sites each walked `collections.json` from a purpose marker by
5
+ // `parent_id` and `display_order`, and each wrote the same reverse
6
+ // "collections containing this item" scan by hand (islamicart, baroqueart,
7
+ // sharinghistory). A fourth family — the DXA sites — carries the identical
8
+ // shape pre-built as `themes.json` (`sub_themes[]`, `pictures[]`), because
9
+ // its importer nests the tree rather than flattening it with `parent_id`.
10
+ // This is that walk, written once: a pure indexer over either shape, and a
11
+ // composable over `entityRef` for the reactive form a page reads.
12
+ //
13
+ // A node's own items are read from whichever shape the record carries:
14
+ // `items: [{ id, ... }]` (collections) or a flat `item_ids` — a theme node
15
+ // has neither; its content is `pictures[].picture_item_id`, so the themes
16
+ // adapter passes its own extractor rather than reusing this one.
17
+ function itemIdsOfCollection(node) {
18
+ if (Array.isArray(node?.items)) {
19
+ return node.items.map((item) => (item && typeof item === 'object' ? item.id : item)).filter((id) => id != null)
20
+ }
21
+ if (Array.isArray(node?.item_ids)) {
22
+ return node.item_ids.filter((id) => id != null)
23
+ }
24
+ return []
25
+ }
26
+
27
+ function itemIdsOfTheme(node) {
28
+ return Array.isArray(node?.pictures)
29
+ ? node.pictures.map((picture) => picture?.picture_item_id).filter((id) => id != null)
30
+ : []
31
+ }
32
+
33
+ // Legacy's "unordered records sort last" convention (the three composables
34
+ // this replaces all wrote `?? 9999` by hand); carried over rather than
35
+ // improved, since a package with more than 9999 siblings under one node
36
+ // does not exist.
37
+ const NO_ORDER = 9999
38
+
39
+ function orderOf(node, order) {
40
+ const value = node?.[order]
41
+ return typeof value === 'number' ? value : NO_ORDER
42
+ }
43
+
44
+ /**
45
+ * The shared indexer behind `buildCollectionTree` and
46
+ * `collectionTreeFromThemes`: a flat array of `{ id, parent_id, ... }`
47
+ * nodes, a `rootId` among them (or a sentinel none of them carries, for a
48
+ * tree with no root record of its own — see `collectionTreeFromThemes`),
49
+ * and the interface both entry points hand back. Carries `entity` and `source`
50
+ * so a consumer handed a built tree can read the nodes' texts without being
51
+ * told the entity a second time.
52
+ */
53
+ function buildIndex(nodes, rootId, { childType, order = 'display_order', itemIdsOf = itemIdsOfCollection, entity, source } = {}) {
54
+ const byIdMap = new Map(nodes.map((node) => [node.id, node]))
55
+ const childIds = new Map() // parent id → ordered child ids
56
+ for (const node of nodes) {
57
+ if (node.parent_id == null) continue
58
+ if (!childIds.has(node.parent_id)) childIds.set(node.parent_id, [])
59
+ childIds.get(node.parent_id).push(node.id)
60
+ }
61
+ for (const list of childIds.values()) {
62
+ list.sort((a, b) => orderOf(byIdMap.get(a), order) - orderOf(byIdMap.get(b), order))
63
+ }
64
+
65
+ // A node's depth below the root: the root itself is 0, its direct
66
+ // children 1, and so on — bounded by `rootId`, not by how far the
67
+ // `parent_id` chain actually goes (an explicit `rootId` partway down the
68
+ // data is depth 0 for this tree, even though it has ancestors of its own).
69
+ // `parents()` below walks the same chain but doesn't stop at `rootId`,
70
+ // because a breadcrumb wants the true ancestry; this does, because a
71
+ // depth-indexed `childType` cares only about position within this tree.
72
+ function depthOf(id) {
73
+ let depth = 0
74
+ let current = byIdMap.get(id)
75
+ const seen = new Set()
76
+ while (current && current.id !== rootId && current.parent_id != null && !seen.has(current.id)) {
77
+ seen.add(current.id)
78
+ depth++
79
+ if (current.parent_id === rootId) break
80
+ current = byIdMap.get(current.parent_id)
81
+ }
82
+ return depth
83
+ }
84
+
85
+ // `childType` filters a node's children, in one of three forms:
86
+ // - a string: applied at every depth, not just the root's — a marker's
87
+ // direct children are sometimes a mix of the real tree and siblings
88
+ // that only look like it (sharinghistory's country-specific "National
89
+ // Context" collections sit next to the real themes), and a caller who
90
+ // names the type wants it held to at every level, not only the first.
91
+ // - an array, indexed by depth below the root: `childType[0]` filters the
92
+ // root's children, `childType[1]` filters their children, and so on.
93
+ // A depth past the end of the array is unfiltered. For example,
94
+ // sharinghistory's exhibitions → themes → chapters uses `['exhibition',
95
+ // 'theme', 'subtheme']` because the root's direct children are
96
+ // exhibitions (depth 0), their children are themes (depth 1), and so on;
97
+ // a tree rooted at one exhibition uses `['theme', 'subtheme']` instead.
98
+ // A single type at every depth (e.g. `['theme', 'theme', …]`) is not the
99
+ // pattern to use when levels differ; it drops real nodes.
100
+ // - a function `(node, depth, parent) => boolean`, for a rule neither of
101
+ // the above expresses.
102
+ function children(id) {
103
+ const kids = (childIds.get(id) ?? []).map((cid) => byIdMap.get(cid)).filter(Boolean)
104
+ if (!childType) return kids
105
+ if (typeof childType === 'function') {
106
+ const parent = byIdMap.get(id) ?? null
107
+ const depth = depthOf(id) + 1
108
+ return kids.filter((node) => childType(node, depth, parent))
109
+ }
110
+ if (Array.isArray(childType)) {
111
+ const type = childType[depthOf(id)]
112
+ return type ? kids.filter((node) => node.type === type) : kids
113
+ }
114
+ return kids.filter((node) => node.type === childType)
115
+ }
116
+
117
+ function parents(id) {
118
+ const chain = []
119
+ let current = byIdMap.get(id)
120
+ while (current && current.parent_id != null && current.parent_id !== current.id) {
121
+ const parent = byIdMap.get(current.parent_id)
122
+ if (!parent) break
123
+ chain.unshift(parent)
124
+ current = parent
125
+ }
126
+ return chain
127
+ }
128
+
129
+ function breadcrumb(id) {
130
+ const node = byIdMap.get(id)
131
+ return node ? [...parents(id), node] : []
132
+ }
133
+
134
+ /** A node's own item ids plus every descendant's, depth-first, each once. */
135
+ function itemsUnder(id) {
136
+ const node = byIdMap.get(id)
137
+ if (!node) return []
138
+ const seen = new Set()
139
+ const out = []
140
+ const visit = (current) => {
141
+ for (const itemId of itemIdsOf(current)) {
142
+ if (!seen.has(itemId)) {
143
+ seen.add(itemId)
144
+ out.push(itemId)
145
+ }
146
+ }
147
+ for (const child of children(current.id)) visit(child)
148
+ }
149
+ visit(node)
150
+ return out
151
+ }
152
+
153
+ // The reverse lookup every site wrote by hand: which collections carry
154
+ // this item directly. Not recursive — a page's items do not make its
155
+ // theme "contain" them for this purpose, the way `itemsUnder` treats them;
156
+ // a caller wanting the enclosing theme or exhibition walks up with
157
+ // `parents` from what this returns, as the sites' own link-builders did.
158
+ function containing(itemId) {
159
+ const out = []
160
+ for (const node of byIdMap.values()) {
161
+ if (itemIdsOf(node).includes(itemId)) out.push(node)
162
+ }
163
+ return out
164
+ }
165
+
166
+ // The tree, flattened depth-first, root excluded (the root is the anchor
167
+ // the tree hangs from, not a page a visitor lands on). `previous`/`next`
168
+ // read this sequence by index, which is what makes them cross a branch
169
+ // boundary for free: the last node of one branch is simply followed in
170
+ // the array by the first node of the next, container or leaf alike.
171
+ function walk() {
172
+ const out = []
173
+ const visit = (id) => {
174
+ for (const child of children(id)) {
175
+ out.push(child)
176
+ visit(child.id)
177
+ }
178
+ }
179
+ if (rootId != null) visit(rootId)
180
+ return out
181
+ }
182
+
183
+ function previous(id) {
184
+ const sequence = walk()
185
+ const index = sequence.findIndex((node) => node.id === id)
186
+ return index > 0 ? sequence[index - 1] : null
187
+ }
188
+
189
+ function next(id) {
190
+ const sequence = walk()
191
+ const index = sequence.findIndex((node) => node.id === id)
192
+ return index !== -1 && index < sequence.length - 1 ? sequence[index + 1] : null
193
+ }
194
+
195
+ return {
196
+ root: rootId != null ? (byIdMap.get(rootId) ?? null) : null,
197
+ byId: byIdMap,
198
+ children,
199
+ parents,
200
+ breadcrumb,
201
+ itemsUnder,
202
+ containing,
203
+ walk,
204
+ previous,
205
+ next,
206
+ entity,
207
+ source,
208
+ }
209
+ }
210
+
211
+ /**
212
+ * A collection tree rooted at a purpose marker, over a flat
213
+ * `collections.json`-shaped array: `{ id, parent_id, display_order, type,
214
+ * purpose, items }`. `purpose` finds the root by its `purpose` field (the
215
+ * anchor the importer keys the tree from, e.g. `"exhibitions-root"`);
216
+ * `rootId` takes an id directly, for the sites that resolve a marker's
217
+ * single child as the real root themselves (islamicart's Artistic
218
+ * Introduction). Exactly one of the two is expected. `childType` filters a
219
+ * node's children by `type`: a string applies the same type at every depth
220
+ * (sharinghistory's themes, sitting next to National Context collections
221
+ * that are not themes); an array is indexed by depth below the root, where
222
+ * `childType[0]` filters the root's children, `childType[1]` filters their
223
+ * children, and so on (sharinghistory's exhibitions → themes → chapters uses
224
+ * `['exhibition', 'theme', 'subtheme']`, and a tree rooted at one exhibition
225
+ * uses `['theme', 'subtheme']`); a depth past the end is unfiltered; a
226
+ * function `(node, depth, parent) => boolean` covers anything else. Pure —
227
+ * no reactivity, so it is also what a Node script or a one-off script can
228
+ * call directly. `entity` is the translations entity the tree reads (default
229
+ * `'collections'`); `source` is always `'collections'`.
230
+ */
231
+ export function buildCollectionTree(collections, { purpose, rootId, childType, order = 'display_order', entity = 'collections' } = {}) {
232
+ const nodes = collections ?? []
233
+ const resolvedRootId = rootId ?? nodes.find((node) => node.purpose === purpose)?.id ?? null
234
+ return buildIndex(nodes, resolvedRootId, { childType, order, itemIdsOf: itemIdsOfCollection, entity, source: 'collections' })
235
+ }
236
+
237
+ // A sentinel no real record's id collides with: `themes.json` is already the
238
+ // whole tree, an ordered array of top-level themes with `sub_themes[]`
239
+ // nested inside each — there is no marker record to key a root on. Flatten
240
+ // it to the same `{ id, parent_id }` shape `buildIndex` reads, with every
241
+ // top-level theme's synthetic parent set to this sentinel, and `root` comes
242
+ // back null: there genuinely isn't one, and no site has read it from
243
+ // `themes.json` before this.
244
+ const THEMES_ROOT = Symbol('collectionTree.themesRoot')
245
+
246
+ function flattenThemes(themes, parentId) {
247
+ const out = []
248
+ for (const theme of themes ?? []) {
249
+ const { sub_themes: subThemes, ...rest } = theme
250
+ out.push({ ...rest, parent_id: parentId })
251
+ out.push(...flattenThemes(subThemes, theme.id))
252
+ }
253
+ return out
254
+ }
255
+
256
+ /**
257
+ * The same interface as `buildCollectionTree`, from a `themes.json` shape
258
+ * (top-level themes, each with `sub_themes[]` and `pictures[]`) instead of a
259
+ * flat, `parent_id`-linked `collections.json`. `root` is always null — see
260
+ * above; `walk`/`children`/… all work the same from the (nulled) top. `entity`
261
+ * is the translations entity the tree reads (default `'themes'`); `source` is
262
+ * always `'themes'`.
263
+ */
264
+ export function collectionTreeFromThemes(themes, { childType, order = 'display_order', entity = 'themes' } = {}) {
265
+ const nodes = flattenThemes(themes, THEMES_ROOT)
266
+ return buildIndex(nodes, THEMES_ROOT, { childType, order, itemIdsOf: itemIdsOfTheme, entity, source: 'themes' })
267
+ }
268
+
269
+ /**
270
+ * The reactive form: `useCollectionTree({ purpose | rootId, childType?,
271
+ * entity = 'collections', order = 'display_order' })` (see
272
+ * `buildCollectionTree` for the forms `childType` accepts) for the
273
+ * `collections.json` shape, or `useCollectionTree({ source: 'themes',
274
+ * entity: 'themes' })` for a `themes.json` package, where `purpose`/`rootId`
275
+ * do not apply (see `collectionTreeFromThemes`). Reads `entity` through
276
+ * `entityRef`, so the tree is empty (not an error) before that entity's
277
+ * chunk has arrived, and recomputes once it does — a website loads the
278
+ * entity itself, through `meta.entities` or `loadEntities`, exactly as it
279
+ * does for every other record. Returns `entity` (the translations entity the
280
+ * tree reads, the value resolved from the options) and `source` (`'collections'`
281
+ * or `'themes'`), so a consumer handed a built tree can read the nodes' texts
282
+ * without being told the entity a second time.
283
+ */
284
+ export function useCollectionTree(options = {}) {
285
+ const { entity = 'collections', source = 'collections' } = options
286
+ const records = entityRef(entity)
287
+ const tree = computed(() => {
288
+ const nodes = records.value ?? []
289
+ return source === 'themes' ? collectionTreeFromThemes(nodes, options) : buildCollectionTree(nodes, options)
290
+ })
291
+ return {
292
+ root: computed(() => tree.value.root),
293
+ byId: computed(() => tree.value.byId),
294
+ children: (id) => tree.value.children(id),
295
+ parents: (id) => tree.value.parents(id),
296
+ breadcrumb: (id) => tree.value.breadcrumb(id),
297
+ itemsUnder: (id) => tree.value.itemsUnder(id),
298
+ containing: (itemId) => tree.value.containing(itemId),
299
+ walk: () => tree.value.walk(),
300
+ previous: (id) => tree.value.previous(id),
301
+ next: (id) => tree.value.next(id),
302
+ // entity and source are fixed by the options at call time and never change;
303
+ // a consumer reads them as plain strings, not refs (entityRef and pkg.tr lookups).
304
+ entity,
305
+ source,
306
+ }
307
+ }
@@ -0,0 +1,154 @@
1
+ import { computed, ref, toValue } from 'vue'
2
+ import { useDataPackage } from '../composables/useDataPackage.js'
3
+ import { byId, entityRef } from '../composables/useEntities.js'
4
+ import { renderPlain } from '../i18n/markdown.js'
5
+ import { roundOutward } from '../catalogue/dates.js'
6
+
7
+ const BASE_LANGUAGE = 'en'
8
+
9
+ // Four derivations every record page makes, written seven times.
10
+
11
+ // ── The glossary popup ─────────────────────────────────────────────────────
12
+ //
13
+ // The renderer marks every glossary spelling as
14
+ // `<span class="gloss-term" data-gid="…">` while it parses. Those spans are
15
+ // inside `v-html`, so nothing binds to them one by one: one listener on the
16
+ // container reads the click and answers with the term.
17
+
18
+ /**
19
+ * `{ active, onClick, close }`: attach `onClick` to the element the sheet
20
+ * renders into; `active` is `{ id, word, spelling, definition }` for the
21
+ * term clicked, null otherwise. `entries` is the `glossary` list of
22
+ * `useRecordSheet` (a ref, getter or array).
23
+ */
24
+ export function useGlossaryPopup(entries) {
25
+ const active = ref(null)
26
+ function onClick(event) {
27
+ const element = event?.target?.closest?.('.gloss-term')
28
+ if (!element) return
29
+ event.preventDefault()
30
+ const id = element.dataset.gid
31
+ const entry = (toValue(entries) ?? []).find((e) => e.id === id)
32
+ active.value = {
33
+ id,
34
+ spelling: element.textContent,
35
+ word: entry?.word ?? element.textContent,
36
+ definition: entry?.definition ?? '',
37
+ }
38
+ }
39
+ function close() {
40
+ active.value = null
41
+ }
42
+ return { active, onClick, close }
43
+ }
44
+
45
+ /**
46
+ * The glossary terms whose spelling starts with `input`, in `language`
47
+ * (falling back to English), for a glossary search box: `[{ id, word,
48
+ * spelling, definition }]`, one per term, sorted by spelling.
49
+ */
50
+ export function searchGlossary(input, language, { entity = 'glossary' } = {}) {
51
+ const needle = String(input ?? '').trim().toLowerCase()
52
+ if (!needle) return []
53
+ const { translations } = useDataPackage()
54
+ const rows = translations(entity, language)
55
+ const fallback = translations(entity, BASE_LANGUAGE)
56
+ const out = []
57
+ for (const term of entityRef(entity).value ?? []) {
58
+ const t = rows[term.id] ?? fallback[term.id] ?? {}
59
+ const spellings = t.spellings?.length ? t.spellings : [term.word]
60
+ for (const spelling of spellings) {
61
+ if (String(spelling ?? '').toLowerCase().startsWith(needle)) {
62
+ out.push({ id: term.id, word: term.word, spelling: String(spelling).trim(), definition: t.definition ?? '' })
63
+ break
64
+ }
65
+ }
66
+ }
67
+ return out.sort((a, b) => a.spelling.localeCompare(b.spelling))
68
+ }
69
+
70
+ // ── Citation ───────────────────────────────────────────────────────────────
71
+
72
+ /**
73
+ * The sentence both families print under a sheet:
74
+ * `Author "Name" in Project, Publisher, Year. Permalink` — assembled here in
75
+ * that order, from parts, rather than written into a text with holes in it.
76
+ * `inWord` is the site's `t('record.citation.in')`. Parts that are missing
77
+ * are left out with their punctuation.
78
+ */
79
+ export function citation({ author, name, project, publisher = 'Museum With No Frontiers', year, permalink, inWord = 'in' } = {}) {
80
+ const title = renderPlain(name ?? '')
81
+ if (!title) return ''
82
+ const when = year ?? new Date().getFullYear()
83
+ const where = [project, publisher].filter(Boolean).join(', ')
84
+ const parts = []
85
+ if (author) parts.push(String(author).trim())
86
+ parts.push(`"${title}"`)
87
+ if (where) parts.push(`${inWord} ${where},`)
88
+ let sentence = `${parts.join(' ')} ${when}.`
89
+ if (permalink) sentence += ` ${permalink}`
90
+ return sentence
91
+ }
92
+
93
+ // ── Related records ────────────────────────────────────────────────────────
94
+
95
+ /**
96
+ * A record's `related_items`, split by whether the package carries them:
97
+ * `inPackage` is `[{ reference, record, justification }]` with the
98
+ * justification in `language` (falling back to English); `outside` is the
99
+ * references the package does not hold, kept as the references they are — a
100
+ * link is never dropped for being unresolvable, and never invented either.
101
+ */
102
+ export function relatedRecords(record, { entity = 'items', language = BASE_LANGUAGE, field = 'related_items' } = {}) {
103
+ const index = byId(entity).value
104
+ const inPackage = []
105
+ const outside = []
106
+ const seen = new Set()
107
+ for (const reference of record?.[field] ?? []) {
108
+ if (!reference || seen.has(reference.id)) continue
109
+ seen.add(reference.id)
110
+ const found = reference.in_package === false ? null : index.get(reference.id)
111
+ if (!found) {
112
+ outside.push(reference)
113
+ continue
114
+ }
115
+ const justifications = reference.justifications ?? {}
116
+ inPackage.push({
117
+ reference,
118
+ record: found,
119
+ justification: justifications[language] ?? justifications[BASE_LANGUAGE] ?? '',
120
+ })
121
+ }
122
+ return { inPackage, outside }
123
+ }
124
+
125
+ /** `relatedRecords` over reactive sources. */
126
+ export function useRelatedRecords(record, options = {}) {
127
+ return computed(() => relatedRecords(toValue(record), { ...options, language: toValue(options.language) ?? BASE_LANGUAGE }))
128
+ }
129
+
130
+ // ── The timeline link ──────────────────────────────────────────────────────
131
+
132
+ /**
133
+ * The route location of the timeline results for a record's country and
134
+ * dates, or null when the record has neither a country nor a date. `name`
135
+ * is the site's timeline-results route; `keys` the query keys it reads
136
+ * (`country`, `begin`, `end` by default); `country(record)` the value the
137
+ * route wants for the country (the id by default — a site whose timeline
138
+ * reads legacy codes passes its own); `round` rounds the dates outward to
139
+ * the century, as the DXA "Timeline for this item" does.
140
+ */
141
+ export function timelineLinkFor(record, { name = 'timeline-results', keys = {}, country, round = false } = {}) {
142
+ if (!record) return null
143
+ const countryValue = country ? country(record) : record.country_id
144
+ const start = typeof record.start_date === 'number' ? record.start_date : null
145
+ const end = typeof record.end_date === 'number' ? record.end_date : null
146
+ if (!countryValue && start == null && end == null) return null
147
+ let [from, to] = [start, end]
148
+ if (round) [from, to] = roundOutward(start ?? undefined, end ?? undefined)
149
+ const query = {}
150
+ if (countryValue) query[keys.country ?? 'country'] = String(countryValue)
151
+ if (from != null) query[keys.begin ?? 'begin'] = String(from)
152
+ if (to != null) query[keys.end ?? 'end'] = String(to)
153
+ return { name, query }
154
+ }
@@ -0,0 +1,87 @@
1
+ // Seven partner lists group by country seven ways. The standalone sites
2
+ // (islamicart, baroqueart, sharinghistory) bucket each country's partners
3
+ // into "main" and "associated" on a `level` field and render an accordion;
4
+ // the DXA sites (carpets and its siblings) group the same way but add an
5
+ // A-Z / Z-A toggle and no tiers at all. Both are this one derivation with a
6
+ // couple of options — pure, so the site's own view supplies the toggle and
7
+ // the label lookup.
8
+ //
9
+ // `parent_id` is carried on a third of the standalone partners and read
10
+ // nowhere: legacy nested an associated partner under the main partner it
11
+ // belongs to, and the port flattened that into the country column instead.
12
+ // `partnerHierarchy` is that relationship, read once.
13
+
14
+ /**
15
+ * Whether `record` counts as a main (rather than associated) partner.
16
+ * `tier` names the field the converged partner shape carries (`level`).
17
+ * A record is associated only when its tier value is a non-empty value
18
+ * other than `'partner'` (legacy values: `'associated_partner'`,
19
+ * `'minor_contributor'`); `null`, `undefined`, `''`, or `'partner'` mean
20
+ * main. This is necessary because exporters emit `level: null` for every
21
+ * partner without curated hierarchy. Without a `tier` at all there is nothing
22
+ * to divide records on, so every record is main, which is what a caller with
23
+ * no tiers (the DXA lists) wants.
24
+ */
25
+ function isMainTier(record, tier) {
26
+ if (!tier) return true
27
+ const tierValue = record?.[tier]
28
+ // Treat null, undefined, empty string, or 'partner' as main
29
+ return tierValue == null || tierValue === '' || tierValue === 'partner'
30
+ }
31
+
32
+ /**
33
+ * Partner records grouped by country: `[{ country, label, main, associated
34
+ * }]`, one entry per distinct `country_id` (partners with none share a
35
+ * `null` group), sorted by `label`, ascending unless `order: 'desc'`.
36
+ * `label(country)` names a group's country — the caller's own lookup, the
37
+ * same shape as a facet's `label(value)` — since a partner record carries
38
+ * only the id. `tier` names the field that marks a main partner (see
39
+ * `isMainTier`); without it every record lands in `main` and `associated`
40
+ * is always empty, which covers the DXA lists' plain grouping. Partners
41
+ * keep the relative order `records` was given in within each tier — sorting
42
+ * them by their own label is the view's concern, not this derivation's.
43
+ */
44
+ export function groupByCountry(records, { label, tier, order = 'asc' } = {}) {
45
+ const groups = new Map()
46
+ for (const record of records ?? []) {
47
+ const country = record?.country_id ?? null
48
+ if (!groups.has(country)) groups.set(country, { country, main: [], associated: [] })
49
+ const group = groups.get(country)
50
+ ;(isMainTier(record, tier) ? group.main : group.associated).push(record)
51
+ }
52
+ const rows = [...groups.values()].map((group) => ({ ...group, label: label ? label(group.country) : group.country }))
53
+ rows.sort((a, b) => String(a.label).localeCompare(String(b.label)))
54
+ return order === 'desc' ? rows.reverse() : rows
55
+ }
56
+
57
+ /**
58
+ * The partner hierarchy `parent_id` encodes: `children(id)` (a partner's
59
+ * associated partners), `parentOf(id)` (the main partner it belongs to, or
60
+ * null), `roots` (every partner with no parent in the list, or whose
61
+ * `parent_id` points outside it). A partner referencing a `parent_id` this
62
+ * list does not carry is treated as a root rather than dropped — the same
63
+ * "keep the reference, don't invent or discard" rule `relatedRecords` uses.
64
+ */
65
+ export function partnerHierarchy(partners) {
66
+ const list = partners ?? []
67
+ const byId = new Map(list.map((partner) => [partner.id, partner]))
68
+ const childrenOf = new Map()
69
+ const roots = []
70
+ for (const partner of list) {
71
+ const parent = partner?.parent_id != null ? byId.get(partner.parent_id) : null
72
+ if (parent) {
73
+ if (!childrenOf.has(parent.id)) childrenOf.set(parent.id, [])
74
+ childrenOf.get(parent.id).push(partner)
75
+ } else {
76
+ roots.push(partner)
77
+ }
78
+ }
79
+ return {
80
+ children: (id) => childrenOf.get(id) ?? [],
81
+ parentOf: (id) => {
82
+ const partner = byId.get(id)
83
+ return partner?.parent_id != null ? (byId.get(partner.parent_id) ?? null) : null
84
+ },
85
+ roots,
86
+ }
87
+ }