@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,100 @@
|
|
|
1
|
+
import { computed, shallowRef } from 'vue'
|
|
2
|
+
import { useDataPackage } from './useDataPackage.js'
|
|
3
|
+
|
|
4
|
+
// The records of an entity are one thing: loaded once, from the chunk Vite
|
|
5
|
+
// emits for that one file, and shared by every page that asks for them. A
|
|
6
|
+
// website reads them through here and keeps no copy of its own — the alias
|
|
7
|
+
// `@inventory-data` is this package's, and a site file importing from it
|
|
8
|
+
// directly ships the whole dataset in its first bundle.
|
|
9
|
+
|
|
10
|
+
const records = new Map() // entity name → shallowRef(null | records)
|
|
11
|
+
const pending = new Map() // entity name → the load in flight
|
|
12
|
+
const indexes = new Map() // `${entity}.${key}` → computed Map
|
|
13
|
+
|
|
14
|
+
function refFor(name) {
|
|
15
|
+
let entry = records.get(name)
|
|
16
|
+
if (!entry) {
|
|
17
|
+
entry = shallowRef(null)
|
|
18
|
+
records.set(name, entry)
|
|
19
|
+
}
|
|
20
|
+
return entry
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The shared ref of one entity, without asking for its load — `null` until a
|
|
25
|
+
* route's `meta.entities` or `loadEntities` brings the chunk in. This is what
|
|
26
|
+
* a website's data composable exports at module level, so that importing the
|
|
27
|
+
* composable loads nothing: the pages that read an entity are the ones that
|
|
28
|
+
* declare it.
|
|
29
|
+
*/
|
|
30
|
+
export function entityRef(name) {
|
|
31
|
+
return refFor(name)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Load the named entities, once each. Resolves with their records in the
|
|
36
|
+
* order asked for; a name the package does not have rejects, as `loadEntity`
|
|
37
|
+
* does.
|
|
38
|
+
*/
|
|
39
|
+
export function loadEntities(names) {
|
|
40
|
+
const { loadEntity } = useDataPackage()
|
|
41
|
+
return Promise.all(
|
|
42
|
+
names.map((name) => {
|
|
43
|
+
const entry = refFor(name)
|
|
44
|
+
if (entry.value !== null) return entry.value
|
|
45
|
+
let load = pending.get(name)
|
|
46
|
+
if (!load) {
|
|
47
|
+
load = loadEntity(name)
|
|
48
|
+
.then((data) => {
|
|
49
|
+
entry.value = data
|
|
50
|
+
pending.delete(name)
|
|
51
|
+
return data
|
|
52
|
+
})
|
|
53
|
+
.catch((error) => {
|
|
54
|
+
pending.delete(name)
|
|
55
|
+
throw error
|
|
56
|
+
})
|
|
57
|
+
pending.set(name, load)
|
|
58
|
+
}
|
|
59
|
+
return load
|
|
60
|
+
}),
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* A record map for one entity, keyed on `key` (the package id by default) —
|
|
66
|
+
* empty until the entity is loaded, and rebuilt if it ever reloads.
|
|
67
|
+
*/
|
|
68
|
+
export function byId(name, key = 'id') {
|
|
69
|
+
const cacheKey = `${name}.${key}`
|
|
70
|
+
let index = indexes.get(cacheKey)
|
|
71
|
+
if (!index) {
|
|
72
|
+
const entry = refFor(name)
|
|
73
|
+
index = computed(() => new Map((entry.value ?? []).map((record) => [record[key], record])))
|
|
74
|
+
indexes.set(cacheKey, index)
|
|
75
|
+
}
|
|
76
|
+
return index
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The standard way a website reads its records.
|
|
81
|
+
*
|
|
82
|
+
* `useEntities(['items', 'partners'])` returns one reactive ref per name —
|
|
83
|
+
* `null` until that entity's chunk has arrived, the records after — plus
|
|
84
|
+
* `ready`, a promise for all of them, `loaded`, the same as a boolean, and
|
|
85
|
+
* `byId`. The refs are the same objects for every caller, so a composable
|
|
86
|
+
* may export them at module level and a view may read them at any time.
|
|
87
|
+
*
|
|
88
|
+
* A route that declares `meta: { entities: [...] }` has them loaded by the
|
|
89
|
+
* router before its view is created, so that view never sees `null`.
|
|
90
|
+
*/
|
|
91
|
+
export function useEntities(names) {
|
|
92
|
+
const list = Array.isArray(names) ? names : [names]
|
|
93
|
+
const result = { ready: loadEntities(list) }
|
|
94
|
+
for (const name of list) {
|
|
95
|
+
result[name] = refFor(name)
|
|
96
|
+
}
|
|
97
|
+
result.loaded = computed(() => list.every((name) => refFor(name).value !== null))
|
|
98
|
+
result.byId = byId
|
|
99
|
+
return result
|
|
100
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { computed, ref, toValue, watch } from 'vue'
|
|
2
|
+
import { useI18n } from '../i18n/index.js'
|
|
3
|
+
import { isRtl } from '../i18n/language.js'
|
|
4
|
+
import { useDataPackage } from './useDataPackage.js'
|
|
5
|
+
|
|
6
|
+
const BASE_LANGUAGE = 'en'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The language a record is read in, given the languages it carries and the
|
|
10
|
+
* language the website is in: the site language when the record has it,
|
|
11
|
+
* English when it does not, the record's first language when it has neither.
|
|
12
|
+
*/
|
|
13
|
+
export function resolveRecordLanguage(languages, siteLanguage) {
|
|
14
|
+
const carried = languages ?? []
|
|
15
|
+
if (carried.length === 0) return siteLanguage
|
|
16
|
+
return (
|
|
17
|
+
carried.find((code) => code === siteLanguage) ??
|
|
18
|
+
carried.find((code) => code === BASE_LANGUAGE) ??
|
|
19
|
+
carried[0]
|
|
20
|
+
)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The site language and the record language are two different things. The
|
|
25
|
+
* site language is negotiated once and holds for the whole visit; a record
|
|
26
|
+
* may carry languages the site does not offer, and the visitor may read the
|
|
27
|
+
* record they are on in any of them. That choice is view state: it is not in
|
|
28
|
+
* the URL, it does not touch the site language, and it is forgotten when the
|
|
29
|
+
* site language changes or another record is shown.
|
|
30
|
+
*
|
|
31
|
+
* `record` is a ref, a computed or a getter to the record; its `languages`
|
|
32
|
+
* field says what it carries. A record without one carries every language
|
|
33
|
+
* `entity` has a translation file for, when `entity` is given; `languages`
|
|
34
|
+
* (a getter) overrides both.
|
|
35
|
+
*/
|
|
36
|
+
export function useRecordLanguage(record, { entity, languages: given } = {}) {
|
|
37
|
+
const { locale } = useI18n()
|
|
38
|
+
const { availableLanguages } = useDataPackage()
|
|
39
|
+
|
|
40
|
+
const languages = computed(() => {
|
|
41
|
+
const own = given ? toValue(given) : toValue(record)?.languages
|
|
42
|
+
if (Array.isArray(own) && own.length > 0) return own
|
|
43
|
+
return entity ? availableLanguages(entity) : []
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
const chosen = ref(null)
|
|
47
|
+
const language = computed(() =>
|
|
48
|
+
chosen.value && languages.value.includes(chosen.value)
|
|
49
|
+
? chosen.value
|
|
50
|
+
: resolveRecordLanguage(languages.value, locale.value),
|
|
51
|
+
)
|
|
52
|
+
const dir = computed(() => (isRtl(language.value) ? 'rtl' : 'ltr'))
|
|
53
|
+
|
|
54
|
+
function select(code) {
|
|
55
|
+
if (languages.value.includes(code)) chosen.value = code
|
|
56
|
+
}
|
|
57
|
+
function reset() {
|
|
58
|
+
chosen.value = null
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
watch(locale, reset)
|
|
62
|
+
watch(
|
|
63
|
+
() => {
|
|
64
|
+
const current = toValue(record)
|
|
65
|
+
return current?.id ?? current
|
|
66
|
+
},
|
|
67
|
+
reset,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
return { language, languages, dir, select, reset }
|
|
71
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { computed, toValue } from 'vue'
|
|
2
|
+
import { useRoute } from 'vue-router'
|
|
3
|
+
import { useI18n } from './i18n/index.js'
|
|
4
|
+
import { entityRef } from './composables/useEntities.js'
|
|
5
|
+
|
|
6
|
+
// Three small conventions each website had written for itself.
|
|
7
|
+
|
|
8
|
+
// ── Project names ──────────────────────────────────────────────────────────
|
|
9
|
+
//
|
|
10
|
+
// `mwnf3.projectnames`, the whole table rather than the projects a website
|
|
11
|
+
// happens to borrow from, since that set moves with every reimport. The key
|
|
12
|
+
// on the left is data; the entry on the right is a shared text
|
|
13
|
+
// (`core.project.*` in viewer-i18n), written out so a reader can see the
|
|
14
|
+
// eight this asks for. A key that is not here reads as itself.
|
|
15
|
+
export const PROJECT_ENTRIES = Object.freeze({
|
|
16
|
+
ISL: 'core.project.islamicArt',
|
|
17
|
+
EPM: 'core.project.explorePartners',
|
|
18
|
+
DBA: 'core.project.baroqueArt',
|
|
19
|
+
BAR: 'core.project.baroqueArt',
|
|
20
|
+
AWE: 'core.project.sharingHistory',
|
|
21
|
+
awe: 'core.project.sharingHistory',
|
|
22
|
+
DCA: 'core.project.carpetArt',
|
|
23
|
+
DGA: 'core.project.glassArt',
|
|
24
|
+
EXTHE: 'core.project.tableIsSet',
|
|
25
|
+
GALLERIES: 'core.project.galleries',
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
/** The name of a project by its legacy key, through `t`; the key itself when unknown. */
|
|
29
|
+
export function projectName(key, t) {
|
|
30
|
+
const entry = PROJECT_ENTRIES[key]
|
|
31
|
+
return entry ? t(entry) : (key ?? '')
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** `projectName` bound to the installed texts, for a component. */
|
|
35
|
+
export function useProjectName() {
|
|
36
|
+
const { t } = useI18n()
|
|
37
|
+
return (key) => projectName(key, t)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// A member is borrowed from the MWNF project that originally published it,
|
|
41
|
+
// and legacy names and colours that project on the item sheet and in the
|
|
42
|
+
// results grid — two separate mappings, because legacy keeps them separate:
|
|
43
|
+
// the name is per project key (`PROJECT_ENTRIES` above), the colour is per
|
|
44
|
+
// project *family*, so ISL and EPM share a swatch and every exhibition
|
|
45
|
+
// shares another. The two exhibition sites re-hard-coded this table as
|
|
46
|
+
// English literals next to their own copy of the name table, which is why a
|
|
47
|
+
// project name translates on the galleries and not on the exhibitions.
|
|
48
|
+
export const PROJECT_FAMILIES = Object.freeze({
|
|
49
|
+
ISL: 'ISLandEPM',
|
|
50
|
+
EPM: 'ISLandEPM',
|
|
51
|
+
DBA: 'DBA',
|
|
52
|
+
BAR: 'DBA',
|
|
53
|
+
AWE: 'AWE',
|
|
54
|
+
awe: 'AWE',
|
|
55
|
+
DCA: 'DCA',
|
|
56
|
+
DGA: 'DGA',
|
|
57
|
+
EXTHE: 'EXH',
|
|
58
|
+
GALLERIES: 'Galleries',
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
/** A project's family by its legacy key — legacy's own class names, so a
|
|
62
|
+
* site's CSS reads as the stylesheet it was copied from. A key with no
|
|
63
|
+
* entry falls back to itself, the same rule `projectName` applies. */
|
|
64
|
+
export function projectFamily(key) {
|
|
65
|
+
return PROJECT_FAMILIES[key] ?? (key ?? '')
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ── The section a route belongs to ─────────────────────────────────────────
|
|
69
|
+
//
|
|
70
|
+
// A route says what section it is (`meta: { section: 'collection' }`), and
|
|
71
|
+
// the shell reads it here for the banner title or the active menu entry.
|
|
72
|
+
// Four shells derived the same thing from `route.path.startsWith(…)` chains
|
|
73
|
+
// of nine branches each; a route already knows.
|
|
74
|
+
export function useSection() {
|
|
75
|
+
const route = useRoute()
|
|
76
|
+
return computed(() => String(route.meta?.section ?? ''))
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ── A featured record ──────────────────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
// mulberry32 — a small seedable generator, so a test can pin the pick.
|
|
82
|
+
function seeded(seed) {
|
|
83
|
+
let a = seed >>> 0
|
|
84
|
+
return () => {
|
|
85
|
+
a = (a + 0x6d2b79f5) >>> 0
|
|
86
|
+
let t = a
|
|
87
|
+
t = Math.imul(t ^ (t >>> 15), t | 1)
|
|
88
|
+
t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
|
|
89
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* One record of `entity` chosen at random for a landing page's spotlight —
|
|
95
|
+
* among those with an image when `withImage` is set (the default), among
|
|
96
|
+
* all when none has one. Null until the entity is loaded. The pick is made
|
|
97
|
+
* once per visit and again only when the records change; `seed` makes it
|
|
98
|
+
* deterministic.
|
|
99
|
+
*/
|
|
100
|
+
export function useFeaturedRecord(entity, { withImage = true, seed, images = 'images' } = {}) {
|
|
101
|
+
const records = entityRef(entity)
|
|
102
|
+
const random = seed == null ? Math.random : seeded(seed)
|
|
103
|
+
return computed(() => {
|
|
104
|
+
const all = toValue(records) ?? []
|
|
105
|
+
if (all.length === 0) return null
|
|
106
|
+
const pool = withImage ? all.filter((record) => (record?.[images]?.length ?? 0) > 0) : all
|
|
107
|
+
const candidates = pool.length > 0 ? pool : all
|
|
108
|
+
return candidates[Math.floor(random() * candidates.length)] ?? null
|
|
109
|
+
})
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ── A route's `meta.entities` ────────────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* `sectionMeta(chrome)` returns a `meta(section, ...entities)` helper: a
|
|
116
|
+
* route's `meta: { section, entities: [...chrome, ...entities] }` in one
|
|
117
|
+
* call. `chrome` is the entities every page of the site loads regardless of
|
|
118
|
+
* what it is (the DXA sites' `['gallery', 'items', 'partners', 'countries']`
|
|
119
|
+
* — the shell reads them on every route); a route names on top of that only
|
|
120
|
+
* the entities its own page adds. Four DXA configs each wrote this same
|
|
121
|
+
* four-line closure next to their own `chrome` list.
|
|
122
|
+
*/
|
|
123
|
+
export function sectionMeta(chrome = []) {
|
|
124
|
+
return (section, ...entities) => ({ section, entities: [...chrome, ...entities] })
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ── The MWNF portal ─────────────────────────────────────────────────────
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The addresses every DXA `dataset.config.js` repeats under `links` —
|
|
131
|
+
* the portal and the sibling standalone sites a gallery or an exhibition
|
|
132
|
+
* links out to. Frozen: a site's own `links` may add to this object but
|
|
133
|
+
* copying it is what this replaces.
|
|
134
|
+
*/
|
|
135
|
+
export const mwnfLinks = Object.freeze({
|
|
136
|
+
portal: 'https://www.museumwnf.org',
|
|
137
|
+
galleries: 'https://galleries.museumwnf.org',
|
|
138
|
+
myCollection: 'https://www.museumwnf.org/mycollection/index.php',
|
|
139
|
+
about: 'https://www.museumwnf.org/about',
|
|
140
|
+
contact: 'https://www.museumwnf.org/about/contact',
|
|
141
|
+
legalNotice: 'https://www.museumwnf.org/about/legal-notice',
|
|
142
|
+
credits: 'https://www.museumwnf.org/about/credits',
|
|
143
|
+
cookies: 'https://www.museumwnf.org/about/cookies',
|
|
144
|
+
overallDatabase: 'https://www.museumwnf.org/database_searchform.php',
|
|
145
|
+
islamicArt: 'https://islamicart.museumwnf.org',
|
|
146
|
+
baroqueArt: 'https://baroqueart.museumwnf.org',
|
|
147
|
+
sharingHistory: 'https://sharinghistory.museumwnf.org',
|
|
148
|
+
})
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { createApp } from 'vue'
|
|
2
|
+
import AppRoot from './AppRoot.vue'
|
|
3
|
+
import { useDataPackage } from './composables/useDataPackage.js'
|
|
4
|
+
import { createI18n } from './i18n/index.js'
|
|
5
|
+
import { connectLanguageToRouter, resolveInitialLanguage } from './i18n/language.js'
|
|
6
|
+
import { VIEWER_CONFIG } from './injectionKeys.js'
|
|
7
|
+
import { createViewerRouter } from './router/index.js'
|
|
8
|
+
import { setSiteConfig } from './siteConfig.js'
|
|
9
|
+
import './styles/base.css'
|
|
10
|
+
|
|
11
|
+
export function createViewer(config = {}) {
|
|
12
|
+
const { languages: datasetLanguages } = useDataPackage()
|
|
13
|
+
const languages = config.languages ?? datasetLanguages
|
|
14
|
+
const messages = config.messages ?? {}
|
|
15
|
+
const resolved = { ...config, languages }
|
|
16
|
+
|
|
17
|
+
const router = createViewerRouter(config)
|
|
18
|
+
const i18n = createI18n({ messages, locale: resolveInitialLanguage(languages) })
|
|
19
|
+
|
|
20
|
+
const app = createApp(AppRoot)
|
|
21
|
+
setSiteConfig(resolved)
|
|
22
|
+
app.provide(VIEWER_CONFIG, resolved)
|
|
23
|
+
app.use(router)
|
|
24
|
+
app.use(i18n)
|
|
25
|
+
|
|
26
|
+
connectLanguageToRouter({ locale: i18n.locale, offered: languages, router })
|
|
27
|
+
|
|
28
|
+
return app
|
|
29
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { computed } from 'vue'
|
|
3
|
+
import { useI18n } from './index.js'
|
|
4
|
+
import { renderBlock } from './markdown.js'
|
|
5
|
+
|
|
6
|
+
const props = defineProps({
|
|
7
|
+
keypath: { type: String, required: true },
|
|
8
|
+
tag: { type: String, default: 'div' },
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
const { t } = useI18n()
|
|
12
|
+
const html = computed(() => renderBlock(t(props.keypath)))
|
|
13
|
+
</script>
|
|
14
|
+
|
|
15
|
+
<template>
|
|
16
|
+
<component :is="tag" class="vc-text" v-html="html" />
|
|
17
|
+
</template>
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { computed } from 'vue'
|
|
3
|
+
import { useI18n } from './index.js'
|
|
4
|
+
import { renderInline } from './markdown.js'
|
|
5
|
+
|
|
6
|
+
const props = defineProps({
|
|
7
|
+
keypath: { type: String, required: true },
|
|
8
|
+
tag: { type: String, default: 'span' },
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
const { t } = useI18n()
|
|
12
|
+
const html = computed(() => renderInline(t(props.keypath)))
|
|
13
|
+
</script>
|
|
14
|
+
|
|
15
|
+
<template>
|
|
16
|
+
<component :is="tag" class="vc-text-inline" v-html="html" />
|
|
17
|
+
</template>
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { computed, inject, ref } from 'vue'
|
|
2
|
+
|
|
3
|
+
// The renderers travel with the texts: a package that has only this entry
|
|
4
|
+
// point (@museumwnf/viewer-layout) renders a Markdown prop through the same
|
|
5
|
+
// pipeline as everything else.
|
|
6
|
+
export { md, mdInline, mdStrip, renderBlock, renderInline, renderPlain } from './markdown.js'
|
|
7
|
+
|
|
8
|
+
// The whole of it: look a key up in the active language, fall back to English,
|
|
9
|
+
// and return the key itself when neither has it, so a missing text shows up as
|
|
10
|
+
// its own name rather than as a blank page.
|
|
11
|
+
//
|
|
12
|
+
// The scope of this module is frozen. It does lookup, fallback and reactivity;
|
|
13
|
+
// it will never grow interpolation, pluralisation or a message format. A text
|
|
14
|
+
// is a text — a value produced at run time is rendered by the component next
|
|
15
|
+
// to the text, never inside it. A future need outside that is a different
|
|
16
|
+
// system, not an extension of this one.
|
|
17
|
+
|
|
18
|
+
// Symbol.for, not Symbol: this module is reachable through two specifiers —
|
|
19
|
+
// `@museumwnf/viewer-core` (which createViewer imports relatively) and
|
|
20
|
+
// `@museumwnf/viewer-core/i18n` (which viewer-layout imports) — and a bundler
|
|
21
|
+
// that resolves them to two module instances would create two distinct keys.
|
|
22
|
+
// The application would then provide one and the layout inject the other,
|
|
23
|
+
// which reads as "no texts installed" in a package that plainly installed
|
|
24
|
+
// them. A key from the global registry is the same key in every instance.
|
|
25
|
+
export const VIEWER_I18N = Symbol.for('@museumwnf/viewer-core:i18n')
|
|
26
|
+
|
|
27
|
+
const BASE_LANGUAGE = 'en'
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Apply the one merge rule of the platform: local wins.
|
|
31
|
+
*
|
|
32
|
+
* Each argument is `{ <language>: { <key>: <text> } }` — the shared bundle
|
|
33
|
+
* first, the website's own file last. Keys carry their namespace, so nothing
|
|
34
|
+
* collides by accident; an entry is overloaded only when a website spells out
|
|
35
|
+
* the same name.
|
|
36
|
+
*/
|
|
37
|
+
export function mergeMessages(...catalogues) {
|
|
38
|
+
const merged = {}
|
|
39
|
+
for (const catalogue of catalogues) {
|
|
40
|
+
for (const [language, entries] of Object.entries(catalogue ?? {})) {
|
|
41
|
+
merged[language] = { ...merged[language], ...entries }
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return merged
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function createI18n({ messages = {}, locale = BASE_LANGUAGE } = {}) {
|
|
48
|
+
const current = ref(locale)
|
|
49
|
+
|
|
50
|
+
const t = (key) => {
|
|
51
|
+
const active = messages[current.value]
|
|
52
|
+
if (active && typeof active[key] === 'string') return active[key]
|
|
53
|
+
const base = messages[BASE_LANGUAGE]
|
|
54
|
+
if (base && typeof base[key] === 'string') return base[key]
|
|
55
|
+
return key
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const i18n = {
|
|
59
|
+
locale: current,
|
|
60
|
+
availableLocales: Object.keys(messages),
|
|
61
|
+
t,
|
|
62
|
+
install(app) {
|
|
63
|
+
app.provide(VIEWER_I18N, i18n)
|
|
64
|
+
// `$t` in a template, `useI18n()` in a script — the two shapes Vue
|
|
65
|
+
// applications already expect of a translation helper.
|
|
66
|
+
app.config.globalProperties.$t = t
|
|
67
|
+
},
|
|
68
|
+
}
|
|
69
|
+
return i18n
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function useI18n() {
|
|
73
|
+
const i18n = inject(VIEWER_I18N, null)
|
|
74
|
+
if (!i18n) {
|
|
75
|
+
throw new Error(
|
|
76
|
+
'useI18n() was called in an application that has no texts installed. ' +
|
|
77
|
+
'Applications built with createViewer() have them; a test mounting a ' +
|
|
78
|
+
'component on its own must install createI18n({ messages }) itself.'
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
return i18n
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The active language, for a component that only needs to read it. */
|
|
85
|
+
export function useLocale() {
|
|
86
|
+
const { locale } = useI18n()
|
|
87
|
+
return computed(() => locale.value)
|
|
88
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { watch } from 'vue'
|
|
2
|
+
import { START_LOCATION } from 'vue-router'
|
|
3
|
+
|
|
4
|
+
// Which language a visitor gets, decided once, in one place.
|
|
5
|
+
//
|
|
6
|
+
// Before this, a website opened at `languages[0]` and stayed there: no
|
|
7
|
+
// negotiation, no memory between visits, nothing in the URL, and no `lang` or
|
|
8
|
+
// `dir` on the document — so a right-to-left language rendered left to right.
|
|
9
|
+
|
|
10
|
+
const STORAGE_KEY = 'mwnf.language'
|
|
11
|
+
|
|
12
|
+
export const RTL_LANGUAGES = new Set([
|
|
13
|
+
'ar', 'arc', 'ckb', 'dv', 'fa', 'he', 'ku', 'ps', 'sd', 'ug', 'ur', 'yi',
|
|
14
|
+
])
|
|
15
|
+
|
|
16
|
+
export function isRtl(code) {
|
|
17
|
+
return RTL_LANGUAGES.has(String(code ?? '').toLowerCase().split('-')[0])
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The URL first, then the visitor's own earlier choice, then what their
|
|
22
|
+
* browser asks for, then English. A language that is not offered is ignored at
|
|
23
|
+
* every step, so nothing here can select a language the website does not have.
|
|
24
|
+
*/
|
|
25
|
+
export function negotiateLanguage(offered, { requested, stored, preferred = [] } = {}) {
|
|
26
|
+
const list = offered?.length ? offered : ['en']
|
|
27
|
+
const match = (code) => {
|
|
28
|
+
if (!code) return undefined
|
|
29
|
+
const wanted = String(code).toLowerCase()
|
|
30
|
+
return (
|
|
31
|
+
list.find((candidate) => candidate.toLowerCase() === wanted) ??
|
|
32
|
+
list.find((candidate) => candidate.toLowerCase().split('-')[0] === wanted.split('-')[0])
|
|
33
|
+
)
|
|
34
|
+
}
|
|
35
|
+
return (
|
|
36
|
+
match(requested) ??
|
|
37
|
+
match(stored) ??
|
|
38
|
+
preferred.map(match).find(Boolean) ??
|
|
39
|
+
match('en') ??
|
|
40
|
+
list[0]
|
|
41
|
+
)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function readStoredLanguage() {
|
|
45
|
+
try {
|
|
46
|
+
return globalThis.localStorage?.getItem(STORAGE_KEY) ?? null
|
|
47
|
+
} catch {
|
|
48
|
+
// A browser refusing storage is not a reason to fail to render a page.
|
|
49
|
+
return null
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function storeLanguage(code) {
|
|
54
|
+
try {
|
|
55
|
+
globalThis.localStorage?.setItem(STORAGE_KEY, code)
|
|
56
|
+
} catch {
|
|
57
|
+
/* see readStoredLanguage */
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function applyDocumentLanguage(code) {
|
|
62
|
+
if (typeof document === 'undefined') return
|
|
63
|
+
document.documentElement.setAttribute('lang', code)
|
|
64
|
+
document.documentElement.setAttribute('dir', isRtl(code) ? 'rtl' : 'ltr')
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function browserLanguages() {
|
|
68
|
+
if (typeof navigator === 'undefined') return []
|
|
69
|
+
return navigator.languages ?? (navigator.language ? [navigator.language] : [])
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The language to start in, before the router has resolved a route. The URL is
|
|
74
|
+
* deliberately not read here — the router owns it, and reading it twice is how
|
|
75
|
+
* the two disagree.
|
|
76
|
+
*/
|
|
77
|
+
export function resolveInitialLanguage(offered) {
|
|
78
|
+
return negotiateLanguage(offered, {
|
|
79
|
+
stored: readStoredLanguage(),
|
|
80
|
+
preferred: browserLanguages(),
|
|
81
|
+
})
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* On a website offering more than one language the URL is the source of truth:
|
|
86
|
+
* `?lang=` carries it, so a page can be linked, bookmarked and shared in the
|
|
87
|
+
* language it was read in. A single-language website never gets the parameter.
|
|
88
|
+
*
|
|
89
|
+
* It is a query parameter rather than a path segment because the legacy routes
|
|
90
|
+
* these websites reproduce are matched by path: prefixing them would break
|
|
91
|
+
* every URL that has ever been published.
|
|
92
|
+
*/
|
|
93
|
+
export function connectLanguageToRouter({ locale, offered, router }) {
|
|
94
|
+
const multilingual = offered.length > 1
|
|
95
|
+
|
|
96
|
+
router.beforeEach((to) => {
|
|
97
|
+
const next = negotiateLanguage(offered, {
|
|
98
|
+
requested: multilingual ? to.query.lang : undefined,
|
|
99
|
+
stored: readStoredLanguage(),
|
|
100
|
+
preferred: browserLanguages(),
|
|
101
|
+
})
|
|
102
|
+
if (locale.value !== next) locale.value = next
|
|
103
|
+
if (multilingual && to.query.lang !== next) {
|
|
104
|
+
// Resolves on the second pass, when the query already says `next`.
|
|
105
|
+
return { ...to, query: { ...to.query, lang: next }, replace: true }
|
|
106
|
+
}
|
|
107
|
+
return true
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
// A change of language from the switcher rewrites the URL. Not before the
|
|
111
|
+
// first navigation has resolved, though: the router is then still at its
|
|
112
|
+
// start location, `/`, and a replace from there cancels the navigation the
|
|
113
|
+
// visitor arrived with — every deep link landed on the home page. The guard
|
|
114
|
+
// above puts the language in the URL of that first navigation itself.
|
|
115
|
+
watch(
|
|
116
|
+
locale,
|
|
117
|
+
(code) => {
|
|
118
|
+
applyDocumentLanguage(code)
|
|
119
|
+
storeLanguage(code)
|
|
120
|
+
const current = router.currentRoute.value
|
|
121
|
+
if (multilingual && current !== START_LOCATION && current.query.lang !== code) {
|
|
122
|
+
router.replace({ query: { ...current.query, lang: code } })
|
|
123
|
+
}
|
|
124
|
+
},
|
|
125
|
+
{ immediate: true }
|
|
126
|
+
)
|
|
127
|
+
}
|