@museumwnf/viewer-core 1.14.0 → 1.15.1
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 +72 -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
|
@@ -1,231 +1,231 @@
|
|
|
1
|
-
import { computed, ref, toValue, watch } from 'vue'
|
|
2
|
-
import { useDataPackage } from '../composables/useDataPackage.js'
|
|
3
|
-
import { entityRef } from '../composables/useEntities.js'
|
|
4
|
-
import { useRecordLanguage } from '../composables/useRecordLanguage.js'
|
|
5
|
-
import { glossaryIdsIn, renderBlock, renderInline, renderPlain } from '../i18n/markdown.js'
|
|
6
|
-
|
|
7
|
-
const BASE_LANGUAGE = 'en'
|
|
8
|
-
|
|
9
|
-
// A record page does the same things on every website before it shows a
|
|
10
|
-
// single field: decide which language the record is read in, load that
|
|
11
|
-
// language for the record's entity and for the entities its sheet names
|
|
12
|
-
// (dynasties, glossary, partners), gather the glossary terms the record
|
|
13
|
-
// reaches in that language for the renderer, and say when it is ready.
|
|
14
|
-
// Seven pages wrote that choreography by hand. This is the one place it is
|
|
15
|
-
// written, on top of `useRecordLanguage`, which decides the language.
|
|
16
|
-
|
|
17
|
-
// A term's row in one language: its spellings and definition from
|
|
18
|
-
// `rows`/`fallback` (English), its headword from `term` (the glossary
|
|
19
|
-
// record itself, language-independent) — `null` when the term has neither,
|
|
20
|
-
// which happens for a headword-only fixture row with no translation file at
|
|
21
|
-
// all. Shared by `glossaryTermsFor` (walks a record's own `glossary_ids`)
|
|
22
|
-
// and `glossaryTermsForText` (walks the whole glossary, filtered by what
|
|
23
|
-
// the text contains), so the two agree on what a term row looks like.
|
|
24
|
-
function termRow(id, term, rows, fallback) {
|
|
25
|
-
const t = rows[id] ?? fallback[id] ?? {}
|
|
26
|
-
const spellings = (t.spellings ?? []).map((s) => String(s).trim()).filter(Boolean)
|
|
27
|
-
const word = term?.word ?? spellings[0] ?? ''
|
|
28
|
-
if (!word && spellings.length === 0) return null
|
|
29
|
-
return { id, word, definition: t.definition ?? '', spellings: spellings.length ? spellings : [word] }
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* `{ id, word, definition, spellings }` for each glossary term of `record`
|
|
34
|
-
* (its `glossary_ids`), with the spellings and definition in `language`,
|
|
35
|
-
* falling back to English, then to the term's own headword.
|
|
36
|
-
*/
|
|
37
|
-
export function glossaryTermsFor(record, language, { entity = 'glossary', translations, byId } = {}) {
|
|
38
|
-
const ids = record?.glossary_ids ?? []
|
|
39
|
-
if (ids.length === 0) return []
|
|
40
|
-
const rows = translations(entity, language)
|
|
41
|
-
const fallback = translations(entity, BASE_LANGUAGE)
|
|
42
|
-
const out = []
|
|
43
|
-
for (const id of ids) {
|
|
44
|
-
const row = termRow(id, byId?.get(id), rows, fallback)
|
|
45
|
-
if (row) out.push(row)
|
|
46
|
-
}
|
|
47
|
-
return out
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* `glossaryTermsFor`'s counterpart for a text with no `glossary_ids` column
|
|
52
|
-
* to read — a dynasty history, a theme essay: every glossary term whose
|
|
53
|
-
* spelling occurs in `text`, matched the same word-bounded way `md`/
|
|
54
|
-
* `renderBlock` highlight it, so what this returns and what the text is
|
|
55
|
-
* rendered with agree. The candidate list — the whole glossary in
|
|
56
|
-
* `language`, English behind it — is the same for every text asked about
|
|
57
|
-
* in that language; `glossaryIdsIn` is what carries the caching (see
|
|
58
|
-
* `markdown.js`), so building it here costs one pass over the glossary,
|
|
59
|
-
* not a rebuilt regex.
|
|
60
|
-
*/
|
|
61
|
-
export function glossaryTermsForText(text, language, { entity = 'glossary' } = {}) {
|
|
62
|
-
const value = String(text ?? '')
|
|
63
|
-
if (!value) return []
|
|
64
|
-
const { translations } = useDataPackage()
|
|
65
|
-
const rows = translations(entity, language)
|
|
66
|
-
const fallback = translations(entity, BASE_LANGUAGE)
|
|
67
|
-
const byId = new Map()
|
|
68
|
-
const candidates = []
|
|
69
|
-
for (const term of entityRef(entity).value ?? []) {
|
|
70
|
-
const row = termRow(term.id, term, rows, fallback)
|
|
71
|
-
if (!row) continue
|
|
72
|
-
byId.set(term.id, row)
|
|
73
|
-
for (const spelling of row.spellings) candidates.push({ id: term.id, spelling })
|
|
74
|
-
}
|
|
75
|
-
return glossaryIdsIn(value, candidates).map((id) => byId.get(id))
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
/** The `[{ id, spelling }]` list the renderers highlight, one entry per spelling. */
|
|
79
|
-
export function glossaryEntries(terms) {
|
|
80
|
-
const out = []
|
|
81
|
-
for (const term of terms ?? []) {
|
|
82
|
-
for (const spelling of term.spellings) out.push({ id: term.id, spelling, definition: term.definition, word: term.word })
|
|
83
|
-
}
|
|
84
|
-
return out
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
/**
|
|
88
|
-
* The record page's engine.
|
|
89
|
-
*
|
|
90
|
-
* `record` is a ref, computed or getter. `entity` is the record's entity;
|
|
91
|
-
* `translations` names the other entities the sheet reads in the record's
|
|
92
|
-
* language (`['dynasties', 'glossary', 'partners']`). Returns what
|
|
93
|
-
* `useRecordLanguage` returns — `language`, `languages`, `dir`, `select`,
|
|
94
|
-
* `reset` — plus `text` (the record's translation in the active language,
|
|
95
|
-
* falling back to English), `ready` (every named load done for this record
|
|
96
|
-
* and language), `terms` and `glossary` (the record's glossary terms, and
|
|
97
|
-
* the spelling list the renderers take), and `attribution` when asked for.
|
|
98
|
-
*
|
|
99
|
-
* `attribution: ['author', 'copy_editor']` names fields that are proper
|
|
100
|
-
* names rather than text: when the active language's row lacks them, the
|
|
101
|
-
* first other language of the record that has them supplies them. This is
|
|
102
|
-
* the one place a page reads across languages, and it exists because the
|
|
103
|
-
* importer files some records' credits on one language only.
|
|
104
|
-
*/
|
|
105
|
-
export function useRecordSheet(record, { entity, translations: related = [], attribution: attributionFields = [], languages } = {}) {
|
|
106
|
-
if (!entity) throw new Error('useRecordSheet: entity is required')
|
|
107
|
-
const pkg = useDataPackage()
|
|
108
|
-
const language = useRecordLanguage(record, { entity, languages })
|
|
109
|
-
const ready = ref(false)
|
|
110
|
-
const attribution = ref({})
|
|
111
|
-
const glossaryRecords = related.includes('glossary') ? entityRef('glossary') : null
|
|
112
|
-
|
|
113
|
-
const text = computed(() => {
|
|
114
|
-
const current = toValue(record)
|
|
115
|
-
return current ? pkg.tr(entity, current.id, language.language.value, BASE_LANGUAGE) : {}
|
|
116
|
-
})
|
|
117
|
-
|
|
118
|
-
async function resolveAttribution(current, lang) {
|
|
119
|
-
const own = pkg.tr(entity, current.id, lang, BASE_LANGUAGE)
|
|
120
|
-
if (attributionFields.some((field) => own[field])) return {}
|
|
121
|
-
for (const code of language.languages.value) {
|
|
122
|
-
if (code === lang) continue
|
|
123
|
-
await pkg.loadTranslations(entity, code)
|
|
124
|
-
const row = pkg.translations(entity, code)[current.id] ?? {}
|
|
125
|
-
if (attributionFields.some((field) => row[field])) {
|
|
126
|
-
const found = { from: code }
|
|
127
|
-
for (const field of attributionFields) if (row[field]) found[field] = row[field]
|
|
128
|
-
return found
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
return {}
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
let generation = 0
|
|
135
|
-
async function load() {
|
|
136
|
-
const current = toValue(record)
|
|
137
|
-
const lang = language.language.value
|
|
138
|
-
const mine = ++generation
|
|
139
|
-
ready.value = false
|
|
140
|
-
attribution.value = {}
|
|
141
|
-
if (!current) {
|
|
142
|
-
ready.value = true
|
|
143
|
-
return
|
|
144
|
-
}
|
|
145
|
-
// The record language and English both: `tr` falls back to English for a
|
|
146
|
-
// record or a term that has no row in the active language, and a fallback
|
|
147
|
-
// that has not been loaded is a blank, not a fallback.
|
|
148
|
-
const languages = lang === BASE_LANGUAGE ? [lang] : [lang, BASE_LANGUAGE]
|
|
149
|
-
await Promise.all(
|
|
150
|
-
[entity, ...related].flatMap((name) => languages.map((code) => pkg.loadTranslations(name, code))),
|
|
151
|
-
)
|
|
152
|
-
if (mine !== generation) return
|
|
153
|
-
if (attributionFields.length) {
|
|
154
|
-
const found = await resolveAttribution(current, lang)
|
|
155
|
-
if (mine !== generation) return
|
|
156
|
-
attribution.value = found
|
|
157
|
-
}
|
|
158
|
-
ready.value = true
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
watch(() => [toValue(record)?.id, language.language.value], load, { immediate: true })
|
|
162
|
-
|
|
163
|
-
const glossaryIndex = computed(() =>
|
|
164
|
-
glossaryRecords ? new Map((glossaryRecords.value ?? []).map((term) => [term.id, term])) : new Map(),
|
|
165
|
-
)
|
|
166
|
-
const terms = computed(() =>
|
|
167
|
-
glossaryTermsFor(toValue(record), language.language.value, {
|
|
168
|
-
translations: pkg.translations,
|
|
169
|
-
byId: glossaryIndex.value,
|
|
170
|
-
}),
|
|
171
|
-
)
|
|
172
|
-
const glossary = computed(() => glossaryEntries(terms.value))
|
|
173
|
-
|
|
174
|
-
return { ...language, text, ready, terms, glossary, attribution }
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
// ── The field engine ───────────────────────────────────────────────────────
|
|
178
|
-
//
|
|
179
|
-
// A sheet is a list of labelled values, in an order, with the empty ones
|
|
180
|
-
// dropped. Which fields, in what order, under which labels, is the site's:
|
|
181
|
-
// "Materials/techniques" on Islamic Art and "Type" on a gallery name the
|
|
182
|
-
// same field and are both legacy facts. The spec is data; this turns it into
|
|
183
|
-
// rows and renders every value through the one pipeline, with the glossary.
|
|
184
|
-
|
|
185
|
-
const RENDER = {
|
|
186
|
-
inline: (value, ctx) => renderInline(value, { glossary: ctx.glossary }),
|
|
187
|
-
block: (value, ctx) => renderBlock(value, { breaks: true, glossary: ctx.glossary }),
|
|
188
|
-
plain: (value) => renderPlain(value),
|
|
189
|
-
link: (value) => String(value),
|
|
190
|
-
custom: (value) => value,
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
function present(value) {
|
|
194
|
-
if (value == null) return false
|
|
195
|
-
if (typeof value === 'string') return value.trim() !== ''
|
|
196
|
-
if (Array.isArray(value)) return value.length > 0
|
|
197
|
-
return true
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
/**
|
|
201
|
-
* `[{ key, label, render, value, html }]` from a spec and a context.
|
|
202
|
-
*
|
|
203
|
-
* A spec entry is `{ key, label, value, render = 'inline', when }`: `label`
|
|
204
|
-
* a text (already looked up) or `(ctx) => text`; `value` a function of the
|
|
205
|
-
* context returning the raw value, or the name of a field of `ctx.text`;
|
|
206
|
-
* `render` one of `inline`, `block`, `plain` (rendered through the pipeline
|
|
207
|
-
* with `ctx.glossary`), `link` (the value is an address) or `custom` (the
|
|
208
|
-
* value is handed back as it is, for a slot); `when(ctx)` gates the row.
|
|
209
|
-
* Rows whose value is empty are dropped. `ctx` is whatever the site passes —
|
|
210
|
-
* typically `{ record, text, glossary, ...helpers }`.
|
|
211
|
-
*/
|
|
212
|
-
export function sheetRows(spec, ctx = {}) {
|
|
213
|
-
const rows = []
|
|
214
|
-
for (const entry of spec ?? []) {
|
|
215
|
-
if (!entry || (entry.when && !entry.when(ctx))) continue
|
|
216
|
-
const raw = typeof entry.value === 'function' ? entry.value(ctx) : ctx.text?.[entry.value ?? entry.key]
|
|
217
|
-
if (!present(raw)) continue
|
|
218
|
-
const render = entry.render ?? 'inline'
|
|
219
|
-
const renderer = RENDER[render]
|
|
220
|
-
if (!renderer) throw new Error(`sheetRows: unknown render "${render}" on "${entry.key}"`)
|
|
221
|
-
const value = Array.isArray(raw) && render !== 'custom' ? raw.join(entry.join ?? ', ') : raw
|
|
222
|
-
rows.push({
|
|
223
|
-
key: entry.key,
|
|
224
|
-
label: typeof entry.label === 'function' ? entry.label(ctx) : (entry.label ?? entry.key),
|
|
225
|
-
render,
|
|
226
|
-
value,
|
|
227
|
-
html: render === 'custom' || render === 'link' ? null : renderer(value, ctx),
|
|
228
|
-
})
|
|
229
|
-
}
|
|
230
|
-
return rows
|
|
231
|
-
}
|
|
1
|
+
import { computed, ref, toValue, watch } from 'vue'
|
|
2
|
+
import { useDataPackage } from '../composables/useDataPackage.js'
|
|
3
|
+
import { entityRef } from '../composables/useEntities.js'
|
|
4
|
+
import { useRecordLanguage } from '../composables/useRecordLanguage.js'
|
|
5
|
+
import { glossaryIdsIn, renderBlock, renderInline, renderPlain } from '../i18n/markdown.js'
|
|
6
|
+
|
|
7
|
+
const BASE_LANGUAGE = 'en'
|
|
8
|
+
|
|
9
|
+
// A record page does the same things on every website before it shows a
|
|
10
|
+
// single field: decide which language the record is read in, load that
|
|
11
|
+
// language for the record's entity and for the entities its sheet names
|
|
12
|
+
// (dynasties, glossary, partners), gather the glossary terms the record
|
|
13
|
+
// reaches in that language for the renderer, and say when it is ready.
|
|
14
|
+
// Seven pages wrote that choreography by hand. This is the one place it is
|
|
15
|
+
// written, on top of `useRecordLanguage`, which decides the language.
|
|
16
|
+
|
|
17
|
+
// A term's row in one language: its spellings and definition from
|
|
18
|
+
// `rows`/`fallback` (English), its headword from `term` (the glossary
|
|
19
|
+
// record itself, language-independent) — `null` when the term has neither,
|
|
20
|
+
// which happens for a headword-only fixture row with no translation file at
|
|
21
|
+
// all. Shared by `glossaryTermsFor` (walks a record's own `glossary_ids`)
|
|
22
|
+
// and `glossaryTermsForText` (walks the whole glossary, filtered by what
|
|
23
|
+
// the text contains), so the two agree on what a term row looks like.
|
|
24
|
+
function termRow(id, term, rows, fallback) {
|
|
25
|
+
const t = rows[id] ?? fallback[id] ?? {}
|
|
26
|
+
const spellings = (t.spellings ?? []).map((s) => String(s).trim()).filter(Boolean)
|
|
27
|
+
const word = term?.word ?? spellings[0] ?? ''
|
|
28
|
+
if (!word && spellings.length === 0) return null
|
|
29
|
+
return { id, word, definition: t.definition ?? '', spellings: spellings.length ? spellings : [word] }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* `{ id, word, definition, spellings }` for each glossary term of `record`
|
|
34
|
+
* (its `glossary_ids`), with the spellings and definition in `language`,
|
|
35
|
+
* falling back to English, then to the term's own headword.
|
|
36
|
+
*/
|
|
37
|
+
export function glossaryTermsFor(record, language, { entity = 'glossary', translations, byId } = {}) {
|
|
38
|
+
const ids = record?.glossary_ids ?? []
|
|
39
|
+
if (ids.length === 0) return []
|
|
40
|
+
const rows = translations(entity, language)
|
|
41
|
+
const fallback = translations(entity, BASE_LANGUAGE)
|
|
42
|
+
const out = []
|
|
43
|
+
for (const id of ids) {
|
|
44
|
+
const row = termRow(id, byId?.get(id), rows, fallback)
|
|
45
|
+
if (row) out.push(row)
|
|
46
|
+
}
|
|
47
|
+
return out
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* `glossaryTermsFor`'s counterpart for a text with no `glossary_ids` column
|
|
52
|
+
* to read — a dynasty history, a theme essay: every glossary term whose
|
|
53
|
+
* spelling occurs in `text`, matched the same word-bounded way `md`/
|
|
54
|
+
* `renderBlock` highlight it, so what this returns and what the text is
|
|
55
|
+
* rendered with agree. The candidate list — the whole glossary in
|
|
56
|
+
* `language`, English behind it — is the same for every text asked about
|
|
57
|
+
* in that language; `glossaryIdsIn` is what carries the caching (see
|
|
58
|
+
* `markdown.js`), so building it here costs one pass over the glossary,
|
|
59
|
+
* not a rebuilt regex.
|
|
60
|
+
*/
|
|
61
|
+
export function glossaryTermsForText(text, language, { entity = 'glossary' } = {}) {
|
|
62
|
+
const value = String(text ?? '')
|
|
63
|
+
if (!value) return []
|
|
64
|
+
const { translations } = useDataPackage()
|
|
65
|
+
const rows = translations(entity, language)
|
|
66
|
+
const fallback = translations(entity, BASE_LANGUAGE)
|
|
67
|
+
const byId = new Map()
|
|
68
|
+
const candidates = []
|
|
69
|
+
for (const term of entityRef(entity).value ?? []) {
|
|
70
|
+
const row = termRow(term.id, term, rows, fallback)
|
|
71
|
+
if (!row) continue
|
|
72
|
+
byId.set(term.id, row)
|
|
73
|
+
for (const spelling of row.spellings) candidates.push({ id: term.id, spelling })
|
|
74
|
+
}
|
|
75
|
+
return glossaryIdsIn(value, candidates).map((id) => byId.get(id))
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The `[{ id, spelling }]` list the renderers highlight, one entry per spelling. */
|
|
79
|
+
export function glossaryEntries(terms) {
|
|
80
|
+
const out = []
|
|
81
|
+
for (const term of terms ?? []) {
|
|
82
|
+
for (const spelling of term.spellings) out.push({ id: term.id, spelling, definition: term.definition, word: term.word })
|
|
83
|
+
}
|
|
84
|
+
return out
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The record page's engine.
|
|
89
|
+
*
|
|
90
|
+
* `record` is a ref, computed or getter. `entity` is the record's entity;
|
|
91
|
+
* `translations` names the other entities the sheet reads in the record's
|
|
92
|
+
* language (`['dynasties', 'glossary', 'partners']`). Returns what
|
|
93
|
+
* `useRecordLanguage` returns — `language`, `languages`, `dir`, `select`,
|
|
94
|
+
* `reset` — plus `text` (the record's translation in the active language,
|
|
95
|
+
* falling back to English), `ready` (every named load done for this record
|
|
96
|
+
* and language), `terms` and `glossary` (the record's glossary terms, and
|
|
97
|
+
* the spelling list the renderers take), and `attribution` when asked for.
|
|
98
|
+
*
|
|
99
|
+
* `attribution: ['author', 'copy_editor']` names fields that are proper
|
|
100
|
+
* names rather than text: when the active language's row lacks them, the
|
|
101
|
+
* first other language of the record that has them supplies them. This is
|
|
102
|
+
* the one place a page reads across languages, and it exists because the
|
|
103
|
+
* importer files some records' credits on one language only.
|
|
104
|
+
*/
|
|
105
|
+
export function useRecordSheet(record, { entity, translations: related = [], attribution: attributionFields = [], languages } = {}) {
|
|
106
|
+
if (!entity) throw new Error('useRecordSheet: entity is required')
|
|
107
|
+
const pkg = useDataPackage()
|
|
108
|
+
const language = useRecordLanguage(record, { entity, languages })
|
|
109
|
+
const ready = ref(false)
|
|
110
|
+
const attribution = ref({})
|
|
111
|
+
const glossaryRecords = related.includes('glossary') ? entityRef('glossary') : null
|
|
112
|
+
|
|
113
|
+
const text = computed(() => {
|
|
114
|
+
const current = toValue(record)
|
|
115
|
+
return current ? pkg.tr(entity, current.id, language.language.value, BASE_LANGUAGE) : {}
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
async function resolveAttribution(current, lang) {
|
|
119
|
+
const own = pkg.tr(entity, current.id, lang, BASE_LANGUAGE)
|
|
120
|
+
if (attributionFields.some((field) => own[field])) return {}
|
|
121
|
+
for (const code of language.languages.value) {
|
|
122
|
+
if (code === lang) continue
|
|
123
|
+
await pkg.loadTranslations(entity, code)
|
|
124
|
+
const row = pkg.translations(entity, code)[current.id] ?? {}
|
|
125
|
+
if (attributionFields.some((field) => row[field])) {
|
|
126
|
+
const found = { from: code }
|
|
127
|
+
for (const field of attributionFields) if (row[field]) found[field] = row[field]
|
|
128
|
+
return found
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return {}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
let generation = 0
|
|
135
|
+
async function load() {
|
|
136
|
+
const current = toValue(record)
|
|
137
|
+
const lang = language.language.value
|
|
138
|
+
const mine = ++generation
|
|
139
|
+
ready.value = false
|
|
140
|
+
attribution.value = {}
|
|
141
|
+
if (!current) {
|
|
142
|
+
ready.value = true
|
|
143
|
+
return
|
|
144
|
+
}
|
|
145
|
+
// The record language and English both: `tr` falls back to English for a
|
|
146
|
+
// record or a term that has no row in the active language, and a fallback
|
|
147
|
+
// that has not been loaded is a blank, not a fallback.
|
|
148
|
+
const languages = lang === BASE_LANGUAGE ? [lang] : [lang, BASE_LANGUAGE]
|
|
149
|
+
await Promise.all(
|
|
150
|
+
[entity, ...related].flatMap((name) => languages.map((code) => pkg.loadTranslations(name, code))),
|
|
151
|
+
)
|
|
152
|
+
if (mine !== generation) return
|
|
153
|
+
if (attributionFields.length) {
|
|
154
|
+
const found = await resolveAttribution(current, lang)
|
|
155
|
+
if (mine !== generation) return
|
|
156
|
+
attribution.value = found
|
|
157
|
+
}
|
|
158
|
+
ready.value = true
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
watch(() => [toValue(record)?.id, language.language.value], load, { immediate: true })
|
|
162
|
+
|
|
163
|
+
const glossaryIndex = computed(() =>
|
|
164
|
+
glossaryRecords ? new Map((glossaryRecords.value ?? []).map((term) => [term.id, term])) : new Map(),
|
|
165
|
+
)
|
|
166
|
+
const terms = computed(() =>
|
|
167
|
+
glossaryTermsFor(toValue(record), language.language.value, {
|
|
168
|
+
translations: pkg.translations,
|
|
169
|
+
byId: glossaryIndex.value,
|
|
170
|
+
}),
|
|
171
|
+
)
|
|
172
|
+
const glossary = computed(() => glossaryEntries(terms.value))
|
|
173
|
+
|
|
174
|
+
return { ...language, text, ready, terms, glossary, attribution }
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ── The field engine ───────────────────────────────────────────────────────
|
|
178
|
+
//
|
|
179
|
+
// A sheet is a list of labelled values, in an order, with the empty ones
|
|
180
|
+
// dropped. Which fields, in what order, under which labels, is the site's:
|
|
181
|
+
// "Materials/techniques" on Islamic Art and "Type" on a gallery name the
|
|
182
|
+
// same field and are both legacy facts. The spec is data; this turns it into
|
|
183
|
+
// rows and renders every value through the one pipeline, with the glossary.
|
|
184
|
+
|
|
185
|
+
const RENDER = {
|
|
186
|
+
inline: (value, ctx) => renderInline(value, { glossary: ctx.glossary }),
|
|
187
|
+
block: (value, ctx) => renderBlock(value, { breaks: true, glossary: ctx.glossary }),
|
|
188
|
+
plain: (value) => renderPlain(value),
|
|
189
|
+
link: (value) => String(value),
|
|
190
|
+
custom: (value) => value,
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function present(value) {
|
|
194
|
+
if (value == null) return false
|
|
195
|
+
if (typeof value === 'string') return value.trim() !== ''
|
|
196
|
+
if (Array.isArray(value)) return value.length > 0
|
|
197
|
+
return true
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* `[{ key, label, render, value, html }]` from a spec and a context.
|
|
202
|
+
*
|
|
203
|
+
* A spec entry is `{ key, label, value, render = 'inline', when }`: `label`
|
|
204
|
+
* a text (already looked up) or `(ctx) => text`; `value` a function of the
|
|
205
|
+
* context returning the raw value, or the name of a field of `ctx.text`;
|
|
206
|
+
* `render` one of `inline`, `block`, `plain` (rendered through the pipeline
|
|
207
|
+
* with `ctx.glossary`), `link` (the value is an address) or `custom` (the
|
|
208
|
+
* value is handed back as it is, for a slot); `when(ctx)` gates the row.
|
|
209
|
+
* Rows whose value is empty are dropped. `ctx` is whatever the site passes —
|
|
210
|
+
* typically `{ record, text, glossary, ...helpers }`.
|
|
211
|
+
*/
|
|
212
|
+
export function sheetRows(spec, ctx = {}) {
|
|
213
|
+
const rows = []
|
|
214
|
+
for (const entry of spec ?? []) {
|
|
215
|
+
if (!entry || (entry.when && !entry.when(ctx))) continue
|
|
216
|
+
const raw = typeof entry.value === 'function' ? entry.value(ctx) : ctx.text?.[entry.value ?? entry.key]
|
|
217
|
+
if (!present(raw)) continue
|
|
218
|
+
const render = entry.render ?? 'inline'
|
|
219
|
+
const renderer = RENDER[render]
|
|
220
|
+
if (!renderer) throw new Error(`sheetRows: unknown render "${render}" on "${entry.key}"`)
|
|
221
|
+
const value = Array.isArray(raw) && render !== 'custom' ? raw.join(entry.join ?? ', ') : raw
|
|
222
|
+
rows.push({
|
|
223
|
+
key: entry.key,
|
|
224
|
+
label: typeof entry.label === 'function' ? entry.label(ctx) : (entry.label ?? entry.key),
|
|
225
|
+
render,
|
|
226
|
+
value,
|
|
227
|
+
html: render === 'custom' || render === 'link' ? null : renderer(value, ctx),
|
|
228
|
+
})
|
|
229
|
+
}
|
|
230
|
+
return rows
|
|
231
|
+
}
|
package/src/rights.js
CHANGED
|
@@ -1,53 +1,53 @@
|
|
|
1
|
-
import { useRouter } from 'vue-router'
|
|
2
|
-
import { mwnfLinks } from './conventions.js'
|
|
3
|
-
import { useDataPackage } from './composables/useDataPackage.js'
|
|
4
|
-
import { useSiteConfig } from './siteConfig.js'
|
|
5
|
-
|
|
6
|
-
// A site's rights and the address of one of its pages — two facts a
|
|
7
|
-
// citation needs that neither `citation()` nor the data package can know by
|
|
8
|
-
// itself. The rights holder and terms come from the exporter's
|
|
9
|
-
// `manifest.json` (inventory-app#1690 puts a `rights` block there); a
|
|
10
|
-
// page's own address depends on where the site is deployed, which the
|
|
11
|
-
// package cannot declare either. Both are read from one place a website
|
|
12
|
-
// declares once — the manifest's `rights` block, and `site.origin` in
|
|
13
|
-
// `dataset.config.js` — so a composed view asks here instead of assembling
|
|
14
|
-
// its own guess.
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* `{ holder, termsUrl, attribution }` from the loaded data package's
|
|
18
|
-
* `manifest.rights`. A package built before the block existed, or one that
|
|
19
|
-
* carries none, has no holder or attribution to offer, and its terms
|
|
20
|
-
* address falls back to the MWNF legal notice — the address every site
|
|
21
|
-
* pointed at before a package could declare its own.
|
|
22
|
-
*/
|
|
23
|
-
export function useSiteRights() {
|
|
24
|
-
const { manifest } = useDataPackage()
|
|
25
|
-
const rights = manifest.rights
|
|
26
|
-
return {
|
|
27
|
-
holder: rights?.rights_holder ?? null,
|
|
28
|
-
termsUrl: rights?.terms_url ?? mwnfLinks.legalNotice,
|
|
29
|
-
attribution: rights?.attribution ?? null,
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
const TRAILING_SLASHES = /\/+$/
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
* The address of `route` on this deployment: the declared `site.origin`
|
|
37
|
-
* plus the hash the router would put in the location bar for it. Null when
|
|
38
|
-
* the site declares no origin, so a page renders its citation without a
|
|
39
|
-
* source address instead of one that resolves nowhere once copied out of
|
|
40
|
-
* the app. `route` is either a route already resolved (its `fullPath` read
|
|
41
|
-
* directly, so asking for the current page's own address costs no second
|
|
42
|
-
* resolution) or a raw location, which the installed router resolves the
|
|
43
|
-
* same way a `<router-link>` would.
|
|
44
|
-
*/
|
|
45
|
-
export function sourceUrl(route) {
|
|
46
|
-
const origin = String(useSiteConfig().site?.origin ?? '').replace(TRAILING_SLASHES, '')
|
|
47
|
-
if (!origin) return null
|
|
48
|
-
const fullPath =
|
|
49
|
-
typeof route === 'object' && route !== null && typeof route.fullPath === 'string'
|
|
50
|
-
? route.fullPath
|
|
51
|
-
: useRouter().resolve(route).fullPath
|
|
52
|
-
return `${origin}/#${fullPath}`
|
|
53
|
-
}
|
|
1
|
+
import { useRouter } from 'vue-router'
|
|
2
|
+
import { mwnfLinks } from './conventions.js'
|
|
3
|
+
import { useDataPackage } from './composables/useDataPackage.js'
|
|
4
|
+
import { useSiteConfig } from './siteConfig.js'
|
|
5
|
+
|
|
6
|
+
// A site's rights and the address of one of its pages — two facts a
|
|
7
|
+
// citation needs that neither `citation()` nor the data package can know by
|
|
8
|
+
// itself. The rights holder and terms come from the exporter's
|
|
9
|
+
// `manifest.json` (inventory-app#1690 puts a `rights` block there); a
|
|
10
|
+
// page's own address depends on where the site is deployed, which the
|
|
11
|
+
// package cannot declare either. Both are read from one place a website
|
|
12
|
+
// declares once — the manifest's `rights` block, and `site.origin` in
|
|
13
|
+
// `dataset.config.js` — so a composed view asks here instead of assembling
|
|
14
|
+
// its own guess.
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* `{ holder, termsUrl, attribution }` from the loaded data package's
|
|
18
|
+
* `manifest.rights`. A package built before the block existed, or one that
|
|
19
|
+
* carries none, has no holder or attribution to offer, and its terms
|
|
20
|
+
* address falls back to the MWNF legal notice — the address every site
|
|
21
|
+
* pointed at before a package could declare its own.
|
|
22
|
+
*/
|
|
23
|
+
export function useSiteRights() {
|
|
24
|
+
const { manifest } = useDataPackage()
|
|
25
|
+
const rights = manifest.rights
|
|
26
|
+
return {
|
|
27
|
+
holder: rights?.rights_holder ?? null,
|
|
28
|
+
termsUrl: rights?.terms_url ?? mwnfLinks.legalNotice,
|
|
29
|
+
attribution: rights?.attribution ?? null,
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const TRAILING_SLASHES = /\/+$/
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The address of `route` on this deployment: the declared `site.origin`
|
|
37
|
+
* plus the hash the router would put in the location bar for it. Null when
|
|
38
|
+
* the site declares no origin, so a page renders its citation without a
|
|
39
|
+
* source address instead of one that resolves nowhere once copied out of
|
|
40
|
+
* the app. `route` is either a route already resolved (its `fullPath` read
|
|
41
|
+
* directly, so asking for the current page's own address costs no second
|
|
42
|
+
* resolution) or a raw location, which the installed router resolves the
|
|
43
|
+
* same way a `<router-link>` would.
|
|
44
|
+
*/
|
|
45
|
+
export function sourceUrl(route) {
|
|
46
|
+
const origin = String(useSiteConfig().site?.origin ?? '').replace(TRAILING_SLASHES, '')
|
|
47
|
+
if (!origin) return null
|
|
48
|
+
const fullPath =
|
|
49
|
+
typeof route === 'object' && route !== null && typeof route.fullPath === 'string'
|
|
50
|
+
? route.fullPath
|
|
51
|
+
: useRouter().resolve(route).fullPath
|
|
52
|
+
return `${origin}/#${fullPath}`
|
|
53
|
+
}
|