@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.
- package/LICENSE.md +44 -0
- package/README.md +635 -0
- package/package.json +50 -0
- package/src/AppRoot.vue +58 -0
- package/src/catalogue/dates.js +209 -0
- package/src/catalogue/facets.js +61 -0
- package/src/catalogue/keywordIndex.js +289 -0
- package/src/catalogue/listQuery.js +100 -0
- package/src/catalogue/pagination.js +57 -0
- package/src/catalogue/searchLanguage.js +32 -0
- package/src/catalogue/timeline.js +220 -0
- package/src/composables/useCatalogueData.js +141 -0
- package/src/composables/useDataPackage.js +98 -0
- package/src/composables/useEntities.js +100 -0
- package/src/composables/useRecordLanguage.js +71 -0
- package/src/conventions.js +148 -0
- package/src/createViewer.js +29 -0
- package/src/i18n/I18nText.vue +17 -0
- package/src/i18n/I18nTextInline.vue +17 -0
- package/src/i18n/index.js +88 -0
- package/src/i18n/language.js +127 -0
- package/src/i18n/markdown.js +265 -0
- package/src/index.js +50 -0
- package/src/injectionKeys.js +1 -0
- package/src/languages.js +49 -0
- package/src/legacy/index.js +58 -0
- package/src/record/collectionTree.js +307 -0
- package/src/record/derivations.js +154 -0
- package/src/record/partners.js +87 -0
- package/src/record/recordSheet.js +231 -0
- package/src/rights.js +53 -0
- package/src/router/index.js +138 -0
- package/src/siteConfig.js +37 -0
- package/src/styles/base.css +49 -0
- package/src/testing/checkOfferedLanguages.js +67 -0
- package/src/testing/index.js +3 -0
- package/src/testing/smoke.js +103 -0
- package/src/testing/viteConfig.js +90 -0
- package/src/views/DetailView.vue +53 -0
- package/src/views/HomeView.vue +20 -0
- package/src/views/ListView.vue +61 -0
- package/src/views/NotFoundView.vue +8 -0
- package/src/vite.js +8 -0
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { computed, ref, toValue, watch } from 'vue'
|
|
2
|
+
import { useDataPackage } from '../composables/useDataPackage.js'
|
|
3
|
+
import { entityRef } from '../composables/useEntities.js'
|
|
4
|
+
import { useRecordLanguage } from '../composables/useRecordLanguage.js'
|
|
5
|
+
import { glossaryIdsIn, renderBlock, renderInline, renderPlain } from '../i18n/markdown.js'
|
|
6
|
+
|
|
7
|
+
const BASE_LANGUAGE = 'en'
|
|
8
|
+
|
|
9
|
+
// A record page does the same things on every website before it shows a
|
|
10
|
+
// single field: decide which language the record is read in, load that
|
|
11
|
+
// language for the record's entity and for the entities its sheet names
|
|
12
|
+
// (dynasties, glossary, partners), gather the glossary terms the record
|
|
13
|
+
// reaches in that language for the renderer, and say when it is ready.
|
|
14
|
+
// Seven pages wrote that choreography by hand. This is the one place it is
|
|
15
|
+
// written, on top of `useRecordLanguage`, which decides the language.
|
|
16
|
+
|
|
17
|
+
// A term's row in one language: its spellings and definition from
|
|
18
|
+
// `rows`/`fallback` (English), its headword from `term` (the glossary
|
|
19
|
+
// record itself, language-independent) — `null` when the term has neither,
|
|
20
|
+
// which happens for a headword-only fixture row with no translation file at
|
|
21
|
+
// all. Shared by `glossaryTermsFor` (walks a record's own `glossary_ids`)
|
|
22
|
+
// and `glossaryTermsForText` (walks the whole glossary, filtered by what
|
|
23
|
+
// the text contains), so the two agree on what a term row looks like.
|
|
24
|
+
function termRow(id, term, rows, fallback) {
|
|
25
|
+
const t = rows[id] ?? fallback[id] ?? {}
|
|
26
|
+
const spellings = (t.spellings ?? []).map((s) => String(s).trim()).filter(Boolean)
|
|
27
|
+
const word = term?.word ?? spellings[0] ?? ''
|
|
28
|
+
if (!word && spellings.length === 0) return null
|
|
29
|
+
return { id, word, definition: t.definition ?? '', spellings: spellings.length ? spellings : [word] }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* `{ id, word, definition, spellings }` for each glossary term of `record`
|
|
34
|
+
* (its `glossary_ids`), with the spellings and definition in `language`,
|
|
35
|
+
* falling back to English, then to the term's own headword.
|
|
36
|
+
*/
|
|
37
|
+
export function glossaryTermsFor(record, language, { entity = 'glossary', translations, byId } = {}) {
|
|
38
|
+
const ids = record?.glossary_ids ?? []
|
|
39
|
+
if (ids.length === 0) return []
|
|
40
|
+
const rows = translations(entity, language)
|
|
41
|
+
const fallback = translations(entity, BASE_LANGUAGE)
|
|
42
|
+
const out = []
|
|
43
|
+
for (const id of ids) {
|
|
44
|
+
const row = termRow(id, byId?.get(id), rows, fallback)
|
|
45
|
+
if (row) out.push(row)
|
|
46
|
+
}
|
|
47
|
+
return out
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* `glossaryTermsFor`'s counterpart for a text with no `glossary_ids` column
|
|
52
|
+
* to read — a dynasty history, a theme essay: every glossary term whose
|
|
53
|
+
* spelling occurs in `text`, matched the same word-bounded way `md`/
|
|
54
|
+
* `renderBlock` highlight it, so what this returns and what the text is
|
|
55
|
+
* rendered with agree. The candidate list — the whole glossary in
|
|
56
|
+
* `language`, English behind it — is the same for every text asked about
|
|
57
|
+
* in that language; `glossaryIdsIn` is what carries the caching (see
|
|
58
|
+
* `markdown.js`), so building it here costs one pass over the glossary,
|
|
59
|
+
* not a rebuilt regex.
|
|
60
|
+
*/
|
|
61
|
+
export function glossaryTermsForText(text, language, { entity = 'glossary' } = {}) {
|
|
62
|
+
const value = String(text ?? '')
|
|
63
|
+
if (!value) return []
|
|
64
|
+
const { translations } = useDataPackage()
|
|
65
|
+
const rows = translations(entity, language)
|
|
66
|
+
const fallback = translations(entity, BASE_LANGUAGE)
|
|
67
|
+
const byId = new Map()
|
|
68
|
+
const candidates = []
|
|
69
|
+
for (const term of entityRef(entity).value ?? []) {
|
|
70
|
+
const row = termRow(term.id, term, rows, fallback)
|
|
71
|
+
if (!row) continue
|
|
72
|
+
byId.set(term.id, row)
|
|
73
|
+
for (const spelling of row.spellings) candidates.push({ id: term.id, spelling })
|
|
74
|
+
}
|
|
75
|
+
return glossaryIdsIn(value, candidates).map((id) => byId.get(id))
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The `[{ id, spelling }]` list the renderers highlight, one entry per spelling. */
|
|
79
|
+
export function glossaryEntries(terms) {
|
|
80
|
+
const out = []
|
|
81
|
+
for (const term of terms ?? []) {
|
|
82
|
+
for (const spelling of term.spellings) out.push({ id: term.id, spelling, definition: term.definition, word: term.word })
|
|
83
|
+
}
|
|
84
|
+
return out
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The record page's engine.
|
|
89
|
+
*
|
|
90
|
+
* `record` is a ref, computed or getter. `entity` is the record's entity;
|
|
91
|
+
* `translations` names the other entities the sheet reads in the record's
|
|
92
|
+
* language (`['dynasties', 'glossary', 'partners']`). Returns what
|
|
93
|
+
* `useRecordLanguage` returns — `language`, `languages`, `dir`, `select`,
|
|
94
|
+
* `reset` — plus `text` (the record's translation in the active language,
|
|
95
|
+
* falling back to English), `ready` (every named load done for this record
|
|
96
|
+
* and language), `terms` and `glossary` (the record's glossary terms, and
|
|
97
|
+
* the spelling list the renderers take), and `attribution` when asked for.
|
|
98
|
+
*
|
|
99
|
+
* `attribution: ['author', 'copy_editor']` names fields that are proper
|
|
100
|
+
* names rather than text: when the active language's row lacks them, the
|
|
101
|
+
* first other language of the record that has them supplies them. This is
|
|
102
|
+
* the one place a page reads across languages, and it exists because the
|
|
103
|
+
* importer files some records' credits on one language only.
|
|
104
|
+
*/
|
|
105
|
+
export function useRecordSheet(record, { entity, translations: related = [], attribution: attributionFields = [], languages } = {}) {
|
|
106
|
+
if (!entity) throw new Error('useRecordSheet: entity is required')
|
|
107
|
+
const pkg = useDataPackage()
|
|
108
|
+
const language = useRecordLanguage(record, { entity, languages })
|
|
109
|
+
const ready = ref(false)
|
|
110
|
+
const attribution = ref({})
|
|
111
|
+
const glossaryRecords = related.includes('glossary') ? entityRef('glossary') : null
|
|
112
|
+
|
|
113
|
+
const text = computed(() => {
|
|
114
|
+
const current = toValue(record)
|
|
115
|
+
return current ? pkg.tr(entity, current.id, language.language.value, BASE_LANGUAGE) : {}
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
async function resolveAttribution(current, lang) {
|
|
119
|
+
const own = pkg.tr(entity, current.id, lang, BASE_LANGUAGE)
|
|
120
|
+
if (attributionFields.some((field) => own[field])) return {}
|
|
121
|
+
for (const code of language.languages.value) {
|
|
122
|
+
if (code === lang) continue
|
|
123
|
+
await pkg.loadTranslations(entity, code)
|
|
124
|
+
const row = pkg.translations(entity, code)[current.id] ?? {}
|
|
125
|
+
if (attributionFields.some((field) => row[field])) {
|
|
126
|
+
const found = { from: code }
|
|
127
|
+
for (const field of attributionFields) if (row[field]) found[field] = row[field]
|
|
128
|
+
return found
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return {}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
let generation = 0
|
|
135
|
+
async function load() {
|
|
136
|
+
const current = toValue(record)
|
|
137
|
+
const lang = language.language.value
|
|
138
|
+
const mine = ++generation
|
|
139
|
+
ready.value = false
|
|
140
|
+
attribution.value = {}
|
|
141
|
+
if (!current) {
|
|
142
|
+
ready.value = true
|
|
143
|
+
return
|
|
144
|
+
}
|
|
145
|
+
// The record language and English both: `tr` falls back to English for a
|
|
146
|
+
// record or a term that has no row in the active language, and a fallback
|
|
147
|
+
// that has not been loaded is a blank, not a fallback.
|
|
148
|
+
const languages = lang === BASE_LANGUAGE ? [lang] : [lang, BASE_LANGUAGE]
|
|
149
|
+
await Promise.all(
|
|
150
|
+
[entity, ...related].flatMap((name) => languages.map((code) => pkg.loadTranslations(name, code))),
|
|
151
|
+
)
|
|
152
|
+
if (mine !== generation) return
|
|
153
|
+
if (attributionFields.length) {
|
|
154
|
+
const found = await resolveAttribution(current, lang)
|
|
155
|
+
if (mine !== generation) return
|
|
156
|
+
attribution.value = found
|
|
157
|
+
}
|
|
158
|
+
ready.value = true
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
watch(() => [toValue(record)?.id, language.language.value], load, { immediate: true })
|
|
162
|
+
|
|
163
|
+
const glossaryIndex = computed(() =>
|
|
164
|
+
glossaryRecords ? new Map((glossaryRecords.value ?? []).map((term) => [term.id, term])) : new Map(),
|
|
165
|
+
)
|
|
166
|
+
const terms = computed(() =>
|
|
167
|
+
glossaryTermsFor(toValue(record), language.language.value, {
|
|
168
|
+
translations: pkg.translations,
|
|
169
|
+
byId: glossaryIndex.value,
|
|
170
|
+
}),
|
|
171
|
+
)
|
|
172
|
+
const glossary = computed(() => glossaryEntries(terms.value))
|
|
173
|
+
|
|
174
|
+
return { ...language, text, ready, terms, glossary, attribution }
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ── The field engine ───────────────────────────────────────────────────────
|
|
178
|
+
//
|
|
179
|
+
// A sheet is a list of labelled values, in an order, with the empty ones
|
|
180
|
+
// dropped. Which fields, in what order, under which labels, is the site's:
|
|
181
|
+
// "Materials/techniques" on Islamic Art and "Type" on a gallery name the
|
|
182
|
+
// same field and are both legacy facts. The spec is data; this turns it into
|
|
183
|
+
// rows and renders every value through the one pipeline, with the glossary.
|
|
184
|
+
|
|
185
|
+
const RENDER = {
|
|
186
|
+
inline: (value, ctx) => renderInline(value, { glossary: ctx.glossary }),
|
|
187
|
+
block: (value, ctx) => renderBlock(value, { breaks: true, glossary: ctx.glossary }),
|
|
188
|
+
plain: (value) => renderPlain(value),
|
|
189
|
+
link: (value) => String(value),
|
|
190
|
+
custom: (value) => value,
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function present(value) {
|
|
194
|
+
if (value == null) return false
|
|
195
|
+
if (typeof value === 'string') return value.trim() !== ''
|
|
196
|
+
if (Array.isArray(value)) return value.length > 0
|
|
197
|
+
return true
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* `[{ key, label, render, value, html }]` from a spec and a context.
|
|
202
|
+
*
|
|
203
|
+
* A spec entry is `{ key, label, value, render = 'inline', when }`: `label`
|
|
204
|
+
* a text (already looked up) or `(ctx) => text`; `value` a function of the
|
|
205
|
+
* context returning the raw value, or the name of a field of `ctx.text`;
|
|
206
|
+
* `render` one of `inline`, `block`, `plain` (rendered through the pipeline
|
|
207
|
+
* with `ctx.glossary`), `link` (the value is an address) or `custom` (the
|
|
208
|
+
* value is handed back as it is, for a slot); `when(ctx)` gates the row.
|
|
209
|
+
* Rows whose value is empty are dropped. `ctx` is whatever the site passes —
|
|
210
|
+
* typically `{ record, text, glossary, ...helpers }`.
|
|
211
|
+
*/
|
|
212
|
+
export function sheetRows(spec, ctx = {}) {
|
|
213
|
+
const rows = []
|
|
214
|
+
for (const entry of spec ?? []) {
|
|
215
|
+
if (!entry || (entry.when && !entry.when(ctx))) continue
|
|
216
|
+
const raw = typeof entry.value === 'function' ? entry.value(ctx) : ctx.text?.[entry.value ?? entry.key]
|
|
217
|
+
if (!present(raw)) continue
|
|
218
|
+
const render = entry.render ?? 'inline'
|
|
219
|
+
const renderer = RENDER[render]
|
|
220
|
+
if (!renderer) throw new Error(`sheetRows: unknown render "${render}" on "${entry.key}"`)
|
|
221
|
+
const value = Array.isArray(raw) && render !== 'custom' ? raw.join(entry.join ?? ', ') : raw
|
|
222
|
+
rows.push({
|
|
223
|
+
key: entry.key,
|
|
224
|
+
label: typeof entry.label === 'function' ? entry.label(ctx) : (entry.label ?? entry.key),
|
|
225
|
+
render,
|
|
226
|
+
value,
|
|
227
|
+
html: render === 'custom' || render === 'link' ? null : renderer(value, ctx),
|
|
228
|
+
})
|
|
229
|
+
}
|
|
230
|
+
return rows
|
|
231
|
+
}
|
package/src/rights.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { useRouter } from 'vue-router'
|
|
2
|
+
import { mwnfLinks } from './conventions.js'
|
|
3
|
+
import { useDataPackage } from './composables/useDataPackage.js'
|
|
4
|
+
import { useSiteConfig } from './siteConfig.js'
|
|
5
|
+
|
|
6
|
+
// A site's rights and the address of one of its pages — two facts a
|
|
7
|
+
// citation needs that neither `citation()` nor the data package can know by
|
|
8
|
+
// itself. The rights holder and terms come from the exporter's
|
|
9
|
+
// `manifest.json` (inventory-app#1690 puts a `rights` block there); a
|
|
10
|
+
// page's own address depends on where the site is deployed, which the
|
|
11
|
+
// package cannot declare either. Both are read from one place a website
|
|
12
|
+
// declares once — the manifest's `rights` block, and `site.origin` in
|
|
13
|
+
// `dataset.config.js` — so a composed view asks here instead of assembling
|
|
14
|
+
// its own guess.
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* `{ holder, termsUrl, attribution }` from the loaded data package's
|
|
18
|
+
* `manifest.rights`. A package built before the block existed, or one that
|
|
19
|
+
* carries none, has no holder or attribution to offer, and its terms
|
|
20
|
+
* address falls back to the MWNF legal notice — the address every site
|
|
21
|
+
* pointed at before a package could declare its own.
|
|
22
|
+
*/
|
|
23
|
+
export function useSiteRights() {
|
|
24
|
+
const { manifest } = useDataPackage()
|
|
25
|
+
const rights = manifest.rights
|
|
26
|
+
return {
|
|
27
|
+
holder: rights?.rights_holder ?? null,
|
|
28
|
+
termsUrl: rights?.terms_url ?? mwnfLinks.legalNotice,
|
|
29
|
+
attribution: rights?.attribution ?? null,
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const TRAILING_SLASHES = /\/+$/
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The address of `route` on this deployment: the declared `site.origin`
|
|
37
|
+
* plus the hash the router would put in the location bar for it. Null when
|
|
38
|
+
* the site declares no origin, so a page renders its citation without a
|
|
39
|
+
* source address instead of one that resolves nowhere once copied out of
|
|
40
|
+
* the app. `route` is either a route already resolved (its `fullPath` read
|
|
41
|
+
* directly, so asking for the current page's own address costs no second
|
|
42
|
+
* resolution) or a raw location, which the installed router resolves the
|
|
43
|
+
* same way a `<router-link>` would.
|
|
44
|
+
*/
|
|
45
|
+
export function sourceUrl(route) {
|
|
46
|
+
const origin = String(useSiteConfig().site?.origin ?? '').replace(TRAILING_SLASHES, '')
|
|
47
|
+
if (!origin) return null
|
|
48
|
+
const fullPath =
|
|
49
|
+
typeof route === 'object' && route !== null && typeof route.fullPath === 'string'
|
|
50
|
+
? route.fullPath
|
|
51
|
+
: useRouter().resolve(route).fullPath
|
|
52
|
+
return `${origin}/#${fullPath}`
|
|
53
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { createRouter, createWebHashHistory } from 'vue-router'
|
|
2
|
+
import { loadEntities } from '../composables/useEntities.js'
|
|
3
|
+
import DetailView from '../views/DetailView.vue'
|
|
4
|
+
import HomeView from '../views/HomeView.vue'
|
|
5
|
+
import ListView from '../views/ListView.vue'
|
|
6
|
+
import NotFoundView from '../views/NotFoundView.vue'
|
|
7
|
+
|
|
8
|
+
// The routing convention, applied to every website by the one router they
|
|
9
|
+
// share: hash history; every route named; `/<section>` and `/<section>/:id`
|
|
10
|
+
// with the package id; language, page and filters in the query; the shapes
|
|
11
|
+
// of legacy URLs kept as redirect-only entries; a catch-all to one not-found
|
|
12
|
+
// view; scroll restoration owned here, not by a shell or a view.
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Where the page is after a navigation: where it was when the visitor comes
|
|
16
|
+
* back through history; at the anchor when the URL names one; where it is
|
|
17
|
+
* when only a filter of the same page changed; at the top otherwise —
|
|
18
|
+
* including a change of `page`, which is a new page of results and is read
|
|
19
|
+
* from the beginning, and including the language, which re-renders
|
|
20
|
+
* everything.
|
|
21
|
+
*/
|
|
22
|
+
export function defaultScrollBehavior(to, from, savedPosition) {
|
|
23
|
+
if (savedPosition) return savedPosition
|
|
24
|
+
if (to.hash) return { el: to.hash, behavior: 'smooth' }
|
|
25
|
+
if (
|
|
26
|
+
from.name !== undefined &&
|
|
27
|
+
to.name === from.name &&
|
|
28
|
+
sameParams(to.params, from.params) &&
|
|
29
|
+
String(to.query.page ?? '') === String(from.query.page ?? '')
|
|
30
|
+
) {
|
|
31
|
+
return false
|
|
32
|
+
}
|
|
33
|
+
return { top: 0 }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function sameParams(a, b) {
|
|
37
|
+
const keys = new Set([...Object.keys(a ?? {}), ...Object.keys(b ?? {})])
|
|
38
|
+
for (const key of keys) {
|
|
39
|
+
if (String(a?.[key] ?? '') !== String(b?.[key] ?? '')) return false
|
|
40
|
+
}
|
|
41
|
+
return true
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* A legacy URL shape becomes a route that only ever redirects: `resolve`
|
|
46
|
+
* receives the matched params (and the route) and answers the canonical
|
|
47
|
+
* location — a name with params, or a path — or nothing, which is the
|
|
48
|
+
* not-found page. It may be async, so a website can load the entity it maps
|
|
49
|
+
* a legacy key through.
|
|
50
|
+
*/
|
|
51
|
+
function legacyRoute({ path, resolve, name }, index) {
|
|
52
|
+
return {
|
|
53
|
+
path,
|
|
54
|
+
name: name ?? `legacy-${index}`,
|
|
55
|
+
component: NotFoundView,
|
|
56
|
+
meta: { legacy: true },
|
|
57
|
+
async beforeEnter(to) {
|
|
58
|
+
const target = await resolve(to.params, to)
|
|
59
|
+
if (!target) {
|
|
60
|
+
return { name: 'not-found', params: { pathMatch: to.path.slice(1).split('/') }, replace: true }
|
|
61
|
+
}
|
|
62
|
+
const location = typeof target === 'string' ? { path: target } : { ...target }
|
|
63
|
+
return { ...location, replace: true }
|
|
64
|
+
},
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The three slots every website has — the home page, a list page, a record
|
|
70
|
+
* page — and what renders them. The generic views are the defaults: a plain
|
|
71
|
+
* list of entity names, a paginated list of records, a record's fields,
|
|
72
|
+
* enough to look at a new dataset. A website names the real pages in
|
|
73
|
+
* `config.views` (`{ home, list, detail }`), typically the composed views of
|
|
74
|
+
* `@museumwnf/viewer-layout/views`, and the same route names then render
|
|
75
|
+
* them: `home` for `/`, `<entity>-list` and `<entity>-detail` for every
|
|
76
|
+
* entity `features.entities` publishes. A component a site registers on the
|
|
77
|
+
* `home` name in `extraViews` still wins — the escape hatch stays open.
|
|
78
|
+
*/
|
|
79
|
+
export function resolveViews(config = {}) {
|
|
80
|
+
return {
|
|
81
|
+
home: HomeView,
|
|
82
|
+
list: ListView,
|
|
83
|
+
detail: DetailView,
|
|
84
|
+
...Object.fromEntries(Object.entries(config.views ?? {}).filter(([, view]) => view)),
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function createViewerRouter(config = {}) {
|
|
89
|
+
const views = resolveViews(config)
|
|
90
|
+
const routes = [{ path: '/', name: 'home', component: views.home }]
|
|
91
|
+
|
|
92
|
+
for (const entity of config.features?.entities ?? []) {
|
|
93
|
+
routes.push({
|
|
94
|
+
path: `/${entity}`,
|
|
95
|
+
name: `${entity}-list`,
|
|
96
|
+
component: views.list,
|
|
97
|
+
props: { entity },
|
|
98
|
+
})
|
|
99
|
+
routes.push({
|
|
100
|
+
path: `/${entity}/:id`,
|
|
101
|
+
name: `${entity}-detail`,
|
|
102
|
+
component: views.detail,
|
|
103
|
+
props: (route) => ({ entity, id: route.params.id }),
|
|
104
|
+
})
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
for (const record of config.extraViews ?? []) {
|
|
108
|
+
routes.push(record)
|
|
109
|
+
}
|
|
110
|
+
for (const record of config.routes ?? []) {
|
|
111
|
+
routes.push(record)
|
|
112
|
+
}
|
|
113
|
+
for (const [index, entry] of (config.legacyRoutes ?? []).entries()) {
|
|
114
|
+
routes.push(legacyRoute(entry, index))
|
|
115
|
+
}
|
|
116
|
+
if (config.notFound !== false) {
|
|
117
|
+
routes.push({
|
|
118
|
+
path: '/:pathMatch(.*)*',
|
|
119
|
+
name: 'not-found',
|
|
120
|
+
component: config.notFound ?? NotFoundView,
|
|
121
|
+
})
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const router = createRouter({
|
|
125
|
+
history: createWebHashHistory(),
|
|
126
|
+
routes,
|
|
127
|
+
scrollBehavior: config.scrollBehavior ?? defaultScrollBehavior,
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
// A route that names the entities it reads has them loaded before its view
|
|
131
|
+
// exists, so the view never renders against records that are not there yet.
|
|
132
|
+
router.beforeResolve(async (to) => {
|
|
133
|
+
const names = [...new Set(to.matched.flatMap((record) => record.meta?.entities ?? []))]
|
|
134
|
+
if (names.length > 0) await loadEntities(names)
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
return router
|
|
138
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { getCurrentInstance, inject } from 'vue'
|
|
2
|
+
import { VIEWER_CONFIG } from './injectionKeys.js'
|
|
3
|
+
|
|
4
|
+
// `dataset.config.js` is the whole declaration of a website. What a site
|
|
5
|
+
// needs outside a component — a composable building an image address, a
|
|
6
|
+
// route helper — reads it from here; a component may inject it as well.
|
|
7
|
+
|
|
8
|
+
let current = {}
|
|
9
|
+
|
|
10
|
+
export function setSiteConfig(config) {
|
|
11
|
+
current = config ?? {}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** The website's configuration, as `createViewer` received it. */
|
|
15
|
+
export function useSiteConfig() {
|
|
16
|
+
if (getCurrentInstance()) {
|
|
17
|
+
const injected = inject(VIEWER_CONFIG, null)
|
|
18
|
+
if (injected) return injected
|
|
19
|
+
}
|
|
20
|
+
return current
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const ABSOLUTE = /^(?:[a-z][a-z0-9+.-]*:|\/\/)/i
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The address of a legacy media file, from the path the data package carries
|
|
27
|
+
* and the host `config.media.legacyHost` declares. `size` is one of the
|
|
28
|
+
* legacy server's size names (`zoom`, `hi_res`, `lo_res`, `small`). A path
|
|
29
|
+
* that is already an address is returned as it is.
|
|
30
|
+
*/
|
|
31
|
+
export function mediaUrl(path, size = 'hi_res') {
|
|
32
|
+
if (!path) return null
|
|
33
|
+
if (ABSOLUTE.test(path)) return path
|
|
34
|
+
const host = String(useSiteConfig().media?.legacyHost ?? '').replace(/\/+$/, '')
|
|
35
|
+
if (!host) return null
|
|
36
|
+
return `${host}/${size}/${String(path).replace(/^\/+/, '')}`
|
|
37
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/* Structural rules only. Colors, fonts and brand values come from CSS custom
|
|
2
|
+
properties defined by the website (or by @museumwnf/viewer-layout). */
|
|
3
|
+
|
|
4
|
+
*,
|
|
5
|
+
*::before,
|
|
6
|
+
*::after {
|
|
7
|
+
box-sizing: border-box;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
body {
|
|
11
|
+
margin: 0;
|
|
12
|
+
font-family: var(--vc-font-body, system-ui, sans-serif);
|
|
13
|
+
line-height: var(--vc-line-height, 1.5);
|
|
14
|
+
background: var(--vc-color-background, transparent);
|
|
15
|
+
color: var(--vc-color-text, inherit);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
img {
|
|
19
|
+
max-width: 100%;
|
|
20
|
+
height: auto;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
.vc-home,
|
|
24
|
+
.vc-list,
|
|
25
|
+
.vc-detail {
|
|
26
|
+
max-width: var(--vc-content-width, 60rem);
|
|
27
|
+
margin-inline: auto;
|
|
28
|
+
padding: var(--vc-content-padding, 1.5rem);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
.vc-detail dl {
|
|
32
|
+
display: grid;
|
|
33
|
+
grid-template-columns: max-content 1fr;
|
|
34
|
+
gap: 0.25rem 1rem;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
.vc-detail dt {
|
|
38
|
+
font-weight: var(--vc-weight-label, 600);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
.vc-detail dd {
|
|
42
|
+
margin: 0;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
.vc-pagination {
|
|
46
|
+
display: flex;
|
|
47
|
+
gap: 1rem;
|
|
48
|
+
align-items: baseline;
|
|
49
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { useDataPackage } from '../composables/useDataPackage.js'
|
|
2
|
+
import { declaredSiteLanguages, offeredLanguages } from '../languages.js'
|
|
3
|
+
|
|
4
|
+
// One check of a website's language declaration, shared by every website so
|
|
5
|
+
// that the rule is tested once rather than rewritten seven times. It returns
|
|
6
|
+
// the problems it finds, as sentences; a website's test asserts the list is
|
|
7
|
+
// empty.
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Checks `config.languages` and `config.navigation.languages` against the
|
|
11
|
+
* data package: only languages with item content are offered; the order is
|
|
12
|
+
* the package's declared order; every offered language has a label, and the
|
|
13
|
+
* switcher lists exactly the offered languages.
|
|
14
|
+
*
|
|
15
|
+
* A website that narrows the package's declaration passes the same `declared`
|
|
16
|
+
* list it gave `offeredLanguages()`, so the check expects the narrowed set —
|
|
17
|
+
* the two helpers apply one rule, and a website that narrows in the config
|
|
18
|
+
* but not in the test would otherwise be told its own list is wrong.
|
|
19
|
+
*/
|
|
20
|
+
export function checkOfferedLanguages(
|
|
21
|
+
config,
|
|
22
|
+
{ declared, entity = 'items', manifest, availableLanguages } = {},
|
|
23
|
+
) {
|
|
24
|
+
const pkg = useDataPackage()
|
|
25
|
+
const withContent = (availableLanguages ?? pkg.availableLanguages)(entity)
|
|
26
|
+
const fromPackage = declaredSiteLanguages(manifest ?? pkg.manifest)
|
|
27
|
+
const offered = config?.languages ?? []
|
|
28
|
+
const problems = []
|
|
29
|
+
|
|
30
|
+
if (offered.length === 0) {
|
|
31
|
+
problems.push('The website offers no language.')
|
|
32
|
+
}
|
|
33
|
+
for (const code of offered) {
|
|
34
|
+
if (!withContent.includes(code)) {
|
|
35
|
+
problems.push(`"${code}" is offered but no ${entity} carry it.`)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
if (declared || fromPackage) {
|
|
39
|
+
const expected = offeredLanguages({ declared, entity, manifest, availableLanguages })
|
|
40
|
+
if (offered.join(',') !== expected.join(',')) {
|
|
41
|
+
const source = declared ? 'the website narrows the package to' : 'the package declares'
|
|
42
|
+
problems.push(
|
|
43
|
+
`The offered languages are [${offered.join(', ')}] where ${source} [${expected.join(', ')}].`,
|
|
44
|
+
)
|
|
45
|
+
}
|
|
46
|
+
} else {
|
|
47
|
+
const sorted = [...offered].sort()
|
|
48
|
+
if (offered.join(',') !== sorted.join(',')) {
|
|
49
|
+
problems.push('The package declares no site languages, so the offered list has to be alphabetical.')
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const switcher = config?.navigation?.languages
|
|
54
|
+
if (switcher !== undefined) {
|
|
55
|
+
const codes = (switcher ?? []).map((entry) => (typeof entry === 'string' ? entry : entry?.code))
|
|
56
|
+
if (codes.join(',') !== offered.join(',')) {
|
|
57
|
+
problems.push(`The switcher lists [${codes.join(', ')}] where the website offers [${offered.join(', ')}].`)
|
|
58
|
+
}
|
|
59
|
+
for (const entry of switcher ?? []) {
|
|
60
|
+
if (typeof entry === 'object' && !(typeof entry.label === 'string' && entry.label.trim())) {
|
|
61
|
+
problems.push(`"${entry.code}" has no label in the switcher.`)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return problems
|
|
67
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { createViewer } from '../createViewer.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Mount a website to a detached DOM node on a given hash, returning the app,
|
|
5
|
+
* host, and router for test assertions. The router is ready and the app is
|
|
6
|
+
* mounted before the promise resolves.
|
|
7
|
+
*
|
|
8
|
+
* `config` and `messages` are the same objects a website's main.js assembles.
|
|
9
|
+
* `hash` defaults to the home page.
|
|
10
|
+
*/
|
|
11
|
+
export async function mountSite(config, messages, hash = '#/') {
|
|
12
|
+
window.location.hash = hash
|
|
13
|
+
const app = createViewer({ ...config, messages })
|
|
14
|
+
const host = document.createElement('div')
|
|
15
|
+
document.body.appendChild(host)
|
|
16
|
+
app.mount(host)
|
|
17
|
+
const router = app.config.globalProperties.$router
|
|
18
|
+
await router.isReady()
|
|
19
|
+
return { app, host, router }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Check that a configuration's routes are properly declared: every route has
|
|
24
|
+
* a name, no route is a catch-all, and every legacy route is listed in
|
|
25
|
+
* `legacyPaths`.
|
|
26
|
+
*
|
|
27
|
+
* Returns the list of problems found (empty if all is well). Routes without
|
|
28
|
+
* names appear by index in the message.
|
|
29
|
+
*/
|
|
30
|
+
export function checkRoutes(config, { names = [], legacyPaths = [] } = {}) {
|
|
31
|
+
const problems = []
|
|
32
|
+
const declaredNames = (config.extraViews ?? []).map((r) => r.name)
|
|
33
|
+
const declaredLegacy = (config.legacyRoutes ?? []).map((r) => r.path)
|
|
34
|
+
|
|
35
|
+
// Check every route has a name.
|
|
36
|
+
for (let i = 0; i < (config.extraViews ?? []).length; i++) {
|
|
37
|
+
if (!config.extraViews[i].name) {
|
|
38
|
+
problems.push(`Route ${i} has no name.`)
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Check no route is a catch-all.
|
|
43
|
+
if ((config.extraViews ?? []).some((r) => r.path?.includes('pathMatch'))) {
|
|
44
|
+
problems.push('A route is a catch-all (contains pathMatch); the catch-all belongs to the router.')
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Check expected names are present.
|
|
48
|
+
for (const name of names) {
|
|
49
|
+
if (!declaredNames.includes(name)) {
|
|
50
|
+
problems.push(`Expected route "${name}" not found.`)
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Check expected legacy paths are present.
|
|
55
|
+
for (const path of legacyPaths) {
|
|
56
|
+
if (!declaredLegacy.includes(path)) {
|
|
57
|
+
problems.push(`Expected legacy path "${path}" not found.`)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return problems
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Check that every route has a `meta.section` string. A route without one
|
|
66
|
+
* leaves the menu unaware of what section the visitor is in.
|
|
67
|
+
*
|
|
68
|
+
* Returns the list of problems found (empty if all is well).
|
|
69
|
+
*/
|
|
70
|
+
export function checkSectionMeta(config) {
|
|
71
|
+
const problems = []
|
|
72
|
+
|
|
73
|
+
for (const route of config.extraViews ?? []) {
|
|
74
|
+
if (typeof route.meta?.section !== 'string' || !route.meta.section.trim()) {
|
|
75
|
+
problems.push(`Route "${route.name}" has no section metadata.`)
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return problems
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Check that the texts in a mounted host include entries from the given
|
|
84
|
+
* namespaces. Matches each namespace against the rendered text with a
|
|
85
|
+
* case-insensitive regex like `/\b(ns1|ns2)\.[a-z]/i`.
|
|
86
|
+
*
|
|
87
|
+
* Returns the list of text keys matched (empty if none found). A missing
|
|
88
|
+
* key renders as its bare name, e.g. `"catalogue.era.ad"`, which the regex
|
|
89
|
+
* would match.
|
|
90
|
+
*/
|
|
91
|
+
export function checkTextsRendered(host, { namespaces = [] } = {}) {
|
|
92
|
+
if (!namespaces.length) return []
|
|
93
|
+
|
|
94
|
+
const text = host.textContent
|
|
95
|
+
const pattern = new RegExp(`\\b(${namespaces.join('|')})\\.[a-z]`, 'gi')
|
|
96
|
+
const found = new Set()
|
|
97
|
+
|
|
98
|
+
for (const match of text.matchAll(pattern)) {
|
|
99
|
+
found.add(match[1])
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return Array.from(found)
|
|
103
|
+
}
|