@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,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
+ }