@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.
- package/LICENSE.md +44 -44
- package/README.md +658 -635
- package/package.json +50 -50
- package/src/AppRoot.vue +58 -58
- package/src/catalogue/dates.js +209 -209
- package/src/catalogue/facets.js +61 -61
- package/src/catalogue/keywordIndex.js +289 -289
- package/src/catalogue/listQuery.js +100 -100
- package/src/catalogue/pagination.js +57 -57
- package/src/catalogue/searchLanguage.js +32 -32
- package/src/catalogue/timeline.js +220 -220
- package/src/composables/useCatalogueData.js +141 -141
- package/src/composables/useDataPackage.js +98 -98
- package/src/composables/useEntities.js +100 -100
- package/src/composables/useRecordLanguage.js +71 -71
- package/src/conventions.js +252 -148
- package/src/createViewer.js +29 -29
- package/src/i18n/I18nText.vue +17 -17
- package/src/i18n/I18nTextInline.vue +17 -17
- package/src/i18n/index.js +88 -88
- package/src/i18n/language.js +127 -127
- package/src/i18n/markdown.js +265 -265
- package/src/index.js +51 -50
- package/src/injectionKeys.js +1 -1
- package/src/languages.js +49 -49
- package/src/legacy/index.js +58 -58
- package/src/record/collectionTree.js +307 -307
- package/src/record/derivations.js +154 -154
- package/src/record/partners.js +87 -87
- package/src/record/recordSheet.js +231 -231
- package/src/rights.js +53 -53
- package/src/router/index.js +138 -138
- package/src/siteConfig.js +37 -37
- package/src/styles/base.css +49 -49
- package/src/testing/checkOfferedLanguages.js +67 -67
- package/src/testing/index.js +3 -3
- package/src/testing/smoke.js +103 -103
- package/src/testing/viteConfig.js +90 -90
- package/src/views/DetailView.vue +53 -53
- package/src/views/HomeView.vue +20 -20
- package/src/views/ListView.vue +61 -61
- package/src/views/NotFoundView.vue +8 -8
- package/src/vite.js +8 -8
package/src/router/index.js
CHANGED
|
@@ -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
|
+
}
|
package/src/styles/base.css
CHANGED
|
@@ -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
|
+
}
|
package/src/testing/index.js
CHANGED
|
@@ -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'
|