@museumwnf/viewer-core 1.14.0 → 1.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/LICENSE.md +44 -44
  2. package/README.md +658 -635
  3. package/package.json +50 -50
  4. package/src/AppRoot.vue +58 -58
  5. package/src/catalogue/dates.js +209 -209
  6. package/src/catalogue/facets.js +61 -61
  7. package/src/catalogue/keywordIndex.js +289 -289
  8. package/src/catalogue/listQuery.js +100 -100
  9. package/src/catalogue/pagination.js +57 -57
  10. package/src/catalogue/searchLanguage.js +32 -32
  11. package/src/catalogue/timeline.js +220 -220
  12. package/src/composables/useCatalogueData.js +141 -141
  13. package/src/composables/useDataPackage.js +98 -98
  14. package/src/composables/useEntities.js +100 -100
  15. package/src/composables/useRecordLanguage.js +71 -71
  16. package/src/conventions.js +252 -148
  17. package/src/createViewer.js +29 -29
  18. package/src/i18n/I18nText.vue +17 -17
  19. package/src/i18n/I18nTextInline.vue +17 -17
  20. package/src/i18n/index.js +88 -88
  21. package/src/i18n/language.js +127 -127
  22. package/src/i18n/markdown.js +265 -265
  23. package/src/index.js +51 -50
  24. package/src/injectionKeys.js +1 -1
  25. package/src/languages.js +49 -49
  26. package/src/legacy/index.js +58 -58
  27. package/src/record/collectionTree.js +307 -307
  28. package/src/record/derivations.js +154 -154
  29. package/src/record/partners.js +87 -87
  30. package/src/record/recordSheet.js +231 -231
  31. package/src/rights.js +53 -53
  32. package/src/router/index.js +138 -138
  33. package/src/siteConfig.js +37 -37
  34. package/src/styles/base.css +49 -49
  35. package/src/testing/checkOfferedLanguages.js +67 -67
  36. package/src/testing/index.js +3 -3
  37. package/src/testing/smoke.js +103 -103
  38. package/src/testing/viteConfig.js +90 -90
  39. package/src/views/DetailView.vue +53 -53
  40. package/src/views/HomeView.vue +20 -20
  41. package/src/views/ListView.vue +61 -61
  42. package/src/views/NotFoundView.vue +8 -8
  43. package/src/vite.js +8 -8
@@ -1,138 +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
- }
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
+ }
package/src/siteConfig.js CHANGED
@@ -1,37 +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
- }
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
+ }
@@ -1,49 +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
- }
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
+ }
@@ -1,67 +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
- }
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
+ }
@@ -1,3 +1,3 @@
1
- export { checkOfferedLanguages } from './checkOfferedLanguages.js'
2
- export { checkRoutes, checkSectionMeta, checkTextsRendered, mountSite } from './smoke.js'
3
- export { defineViewerConfig } from './viteConfig.js'
1
+ export { checkOfferedLanguages } from './checkOfferedLanguages.js'
2
+ export { checkRoutes, checkSectionMeta, checkTextsRendered, mountSite } from './smoke.js'
3
+ export { defineViewerConfig } from './viteConfig.js'