@museumwnf/viewer-core 1.14.0 → 1.15.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 -44
  2. package/README.md +658 -635
  3. package/package.json +50 -50
  4. package/src/AppRoot.vue +58 -58
  5. package/src/catalogue/dates.js +209 -209
  6. package/src/catalogue/facets.js +61 -61
  7. package/src/catalogue/keywordIndex.js +289 -289
  8. package/src/catalogue/listQuery.js +100 -100
  9. package/src/catalogue/pagination.js +57 -57
  10. package/src/catalogue/searchLanguage.js +32 -32
  11. package/src/catalogue/timeline.js +220 -220
  12. package/src/composables/useCatalogueData.js +141 -141
  13. package/src/composables/useDataPackage.js +98 -98
  14. package/src/composables/useEntities.js +100 -100
  15. package/src/composables/useRecordLanguage.js +71 -71
  16. package/src/conventions.js +252 -148
  17. package/src/createViewer.js +29 -29
  18. package/src/i18n/I18nText.vue +17 -17
  19. package/src/i18n/I18nTextInline.vue +17 -17
  20. package/src/i18n/index.js +88 -88
  21. package/src/i18n/language.js +127 -127
  22. package/src/i18n/markdown.js +265 -265
  23. package/src/index.js +51 -50
  24. package/src/injectionKeys.js +1 -1
  25. package/src/languages.js +49 -49
  26. package/src/legacy/index.js +58 -58
  27. package/src/record/collectionTree.js +307 -307
  28. package/src/record/derivations.js +154 -154
  29. package/src/record/partners.js +87 -87
  30. package/src/record/recordSheet.js +231 -231
  31. package/src/rights.js +53 -53
  32. package/src/router/index.js +138 -138
  33. package/src/siteConfig.js +37 -37
  34. package/src/styles/base.css +49 -49
  35. package/src/testing/checkOfferedLanguages.js +67 -67
  36. package/src/testing/index.js +3 -3
  37. package/src/testing/smoke.js +103 -103
  38. package/src/testing/viteConfig.js +90 -90
  39. package/src/views/DetailView.vue +53 -53
  40. package/src/views/HomeView.vue +20 -20
  41. package/src/views/ListView.vue +61 -61
  42. package/src/views/NotFoundView.vue +8 -8
  43. package/src/vite.js +8 -8
@@ -1,307 +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
- }
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
+ }