@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,265 @@
1
+ import { Marked } from 'marked'
2
+
3
+ // One Markdown pipeline for every text on every website, configured here and
4
+ // nowhere else. Its own instance rather than the shared `marked` singleton, so
5
+ // configuring it cannot change how a website renders anything else.
6
+ //
7
+ // Raw HTML is escaped rather than rendered. Texts are written by translators
8
+ // through the GitHub web interface, and the rules they are given say Markdown
9
+ // only; escaping is what makes that a guarantee instead of a request.
10
+ //
11
+ // This is the only place that guarantee lives. viewer-i18n's checker used to
12
+ // reject HTML too, which added nothing here and was wrong about autolinks and
13
+ // code spans; it no longer looks. Anything relaxed in this file is relaxed
14
+ // everywhere, with nothing behind it.
15
+
16
+ const ESCAPES = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }
17
+
18
+ function escapeHtml(text) {
19
+ return String(text).replace(/[&<>"']/g, (character) => ESCAPES[character])
20
+ }
21
+
22
+ // Raw HTML is escaped in both, because both render text that came from
23
+ // somewhere else: a translator's file, or a museum record carried in from the
24
+ // legacy system. Neither is a place markup may be authored.
25
+ const escaping = {
26
+ renderer: {
27
+ html(token) {
28
+ return escapeHtml(token?.raw ?? token?.text ?? token ?? '')
29
+ },
30
+ },
31
+ }
32
+
33
+ // `breaks` is the one difference, and it is a difference in the source rather
34
+ // than in policy. A text in the dictionary is written as prose and wraps freely,
35
+ // so a single newline means nothing. A record's field arrives from a database
36
+ // where a newline was typed to be a line break, and dropping it runs the lines
37
+ // together.
38
+ const prose = new Marked({ async: false, gfm: true, breaks: false })
39
+ const record = new Marked({ async: false, gfm: true, breaks: true })
40
+ prose.use(escaping)
41
+ record.use(escaping)
42
+
43
+ const pipeline = (breaks) => (breaks ? record : prose)
44
+
45
+ const REGEXP_SPECIAL = /[.*+?^${}()|[\]\\]/g
46
+ function escapeRegExp(text) {
47
+ return text.replace(REGEXP_SPECIAL, '\\$&')
48
+ }
49
+
50
+ // A word boundary that also works for a spelling starting or ending in an
51
+ // accented letter — JS's own `\b` is `\w`-based (`[A-Za-z0-9_]`), so it does
52
+ // not see "é" as a word character and misses the boundary right before one.
53
+ const NOT_WORD = '[\\p{L}\\p{N}_]'
54
+
55
+ /**
56
+ * A marked inline extension: highlights every occurrence of a glossary
57
+ * spelling as `<span class="gloss-term" data-gid="…">`, as a token the
58
+ * parser produces rather than HTML smuggled through the source text — the
59
+ * escaping above still applies to everything else. One instance per
60
+ * distinct glossary (see the cache below); built once, matched against
61
+ * every text rendered with that glossary.
62
+ */
63
+ function spellingIndex(glossary) {
64
+ const bySpelling = new Map()
65
+ for (const { id, spelling } of glossary) {
66
+ const trimmed = spelling?.trim()
67
+ if (!trimmed) continue
68
+ bySpelling.set(trimmed.toLowerCase(), id)
69
+ }
70
+
71
+ // Longest spelling first, so "kufic script" wins over "kufic" when a text
72
+ // contains both.
73
+ const spellings = [...bySpelling.keys()].sort((a, b) => b.length - a.length)
74
+ if (spellings.length === 0) return null
75
+ return { bySpelling, alternation: spellings.map(escapeRegExp).join('|') }
76
+ }
77
+
78
+ function glossaryExtension(glossary) {
79
+ const index = spellingIndex(glossary)
80
+ if (!index) return null
81
+ const { bySpelling, alternation } = index
82
+ const findAnywhere = new RegExp(`(?<!${NOT_WORD})(?:${alternation})(?!${NOT_WORD})`, 'iu')
83
+ const matchAtStart = new RegExp(`^(?:${alternation})(?!${NOT_WORD})`, 'iu')
84
+
85
+ return {
86
+ name: 'glossaryTerm',
87
+ level: 'inline',
88
+ start(src) {
89
+ return findAnywhere.exec(src)?.index
90
+ },
91
+ tokenizer(src) {
92
+ const match = matchAtStart.exec(src)
93
+ if (!match) return undefined
94
+ return {
95
+ type: 'glossaryTerm',
96
+ raw: match[0],
97
+ text: match[0],
98
+ id: bySpelling.get(match[0].toLowerCase()),
99
+ }
100
+ },
101
+ renderer(token) {
102
+ return `<span class="gloss-term" data-gid="${escapeHtml(token.id)}">${escapeHtml(token.text)}</span>`
103
+ },
104
+ }
105
+ }
106
+
107
+ // Keyed on the spelling list, not on which text is being rendered — every
108
+ // field of one record shares the same glossary, and re-parsing that list
109
+ // into a regex and a Marked instance for each field would be wasted work.
110
+ const glossaryPipelines = new Map()
111
+
112
+ function glossaryKey(glossary) {
113
+ return JSON.stringify([...glossary].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)))
114
+ }
115
+
116
+ function withGlossary(breaks, glossary) {
117
+ const key = glossaryKey(glossary)
118
+ let entry = glossaryPipelines.get(key)
119
+ if (entry === undefined) {
120
+ const extension = glossaryExtension(glossary)
121
+ if (!extension) {
122
+ entry = null // no usable spelling in this list — fall back below
123
+ } else {
124
+ const withProse = new Marked({ async: false, gfm: true, breaks: false })
125
+ const withRecord = new Marked({ async: false, gfm: true, breaks: true })
126
+ withProse.use(escaping, { extensions: [extension] })
127
+ withRecord.use(escaping, { extensions: [extension] })
128
+ entry = { prose: withProse, record: withRecord }
129
+ }
130
+ glossaryPipelines.set(key, entry)
131
+ }
132
+ return entry ? (breaks ? entry.record : entry.prose) : pipeline(breaks)
133
+ }
134
+
135
+ function engineFor(breaks, glossary) {
136
+ return glossary && glossary.length > 0 ? withGlossary(breaks, glossary) : pipeline(breaks)
137
+ }
138
+
139
+ // The same key as the render pipelines, for the same reason: a glossary list
140
+ // is rebuilt into a regex once, however many texts get scanned against it.
141
+ const glossaryMatchers = new Map()
142
+
143
+ function matcherFor(glossary) {
144
+ const key = glossaryKey(glossary)
145
+ let matcher = glossaryMatchers.get(key)
146
+ if (matcher === undefined) {
147
+ const index = spellingIndex(glossary)
148
+ matcher = index
149
+ ? { bySpelling: index.bySpelling, regex: new RegExp(`(?<!${NOT_WORD})(?:${index.alternation})(?!${NOT_WORD})`, 'giu') }
150
+ : null
151
+ glossaryMatchers.set(key, matcher)
152
+ }
153
+ return matcher
154
+ }
155
+
156
+ /**
157
+ * The ids of `glossary` (`[{ id, spelling }]`) whose spelling occurs in
158
+ * `text`, matched the same word-bounded, case-insensitive way
159
+ * `renderBlock`/`renderInline` highlight them — a text scanned this way and
160
+ * the same text rendered with the same list agree on what counts as a hit.
161
+ * The free-text counterpart to a record's own `glossary_ids`: a dynasty
162
+ * history or a theme essay has no such column to read, so this scans the
163
+ * whole glossary against the text instead. One id per term, in no
164
+ * particular order.
165
+ */
166
+ export function glossaryIdsIn(text, glossary) {
167
+ if (!glossary || glossary.length === 0) return []
168
+ const matcher = matcherFor(glossary)
169
+ if (!matcher) return []
170
+ const found = new Set()
171
+ for (const match of String(text ?? '').matchAll(matcher.regex)) {
172
+ found.add(matcher.bySpelling.get(match[0].toLowerCase()))
173
+ }
174
+ return [...found]
175
+ }
176
+
177
+ /** A text as one or more paragraphs. `glossary`: `[{ id, spelling }]` to highlight. */
178
+ export function renderBlock(text, { breaks = false, glossary } = {}) {
179
+ return engineFor(breaks, glossary).parse(String(text ?? ''))
180
+ }
181
+
182
+ /** A text without the surrounding paragraph, for a heading or a table cell. */
183
+ export function renderInline(text, { breaks = false, glossary } = {}) {
184
+ return engineFor(breaks, glossary).parseInline(String(text ?? ''))
185
+ }
186
+
187
+ // Plain text is read off the token tree the same parser produces, never by
188
+ // rewriting the source with a regex: what a Markdown text says is what its
189
+ // tokens say. Raw HTML is dropped, since it was never content; an image is
190
+ // its alt text; a break is a space.
191
+ const BLOCKS = new Set(['paragraph', 'heading', 'blockquote', 'list', 'list_item', 'code', 'table', 'hr', 'html'])
192
+
193
+ const ENTITIES = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ' }
194
+ function decodeEntities(text) {
195
+ return text.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (match, entity) => {
196
+ if (entity[0] === '#') {
197
+ const code =
198
+ entity[1].toLowerCase() === 'x' ? parseInt(entity.slice(2), 16) : parseInt(entity.slice(1), 10)
199
+ return Number.isFinite(code) ? String.fromCodePoint(code) : match
200
+ }
201
+ return ENTITIES[entity.toLowerCase()] ?? match
202
+ })
203
+ }
204
+
205
+ function plainText(tokens) {
206
+ let out = ''
207
+ for (const token of tokens ?? []) {
208
+ switch (token.type) {
209
+ case 'html':
210
+ break
211
+ case 'image':
212
+ out += token.text ?? ''
213
+ break
214
+ case 'br':
215
+ case 'space':
216
+ out += ' '
217
+ break
218
+ case 'list':
219
+ out += plainText(token.items)
220
+ break
221
+ case 'table':
222
+ out += plainText(token.header)
223
+ for (const row of token.rows ?? []) out += plainText(row)
224
+ break
225
+ default:
226
+ if (token.tokens?.length) out += plainText(token.tokens)
227
+ else out += token.text ?? ''
228
+ }
229
+ if (BLOCKS.has(token.type)) out += ' '
230
+ }
231
+ return out
232
+ }
233
+
234
+ /** A text as plain text — for an `alt`, a `title`, an option label, a sort key. */
235
+ export function renderPlain(text) {
236
+ return decodeEntities(plainText(record.lexer(String(text ?? ''))))
237
+ .replace(/\s+/g, ' ')
238
+ .trim()
239
+ }
240
+
241
+ // `md`/`mdInline`/`mdStrip` are `renderBlock`/`renderInline`/`renderPlain`
242
+ // under the one signature and the one convention every site wrote for
243
+ // itself: a missing text is `''`, not an empty paragraph or "undefined", and
244
+ // a record's own line breaks are kept unless a caller says otherwise — a
245
+ // data-package field is typed with breaks on purpose, the way a dictionary
246
+ // entry is not. `renderBlock`/`renderInline`/`renderPlain` stay, for a
247
+ // caller that wants the pipeline's own default (`breaks: false`) instead.
248
+
249
+ /** `renderBlock`, record convention: `''` for a missing text, line breaks kept by default. */
250
+ export function md(text, { glossary, breaks = true } = {}) {
251
+ if (!text) return ''
252
+ return renderBlock(text, { breaks, glossary })
253
+ }
254
+
255
+ /** `renderInline`, the same convention, for a heading or a table cell. */
256
+ export function mdInline(text, { glossary } = {}) {
257
+ if (!text) return ''
258
+ return renderInline(text, { glossary })
259
+ }
260
+
261
+ /** `renderPlain`, the same convention, for an `alt`, a `title`, a sort key. */
262
+ export function mdStrip(text) {
263
+ if (!text) return ''
264
+ return renderPlain(text)
265
+ }
package/src/index.js ADDED
@@ -0,0 +1,50 @@
1
+ export { createViewer } from './createViewer.js'
2
+ export { useCatalogueData } from './composables/useCatalogueData.js'
3
+ export { useDataPackage } from './composables/useDataPackage.js'
4
+ export { byId, entityRef, loadEntities, useEntities } from './composables/useEntities.js'
5
+ export { resolveRecordLanguage, useRecordLanguage } from './composables/useRecordLanguage.js'
6
+ export { createI18n, mergeMessages, useI18n, useLocale } from './i18n/index.js'
7
+ export { isRtl, negotiateLanguage } from './i18n/language.js'
8
+ export { md, mdInline, mdStrip, renderBlock, renderInline, renderPlain } from './i18n/markdown.js'
9
+ export { languageLabels, offeredLanguages } from './languages.js'
10
+ export { defaultScrollBehavior, resolveViews } from './router/index.js'
11
+ export { mediaUrl, useSiteConfig } from './siteConfig.js'
12
+ export { sourceUrl, useSiteRights } from './rights.js'
13
+
14
+ // The list pages: query state, pagination, facets, dates, keyword search.
15
+ export { useListQuery } from './catalogue/listQuery.js'
16
+ export { paginate, sortChronological, usePagination } from './catalogue/pagination.js'
17
+ export { facetOptions, useFacets } from './catalogue/facets.js'
18
+ export {
19
+ centuryPresets, dateRange, eraLabel, inDateRange, roundOutward, yearBuckets, yearBucketsFromRange,
20
+ } from './catalogue/dates.js'
21
+ export { useSearchLanguage } from './catalogue/searchLanguage.js'
22
+ export {
23
+ combineExpansions, countryExpansion, glossaryExpansion, parseBooleanQuery, useKeywordIndex,
24
+ } from './catalogue/keywordIndex.js'
25
+ export {
26
+ effectiveYearTo, eventDateLabel, overlapsRange, useTimelineEvents,
27
+ } from './catalogue/timeline.js'
28
+
29
+ // The record page: the sheet engine and its derivations.
30
+ export {
31
+ glossaryEntries, glossaryTermsFor, glossaryTermsForText, sheetRows, useRecordSheet,
32
+ } from './record/recordSheet.js'
33
+ export {
34
+ citation, relatedRecords, searchGlossary, timelineLinkFor, useGlossaryPopup, useRelatedRecords,
35
+ } from './record/derivations.js'
36
+ export { buildCollectionTree, collectionTreeFromThemes, useCollectionTree } from './record/collectionTree.js'
37
+ export { groupByCountry, partnerHierarchy } from './record/partners.js'
38
+
39
+ // Conventions every website had written for itself.
40
+ export {
41
+ PROJECT_ENTRIES, PROJECT_FAMILIES, mwnfLinks, projectFamily, projectName,
42
+ sectionMeta, useFeaturedRecord, useProjectName, useSection,
43
+ } from './conventions.js'
44
+
45
+ export { default as I18nText } from './i18n/I18nText.vue'
46
+ export { default as I18nTextInline } from './i18n/I18nTextInline.vue'
47
+ export { default as HomeView } from './views/HomeView.vue'
48
+ export { default as ListView } from './views/ListView.vue'
49
+ export { default as DetailView } from './views/DetailView.vue'
50
+ export { default as NotFoundView } from './views/NotFoundView.vue'
@@ -0,0 +1 @@
1
+ export const VIEWER_CONFIG = Symbol('viewer-config')
@@ -0,0 +1,49 @@
1
+ import { useDataPackage } from './composables/useDataPackage.js'
2
+
3
+ // Which languages a website offers, decided by one rule for every website: the
4
+ // languages the package declares for the site, in the package's order, kept
5
+ // only where the site's items actually have content.
6
+
7
+ export function declaredSiteLanguages(manifest) {
8
+ const declared = manifest?.site?.languages
9
+ if (!Array.isArray(declared)) return null
10
+ return declared.map((entry) => (typeof entry === 'string' ? entry : entry?.code)).filter(Boolean)
11
+ }
12
+
13
+ /**
14
+ * `declared ∩ availableLanguages(entity)`, in the declared order. `declared`
15
+ * defaults to `manifest.site.languages`; a package that carries no such list
16
+ * offers every language its items have content in, alphabetically.
17
+ *
18
+ * A website may pass its own `declared` list to narrow what the package
19
+ * declares — a language that covers a handful of item sheets is worse than no
20
+ * switcher — but never to widen it: a code the package does not declare for
21
+ * this site is dropped even where some items carry it. That keeps the
22
+ * decision of which languages a site *can* offer in the package, and only the
23
+ * decision of which of those it *does* offer in the site.
24
+ */
25
+ export function offeredLanguages({ declared, entity = 'items', manifest, availableLanguages } = {}) {
26
+ const pkg = useDataPackage()
27
+ const withContent = (availableLanguages ?? pkg.availableLanguages)(entity)
28
+ const fromPackage = declaredSiteLanguages(manifest ?? pkg.manifest)
29
+ if (!declared && !fromPackage) return [...withContent].sort()
30
+ const list = declared
31
+ ? fromPackage
32
+ ? declared.filter((code) => fromPackage.includes(code))
33
+ : declared
34
+ : fromPackage
35
+ return list.filter((code) => withContent.includes(code))
36
+ }
37
+
38
+ /**
39
+ * `[{ code, label }]` for the language switcher: the native label the package
40
+ * declares for each code, the code itself in capitals where it declares none.
41
+ */
42
+ export function languageLabels(codes, manifest) {
43
+ const declared = (manifest ?? useDataPackage().manifest)?.site?.languages
44
+ const labels = new Map()
45
+ for (const entry of Array.isArray(declared) ? declared : []) {
46
+ if (entry && typeof entry === 'object' && entry.code) labels.set(entry.code, entry.label)
47
+ }
48
+ return codes.map((code) => ({ code, label: labels.get(code) || code.toUpperCase() }))
49
+ }
@@ -0,0 +1,58 @@
1
+ // The legacy URL mappings the four DXA sites decode from `backward_compatibility`
2
+ // — near-identically, because nothing shared did it before this. Pure, no Vue:
3
+ // a site's own composable stays the reactive wrapper around its own
4
+ // `partners` / `countries` / `items` refs, and hands this module plain lists.
5
+ // It is a separate entry point rather than part of the package root because
6
+ // only a site built against DXA's legacy URL shapes needs it — the standalone
7
+ // sites never import it.
8
+
9
+ /**
10
+ * A partner's legacy identity, `{ legacyId, countryCode }`, read from its
11
+ * `backward_compatibility`.
12
+ *
13
+ * The DXA sites carry `mwnf3:museums:Mus21:ua` — the third and fourth
14
+ * colon-separated segments are the legacy id and the two-letter country
15
+ * code. Sharing History keys its partners differently —
16
+ * `mwnf3_sharing_history:sh_partners:at_01_d` has no country segment at
17
+ * all; the country is the key's own prefix, so that shape is decoded apart
18
+ * rather than by segment index. `countries` (a country's own `code`, its
19
+ * `backward_compatibility`) is the fallback for a partner carrying no
20
+ * `backward_compatibility` at all: its `country_id` resolved to the same
21
+ * two-letter code, so a package still missing the legacy key can be routed
22
+ * by the country a visitor is already on.
23
+ */
24
+ export function partnerKey(partner, countries = []) {
25
+ const parts = String(partner?.backward_compatibility ?? '').split(':')
26
+ if (parts[0] === 'mwnf3_sharing_history') {
27
+ const key = parts[2] ?? partner?.id ?? ''
28
+ return { legacyId: key, countryCode: key.split('_')[0] ?? '' }
29
+ }
30
+ if (parts.length > 1) {
31
+ return { legacyId: parts[2] ?? partner?.id ?? '', countryCode: parts[3] ?? '' }
32
+ }
33
+ const country = countries.find((c) => c.id === partner?.country_id)
34
+ return { legacyId: partner?.id ?? '', countryCode: country?.code ?? '' }
35
+ }
36
+
37
+ /**
38
+ * The partner a legacy `/partner/:country/:id` route names, or null — the
39
+ * reverse of `partnerKey`, scanning `partners` for the one whose key
40
+ * matches both parts.
41
+ */
42
+ export function partnerFromKey(partners, countries, countryCode, legacyId) {
43
+ return (partners ?? []).find((partner) => {
44
+ const key = partnerKey(partner, countries)
45
+ return key.legacyId === legacyId && key.countryCode === countryCode
46
+ }) ?? null
47
+ }
48
+
49
+ /**
50
+ * The item a legacy dbUid *path* names, or null. The public item URL keeps
51
+ * the dbUid path, which is exactly `backward_compatibility` with `:`
52
+ * swapped for `/` — matched case-insensitively, because Sharing History
53
+ * stores its keys lowercase.
54
+ */
55
+ export function itemFromUidPath(items, path) {
56
+ const needle = String(path ?? '').split('/').join(':').toLowerCase()
57
+ return (items ?? []).find((item) => item?.backward_compatibility?.toLowerCase() === needle) ?? null
58
+ }