@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/i18n/markdown.js
CHANGED
|
@@ -1,265 +1,265 @@
|
|
|
1
|
-
import { Marked } from 'marked'
|
|
2
|
-
|
|
3
|
-
// One Markdown pipeline for every text on every website, configured here and
|
|
4
|
-
// nowhere else. Its own instance rather than the shared `marked` singleton, so
|
|
5
|
-
// configuring it cannot change how a website renders anything else.
|
|
6
|
-
//
|
|
7
|
-
// Raw HTML is escaped rather than rendered. Texts are written by translators
|
|
8
|
-
// through the GitHub web interface, and the rules they are given say Markdown
|
|
9
|
-
// only; escaping is what makes that a guarantee instead of a request.
|
|
10
|
-
//
|
|
11
|
-
// This is the only place that guarantee lives. viewer-i18n's checker used to
|
|
12
|
-
// reject HTML too, which added nothing here and was wrong about autolinks and
|
|
13
|
-
// code spans; it no longer looks. Anything relaxed in this file is relaxed
|
|
14
|
-
// everywhere, with nothing behind it.
|
|
15
|
-
|
|
16
|
-
const ESCAPES = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }
|
|
17
|
-
|
|
18
|
-
function escapeHtml(text) {
|
|
19
|
-
return String(text).replace(/[&<>"']/g, (character) => ESCAPES[character])
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
// Raw HTML is escaped in both, because both render text that came from
|
|
23
|
-
// somewhere else: a translator's file, or a museum record carried in from the
|
|
24
|
-
// legacy system. Neither is a place markup may be authored.
|
|
25
|
-
const escaping = {
|
|
26
|
-
renderer: {
|
|
27
|
-
html(token) {
|
|
28
|
-
return escapeHtml(token?.raw ?? token?.text ?? token ?? '')
|
|
29
|
-
},
|
|
30
|
-
},
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
// `breaks` is the one difference, and it is a difference in the source rather
|
|
34
|
-
// than in policy. A text in the dictionary is written as prose and wraps freely,
|
|
35
|
-
// so a single newline means nothing. A record's field arrives from a database
|
|
36
|
-
// where a newline was typed to be a line break, and dropping it runs the lines
|
|
37
|
-
// together.
|
|
38
|
-
const prose = new Marked({ async: false, gfm: true, breaks: false })
|
|
39
|
-
const record = new Marked({ async: false, gfm: true, breaks: true })
|
|
40
|
-
prose.use(escaping)
|
|
41
|
-
record.use(escaping)
|
|
42
|
-
|
|
43
|
-
const pipeline = (breaks) => (breaks ? record : prose)
|
|
44
|
-
|
|
45
|
-
const REGEXP_SPECIAL = /[.*+?^${}()|[\]\\]/g
|
|
46
|
-
function escapeRegExp(text) {
|
|
47
|
-
return text.replace(REGEXP_SPECIAL, '\\$&')
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
// A word boundary that also works for a spelling starting or ending in an
|
|
51
|
-
// accented letter — JS's own `\b` is `\w`-based (`[A-Za-z0-9_]`), so it does
|
|
52
|
-
// not see "é" as a word character and misses the boundary right before one.
|
|
53
|
-
const NOT_WORD = '[\\p{L}\\p{N}_]'
|
|
54
|
-
|
|
55
|
-
/**
|
|
56
|
-
* A marked inline extension: highlights every occurrence of a glossary
|
|
57
|
-
* spelling as `<span class="gloss-term" data-gid="…">`, as a token the
|
|
58
|
-
* parser produces rather than HTML smuggled through the source text — the
|
|
59
|
-
* escaping above still applies to everything else. One instance per
|
|
60
|
-
* distinct glossary (see the cache below); built once, matched against
|
|
61
|
-
* every text rendered with that glossary.
|
|
62
|
-
*/
|
|
63
|
-
function spellingIndex(glossary) {
|
|
64
|
-
const bySpelling = new Map()
|
|
65
|
-
for (const { id, spelling } of glossary) {
|
|
66
|
-
const trimmed = spelling?.trim()
|
|
67
|
-
if (!trimmed) continue
|
|
68
|
-
bySpelling.set(trimmed.toLowerCase(), id)
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
// Longest spelling first, so "kufic script" wins over "kufic" when a text
|
|
72
|
-
// contains both.
|
|
73
|
-
const spellings = [...bySpelling.keys()].sort((a, b) => b.length - a.length)
|
|
74
|
-
if (spellings.length === 0) return null
|
|
75
|
-
return { bySpelling, alternation: spellings.map(escapeRegExp).join('|') }
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
function glossaryExtension(glossary) {
|
|
79
|
-
const index = spellingIndex(glossary)
|
|
80
|
-
if (!index) return null
|
|
81
|
-
const { bySpelling, alternation } = index
|
|
82
|
-
const findAnywhere = new RegExp(`(?<!${NOT_WORD})(?:${alternation})(?!${NOT_WORD})`, 'iu')
|
|
83
|
-
const matchAtStart = new RegExp(`^(?:${alternation})(?!${NOT_WORD})`, 'iu')
|
|
84
|
-
|
|
85
|
-
return {
|
|
86
|
-
name: 'glossaryTerm',
|
|
87
|
-
level: 'inline',
|
|
88
|
-
start(src) {
|
|
89
|
-
return findAnywhere.exec(src)?.index
|
|
90
|
-
},
|
|
91
|
-
tokenizer(src) {
|
|
92
|
-
const match = matchAtStart.exec(src)
|
|
93
|
-
if (!match) return undefined
|
|
94
|
-
return {
|
|
95
|
-
type: 'glossaryTerm',
|
|
96
|
-
raw: match[0],
|
|
97
|
-
text: match[0],
|
|
98
|
-
id: bySpelling.get(match[0].toLowerCase()),
|
|
99
|
-
}
|
|
100
|
-
},
|
|
101
|
-
renderer(token) {
|
|
102
|
-
return `<span class="gloss-term" data-gid="${escapeHtml(token.id)}">${escapeHtml(token.text)}</span>`
|
|
103
|
-
},
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
// Keyed on the spelling list, not on which text is being rendered — every
|
|
108
|
-
// field of one record shares the same glossary, and re-parsing that list
|
|
109
|
-
// into a regex and a Marked instance for each field would be wasted work.
|
|
110
|
-
const glossaryPipelines = new Map()
|
|
111
|
-
|
|
112
|
-
function glossaryKey(glossary) {
|
|
113
|
-
return JSON.stringify([...glossary].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)))
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
function withGlossary(breaks, glossary) {
|
|
117
|
-
const key = glossaryKey(glossary)
|
|
118
|
-
let entry = glossaryPipelines.get(key)
|
|
119
|
-
if (entry === undefined) {
|
|
120
|
-
const extension = glossaryExtension(glossary)
|
|
121
|
-
if (!extension) {
|
|
122
|
-
entry = null // no usable spelling in this list — fall back below
|
|
123
|
-
} else {
|
|
124
|
-
const withProse = new Marked({ async: false, gfm: true, breaks: false })
|
|
125
|
-
const withRecord = new Marked({ async: false, gfm: true, breaks: true })
|
|
126
|
-
withProse.use(escaping, { extensions: [extension] })
|
|
127
|
-
withRecord.use(escaping, { extensions: [extension] })
|
|
128
|
-
entry = { prose: withProse, record: withRecord }
|
|
129
|
-
}
|
|
130
|
-
glossaryPipelines.set(key, entry)
|
|
131
|
-
}
|
|
132
|
-
return entry ? (breaks ? entry.record : entry.prose) : pipeline(breaks)
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
function engineFor(breaks, glossary) {
|
|
136
|
-
return glossary && glossary.length > 0 ? withGlossary(breaks, glossary) : pipeline(breaks)
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
// The same key as the render pipelines, for the same reason: a glossary list
|
|
140
|
-
// is rebuilt into a regex once, however many texts get scanned against it.
|
|
141
|
-
const glossaryMatchers = new Map()
|
|
142
|
-
|
|
143
|
-
function matcherFor(glossary) {
|
|
144
|
-
const key = glossaryKey(glossary)
|
|
145
|
-
let matcher = glossaryMatchers.get(key)
|
|
146
|
-
if (matcher === undefined) {
|
|
147
|
-
const index = spellingIndex(glossary)
|
|
148
|
-
matcher = index
|
|
149
|
-
? { bySpelling: index.bySpelling, regex: new RegExp(`(?<!${NOT_WORD})(?:${index.alternation})(?!${NOT_WORD})`, 'giu') }
|
|
150
|
-
: null
|
|
151
|
-
glossaryMatchers.set(key, matcher)
|
|
152
|
-
}
|
|
153
|
-
return matcher
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
/**
|
|
157
|
-
* The ids of `glossary` (`[{ id, spelling }]`) whose spelling occurs in
|
|
158
|
-
* `text`, matched the same word-bounded, case-insensitive way
|
|
159
|
-
* `renderBlock`/`renderInline` highlight them — a text scanned this way and
|
|
160
|
-
* the same text rendered with the same list agree on what counts as a hit.
|
|
161
|
-
* The free-text counterpart to a record's own `glossary_ids`: a dynasty
|
|
162
|
-
* history or a theme essay has no such column to read, so this scans the
|
|
163
|
-
* whole glossary against the text instead. One id per term, in no
|
|
164
|
-
* particular order.
|
|
165
|
-
*/
|
|
166
|
-
export function glossaryIdsIn(text, glossary) {
|
|
167
|
-
if (!glossary || glossary.length === 0) return []
|
|
168
|
-
const matcher = matcherFor(glossary)
|
|
169
|
-
if (!matcher) return []
|
|
170
|
-
const found = new Set()
|
|
171
|
-
for (const match of String(text ?? '').matchAll(matcher.regex)) {
|
|
172
|
-
found.add(matcher.bySpelling.get(match[0].toLowerCase()))
|
|
173
|
-
}
|
|
174
|
-
return [...found]
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
/** A text as one or more paragraphs. `glossary`: `[{ id, spelling }]` to highlight. */
|
|
178
|
-
export function renderBlock(text, { breaks = false, glossary } = {}) {
|
|
179
|
-
return engineFor(breaks, glossary).parse(String(text ?? ''))
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
/** A text without the surrounding paragraph, for a heading or a table cell. */
|
|
183
|
-
export function renderInline(text, { breaks = false, glossary } = {}) {
|
|
184
|
-
return engineFor(breaks, glossary).parseInline(String(text ?? ''))
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
// Plain text is read off the token tree the same parser produces, never by
|
|
188
|
-
// rewriting the source with a regex: what a Markdown text says is what its
|
|
189
|
-
// tokens say. Raw HTML is dropped, since it was never content; an image is
|
|
190
|
-
// its alt text; a break is a space.
|
|
191
|
-
const BLOCKS = new Set(['paragraph', 'heading', 'blockquote', 'list', 'list_item', 'code', 'table', 'hr', 'html'])
|
|
192
|
-
|
|
193
|
-
const ENTITIES = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ' }
|
|
194
|
-
function decodeEntities(text) {
|
|
195
|
-
return text.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (match, entity) => {
|
|
196
|
-
if (entity[0] === '#') {
|
|
197
|
-
const code =
|
|
198
|
-
entity[1].toLowerCase() === 'x' ? parseInt(entity.slice(2), 16) : parseInt(entity.slice(1), 10)
|
|
199
|
-
return Number.isFinite(code) ? String.fromCodePoint(code) : match
|
|
200
|
-
}
|
|
201
|
-
return ENTITIES[entity.toLowerCase()] ?? match
|
|
202
|
-
})
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
function plainText(tokens) {
|
|
206
|
-
let out = ''
|
|
207
|
-
for (const token of tokens ?? []) {
|
|
208
|
-
switch (token.type) {
|
|
209
|
-
case 'html':
|
|
210
|
-
break
|
|
211
|
-
case 'image':
|
|
212
|
-
out += token.text ?? ''
|
|
213
|
-
break
|
|
214
|
-
case 'br':
|
|
215
|
-
case 'space':
|
|
216
|
-
out += ' '
|
|
217
|
-
break
|
|
218
|
-
case 'list':
|
|
219
|
-
out += plainText(token.items)
|
|
220
|
-
break
|
|
221
|
-
case 'table':
|
|
222
|
-
out += plainText(token.header)
|
|
223
|
-
for (const row of token.rows ?? []) out += plainText(row)
|
|
224
|
-
break
|
|
225
|
-
default:
|
|
226
|
-
if (token.tokens?.length) out += plainText(token.tokens)
|
|
227
|
-
else out += token.text ?? ''
|
|
228
|
-
}
|
|
229
|
-
if (BLOCKS.has(token.type)) out += ' '
|
|
230
|
-
}
|
|
231
|
-
return out
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
/** A text as plain text — for an `alt`, a `title`, an option label, a sort key. */
|
|
235
|
-
export function renderPlain(text) {
|
|
236
|
-
return decodeEntities(plainText(record.lexer(String(text ?? ''))))
|
|
237
|
-
.replace(/\s+/g, ' ')
|
|
238
|
-
.trim()
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
// `md`/`mdInline`/`mdStrip` are `renderBlock`/`renderInline`/`renderPlain`
|
|
242
|
-
// under the one signature and the one convention every site wrote for
|
|
243
|
-
// itself: a missing text is `''`, not an empty paragraph or "undefined", and
|
|
244
|
-
// a record's own line breaks are kept unless a caller says otherwise — a
|
|
245
|
-
// data-package field is typed with breaks on purpose, the way a dictionary
|
|
246
|
-
// entry is not. `renderBlock`/`renderInline`/`renderPlain` stay, for a
|
|
247
|
-
// caller that wants the pipeline's own default (`breaks: false`) instead.
|
|
248
|
-
|
|
249
|
-
/** `renderBlock`, record convention: `''` for a missing text, line breaks kept by default. */
|
|
250
|
-
export function md(text, { glossary, breaks = true } = {}) {
|
|
251
|
-
if (!text) return ''
|
|
252
|
-
return renderBlock(text, { breaks, glossary })
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
/** `renderInline`, the same convention, for a heading or a table cell. */
|
|
256
|
-
export function mdInline(text, { glossary } = {}) {
|
|
257
|
-
if (!text) return ''
|
|
258
|
-
return renderInline(text, { glossary })
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
/** `renderPlain`, the same convention, for an `alt`, a `title`, a sort key. */
|
|
262
|
-
export function mdStrip(text) {
|
|
263
|
-
if (!text) return ''
|
|
264
|
-
return renderPlain(text)
|
|
265
|
-
}
|
|
1
|
+
import { Marked } from 'marked'
|
|
2
|
+
|
|
3
|
+
// One Markdown pipeline for every text on every website, configured here and
|
|
4
|
+
// nowhere else. Its own instance rather than the shared `marked` singleton, so
|
|
5
|
+
// configuring it cannot change how a website renders anything else.
|
|
6
|
+
//
|
|
7
|
+
// Raw HTML is escaped rather than rendered. Texts are written by translators
|
|
8
|
+
// through the GitHub web interface, and the rules they are given say Markdown
|
|
9
|
+
// only; escaping is what makes that a guarantee instead of a request.
|
|
10
|
+
//
|
|
11
|
+
// This is the only place that guarantee lives. viewer-i18n's checker used to
|
|
12
|
+
// reject HTML too, which added nothing here and was wrong about autolinks and
|
|
13
|
+
// code spans; it no longer looks. Anything relaxed in this file is relaxed
|
|
14
|
+
// everywhere, with nothing behind it.
|
|
15
|
+
|
|
16
|
+
const ESCAPES = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }
|
|
17
|
+
|
|
18
|
+
function escapeHtml(text) {
|
|
19
|
+
return String(text).replace(/[&<>"']/g, (character) => ESCAPES[character])
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Raw HTML is escaped in both, because both render text that came from
|
|
23
|
+
// somewhere else: a translator's file, or a museum record carried in from the
|
|
24
|
+
// legacy system. Neither is a place markup may be authored.
|
|
25
|
+
const escaping = {
|
|
26
|
+
renderer: {
|
|
27
|
+
html(token) {
|
|
28
|
+
return escapeHtml(token?.raw ?? token?.text ?? token ?? '')
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// `breaks` is the one difference, and it is a difference in the source rather
|
|
34
|
+
// than in policy. A text in the dictionary is written as prose and wraps freely,
|
|
35
|
+
// so a single newline means nothing. A record's field arrives from a database
|
|
36
|
+
// where a newline was typed to be a line break, and dropping it runs the lines
|
|
37
|
+
// together.
|
|
38
|
+
const prose = new Marked({ async: false, gfm: true, breaks: false })
|
|
39
|
+
const record = new Marked({ async: false, gfm: true, breaks: true })
|
|
40
|
+
prose.use(escaping)
|
|
41
|
+
record.use(escaping)
|
|
42
|
+
|
|
43
|
+
const pipeline = (breaks) => (breaks ? record : prose)
|
|
44
|
+
|
|
45
|
+
const REGEXP_SPECIAL = /[.*+?^${}()|[\]\\]/g
|
|
46
|
+
function escapeRegExp(text) {
|
|
47
|
+
return text.replace(REGEXP_SPECIAL, '\\$&')
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// A word boundary that also works for a spelling starting or ending in an
|
|
51
|
+
// accented letter — JS's own `\b` is `\w`-based (`[A-Za-z0-9_]`), so it does
|
|
52
|
+
// not see "é" as a word character and misses the boundary right before one.
|
|
53
|
+
const NOT_WORD = '[\\p{L}\\p{N}_]'
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* A marked inline extension: highlights every occurrence of a glossary
|
|
57
|
+
* spelling as `<span class="gloss-term" data-gid="…">`, as a token the
|
|
58
|
+
* parser produces rather than HTML smuggled through the source text — the
|
|
59
|
+
* escaping above still applies to everything else. One instance per
|
|
60
|
+
* distinct glossary (see the cache below); built once, matched against
|
|
61
|
+
* every text rendered with that glossary.
|
|
62
|
+
*/
|
|
63
|
+
function spellingIndex(glossary) {
|
|
64
|
+
const bySpelling = new Map()
|
|
65
|
+
for (const { id, spelling } of glossary) {
|
|
66
|
+
const trimmed = spelling?.trim()
|
|
67
|
+
if (!trimmed) continue
|
|
68
|
+
bySpelling.set(trimmed.toLowerCase(), id)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Longest spelling first, so "kufic script" wins over "kufic" when a text
|
|
72
|
+
// contains both.
|
|
73
|
+
const spellings = [...bySpelling.keys()].sort((a, b) => b.length - a.length)
|
|
74
|
+
if (spellings.length === 0) return null
|
|
75
|
+
return { bySpelling, alternation: spellings.map(escapeRegExp).join('|') }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function glossaryExtension(glossary) {
|
|
79
|
+
const index = spellingIndex(glossary)
|
|
80
|
+
if (!index) return null
|
|
81
|
+
const { bySpelling, alternation } = index
|
|
82
|
+
const findAnywhere = new RegExp(`(?<!${NOT_WORD})(?:${alternation})(?!${NOT_WORD})`, 'iu')
|
|
83
|
+
const matchAtStart = new RegExp(`^(?:${alternation})(?!${NOT_WORD})`, 'iu')
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
name: 'glossaryTerm',
|
|
87
|
+
level: 'inline',
|
|
88
|
+
start(src) {
|
|
89
|
+
return findAnywhere.exec(src)?.index
|
|
90
|
+
},
|
|
91
|
+
tokenizer(src) {
|
|
92
|
+
const match = matchAtStart.exec(src)
|
|
93
|
+
if (!match) return undefined
|
|
94
|
+
return {
|
|
95
|
+
type: 'glossaryTerm',
|
|
96
|
+
raw: match[0],
|
|
97
|
+
text: match[0],
|
|
98
|
+
id: bySpelling.get(match[0].toLowerCase()),
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
renderer(token) {
|
|
102
|
+
return `<span class="gloss-term" data-gid="${escapeHtml(token.id)}">${escapeHtml(token.text)}</span>`
|
|
103
|
+
},
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Keyed on the spelling list, not on which text is being rendered — every
|
|
108
|
+
// field of one record shares the same glossary, and re-parsing that list
|
|
109
|
+
// into a regex and a Marked instance for each field would be wasted work.
|
|
110
|
+
const glossaryPipelines = new Map()
|
|
111
|
+
|
|
112
|
+
function glossaryKey(glossary) {
|
|
113
|
+
return JSON.stringify([...glossary].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)))
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function withGlossary(breaks, glossary) {
|
|
117
|
+
const key = glossaryKey(glossary)
|
|
118
|
+
let entry = glossaryPipelines.get(key)
|
|
119
|
+
if (entry === undefined) {
|
|
120
|
+
const extension = glossaryExtension(glossary)
|
|
121
|
+
if (!extension) {
|
|
122
|
+
entry = null // no usable spelling in this list — fall back below
|
|
123
|
+
} else {
|
|
124
|
+
const withProse = new Marked({ async: false, gfm: true, breaks: false })
|
|
125
|
+
const withRecord = new Marked({ async: false, gfm: true, breaks: true })
|
|
126
|
+
withProse.use(escaping, { extensions: [extension] })
|
|
127
|
+
withRecord.use(escaping, { extensions: [extension] })
|
|
128
|
+
entry = { prose: withProse, record: withRecord }
|
|
129
|
+
}
|
|
130
|
+
glossaryPipelines.set(key, entry)
|
|
131
|
+
}
|
|
132
|
+
return entry ? (breaks ? entry.record : entry.prose) : pipeline(breaks)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function engineFor(breaks, glossary) {
|
|
136
|
+
return glossary && glossary.length > 0 ? withGlossary(breaks, glossary) : pipeline(breaks)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// The same key as the render pipelines, for the same reason: a glossary list
|
|
140
|
+
// is rebuilt into a regex once, however many texts get scanned against it.
|
|
141
|
+
const glossaryMatchers = new Map()
|
|
142
|
+
|
|
143
|
+
function matcherFor(glossary) {
|
|
144
|
+
const key = glossaryKey(glossary)
|
|
145
|
+
let matcher = glossaryMatchers.get(key)
|
|
146
|
+
if (matcher === undefined) {
|
|
147
|
+
const index = spellingIndex(glossary)
|
|
148
|
+
matcher = index
|
|
149
|
+
? { bySpelling: index.bySpelling, regex: new RegExp(`(?<!${NOT_WORD})(?:${index.alternation})(?!${NOT_WORD})`, 'giu') }
|
|
150
|
+
: null
|
|
151
|
+
glossaryMatchers.set(key, matcher)
|
|
152
|
+
}
|
|
153
|
+
return matcher
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* The ids of `glossary` (`[{ id, spelling }]`) whose spelling occurs in
|
|
158
|
+
* `text`, matched the same word-bounded, case-insensitive way
|
|
159
|
+
* `renderBlock`/`renderInline` highlight them — a text scanned this way and
|
|
160
|
+
* the same text rendered with the same list agree on what counts as a hit.
|
|
161
|
+
* The free-text counterpart to a record's own `glossary_ids`: a dynasty
|
|
162
|
+
* history or a theme essay has no such column to read, so this scans the
|
|
163
|
+
* whole glossary against the text instead. One id per term, in no
|
|
164
|
+
* particular order.
|
|
165
|
+
*/
|
|
166
|
+
export function glossaryIdsIn(text, glossary) {
|
|
167
|
+
if (!glossary || glossary.length === 0) return []
|
|
168
|
+
const matcher = matcherFor(glossary)
|
|
169
|
+
if (!matcher) return []
|
|
170
|
+
const found = new Set()
|
|
171
|
+
for (const match of String(text ?? '').matchAll(matcher.regex)) {
|
|
172
|
+
found.add(matcher.bySpelling.get(match[0].toLowerCase()))
|
|
173
|
+
}
|
|
174
|
+
return [...found]
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** A text as one or more paragraphs. `glossary`: `[{ id, spelling }]` to highlight. */
|
|
178
|
+
export function renderBlock(text, { breaks = false, glossary } = {}) {
|
|
179
|
+
return engineFor(breaks, glossary).parse(String(text ?? ''))
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** A text without the surrounding paragraph, for a heading or a table cell. */
|
|
183
|
+
export function renderInline(text, { breaks = false, glossary } = {}) {
|
|
184
|
+
return engineFor(breaks, glossary).parseInline(String(text ?? ''))
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Plain text is read off the token tree the same parser produces, never by
|
|
188
|
+
// rewriting the source with a regex: what a Markdown text says is what its
|
|
189
|
+
// tokens say. Raw HTML is dropped, since it was never content; an image is
|
|
190
|
+
// its alt text; a break is a space.
|
|
191
|
+
const BLOCKS = new Set(['paragraph', 'heading', 'blockquote', 'list', 'list_item', 'code', 'table', 'hr', 'html'])
|
|
192
|
+
|
|
193
|
+
const ENTITIES = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ' }
|
|
194
|
+
function decodeEntities(text) {
|
|
195
|
+
return text.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (match, entity) => {
|
|
196
|
+
if (entity[0] === '#') {
|
|
197
|
+
const code =
|
|
198
|
+
entity[1].toLowerCase() === 'x' ? parseInt(entity.slice(2), 16) : parseInt(entity.slice(1), 10)
|
|
199
|
+
return Number.isFinite(code) ? String.fromCodePoint(code) : match
|
|
200
|
+
}
|
|
201
|
+
return ENTITIES[entity.toLowerCase()] ?? match
|
|
202
|
+
})
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function plainText(tokens) {
|
|
206
|
+
let out = ''
|
|
207
|
+
for (const token of tokens ?? []) {
|
|
208
|
+
switch (token.type) {
|
|
209
|
+
case 'html':
|
|
210
|
+
break
|
|
211
|
+
case 'image':
|
|
212
|
+
out += token.text ?? ''
|
|
213
|
+
break
|
|
214
|
+
case 'br':
|
|
215
|
+
case 'space':
|
|
216
|
+
out += ' '
|
|
217
|
+
break
|
|
218
|
+
case 'list':
|
|
219
|
+
out += plainText(token.items)
|
|
220
|
+
break
|
|
221
|
+
case 'table':
|
|
222
|
+
out += plainText(token.header)
|
|
223
|
+
for (const row of token.rows ?? []) out += plainText(row)
|
|
224
|
+
break
|
|
225
|
+
default:
|
|
226
|
+
if (token.tokens?.length) out += plainText(token.tokens)
|
|
227
|
+
else out += token.text ?? ''
|
|
228
|
+
}
|
|
229
|
+
if (BLOCKS.has(token.type)) out += ' '
|
|
230
|
+
}
|
|
231
|
+
return out
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** A text as plain text — for an `alt`, a `title`, an option label, a sort key. */
|
|
235
|
+
export function renderPlain(text) {
|
|
236
|
+
return decodeEntities(plainText(record.lexer(String(text ?? ''))))
|
|
237
|
+
.replace(/\s+/g, ' ')
|
|
238
|
+
.trim()
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// `md`/`mdInline`/`mdStrip` are `renderBlock`/`renderInline`/`renderPlain`
|
|
242
|
+
// under the one signature and the one convention every site wrote for
|
|
243
|
+
// itself: a missing text is `''`, not an empty paragraph or "undefined", and
|
|
244
|
+
// a record's own line breaks are kept unless a caller says otherwise — a
|
|
245
|
+
// data-package field is typed with breaks on purpose, the way a dictionary
|
|
246
|
+
// entry is not. `renderBlock`/`renderInline`/`renderPlain` stay, for a
|
|
247
|
+
// caller that wants the pipeline's own default (`breaks: false`) instead.
|
|
248
|
+
|
|
249
|
+
/** `renderBlock`, record convention: `''` for a missing text, line breaks kept by default. */
|
|
250
|
+
export function md(text, { glossary, breaks = true } = {}) {
|
|
251
|
+
if (!text) return ''
|
|
252
|
+
return renderBlock(text, { breaks, glossary })
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** `renderInline`, the same convention, for a heading or a table cell. */
|
|
256
|
+
export function mdInline(text, { glossary } = {}) {
|
|
257
|
+
if (!text) return ''
|
|
258
|
+
return renderInline(text, { glossary })
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** `renderPlain`, the same convention, for an `alt`, a `title`, a sort key. */
|
|
262
|
+
export function mdStrip(text) {
|
|
263
|
+
if (!text) return ''
|
|
264
|
+
return renderPlain(text)
|
|
265
|
+
}
|
package/src/index.js
CHANGED
|
@@ -1,50 +1,51 @@
|
|
|
1
|
-
export { createViewer } from './createViewer.js'
|
|
2
|
-
export { useCatalogueData } from './composables/useCatalogueData.js'
|
|
3
|
-
export { useDataPackage } from './composables/useDataPackage.js'
|
|
4
|
-
export { byId, entityRef, loadEntities, useEntities } from './composables/useEntities.js'
|
|
5
|
-
export { resolveRecordLanguage, useRecordLanguage } from './composables/useRecordLanguage.js'
|
|
6
|
-
export { createI18n, mergeMessages, useI18n, useLocale } from './i18n/index.js'
|
|
7
|
-
export { isRtl, negotiateLanguage } from './i18n/language.js'
|
|
8
|
-
export { md, mdInline, mdStrip, renderBlock, renderInline, renderPlain } from './i18n/markdown.js'
|
|
9
|
-
export { languageLabels, offeredLanguages } from './languages.js'
|
|
10
|
-
export { defaultScrollBehavior, resolveViews } from './router/index.js'
|
|
11
|
-
export { mediaUrl, useSiteConfig } from './siteConfig.js'
|
|
12
|
-
export { sourceUrl, useSiteRights } from './rights.js'
|
|
13
|
-
|
|
14
|
-
// The list pages: query state, pagination, facets, dates, keyword search.
|
|
15
|
-
export { useListQuery } from './catalogue/listQuery.js'
|
|
16
|
-
export { paginate, sortChronological, usePagination } from './catalogue/pagination.js'
|
|
17
|
-
export { facetOptions, useFacets } from './catalogue/facets.js'
|
|
18
|
-
export {
|
|
19
|
-
centuryPresets, dateRange, eraLabel, inDateRange, roundOutward, yearBuckets, yearBucketsFromRange,
|
|
20
|
-
} from './catalogue/dates.js'
|
|
21
|
-
export { useSearchLanguage } from './catalogue/searchLanguage.js'
|
|
22
|
-
export {
|
|
23
|
-
combineExpansions, countryExpansion, glossaryExpansion, parseBooleanQuery, useKeywordIndex,
|
|
24
|
-
} from './catalogue/keywordIndex.js'
|
|
25
|
-
export {
|
|
26
|
-
effectiveYearTo, eventDateLabel, overlapsRange, useTimelineEvents,
|
|
27
|
-
} from './catalogue/timeline.js'
|
|
28
|
-
|
|
29
|
-
// The record page: the sheet engine and its derivations.
|
|
30
|
-
export {
|
|
31
|
-
glossaryEntries, glossaryTermsFor, glossaryTermsForText, sheetRows, useRecordSheet,
|
|
32
|
-
} from './record/recordSheet.js'
|
|
33
|
-
export {
|
|
34
|
-
citation, relatedRecords, searchGlossary, timelineLinkFor, useGlossaryPopup, useRelatedRecords,
|
|
35
|
-
} from './record/derivations.js'
|
|
36
|
-
export { buildCollectionTree, collectionTreeFromThemes, useCollectionTree } from './record/collectionTree.js'
|
|
37
|
-
export { groupByCountry, partnerHierarchy } from './record/partners.js'
|
|
38
|
-
|
|
39
|
-
// Conventions every website had written for itself.
|
|
40
|
-
export {
|
|
41
|
-
PROJECT_ENTRIES, PROJECT_FAMILIES, mwnfLinks, projectFamily,
|
|
42
|
-
sectionMeta, useFeaturedRecord, useProjectName,
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
export { default as
|
|
47
|
-
export { default as
|
|
48
|
-
export { default as
|
|
49
|
-
export { default as
|
|
50
|
-
export { default as
|
|
1
|
+
export { createViewer } from './createViewer.js'
|
|
2
|
+
export { useCatalogueData } from './composables/useCatalogueData.js'
|
|
3
|
+
export { useDataPackage } from './composables/useDataPackage.js'
|
|
4
|
+
export { byId, entityRef, loadEntities, useEntities } from './composables/useEntities.js'
|
|
5
|
+
export { resolveRecordLanguage, useRecordLanguage } from './composables/useRecordLanguage.js'
|
|
6
|
+
export { createI18n, mergeMessages, useI18n, useLocale } from './i18n/index.js'
|
|
7
|
+
export { isRtl, negotiateLanguage } from './i18n/language.js'
|
|
8
|
+
export { md, mdInline, mdStrip, renderBlock, renderInline, renderPlain } from './i18n/markdown.js'
|
|
9
|
+
export { languageLabels, offeredLanguages } from './languages.js'
|
|
10
|
+
export { defaultScrollBehavior, resolveViews } from './router/index.js'
|
|
11
|
+
export { mediaUrl, useSiteConfig } from './siteConfig.js'
|
|
12
|
+
export { sourceUrl, useSiteRights } from './rights.js'
|
|
13
|
+
|
|
14
|
+
// The list pages: query state, pagination, facets, dates, keyword search.
|
|
15
|
+
export { useListQuery } from './catalogue/listQuery.js'
|
|
16
|
+
export { paginate, sortChronological, usePagination } from './catalogue/pagination.js'
|
|
17
|
+
export { facetOptions, useFacets } from './catalogue/facets.js'
|
|
18
|
+
export {
|
|
19
|
+
centuryPresets, dateRange, eraLabel, inDateRange, roundOutward, yearBuckets, yearBucketsFromRange,
|
|
20
|
+
} from './catalogue/dates.js'
|
|
21
|
+
export { useSearchLanguage } from './catalogue/searchLanguage.js'
|
|
22
|
+
export {
|
|
23
|
+
combineExpansions, countryExpansion, glossaryExpansion, parseBooleanQuery, useKeywordIndex,
|
|
24
|
+
} from './catalogue/keywordIndex.js'
|
|
25
|
+
export {
|
|
26
|
+
effectiveYearTo, eventDateLabel, overlapsRange, useTimelineEvents,
|
|
27
|
+
} from './catalogue/timeline.js'
|
|
28
|
+
|
|
29
|
+
// The record page: the sheet engine and its derivations.
|
|
30
|
+
export {
|
|
31
|
+
glossaryEntries, glossaryTermsFor, glossaryTermsForText, sheetRows, useRecordSheet,
|
|
32
|
+
} from './record/recordSheet.js'
|
|
33
|
+
export {
|
|
34
|
+
citation, relatedRecords, searchGlossary, timelineLinkFor, useGlossaryPopup, useRelatedRecords,
|
|
35
|
+
} from './record/derivations.js'
|
|
36
|
+
export { buildCollectionTree, collectionTreeFromThemes, useCollectionTree } from './record/collectionTree.js'
|
|
37
|
+
export { groupByCountry, partnerHierarchy } from './record/partners.js'
|
|
38
|
+
|
|
39
|
+
// Conventions every website had written for itself.
|
|
40
|
+
export {
|
|
41
|
+
PROJECT_ENTRIES, PROJECT_FAMILIES, mwnfLinks, projectFamily, projectLabel,
|
|
42
|
+
projectLinks, projectName, sectionMeta, useFeaturedRecord, useProjectName,
|
|
43
|
+
useProjects, useSection,
|
|
44
|
+
} from './conventions.js'
|
|
45
|
+
|
|
46
|
+
export { default as I18nText } from './i18n/I18nText.vue'
|
|
47
|
+
export { default as I18nTextInline } from './i18n/I18nTextInline.vue'
|
|
48
|
+
export { default as HomeView } from './views/HomeView.vue'
|
|
49
|
+
export { default as ListView } from './views/ListView.vue'
|
|
50
|
+
export { default as DetailView } from './views/DetailView.vue'
|
|
51
|
+
export { default as NotFoundView } from './views/NotFoundView.vue'
|
package/src/injectionKeys.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const VIEWER_CONFIG = Symbol('viewer-config')
|
|
1
|
+
export const VIEWER_CONFIG = Symbol('viewer-config')
|