@museumwnf/viewer-core 2.15.0 → 2.16.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@museumwnf/viewer-core",
3
- "version": "2.15.0",
3
+ "version": "2.16.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",
@@ -33,19 +33,19 @@
33
33
  "test": "vitest run"
34
34
  },
35
35
  "dependencies": {
36
- "marked": "^18.0.11"
36
+ "marked": "^18.1.3"
37
37
  },
38
38
  "peerDependencies": {
39
39
  "vue": "^3.5.0",
40
40
  "vue-router": "^4.5.0 || ^5.0.0"
41
41
  },
42
42
  "devDependencies": {
43
- "@vitejs/plugin-vue": "^6.0.8",
44
- "@vue/test-utils": "^2.4.11",
45
- "jsdom": "^30.0.1",
46
- "vite": "^8.2.2",
47
- "vitest": "^4.1.11",
48
- "vue": "^3.5.0",
49
- "vue-router": "^5.0.0"
43
+ "@vitejs/plugin-vue": "^6.0.10",
44
+ "@vue/test-utils": "^2.5.1",
45
+ "jsdom": "^30.1.3",
46
+ "vite": "^8.3.5",
47
+ "vitest": "^5.1.0",
48
+ "vue": "^3.5.44",
49
+ "vue-router": "^5.4.0"
50
50
  }
51
51
  }
@@ -182,6 +182,16 @@ export function eraLabel(year, t) {
182
182
  return year < 0 ? `${Math.abs(year)} ${t('catalogue.era.bc')}` : `${year} ${t('catalogue.era.ad')}`
183
183
  }
184
184
 
185
+ /**
186
+ * `t`, with a family's own era words: `words` maps a `catalogue.era.*`
187
+ * entry to the one to read instead (the galleries' "A.D." and "B.C.",
188
+ * `GALLERY_ERA_WORDS`). Every other entry reads as before; no `words`, `t`.
189
+ */
190
+ export function withEraWords(t, words) {
191
+ if (!words) return t
192
+ return (key, ...rest) => t(words[key] ?? key, ...rest)
193
+ }
194
+
185
195
  /**
186
196
  * A record's dates rounded outward to the century, the window legacy's
187
197
  * "Timeline for this item" opens, so a single-year object still lands on a
@@ -45,13 +45,20 @@ export function usePagination(list, { page = 1, size = DEFAULT_SIZE } = {}) {
45
45
  /**
46
46
  * Chronological order by `key` (a record's `start_date`), undated records
47
47
  * last — or first, with `undated: 'first'`. Stable: two records of the same
48
- * year keep the order they arrived in. Returns a new array.
48
+ * year keep the order they arrived in, or the order `tiebreak(a, b)` gives
49
+ * them (a family's own, e.g. the galleries' `legacyOrder`). Returns a new
50
+ * array.
49
51
  */
50
- export function sortChronological(list, { key = 'start_date', undated = 'last' } = {}) {
52
+ export function sortChronological(list, { key = 'start_date', undated = 'last', tiebreak = null } = {}) {
51
53
  const missing = undated === 'first' ? -Infinity : Infinity
52
54
  const year = (record) => {
53
55
  const value = record?.[key]
54
56
  return typeof value === 'number' && Number.isFinite(value) ? value : missing
55
57
  }
56
- return [...(list ?? [])].sort((a, b) => year(a) - year(b))
58
+ const byYear = (a, b) => {
59
+ const ya = year(a)
60
+ const yb = year(b)
61
+ return ya === yb ? 0 : ya - yb
62
+ }
63
+ return [...(list ?? [])].sort((a, b) => byYear(a, b) || (tiebreak ? tiebreak(a, b) : 0))
57
64
  }
@@ -0,0 +1,8 @@
1
+ // Legacy's galleries wrote an era "A.D." and "B.C." (dxa-client: the timeline
2
+ // and collection year lists, the item sheet, the timeline results), where
3
+ // every other website writes "AD" and "BC". A gallery reads its own words
4
+ // for them (`withEraWords`, inventory-app#2423).
5
+ export const GALLERY_ERA_WORDS = {
6
+ 'catalogue.era.ad': 'gallery.era.ad',
7
+ 'catalogue.era.bc': 'gallery.era.bc',
8
+ }
@@ -1,7 +1,7 @@
1
1
  import { projectLabel } from '../../conventions.js'
2
2
  import { DATE_MODE, FACET_CATEGORIES, PAGE_SIZE } from '../shared.js'
3
3
 
4
- export { DATE_MODE, FACET_CATEGORIES, FACET_LABEL_KEYS, PAGE_SIZE, useFacetLabels } from '../shared.js'
4
+ export { DATE_MODE, FACET_CATEGORIES, FACET_LABEL_KEYS, GALLERY_PAGE_SIZE, PAGE_SIZE, useFacetLabels } from '../shared.js'
5
5
 
6
6
  // The catalogue spec: what a DXA gallery's lists filter and search on. The
7
7
  // engine — query state, options, dates, pages, the boolean grammar — is
@@ -97,25 +97,41 @@ export function useGalleryCollection(data, config = {}) {
97
97
  /**
98
98
  * A record as viewer-layout's grid contract: the thumbnail, the name, the
99
99
  * lines legacy's hover card carried (date, holder, place, source
100
- * project).
100
+ * project). The same lines one by one (`date`, `partner`, `place`,
101
+ * `project`), for a card that sets each its own way, as legacy's
102
+ * ObjectGrid did.
101
103
  */
102
104
  function tile(item, t) {
103
105
  const text = tr('items', item.id, defaultLang)
106
+ const date = text.dates ?? ''
107
+ const partner = labelOf('partners', item.partner_id)
108
+ const place = [text.location, labelOf('countries', item.country_id)].filter(Boolean).join(', ')
109
+ const project = projectLabel(manifest, item.project_id, defaultLang) ?? ''
104
110
  return {
105
111
  id: item.id,
106
112
  image: item.images?.[0]?.url ?? '',
107
113
  imageAlt: labelOf('items', item.id),
108
114
  name: mdInline(text.name ?? item.internal_name ?? ''),
109
- meta: [
110
- text.dates ?? '',
111
- labelOf('partners', item.partner_id),
112
- [text.location, labelOf('countries', item.country_id)].filter(Boolean).join(', '),
113
- `${t('catalogue.results.forProject')} ${projectLabel(manifest, item.project_id, defaultLang) ?? ''}`,
114
- ].filter(Boolean),
115
+ meta: [date, partner, place, `${t('catalogue.results.forProject')} ${project}`].filter(Boolean),
116
+ date,
117
+ partner,
118
+ place,
119
+ project,
115
120
  to: itemRoute(item),
116
121
  }
117
122
  }
118
123
 
124
+ // Legacy's order among records of one year (a gallery page's
125
+ // `sort.tiebreak`, viewer-layout's dxa/gallery pages):
126
+ // dxa-api's `ORDER BY startDate, countryName, partnerName`, then MySQL's
127
+ // own order, which is the table's key: the record's legacy key, its
128
+ // numbers compared as numbers (inventory-app#2423).
129
+ function legacyOrder(a, b) {
130
+ return compareText(labelOf('countries', a.country_id), labelOf('countries', b.country_id))
131
+ || compareText(labelOf('partners', a.partner_id), labelOf('partners', b.partner_id))
132
+ || compareKeys(a.backward_compatibility, b.backward_compatibility)
133
+ }
134
+
119
135
  // ── The results page, as a spec ──────────────────────────────────────
120
136
  //
121
137
  // What viewer-layout's `CatalogueResultsView` renders on
@@ -166,7 +182,26 @@ export function useGalleryCollection(data, config = {}) {
166
182
  }
167
183
 
168
184
  return {
169
- FACETS, hasEveryTag, haystack, tile, collectionResults,
185
+ FACETS, hasEveryTag, haystack, tile, legacyOrder, collectionResults,
170
186
  countryIdForCode, tagIdForLegacy, tagLabelForLegacy,
171
187
  }
172
188
  }
189
+
190
+ function compareText(a, b) {
191
+ return String(a ?? '').localeCompare(String(b ?? ''), 'en', { sensitivity: 'base' })
192
+ }
193
+
194
+ // Two legacy keys (`mwnf3:objects:ISL:dz:Mus01:24`), segment by segment, a
195
+ // number against a number as numbers.
196
+ function compareKeys(a, b) {
197
+ const left = String(a ?? '').split(':')
198
+ const right = String(b ?? '').split(':')
199
+ for (let i = 0; i < Math.max(left.length, right.length); i++) {
200
+ const x = left[i] ?? ''
201
+ const y = right[i] ?? ''
202
+ if (x === y) continue
203
+ if (/^\d+$/.test(x) && /^\d+$/.test(y)) return Number(x) - Number(y)
204
+ return x.localeCompare(y, 'en')
205
+ }
206
+ return 0
207
+ }
@@ -1,4 +1,5 @@
1
- import { eraLabel, roundOutward } from '../../catalogue/dates.js'
1
+ import { eraLabel, roundOutward, withEraWords } from '../../catalogue/dates.js'
2
+ import { GALLERY_ERA_WORDS } from './eraWords.js'
2
3
  import { useSiteConfig } from '../../siteConfig.js'
3
4
  import { useGallerySheet } from './useGallerySheet.js'
4
5
 
@@ -72,7 +73,7 @@ export function useGalleryItemDetail(data, timeline) {
72
73
  },
73
74
  events: (country) => findEvents({ country, begin: from, end: to }),
74
75
  range: [from, to],
75
- era: (year) => eraLabel(year, ctx.t),
76
+ era: (year) => eraLabel(year, withEraWords(ctx.t, GALLERY_ERA_WORDS)),
76
77
  searchTo: (country, [begin, end]) => ({ name: 'timeline-results', query: { country, begin, end } }),
77
78
  }
78
79
  }
@@ -59,7 +59,7 @@ export function useGalleryPartner(data, collection, config = {}) {
59
59
  // results and the timeline gallery use. `scope` is built per-page in the
60
60
  // site's own PartnerObjects.vue, over the route's own id —
61
61
  // `CatalogueResultsView` takes no record id of its own, unlike
62
- // `RecordView`.
62
+ // `RecordView`. Legacy's page (PartnerObjects.vue) printed no count.
63
63
  const partnerObjects = {
64
64
  entity: 'items',
65
65
  sort: { undated: 'first' },
@@ -70,7 +70,7 @@ export function useGalleryPartner(data, collection, config = {}) {
70
70
  empty: 'gallery.partner.noObjects',
71
71
  pagination: { jump: true },
72
72
  record: (item, { t }) => tile(item, t),
73
- summary: ({ pageInfo, t }) => [{ count: pageInfo.total, value: t('partner.item.objectsInSite') }],
73
+ summary: () => [],
74
74
  }
75
75
 
76
76
  return { partnerList, partnerSheet, partnerObjects }
@@ -1,4 +1,5 @@
1
- import { dateRange, eraLabel, roundOutward, yearBucketsFromRange } from '../../catalogue/dates.js'
1
+ import { dateRange, eraLabel, roundOutward, withEraWords, yearBucketsFromRange } from '../../catalogue/dates.js'
2
+ import { GALLERY_ERA_WORDS } from './eraWords.js'
2
3
  import { useTimelineEvents } from '../../catalogue/timeline.js'
3
4
  import { PAGE_SIZE } from '../shared.js'
4
5
 
@@ -84,9 +85,10 @@ export function useGalleryTimeline(data, collection, config = {}) {
84
85
  route: 'timeline-results',
85
86
  controls: [
86
87
  { key: 'country' },
87
- { key: 'begin', options: (ctx) => yearBucketsFromRange(ctx.years.min, ctx.years.max, ctx.t) },
88
- { key: 'end', options: (ctx) => yearBucketsFromRange(ctx.years.min, ctx.years.max, ctx.t) },
88
+ { key: 'begin', options: (ctx) => yearBucketsFromRange(ctx.years.min, ctx.years.max, withEraWords(ctx.t, GALLERY_ERA_WORDS)) },
89
+ { key: 'end', options: (ctx) => yearBucketsFromRange(ctx.years.min, ctx.years.max, withEraWords(ctx.t, GALLERY_ERA_WORDS)) },
89
90
  ],
91
+ eraWords: GALLERY_ERA_WORDS,
90
92
  gallery: {
91
93
  route: 'timeline-gallery',
92
94
  items: (ctx) => timelineGalleryItems(ctx.filters),
@@ -127,8 +129,8 @@ export function useGalleryTimeline(data, collection, config = {}) {
127
129
  value: countryId ? labelOf('countries', countryId) : t('timeline.form.allCountries'),
128
130
  }]
129
131
  if (begin != null || end != null) {
130
- const from = begin != null ? eraLabel(begin, t) : t('timeline.form.earliest')
131
- const to = end != null ? eraLabel(end, t) : t('timeline.form.latest')
132
+ const from = begin != null ? eraLabel(begin, withEraWords(t, GALLERY_ERA_WORDS)) : t('timeline.form.earliest')
133
+ const to = end != null ? eraLabel(end, withEraWords(t, GALLERY_ERA_WORDS)) : t('timeline.form.latest')
132
134
  parts.push({ value: `${from} ${t('timeline.form.to')} ${to}` })
133
135
  }
134
136
  parts.push({ count: pageInfo.total, value: t('catalogue.results.objects') })
package/src/dxa/index.js CHANGED
@@ -19,11 +19,12 @@
19
19
  // const sheet = useGallerySheet(data)
20
20
 
21
21
  // Shared verbatim by both families — see shared.js.
22
- export { DATE_MODE, FACET_CATEGORIES, FACET_LABEL_KEYS, PAGE_SIZE, useFacetLabels } from './shared.js'
22
+ export { DATE_MODE, FACET_CATEGORIES, FACET_LABEL_KEYS, GALLERY_PAGE_SIZE, PAGE_SIZE, useFacetLabels } from './shared.js'
23
23
 
24
24
  export { useGalleryData } from './gallery/useGalleryData.js'
25
25
  export { useGalleryCollection } from './gallery/useGalleryCollection.js'
26
26
  export { useGalleryTimeline } from './gallery/useGalleryTimeline.js'
27
+ export { GALLERY_ERA_WORDS } from './gallery/eraWords.js'
27
28
  export { useGalleryPartner } from './gallery/useGalleryPartner.js'
28
29
  export { useGallerySheet } from './gallery/useGallerySheet.js'
29
30
  export { useGalleryItemDetail } from './gallery/useGalleryItemDetail.js'
package/src/dxa/shared.js CHANGED
@@ -10,6 +10,13 @@ import { useI18n } from '../i18n/index.js'
10
10
  /** Nine tiles a page, as legacy's grids showed. */
11
11
  export const PAGE_SIZE = 9
12
12
 
13
+ /**
14
+ * A gallery's pages show twenty tiles: dxa-api's own page size
15
+ * (`API_PAGESIZE`), which the gallery client never overrode — measured on
16
+ * Ceramics' collection results, 20 tiles a page (inventory-app#2423).
17
+ */
18
+ export const GALLERY_PAGE_SIZE = 20
19
+
13
20
  /**
14
21
  * Decision D5: the DXA sites test containment — `na <= start_date` and
15
22
  * `nz >= coalesce(end_date, start_date)` in `Objects.blade.php` — so an
@@ -3,13 +3,16 @@ import { computed } from 'vue'
3
3
  import { useI18n } from './index.js'
4
4
  import { renderBlock } from './markdown.js'
5
5
 
6
+ // `blankLines`: each blank line between two blocks a `<br>` (renderBlock),
7
+ // for a page that sets legacy's line spacing.
6
8
  const props = defineProps({
7
9
  keypath: { type: String, required: true },
8
10
  tag: { type: String, default: 'div' },
11
+ blankLines: { type: Boolean, default: false },
9
12
  })
10
13
 
11
14
  const { t } = useI18n()
12
- const html = computed(() => renderBlock(t(props.keypath)))
15
+ const html = computed(() => renderBlock(t(props.keypath), { blankLines: props.blankLines }))
13
16
  </script>
14
17
 
15
18
  <template>
@@ -61,7 +61,30 @@ const record = new Marked({ async: false, gfm: true, breaks: true })
61
61
  prose.use(escaping, noCode)
62
62
  record.use(escaping, noCode)
63
63
 
64
- const pipeline = (breaks) => (breaks ? record : prose)
64
+ // `blankLines` keeps the number of blank lines between two blocks. Legacy's
65
+ // text pages were HTML, a blank line a `<br>`, and some put two blank lines
66
+ // where others put one; Markdown reads any run of blank lines as one
67
+ // paragraph break. With it, each blank line of the source is a `<br>`
68
+ // between the blocks, and the page sets its paragraphs without margins (a
69
+ // gallery's Credits, inventory-app#2423). A run of N blank lines is one
70
+ // `space` token holding N + 1 newlines.
71
+ const blankLineSpacing = {
72
+ renderer: {
73
+ space(token) {
74
+ const newlines = String(token?.raw ?? '').split('\n').length - 1
75
+ return '<br>'.repeat(Math.max(0, newlines - 1))
76
+ },
77
+ },
78
+ }
79
+ const proseLines = new Marked({ async: false, gfm: true, breaks: false })
80
+ const recordLines = new Marked({ async: false, gfm: true, breaks: true })
81
+ proseLines.use(escaping, noCode, blankLineSpacing)
82
+ recordLines.use(escaping, noCode, blankLineSpacing)
83
+
84
+ const pipeline = (breaks, blankLines = false) => {
85
+ if (blankLines) return breaks ? recordLines : proseLines
86
+ return breaks ? record : prose
87
+ }
65
88
 
66
89
  const REGEXP_SPECIAL = /[.*+?^${}()|[\]\\]/g
67
90
  function escapeRegExp(text) {
@@ -153,8 +176,10 @@ function withGlossary(breaks, glossary) {
153
176
  return entry ? (breaks ? entry.record : entry.prose) : pipeline(breaks)
154
177
  }
155
178
 
156
- function engineFor(breaks, glossary) {
157
- return glossary && glossary.length > 0 ? withGlossary(breaks, glossary) : pipeline(breaks)
179
+ // `blankLines` is for a page's own text, which has no glossary; a glossary
180
+ // wins over it.
181
+ function engineFor(breaks, glossary, blankLines = false) {
182
+ return glossary && glossary.length > 0 ? withGlossary(breaks, glossary) : pipeline(breaks, blankLines)
158
183
  }
159
184
 
160
185
  // The same key as the render pipelines, for the same reason: a glossary list
@@ -195,9 +220,12 @@ export function glossaryIdsIn(text, glossary) {
195
220
  return [...found]
196
221
  }
197
222
 
198
- /** A text as one or more paragraphs. `glossary`: `[{ id, spelling }]` to highlight. */
199
- export function renderBlock(text, { breaks = false, glossary } = {}) {
200
- return engineFor(breaks, glossary).parse(String(text ?? ''))
223
+ /**
224
+ * A text as one or more paragraphs. `glossary`: `[{ id, spelling }]` to
225
+ * highlight. `blankLines`: each blank line between two blocks a `<br>`.
226
+ */
227
+ export function renderBlock(text, { breaks = false, glossary, blankLines = false } = {}) {
228
+ return engineFor(breaks, glossary, blankLines).parse(String(text ?? ''))
201
229
  }
202
230
 
203
231
  /** A text without the surrounding paragraph, for a heading or a table cell. */
package/src/index.js CHANGED
@@ -26,7 +26,7 @@ export { useListQuery } from './catalogue/listQuery.js'
26
26
  export { paginate, sortChronological, usePagination } from './catalogue/pagination.js'
27
27
  export { facetOptions, useFacets } from './catalogue/facets.js'
28
28
  export {
29
- centuryPresets, dateRange, eraLabel, inDateRange, roundOutward, yearBuckets, yearBucketsFromRange,
29
+ centuryPresets, dateRange, eraLabel, inDateRange, roundOutward, withEraWords, yearBuckets, yearBucketsFromRange,
30
30
  } from './catalogue/dates.js'
31
31
  export { useSearchLanguage } from './catalogue/searchLanguage.js'
32
32
  export {