@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.
Files changed (43) hide show
  1. package/LICENSE.md +44 -44
  2. package/README.md +658 -635
  3. package/package.json +50 -50
  4. package/src/AppRoot.vue +58 -58
  5. package/src/catalogue/dates.js +209 -209
  6. package/src/catalogue/facets.js +61 -61
  7. package/src/catalogue/keywordIndex.js +289 -289
  8. package/src/catalogue/listQuery.js +100 -100
  9. package/src/catalogue/pagination.js +57 -57
  10. package/src/catalogue/searchLanguage.js +32 -32
  11. package/src/catalogue/timeline.js +220 -220
  12. package/src/composables/useCatalogueData.js +141 -141
  13. package/src/composables/useDataPackage.js +98 -98
  14. package/src/composables/useEntities.js +100 -100
  15. package/src/composables/useRecordLanguage.js +71 -71
  16. package/src/conventions.js +252 -148
  17. package/src/createViewer.js +29 -29
  18. package/src/i18n/I18nText.vue +17 -17
  19. package/src/i18n/I18nTextInline.vue +17 -17
  20. package/src/i18n/index.js +88 -88
  21. package/src/i18n/language.js +127 -127
  22. package/src/i18n/markdown.js +265 -265
  23. package/src/index.js +51 -50
  24. package/src/injectionKeys.js +1 -1
  25. package/src/languages.js +49 -49
  26. package/src/legacy/index.js +58 -58
  27. package/src/record/collectionTree.js +307 -307
  28. package/src/record/derivations.js +154 -154
  29. package/src/record/partners.js +87 -87
  30. package/src/record/recordSheet.js +231 -231
  31. package/src/rights.js +53 -53
  32. package/src/router/index.js +138 -138
  33. package/src/siteConfig.js +37 -37
  34. package/src/styles/base.css +49 -49
  35. package/src/testing/checkOfferedLanguages.js +67 -67
  36. package/src/testing/index.js +3 -3
  37. package/src/testing/smoke.js +103 -103
  38. package/src/testing/viteConfig.js +90 -90
  39. package/src/views/DetailView.vue +53 -53
  40. package/src/views/HomeView.vue +20 -20
  41. package/src/views/ListView.vue +61 -61
  42. package/src/views/NotFoundView.vue +8 -8
  43. package/src/vite.js +8 -8
@@ -1,141 +1,141 @@
1
- import { computed } from 'vue'
2
- import { useDataPackage } from './useDataPackage.js'
3
- import { byId as coreById, entityRef } from './useEntities.js'
4
- import { md as coreMd, mdInline as coreMdInline, mdStrip as coreMdStrip } from '../i18n/markdown.js'
5
- import { glossaryEntries } from '../record/recordSheet.js'
6
-
7
- // The wrapper half of seven sites' data composables (2,376 lines across
8
- // them), written once. Every one of them re-wrapped the same `tr`, the same
9
- // memoised `loadEnglish`, the same one-shape label helper
10
- // (`mdStrip(tr(entity, id).name ?? record.internal_name ?? id)`), and the
11
- // same `md`/`mdInline`/`mdStrip` re-export, around whichever entities and
12
- // visibility rules were its own — a per-build language filter, a hidden-
13
- // partner rule, `display_status` — coded rather than declared. This is that
14
- // wrapper. What survives in a site's own composable is what genuinely
15
- // differs: routes, legacy key mappings, chrome images, sibling lists.
16
- //
17
- // Called once, at the top of a site's data composable module, and its
18
- // return value re-exported from there — the way `entityRef`/`byId` are
19
- // already read at module scope, so importing the module loads nothing and a
20
- // page pays only for the entity it declares. `entity`/`index` read the same
21
- // shared `entityRef`/`byId` this file does, so a second call would only
22
- // duplicate the memoisation `loadEnglish` and the glossary binding carry —
23
- // one instance per site, not one per component.
24
- const BASE_LANGUAGE = 'en'
25
-
26
- // A term's spellings and definition in one language, falling back to
27
- // English, then to the term's own headword — the same row shape
28
- // `useRecordSheet`'s `termRow` builds, but for the whole glossary in
29
- // `defaultLanguage` rather than one record's `glossary_ids` in the record's
30
- // own language, which is why it is not reused: a catalogue list or a label
31
- // is always shown in `defaultLanguage`, a record sheet's text is not.
32
- function glossaryRows(language, translations) {
33
- const rows = translations('glossary', language)
34
- const fallback = translations('glossary', BASE_LANGUAGE)
35
- const terms = []
36
- for (const term of entityRef('glossary').value ?? []) {
37
- const t = rows[term.id] ?? fallback[term.id] ?? {}
38
- const spellings = (t.spellings ?? []).map((s) => String(s).trim()).filter(Boolean)
39
- const word = term.word ?? spellings[0] ?? ''
40
- if (!word && spellings.length === 0) continue
41
- terms.push({ id: term.id, word, definition: t.definition ?? '', spellings: spellings.length ? spellings : [word] })
42
- }
43
- return terms
44
- }
45
-
46
- /**
47
- * The wrapper half of a website's data composable: `tr`, `md`/`mdInline`/
48
- * `mdStrip` bound to the site's glossary, `loadEnglish`, `labelOf`, the
49
- * visible form of an entity and its index, and the package re-exports.
50
- *
51
- * `eager` names the entities `loadEnglish` loads — every entity the package
52
- * carries by default. `defaultLanguage` is the language `tr`, the labels and
53
- * the glossary binding read (`'en'` for every site so far). `visible` names,
54
- * per entity, the predicate `entity()`/`index()` apply — a per-build
55
- * language filter, a hidden-partner rule, a display-status check are all
56
- * "which records this build shows", declared here once instead of coded
57
- * into every page that lists that entity. `glossary` turns off the default
58
- * binding on `md`/`mdInline` (a caller may still pass its own `glossary` to
59
- * either, on or off).
60
- */
61
- export function useCatalogueData({ eager, defaultLanguage = BASE_LANGUAGE, visible = {}, glossary = true } = {}) {
62
- const pkg = useDataPackage()
63
- const { availableLanguages, loadTranslations, translations } = pkg
64
- const eagerEntities = eager ?? pkg.entityNames
65
-
66
- /** One record's translated fields, falling back to `defaultLanguage` then `{}`. */
67
- function tr(entity, id, lang = defaultLanguage) {
68
- return pkg.tr(entity, id, lang, defaultLanguage)
69
- }
70
-
71
- const defaultGlossary = computed(() =>
72
- glossary ? glossaryEntries(glossaryRows(defaultLanguage, translations)) : [],
73
- )
74
-
75
- // A caller's own `glossary` — a record's own terms, not every term in the
76
- // package — always wins, whether the default binding is on or off; `md`
77
- // and `mdInline` differ only in which core renderer they wrap, `mdStrip`
78
- // has nothing to bind, since plain text carries no highlighting.
79
- function bindGlossary(renderer) {
80
- return (text, opts = {}) =>
81
- renderer(text, { ...opts, glossary: 'glossary' in opts ? opts.glossary : defaultGlossary.value })
82
- }
83
- const md = bindGlossary(coreMd)
84
- const mdInline = bindGlossary(coreMdInline)
85
- const mdStrip = coreMdStrip
86
-
87
- /** `mdStrip(tr(entity, id).name ?? record[fallback] ?? id)` — the one label shape every site wrote. */
88
- function labelOf(entity, id, { fallback = 'internal_name' } = {}) {
89
- if (!id) return ''
90
- const name = tr(entity, id).name
91
- if (name) return mdStrip(name)
92
- const record = coreById(entity).value.get(id)
93
- return mdStrip(record?.[fallback] ?? id)
94
- }
95
-
96
- let englishReady = null
97
- /** Loads `defaultLanguage` for every entity in `eager`, once. */
98
- function loadEnglish() {
99
- if (!englishReady) {
100
- englishReady = Promise.all(eagerEntities.map((name) => loadTranslations(name, defaultLanguage)))
101
- }
102
- return englishReady
103
- }
104
-
105
- // Cached the way `byId` is: a site reads `entity('items')`/`index('items')`
106
- // from more than one place, and each should see the same ref rather than
107
- // a fresh filter pass and a fresh Map per call.
108
- const entityCache = new Map()
109
- /** `entityRef(name)`'s records, narrowed by `visible[name]` when one is declared; `null` until loaded. */
110
- function entity(name) {
111
- let ref = entityCache.get(name)
112
- if (!ref) {
113
- const source = entityRef(name)
114
- const predicate = visible[name]
115
- ref = computed(() => {
116
- if (source.value === null) return null
117
- return predicate ? source.value.filter(predicate) : source.value
118
- })
119
- entityCache.set(name, ref)
120
- }
121
- return ref
122
- }
123
-
124
- const indexCache = new Map()
125
- /** A `Map` of `entity(name)`'s records by `key` — the visible ones, unlike `byId`. */
126
- function index(name, key = 'id') {
127
- const cacheKey = `${name}.${key}`
128
- let ref = indexCache.get(cacheKey)
129
- if (!ref) {
130
- const list = entity(name)
131
- ref = computed(() => new Map((list.value ?? []).map((record) => [record[key], record])))
132
- indexCache.set(cacheKey, ref)
133
- }
134
- return ref
135
- }
136
-
137
- return {
138
- tr, md, mdInline, mdStrip, loadEnglish, labelOf, entity, index,
139
- availableLanguages, loadTranslations, translations,
140
- }
141
- }
1
+ import { computed } from 'vue'
2
+ import { useDataPackage } from './useDataPackage.js'
3
+ import { byId as coreById, entityRef } from './useEntities.js'
4
+ import { md as coreMd, mdInline as coreMdInline, mdStrip as coreMdStrip } from '../i18n/markdown.js'
5
+ import { glossaryEntries } from '../record/recordSheet.js'
6
+
7
+ // The wrapper half of seven sites' data composables (2,376 lines across
8
+ // them), written once. Every one of them re-wrapped the same `tr`, the same
9
+ // memoised `loadEnglish`, the same one-shape label helper
10
+ // (`mdStrip(tr(entity, id).name ?? record.internal_name ?? id)`), and the
11
+ // same `md`/`mdInline`/`mdStrip` re-export, around whichever entities and
12
+ // visibility rules were its own — a per-build language filter, a hidden-
13
+ // partner rule, `display_status` — coded rather than declared. This is that
14
+ // wrapper. What survives in a site's own composable is what genuinely
15
+ // differs: routes, legacy key mappings, chrome images, sibling lists.
16
+ //
17
+ // Called once, at the top of a site's data composable module, and its
18
+ // return value re-exported from there — the way `entityRef`/`byId` are
19
+ // already read at module scope, so importing the module loads nothing and a
20
+ // page pays only for the entity it declares. `entity`/`index` read the same
21
+ // shared `entityRef`/`byId` this file does, so a second call would only
22
+ // duplicate the memoisation `loadEnglish` and the glossary binding carry —
23
+ // one instance per site, not one per component.
24
+ const BASE_LANGUAGE = 'en'
25
+
26
+ // A term's spellings and definition in one language, falling back to
27
+ // English, then to the term's own headword — the same row shape
28
+ // `useRecordSheet`'s `termRow` builds, but for the whole glossary in
29
+ // `defaultLanguage` rather than one record's `glossary_ids` in the record's
30
+ // own language, which is why it is not reused: a catalogue list or a label
31
+ // is always shown in `defaultLanguage`, a record sheet's text is not.
32
+ function glossaryRows(language, translations) {
33
+ const rows = translations('glossary', language)
34
+ const fallback = translations('glossary', BASE_LANGUAGE)
35
+ const terms = []
36
+ for (const term of entityRef('glossary').value ?? []) {
37
+ const t = rows[term.id] ?? fallback[term.id] ?? {}
38
+ const spellings = (t.spellings ?? []).map((s) => String(s).trim()).filter(Boolean)
39
+ const word = term.word ?? spellings[0] ?? ''
40
+ if (!word && spellings.length === 0) continue
41
+ terms.push({ id: term.id, word, definition: t.definition ?? '', spellings: spellings.length ? spellings : [word] })
42
+ }
43
+ return terms
44
+ }
45
+
46
+ /**
47
+ * The wrapper half of a website's data composable: `tr`, `md`/`mdInline`/
48
+ * `mdStrip` bound to the site's glossary, `loadEnglish`, `labelOf`, the
49
+ * visible form of an entity and its index, and the package re-exports.
50
+ *
51
+ * `eager` names the entities `loadEnglish` loads — every entity the package
52
+ * carries by default. `defaultLanguage` is the language `tr`, the labels and
53
+ * the glossary binding read (`'en'` for every site so far). `visible` names,
54
+ * per entity, the predicate `entity()`/`index()` apply — a per-build
55
+ * language filter, a hidden-partner rule, a display-status check are all
56
+ * "which records this build shows", declared here once instead of coded
57
+ * into every page that lists that entity. `glossary` turns off the default
58
+ * binding on `md`/`mdInline` (a caller may still pass its own `glossary` to
59
+ * either, on or off).
60
+ */
61
+ export function useCatalogueData({ eager, defaultLanguage = BASE_LANGUAGE, visible = {}, glossary = true } = {}) {
62
+ const pkg = useDataPackage()
63
+ const { availableLanguages, loadTranslations, translations } = pkg
64
+ const eagerEntities = eager ?? pkg.entityNames
65
+
66
+ /** One record's translated fields, falling back to `defaultLanguage` then `{}`. */
67
+ function tr(entity, id, lang = defaultLanguage) {
68
+ return pkg.tr(entity, id, lang, defaultLanguage)
69
+ }
70
+
71
+ const defaultGlossary = computed(() =>
72
+ glossary ? glossaryEntries(glossaryRows(defaultLanguage, translations)) : [],
73
+ )
74
+
75
+ // A caller's own `glossary` — a record's own terms, not every term in the
76
+ // package — always wins, whether the default binding is on or off; `md`
77
+ // and `mdInline` differ only in which core renderer they wrap, `mdStrip`
78
+ // has nothing to bind, since plain text carries no highlighting.
79
+ function bindGlossary(renderer) {
80
+ return (text, opts = {}) =>
81
+ renderer(text, { ...opts, glossary: 'glossary' in opts ? opts.glossary : defaultGlossary.value })
82
+ }
83
+ const md = bindGlossary(coreMd)
84
+ const mdInline = bindGlossary(coreMdInline)
85
+ const mdStrip = coreMdStrip
86
+
87
+ /** `mdStrip(tr(entity, id).name ?? record[fallback] ?? id)` — the one label shape every site wrote. */
88
+ function labelOf(entity, id, { fallback = 'internal_name' } = {}) {
89
+ if (!id) return ''
90
+ const name = tr(entity, id).name
91
+ if (name) return mdStrip(name)
92
+ const record = coreById(entity).value.get(id)
93
+ return mdStrip(record?.[fallback] ?? id)
94
+ }
95
+
96
+ let englishReady = null
97
+ /** Loads `defaultLanguage` for every entity in `eager`, once. */
98
+ function loadEnglish() {
99
+ if (!englishReady) {
100
+ englishReady = Promise.all(eagerEntities.map((name) => loadTranslations(name, defaultLanguage)))
101
+ }
102
+ return englishReady
103
+ }
104
+
105
+ // Cached the way `byId` is: a site reads `entity('items')`/`index('items')`
106
+ // from more than one place, and each should see the same ref rather than
107
+ // a fresh filter pass and a fresh Map per call.
108
+ const entityCache = new Map()
109
+ /** `entityRef(name)`'s records, narrowed by `visible[name]` when one is declared; `null` until loaded. */
110
+ function entity(name) {
111
+ let ref = entityCache.get(name)
112
+ if (!ref) {
113
+ const source = entityRef(name)
114
+ const predicate = visible[name]
115
+ ref = computed(() => {
116
+ if (source.value === null) return null
117
+ return predicate ? source.value.filter(predicate) : source.value
118
+ })
119
+ entityCache.set(name, ref)
120
+ }
121
+ return ref
122
+ }
123
+
124
+ const indexCache = new Map()
125
+ /** A `Map` of `entity(name)`'s records by `key` — the visible ones, unlike `byId`. */
126
+ function index(name, key = 'id') {
127
+ const cacheKey = `${name}.${key}`
128
+ let ref = indexCache.get(cacheKey)
129
+ if (!ref) {
130
+ const list = entity(name)
131
+ ref = computed(() => new Map((list.value ?? []).map((record) => [record[key], record])))
132
+ indexCache.set(cacheKey, ref)
133
+ }
134
+ return ref
135
+ }
136
+
137
+ return {
138
+ tr, md, mdInline, mdStrip, loadEnglish, labelOf, entity, index,
139
+ availableLanguages, loadTranslations, translations,
140
+ }
141
+ }
@@ -1,98 +1,98 @@
1
- import { ref } from 'vue'
2
-
3
- // package.json is npm metadata, not an entity — exclude it from the glob.
4
- const entityModules = import.meta.glob(['@inventory-data/*.json', '!**/package.json'])
5
- const manifestModules = import.meta.glob('@inventory-data/manifest.json', { eager: true })
6
-
7
- // One file per entity per language; a file is simply absent when that entity
8
- // has no translation in that language. Left lazy (unlike entityModules'
9
- // eager-in-spirit-but-actually-per-call loaders, this one also needs a
10
- // reactive cache — see below) so a build only bundles what a page visit
11
- // actually requests, not every language of every entity.
12
- const translationModules = import.meta.glob('@inventory-data/translations/*.json')
13
-
14
- const manifest = Object.values(manifestModules)[0]?.default ?? {}
15
-
16
- const entityLoaders = {}
17
- for (const [path, load] of Object.entries(entityModules)) {
18
- const name = path.split('/').pop().replace(/\.json$/, '')
19
- if (name !== 'manifest') {
20
- entityLoaders[name] = load
21
- }
22
- }
23
-
24
- function translationLoader(entity, lang) {
25
- const suffix = `/translations/${entity}.${lang}.json`
26
- const path = Object.keys(translationModules).find((k) => k.endsWith(suffix))
27
- return path ? translationModules[path] : null
28
- }
29
-
30
- // `${entity}.${lang}` → record map, or `{}` once a miss has been resolved.
31
- // A plain `await import()` would already be deduped by the module system;
32
- // this cache exists so a component reading through `translations`/`tr` -
33
- // synchronous, not awaited - re-renders once a lazy `loadTranslations` call
34
- // elsewhere resolves.
35
- const translationCache = ref({})
36
-
37
- export function useDataPackage() {
38
- return {
39
- manifest,
40
- languages: manifest.languages ?? ['en'],
41
- entityNames: Object.keys(entityLoaders).sort(),
42
- async loadEntity(name) {
43
- const load = entityLoaders[name]
44
- if (!load) {
45
- throw new Error(`Unknown entity "${name}" in the data package`)
46
- }
47
- const module = await load()
48
- return module.default ?? module
49
- },
50
-
51
- /** Languages `translations/<entity>.<lang>.json` actually exists for. */
52
- availableLanguages(entity) {
53
- const prefix = `/translations/${entity}.`
54
- return Object.keys(translationModules)
55
- .filter((k) => k.includes(prefix))
56
- .map((k) => k.slice(k.lastIndexOf(prefix) + prefix.length, -'.json'.length))
57
- },
58
-
59
- /**
60
- * Lazy-loads `translations/<entity>.<lang>.json` (a map of record id to
61
- * that record's translated fields); `{}` when the file does not exist.
62
- * Cached, so calling it again for the same pair is free.
63
- */
64
- async loadTranslations(entity, lang) {
65
- const key = `${entity}.${lang}`
66
- if (translationCache.value[key]) return translationCache.value[key]
67
- const load = translationLoader(entity, lang)
68
- let data = {}
69
- if (load) {
70
- try {
71
- data = (await load()).default ?? {}
72
- } catch {
73
- data = {}
74
- }
75
- }
76
- translationCache.value = { ...translationCache.value, [key]: data }
77
- return data
78
- },
79
-
80
- /** Already-loaded translations for one entity/lang; `{}` if not loaded yet (or absent). */
81
- translations(entity, lang) {
82
- return translationCache.value[`${entity}.${lang}`] ?? {}
83
- },
84
-
85
- /**
86
- * One record's translated fields, falling back to `fallbackLang` (default
87
- * `'en'`) then to `{}`. Reads only what is already loaded — call
88
- * `loadTranslations` first for each language a page may need.
89
- */
90
- tr(entity, id, lang, fallbackLang = 'en') {
91
- return (
92
- translationCache.value[`${entity}.${lang}`]?.[id] ??
93
- translationCache.value[`${entity}.${fallbackLang}`]?.[id] ??
94
- {}
95
- )
96
- },
97
- }
98
- }
1
+ import { ref } from 'vue'
2
+
3
+ // package.json is npm metadata, not an entity — exclude it from the glob.
4
+ const entityModules = import.meta.glob(['@inventory-data/*.json', '!**/package.json'])
5
+ const manifestModules = import.meta.glob('@inventory-data/manifest.json', { eager: true })
6
+
7
+ // One file per entity per language; a file is simply absent when that entity
8
+ // has no translation in that language. Left lazy (unlike entityModules'
9
+ // eager-in-spirit-but-actually-per-call loaders, this one also needs a
10
+ // reactive cache — see below) so a build only bundles what a page visit
11
+ // actually requests, not every language of every entity.
12
+ const translationModules = import.meta.glob('@inventory-data/translations/*.json')
13
+
14
+ const manifest = Object.values(manifestModules)[0]?.default ?? {}
15
+
16
+ const entityLoaders = {}
17
+ for (const [path, load] of Object.entries(entityModules)) {
18
+ const name = path.split('/').pop().replace(/\.json$/, '')
19
+ if (name !== 'manifest') {
20
+ entityLoaders[name] = load
21
+ }
22
+ }
23
+
24
+ function translationLoader(entity, lang) {
25
+ const suffix = `/translations/${entity}.${lang}.json`
26
+ const path = Object.keys(translationModules).find((k) => k.endsWith(suffix))
27
+ return path ? translationModules[path] : null
28
+ }
29
+
30
+ // `${entity}.${lang}` → record map, or `{}` once a miss has been resolved.
31
+ // A plain `await import()` would already be deduped by the module system;
32
+ // this cache exists so a component reading through `translations`/`tr` -
33
+ // synchronous, not awaited - re-renders once a lazy `loadTranslations` call
34
+ // elsewhere resolves.
35
+ const translationCache = ref({})
36
+
37
+ export function useDataPackage() {
38
+ return {
39
+ manifest,
40
+ languages: manifest.languages ?? ['en'],
41
+ entityNames: Object.keys(entityLoaders).sort(),
42
+ async loadEntity(name) {
43
+ const load = entityLoaders[name]
44
+ if (!load) {
45
+ throw new Error(`Unknown entity "${name}" in the data package`)
46
+ }
47
+ const module = await load()
48
+ return module.default ?? module
49
+ },
50
+
51
+ /** Languages `translations/<entity>.<lang>.json` actually exists for. */
52
+ availableLanguages(entity) {
53
+ const prefix = `/translations/${entity}.`
54
+ return Object.keys(translationModules)
55
+ .filter((k) => k.includes(prefix))
56
+ .map((k) => k.slice(k.lastIndexOf(prefix) + prefix.length, -'.json'.length))
57
+ },
58
+
59
+ /**
60
+ * Lazy-loads `translations/<entity>.<lang>.json` (a map of record id to
61
+ * that record's translated fields); `{}` when the file does not exist.
62
+ * Cached, so calling it again for the same pair is free.
63
+ */
64
+ async loadTranslations(entity, lang) {
65
+ const key = `${entity}.${lang}`
66
+ if (translationCache.value[key]) return translationCache.value[key]
67
+ const load = translationLoader(entity, lang)
68
+ let data = {}
69
+ if (load) {
70
+ try {
71
+ data = (await load()).default ?? {}
72
+ } catch {
73
+ data = {}
74
+ }
75
+ }
76
+ translationCache.value = { ...translationCache.value, [key]: data }
77
+ return data
78
+ },
79
+
80
+ /** Already-loaded translations for one entity/lang; `{}` if not loaded yet (or absent). */
81
+ translations(entity, lang) {
82
+ return translationCache.value[`${entity}.${lang}`] ?? {}
83
+ },
84
+
85
+ /**
86
+ * One record's translated fields, falling back to `fallbackLang` (default
87
+ * `'en'`) then to `{}`. Reads only what is already loaded — call
88
+ * `loadTranslations` first for each language a page may need.
89
+ */
90
+ tr(entity, id, lang, fallbackLang = 'en') {
91
+ return (
92
+ translationCache.value[`${entity}.${lang}`]?.[id] ??
93
+ translationCache.value[`${entity}.${fallbackLang}`]?.[id] ??
94
+ {}
95
+ )
96
+ },
97
+ }
98
+ }