@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,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,3 @@
1
+ export { checkOfferedLanguages } from './checkOfferedLanguages.js'
2
+ export { checkRoutes, checkSectionMeta, checkTextsRendered, mountSite } from './smoke.js'
3
+ export { defineViewerConfig } from './viteConfig.js'
@@ -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
+ }