@glossarist/concept-browser 0.7.50 → 0.7.52
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/cli/index.mjs +32 -0
- package/env.d.ts +15 -0
- package/package.json +12 -2
- package/scripts/__tests__/doctor.test.mjs +147 -0
- package/scripts/doctor.mjs +327 -0
- package/scripts/generate-data.mjs +136 -0
- package/scripts/generate-ontology-data.mjs +3 -3
- package/scripts/generate-ontology-schema.mjs +3 -3
- package/scripts/lib/agents-turtle.mjs +64 -0
- package/scripts/lib/bibliography-turtle.mjs +54 -0
- package/scripts/lib/build-activity-turtle.mjs +92 -0
- package/scripts/lib/build-cache.mjs +70 -0
- package/scripts/lib/dataset-turtle.mjs +79 -0
- package/scripts/lib/turtle-escape.mjs +0 -0
- package/scripts/lib/version-turtle.mjs +56 -0
- package/scripts/lib/vocab-turtle.mjs +64 -0
- package/scripts/normalize-yaml.mjs +99 -0
- package/scripts/sync-concept-model.mjs +86 -0
- package/scripts/validate-shacl.mjs +194 -0
- package/src/__fixtures__/concept-shape.ttl +20 -0
- package/src/__fixtures__/shacl/bad/concept.ttl +7 -0
- package/src/__fixtures__/shacl/empty/.gitkeep +0 -0
- package/src/__fixtures__/shacl/good/concept.ttl +8 -0
- package/src/__tests__/__fixtures__/concepts.ts +221 -0
- package/src/__tests__/adapters/concept-identity.test.ts +76 -0
- package/src/__tests__/components/error-boundary.test.ts +109 -0
- package/src/__tests__/composables/use-dataset-series.test.ts +262 -0
- package/src/__tests__/concept-rdf/agents-emitter.test.ts +110 -0
- package/src/__tests__/concept-rdf/bibliography-emitter.test.ts +159 -0
- package/src/__tests__/concept-rdf/build-activity-emitter.test.ts +119 -0
- package/src/__tests__/concept-rdf/coerce-date.test.ts +97 -0
- package/src/__tests__/concept-rdf/concept-emitter.test.ts +258 -0
- package/src/__tests__/concept-rdf/dataset-emitter.test.ts +224 -0
- package/src/__tests__/concept-rdf/differential.test.ts +96 -0
- package/src/__tests__/concept-rdf/group-emitter.test.ts +109 -0
- package/src/__tests__/concept-rdf/image-variant-emitter.test.ts +135 -0
- package/src/__tests__/concept-rdf/jsonld-writer.test.ts +109 -0
- package/src/__tests__/concept-rdf/nonverbal-rep.test.ts +177 -0
- package/src/__tests__/concept-rdf/property-based.test.ts +179 -0
- package/src/__tests__/concept-rdf/provenance.test.ts +110 -0
- package/src/__tests__/concept-rdf/quad-isomorphism.test.ts +43 -0
- package/src/__tests__/concept-rdf/quad-isomorphism.ts +47 -0
- package/src/__tests__/concept-rdf/rdf-components.test.ts +145 -0
- package/src/__tests__/concept-rdf/rdf-graph.test.ts +115 -0
- package/src/__tests__/concept-rdf/round-trip.test.ts +243 -0
- package/src/__tests__/concept-rdf/scoped-examples.test.ts +126 -0
- package/src/__tests__/concept-rdf/sections-builder.test.ts +94 -0
- package/src/__tests__/concept-rdf/shacl-conformance.test.ts +110 -0
- package/src/__tests__/concept-rdf/shape-consistency.test.ts +138 -0
- package/src/__tests__/concept-rdf/snapshot-generation.test.ts +75 -0
- package/src/__tests__/concept-rdf/table-formula-emitter.test.ts +142 -0
- package/src/__tests__/concept-rdf/turtle-writer.test.ts +114 -0
- package/src/__tests__/concept-rdf/use-rdf-document.test.ts +246 -0
- package/src/__tests__/concept-rdf/version-emitter.test.ts +120 -0
- package/src/__tests__/concept-rdf/vocabulary-ssot.test.ts +100 -0
- package/src/__tests__/concept-rdf-view.test.ts +136 -0
- package/src/__tests__/dataset-style.test.ts +12 -7
- package/src/__tests__/errors/errors.test.ts +142 -0
- package/src/__tests__/format-downloads.test.ts +47 -65
- package/src/__tests__/markdown-lite.test.ts +19 -0
- package/src/__tests__/perf/bundle-layout.test.ts +50 -0
- package/src/__tests__/perf/serialization-perf.test.ts +121 -0
- package/src/__tests__/scripts/agents-turtle.test.ts +61 -0
- package/src/__tests__/scripts/bibliography-turtle.test.ts +59 -0
- package/src/__tests__/scripts/build-activity-turtle.test.ts +75 -0
- package/src/__tests__/scripts/build-cache.test.ts +78 -0
- package/src/__tests__/scripts/build-integration.test.ts +134 -0
- package/src/__tests__/scripts/dataset-turtle.test.ts +94 -0
- package/src/__tests__/scripts/normalize-yaml.test.ts +98 -0
- package/src/__tests__/scripts/stryker-config.test.ts +33 -0
- package/src/__tests__/scripts/turtle-escape.test.ts +63 -0
- package/src/__tests__/scripts/version-turtle.test.ts +72 -0
- package/src/__tests__/scripts/vocab-turtle.test.ts +63 -0
- package/src/__tests__/use-format-registry.test.ts +125 -0
- package/src/__tests__/utils/bcp47.test.ts +166 -0
- package/src/__tests__/utils/color-theme.test.ts +143 -0
- package/src/__tests__/utils/url-safety.test.ts +65 -0
- package/src/__tests__/validate-shacl.test.ts +100 -0
- package/src/adapters/DatasetAdapter.ts +11 -5
- package/src/adapters/GraphDataSource.ts +2 -1
- package/src/adapters/UriRouter.ts +2 -1
- package/src/adapters/concept-identity.ts +69 -0
- package/src/adapters/factory.ts +3 -2
- package/src/adapters/model-bridge.ts +2 -1
- package/src/adapters/non-verbal/figure-bridge.ts +22 -23
- package/src/adapters/non-verbal/formula-bridge.ts +11 -9
- package/src/adapters/non-verbal/glossarist-augment.d.ts +133 -0
- package/src/adapters/non-verbal/index.ts +12 -9
- package/src/adapters/non-verbal/kind.ts +2 -1
- package/src/adapters/non-verbal/table-bridge.ts +12 -10
- package/src/adapters/non-verbal/types.ts +36 -54
- package/src/adapters/non-verbal-resolver.ts +6 -3
- package/src/components/AppSidebar.vue +189 -93
- package/src/components/ConceptDetail.vue +8 -0
- package/src/components/ConceptEditionRail.vue +222 -0
- package/src/components/ConceptRdfView.vue +37 -377
- package/src/components/DatasetSeriesCard.vue +270 -0
- package/src/components/ErrorBoundary.vue +95 -0
- package/src/components/FormatDownloads.vue +17 -13
- package/src/components/HomeSeriesSection.vue +277 -0
- package/src/components/NonVerbalRepDisplay.vue +2 -2
- package/src/components/RelationSphere.vue +1672 -0
- package/src/components/SidebarSeriesSection.vue +239 -0
- package/src/components/concept-rdf/RdfInstanceHeader.vue +47 -0
- package/src/components/concept-rdf/RdfInstanceSection.vue +54 -0
- package/src/components/concept-rdf/RdfPrefixLegend.vue +27 -0
- package/src/components/concept-rdf/RdfSourcePanel.vue +72 -0
- package/src/components/concept-rdf/agents-emitter.ts +82 -0
- package/src/components/concept-rdf/bibliography-emitter.ts +83 -0
- package/src/components/concept-rdf/build-activity-emitter.ts +89 -0
- package/src/components/concept-rdf/concept-emitter.ts +443 -0
- package/src/components/concept-rdf/dataset-emitter.ts +95 -0
- package/src/components/concept-rdf/group-emitter.ts +69 -0
- package/src/components/concept-rdf/image-variant-emitter.ts +46 -0
- package/src/components/concept-rdf/jsonld-writer.ts +82 -0
- package/src/components/concept-rdf/predicates.ts +261 -0
- package/src/components/concept-rdf/provenance.ts +80 -0
- package/src/components/concept-rdf/rdf-graph.ts +211 -0
- package/src/components/concept-rdf/rdf-prefixes.ts +23 -0
- package/src/components/concept-rdf/sections-builder.ts +62 -0
- package/src/components/concept-rdf/table-formula-emitter.ts +101 -0
- package/src/components/concept-rdf/turtle-writer.ts +116 -0
- package/src/components/concept-rdf/use-rdf-document.ts +72 -0
- package/src/components/concept-rdf/version-emitter.ts +65 -0
- package/src/components/concept-rdf/vocabulary-emitter.ts +62 -0
- package/src/components/figure/FigureDisplay.vue +16 -15
- package/src/components/figure/FigureImages.vue +38 -16
- package/src/components/figure/figure-image-pick.ts +1 -1
- package/src/components/figure/figure-layout.ts +1 -1
- package/src/components/formula/FormulaDisplay.vue +11 -9
- package/src/components/formula/FormulaExpression.vue +4 -4
- package/src/components/non-verbal/NonVerbalCaption.vue +5 -5
- package/src/components/non-verbal/NonVerbalSources.vue +3 -11
- package/src/components/table/TableDisplay.vue +6 -4
- package/src/components/table/TableMarkup.vue +1 -1
- package/src/composables/use-color-theme.ts +82 -0
- package/src/composables/use-format-registry.ts +42 -0
- package/src/composables/use-non-verbal-entity.ts +2 -1
- package/src/composables/useDatasetSeries.ts +258 -0
- package/src/composables/useSphereProjection.ts +125 -0
- package/src/config/group-types.ts +92 -0
- package/src/config/types.ts +81 -2
- package/src/config/use-site-config.ts +2 -1
- package/src/errors.ts +136 -0
- package/src/i18n/locales/eng.yml +24 -0
- package/src/i18n/locales/fra.yml +24 -0
- package/src/stores/vocabulary.ts +3 -1
- package/src/style.css +17 -2
- package/src/types/agents-version-turtle.d.ts +27 -0
- package/src/types/bibliography-turtle.d.ts +12 -0
- package/src/types/build-activity-turtle.d.ts +16 -0
- package/src/types/build-cache.d.ts +20 -0
- package/src/types/dataset-turtle.d.ts +32 -0
- package/src/types/normalize-yaml.d.ts +16 -0
- package/src/types/turtle-escape.d.ts +6 -0
- package/src/types/vocab-turtle.d.ts +13 -0
- package/src/utils/asciidoc-lite.ts +11 -6
- package/src/utils/bcp47.ts +141 -0
- package/src/utils/color-theme-integration.ts +11 -0
- package/src/utils/color-theme.ts +129 -0
- package/src/utils/dataset-style.ts +31 -6
- package/src/utils/locale.ts +6 -14
- package/src/utils/markdown-lite.ts +6 -1
- package/src/utils/relation-sphere-styling.ts +63 -0
- package/src/utils/relationship-categories.ts +30 -0
- package/src/utils/url-safety.ts +30 -0
- package/src/views/ConceptView.vue +183 -9
- package/src/views/DatasetView.vue +6 -0
- package/src/views/HomeView.vue +5 -0
- package/vite.config.ts +7 -0
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
GlossaristError,
|
|
4
|
+
ConfigurationError,
|
|
5
|
+
DataSourceError,
|
|
6
|
+
SerializationError,
|
|
7
|
+
ValidationError,
|
|
8
|
+
UnknownDatasetError,
|
|
9
|
+
DatasetRegistryLoadError,
|
|
10
|
+
ManifestLoadError,
|
|
11
|
+
IndexLoadError,
|
|
12
|
+
ChunkLoadError,
|
|
13
|
+
ConceptNotFoundError,
|
|
14
|
+
NonVerbalEntityNotFoundError,
|
|
15
|
+
InvalidConceptIdentityError,
|
|
16
|
+
InvalidConceptUriError,
|
|
17
|
+
formatError,
|
|
18
|
+
isGlossaristError,
|
|
19
|
+
} from '../../errors';
|
|
20
|
+
|
|
21
|
+
describe('GlossaristError hierarchy', () => {
|
|
22
|
+
it('preserves the constructor name as Error#name', () => {
|
|
23
|
+
const err = new UnknownDatasetError('foo', { registerId: 'foo' });
|
|
24
|
+
expect(err.name).toBe('UnknownDatasetError');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('attaches the context object verbatim', () => {
|
|
28
|
+
const err = ConceptNotFoundError.make('iso1', '3.1.1');
|
|
29
|
+
expect(err.context.registerId).toBe('iso1');
|
|
30
|
+
expect(err.context.conceptId).toBe('3.1.1');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('branches inherit from the correct top-level class', () => {
|
|
34
|
+
expect(new UnknownDatasetError('x')).toBeInstanceOf(ConfigurationError);
|
|
35
|
+
expect(ConceptNotFoundError.make('r', 'c')).toBeInstanceOf(DataSourceError);
|
|
36
|
+
expect(new InvalidConceptIdentityError('bad')).toBeInstanceOf(SerializationError);
|
|
37
|
+
expect(new ValidationError('bad')).toBeInstanceOf(GlossaristError);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('all errors are instanceof GlossaristError and Error', () => {
|
|
41
|
+
const cases = [
|
|
42
|
+
new UnknownDatasetError('x'),
|
|
43
|
+
new DataSourceError('x'),
|
|
44
|
+
new SerializationError('x'),
|
|
45
|
+
new ValidationError('x'),
|
|
46
|
+
];
|
|
47
|
+
for (const err of cases) {
|
|
48
|
+
expect(err).toBeInstanceOf(GlossaristError);
|
|
49
|
+
expect(err).toBeInstanceOf(Error);
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe('factories', () => {
|
|
55
|
+
it('UnknownDatasetError.make produces a descriptive message', () => {
|
|
56
|
+
const err = UnknownDatasetError.make('iso-foobar');
|
|
57
|
+
expect(err.message).toContain('iso-foobar');
|
|
58
|
+
expect(err.context.registerId).toBe('iso-foobar');
|
|
59
|
+
expect(err.hint()).toMatch(/datasets/i);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('DatasetRegistryLoadError.make captures status and URL', () => {
|
|
63
|
+
const err = DatasetRegistryLoadError.make(503, '/datasets.json');
|
|
64
|
+
expect(err.context.status).toBe(503);
|
|
65
|
+
expect(err.context.url).toBe('/datasets.json');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('ManifestLoadError and IndexLoadError tag registerId', () => {
|
|
69
|
+
expect(ManifestLoadError.make('iso1', 500).context.registerId).toBe('iso1');
|
|
70
|
+
expect(IndexLoadError.make('iso1', 404).context.registerId).toBe('iso1');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('ChunkLoadError records chunk index', () => {
|
|
74
|
+
const err = ChunkLoadError.make('iso1', 3, 500);
|
|
75
|
+
expect(err.context.chunkIndex).toBe(3);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('ConceptNotFoundError formats a stable message', () => {
|
|
79
|
+
const err = ConceptNotFoundError.make('iso1', '3.1.1');
|
|
80
|
+
expect(err.message).toBe('Concept 3.1.1 not found in iso1');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('NonVerbalEntityNotFoundError captures kind and id', () => {
|
|
84
|
+
const err = NonVerbalEntityNotFoundError.make('iso1', 'figure', 'fig-1', 404);
|
|
85
|
+
expect(err.context.kind).toBe('figure');
|
|
86
|
+
expect(err.context.entityId).toBe('fig-1');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('InvalidConceptUriError.make formats a hint', () => {
|
|
90
|
+
const err = InvalidConceptUriError.make('https://example.com/foo');
|
|
91
|
+
expect(err.context.uri).toBe('https://example.com/foo');
|
|
92
|
+
expect(err.hint()).toContain('<uriBase>');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('InvalidConceptIdentityError provides a hint', () => {
|
|
96
|
+
const err = new InvalidConceptIdentityError('bad');
|
|
97
|
+
expect(err.hint()).toMatch(/localId/);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe('formatError', () => {
|
|
102
|
+
it('starts with the class name and message', () => {
|
|
103
|
+
const out = formatError(ConceptNotFoundError.make('iso1', '3.1.1'));
|
|
104
|
+
expect(out.split('\n')[0]).toBe('ConceptNotFoundError: Concept 3.1.1 not found in iso1');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('renders context fields one per line', () => {
|
|
108
|
+
const out = formatError(ManifestLoadError.make('iso1', 500));
|
|
109
|
+
expect(out).toContain('registerId: iso1');
|
|
110
|
+
expect(out).toContain('status: 500');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('appends the hint when present', () => {
|
|
114
|
+
const out = formatError(UnknownDatasetError.make('iso1'));
|
|
115
|
+
expect(out).toContain('hint:');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('omits empty context', () => {
|
|
119
|
+
const out = formatError(new ValidationError('boom'));
|
|
120
|
+
expect(out).toBe('ValidationError: boom');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('omits undefined fields but keeps zero/false values', () => {
|
|
124
|
+
const err = new ValidationError('boom', { count: 0, flag: false, missing: undefined });
|
|
125
|
+
const out = formatError(err);
|
|
126
|
+
expect(out).toContain('count: 0');
|
|
127
|
+
expect(out).toContain('flag: false');
|
|
128
|
+
expect(out).not.toContain('missing');
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
describe('isGlossaristError', () => {
|
|
133
|
+
it('returns true for any subclass of GlossaristError', () => {
|
|
134
|
+
expect(isGlossaristError(ConceptNotFoundError.make('r', 'c'))).toBe(true);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('returns false for plain Errors and non-errors', () => {
|
|
138
|
+
expect(isGlossaristError(new Error('plain'))).toBe(false);
|
|
139
|
+
expect(isGlossaristError(null)).toBe(false);
|
|
140
|
+
expect(isGlossaristError('string')).toBe(false);
|
|
141
|
+
});
|
|
142
|
+
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest';
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
2
|
import { mount } from '@vue/test-utils';
|
|
3
|
-
import { createPinia
|
|
3
|
+
import { createPinia } from 'pinia';
|
|
4
4
|
import { createRouter, createMemoryHistory } from 'vue-router';
|
|
5
5
|
import FormatDownloads from '../components/FormatDownloads.vue';
|
|
6
6
|
|
|
@@ -13,86 +13,68 @@ async function createTestRouter() {
|
|
|
13
13
|
});
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
async function mountWithRouter(props: Partial<{ registerId: string; conceptId: string; formats: string[] }> = {}) {
|
|
17
|
+
const router = await createTestRouter();
|
|
18
|
+
return mount(FormatDownloads, {
|
|
19
|
+
global: { plugins: [createPinia(), router] },
|
|
20
|
+
props: { registerId: 'test', conceptId: '1', formats: [], ...props },
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
16
24
|
describe('FormatDownloads', () => {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
props: { registerId: 'test', conceptId: '3.1.1.1', formats: ['ttl', 'jsonld'] },
|
|
24
|
-
});
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
(import.meta.env as any).BASE_URL = '/';
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('renders download links for known per-concept formats', async () => {
|
|
30
|
+
const wrapper = await mountWithRouter({ conceptId: '3.1.1.1', formats: ['ttl', 'jsonld'] });
|
|
25
31
|
expect(wrapper.text()).toContain('Downloads');
|
|
26
|
-
expect(wrapper.text()).toContain('Turtle
|
|
32
|
+
expect(wrapper.text()).toContain('Turtle');
|
|
27
33
|
expect(wrapper.text()).toContain('JSON-LD');
|
|
28
34
|
});
|
|
29
35
|
|
|
30
|
-
it('generates
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
|
|
36
|
+
it('generates URLs rooted at import.meta.env.BASE_URL', async () => {
|
|
37
|
+
const wrapper = await mountWithRouter({ conceptId: '3.1.1.1', formats: ['ttl'] });
|
|
38
|
+
expect(wrapper.find('a').attributes('href')).toBe('/data/test/concepts/3.1.1.1.ttl');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('prefixes URLs with BASE_URL on subpath deployments', async () => {
|
|
42
|
+
(import.meta.env as any).BASE_URL = '/iso-10303-2-vocab/';
|
|
43
|
+
const wrapper = await mountWithRouter({ conceptId: '3.1.1.1', formats: ['ttl'] });
|
|
44
|
+
expect(wrapper.find('a').attributes('href')).toBe('/iso-10303-2-vocab/data/test/concepts/3.1.1.1.ttl');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('strips trailing slashes from BASE_URL before joining', async () => {
|
|
48
|
+
(import.meta.env as any).BASE_URL = '/foo///';
|
|
49
|
+
const wrapper = await mountWithRouter({ conceptId: '3.1.1.1', formats: ['ttl'] });
|
|
50
|
+
expect(wrapper.find('a').attributes('href')).toBe('/foo/data/test/concepts/3.1.1.1.ttl');
|
|
40
51
|
});
|
|
41
52
|
|
|
42
53
|
it('filters unknown formats', async () => {
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
expect(
|
|
54
|
+
const wrapper = await mountWithRouter({ conceptId: '1', formats: ['ttl', 'unknown'] });
|
|
55
|
+
expect(wrapper.findAll('a').length).toBe(1);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('filters aggregate-only formats (e.g. TBX) out of per-concept downloads', async () => {
|
|
59
|
+
const wrapper = await mountWithRouter({ conceptId: '1', formats: ['ttl', 'tbx', 'jsonl'] });
|
|
60
|
+
const linkLabels = wrapper.findAll('a').map(a => a.text());
|
|
61
|
+
expect(linkLabels.some(l => l.includes('Turtle'))).toBe(true);
|
|
62
|
+
expect(linkLabels.some(l => l.includes('TBX'))).toBe(false);
|
|
63
|
+
expect(linkLabels.some(l => l.includes('JSON-Lines'))).toBe(false);
|
|
52
64
|
});
|
|
53
65
|
|
|
54
66
|
it('renders nothing when all formats are unknown', async () => {
|
|
55
|
-
const
|
|
56
|
-
setActivePinia(pinia);
|
|
57
|
-
const router = await createTestRouter();
|
|
58
|
-
const wrapper = mount(FormatDownloads, {
|
|
59
|
-
global: { plugins: [pinia, router] },
|
|
60
|
-
props: { registerId: 'test', conceptId: '1', formats: ['unknown'] },
|
|
61
|
-
});
|
|
67
|
+
const wrapper = await mountWithRouter({ formats: ['unknown'] });
|
|
62
68
|
expect(wrapper.find('a').exists()).toBe(false);
|
|
63
69
|
});
|
|
64
70
|
|
|
65
71
|
it('renders nothing when formats array is empty', async () => {
|
|
66
|
-
const
|
|
67
|
-
setActivePinia(pinia);
|
|
68
|
-
const router = await createTestRouter();
|
|
69
|
-
const wrapper = mount(FormatDownloads, {
|
|
70
|
-
global: { plugins: [pinia, router] },
|
|
71
|
-
props: { registerId: 'test', conceptId: '1', formats: [] },
|
|
72
|
-
});
|
|
72
|
+
const wrapper = await mountWithRouter({ formats: [] });
|
|
73
73
|
expect(wrapper.find('a').exists()).toBe(false);
|
|
74
74
|
});
|
|
75
75
|
|
|
76
|
-
it('sets download attribute
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
const router = await createTestRouter();
|
|
80
|
-
const wrapper = mount(FormatDownloads, {
|
|
81
|
-
global: { plugins: [pinia, router] },
|
|
82
|
-
props: { registerId: 'test', conceptId: '3.1.1.1', formats: ['jsonld'] },
|
|
83
|
-
});
|
|
84
|
-
const link = wrapper.find('a');
|
|
85
|
-
expect(link.attributes('download')).toBe('3.1.1.1.jsonld');
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
it('shows TBX-XML format', async () => {
|
|
89
|
-
const pinia = createPinia();
|
|
90
|
-
setActivePinia(pinia);
|
|
91
|
-
const router = await createTestRouter();
|
|
92
|
-
const wrapper = mount(FormatDownloads, {
|
|
93
|
-
global: { plugins: [pinia, router] },
|
|
94
|
-
props: { registerId: 'test', conceptId: '1', formats: ['tbx'] },
|
|
95
|
-
});
|
|
96
|
-
expect(wrapper.text()).toContain('TBX-XML');
|
|
76
|
+
it('sets download attribute to bare filename (no path)', async () => {
|
|
77
|
+
const wrapper = await mountWithRouter({ conceptId: '3.1.1.1', formats: ['jsonld'] });
|
|
78
|
+
expect(wrapper.find('a').attributes('download')).toBe('3.1.1.1.jsonld');
|
|
97
79
|
});
|
|
98
80
|
});
|
|
@@ -40,6 +40,25 @@ describe('renderMarkdown', () => {
|
|
|
40
40
|
expect(result).toContain('>label</a>');
|
|
41
41
|
});
|
|
42
42
|
|
|
43
|
+
it('strips dangerous schemes from markdown links (XSS defense)', () => {
|
|
44
|
+
const out1 = renderMarkdown('[click](javascript:alert(1))');
|
|
45
|
+
expect(out1).not.toContain('javascript:');
|
|
46
|
+
expect(out1).not.toContain('<a ');
|
|
47
|
+
|
|
48
|
+
const out2 = renderMarkdown('[x](data:text/html,<script>alert(1)</script>)');
|
|
49
|
+
expect(out2).not.toContain('data:');
|
|
50
|
+
expect(out2).not.toContain('<script');
|
|
51
|
+
|
|
52
|
+
const out3 = renderMarkdown('[x](vbscript:msgbox(1))');
|
|
53
|
+
expect(out3).not.toContain('vbscript:');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('still emits rel="noopener" on safe external links', () => {
|
|
57
|
+
const out = renderMarkdown('[link](https://example.com)');
|
|
58
|
+
expect(out).toContain('rel="noopener"');
|
|
59
|
+
expect(out).toContain('target="_blank"');
|
|
60
|
+
});
|
|
61
|
+
|
|
43
62
|
it('renders unordered lists', () => {
|
|
44
63
|
const result = renderMarkdown('- one\n- two');
|
|
45
64
|
expect(result).toContain('<ul>');
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
const DIST_DIR = 'dist';
|
|
6
|
+
const ASSETS_DIR = join(DIST_DIR, 'assets');
|
|
7
|
+
|
|
8
|
+
function distExists(): boolean {
|
|
9
|
+
return existsSync(ASSETS_DIR);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function readAsset(name: string): string {
|
|
13
|
+
return readFileSync(join(ASSETS_DIR, name), 'utf8');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function listJsAssets(): string[] {
|
|
17
|
+
return readdirSync(ASSETS_DIR).filter(f => f.endsWith('.js'));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const MAIN_CHUNK_RE = /^index-[^/]*\.js$/;
|
|
21
|
+
const CONCEPT_CHUNK_RE = /^ConceptView-[^/]*\.js$/;
|
|
22
|
+
|
|
23
|
+
describe.skipIf(!distExists())('bundle: RDF serializer stays out of main chunk', () => {
|
|
24
|
+
it('the main index-* chunk does not embed the RDF emitter', () => {
|
|
25
|
+
const mainChunks = listJsAssets().filter(f => MAIN_CHUNK_RE.test(f));
|
|
26
|
+
expect(mainChunks.length).toBeGreaterThan(0);
|
|
27
|
+
|
|
28
|
+
for (const chunk of mainChunks) {
|
|
29
|
+
const text = readAsset(chunk);
|
|
30
|
+
expect(text, `${chunk} should not embed use-rdf-document`).not.toMatch(/use-rdf-document/);
|
|
31
|
+
expect(text, `${chunk} should not emit skosxl:literalForm literals`).not.toMatch(/skosxl:literalForm/);
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('the ConceptView chunk DOES contain the RDF emitter', () => {
|
|
36
|
+
const conceptChunks = listJsAssets().filter(f => CONCEPT_CHUNK_RE.test(f));
|
|
37
|
+
expect(conceptChunks.length).toBeGreaterThan(0);
|
|
38
|
+
|
|
39
|
+
const merged = conceptChunks.map(readAsset).join('\n');
|
|
40
|
+
expect(merged).toMatch(/skosxl:literalForm/);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('no chunk emits the legacy `xl:` prefix', () => {
|
|
44
|
+
for (const chunk of listJsAssets()) {
|
|
45
|
+
const text = readAsset(chunk);
|
|
46
|
+
expect(text, `${chunk} should not contain the legacy xl: prefix`).not.toMatch(/\bxl:prefLabel/);
|
|
47
|
+
expect(text, `${chunk} should not contain the legacy xl:altLabel/`).not.toMatch(/\bxl:altLabel/);
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
});
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { Concept } from 'glossarist';
|
|
3
|
+
import { emitConceptGraph } from '../../components/concept-rdf/concept-emitter';
|
|
4
|
+
import { writeTurtle } from '../../components/concept-rdf/turtle-writer';
|
|
5
|
+
import { writeJsonLd } from '../../components/concept-rdf/jsonld-writer';
|
|
6
|
+
import type { ConceptFixture } from '../__fixtures__/concepts';
|
|
7
|
+
import { CONCEPT_FIXTURES } from '../__fixtures__/concepts';
|
|
8
|
+
|
|
9
|
+
const BASE = 'https://glossarist.org/fixtures/perf';
|
|
10
|
+
const TARGET_CONCEPT_COUNT = 500;
|
|
11
|
+
const SCALE_CONCEPT_COUNT = 10_000;
|
|
12
|
+
const TIME_BUDGET_MS = 2000;
|
|
13
|
+
const SCALE_TIME_BUDGET_MS = 15_000;
|
|
14
|
+
|
|
15
|
+
function makeConcepts(n: number): { uri: string; concept: Concept }[] {
|
|
16
|
+
const fixtures = CONCEPT_FIXTURES;
|
|
17
|
+
const out: { uri: string; concept: Concept }[] = [];
|
|
18
|
+
for (let i = 0; i < n; i++) {
|
|
19
|
+
const tpl: ConceptFixture = fixtures[i % fixtures.length];
|
|
20
|
+
const id = `${i + 1}`;
|
|
21
|
+
const concept = Concept.fromJSON({
|
|
22
|
+
id,
|
|
23
|
+
uri: `${BASE}/${id}`,
|
|
24
|
+
status: 'valid',
|
|
25
|
+
localizations: {
|
|
26
|
+
eng: {
|
|
27
|
+
language_code: 'eng',
|
|
28
|
+
entry_status: 'valid',
|
|
29
|
+
terms: [{ type: 'expression', designation: `${tpl.name} concept ${id}`, normative_status: 'preferred' }],
|
|
30
|
+
definition: [{ content: `Definition for ${tpl.name} #${id}.` }],
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
});
|
|
34
|
+
out.push({ uri: `${BASE}/${id}`, concept });
|
|
35
|
+
}
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe('Layer 7 — serialization performance regression', () => {
|
|
40
|
+
it(`emits Turtle for ${TARGET_CONCEPT_COUNT} concepts under ${TIME_BUDGET_MS}ms`, () => {
|
|
41
|
+
const concepts = makeConcepts(TARGET_CONCEPT_COUNT);
|
|
42
|
+
|
|
43
|
+
const start = performance.now();
|
|
44
|
+
for (const { concept, uri } of concepts) {
|
|
45
|
+
const { graph } = emitConceptGraph(concept, uri);
|
|
46
|
+
writeTurtle(graph);
|
|
47
|
+
}
|
|
48
|
+
const elapsed = performance.now() - start;
|
|
49
|
+
|
|
50
|
+
// eslint-disable-next-line no-console
|
|
51
|
+
console.log(`Layer 7 perf: emitted ${TARGET_CONCEPT_COUNT} concepts to Turtle in ${elapsed.toFixed(0)}ms`);
|
|
52
|
+
expect(elapsed).toBeLessThan(TIME_BUDGET_MS);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it(`emits JSON-LD for ${TARGET_CONCEPT_COUNT} concepts under ${TIME_BUDGET_MS}ms`, () => {
|
|
56
|
+
const concepts = makeConcepts(TARGET_CONCEPT_COUNT);
|
|
57
|
+
|
|
58
|
+
const start = performance.now();
|
|
59
|
+
for (const { concept, uri } of concepts) {
|
|
60
|
+
const { graph } = emitConceptGraph(concept, uri);
|
|
61
|
+
writeJsonLd(graph);
|
|
62
|
+
}
|
|
63
|
+
const elapsed = performance.now() - start;
|
|
64
|
+
|
|
65
|
+
// eslint-disable-next-line no-console
|
|
66
|
+
console.log(`Layer 7 perf: emitted ${TARGET_CONCEPT_COUNT} concepts to JSON-LD in ${elapsed.toFixed(0)}ms`);
|
|
67
|
+
expect(elapsed).toBeLessThan(TIME_BUDGET_MS);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('per-concept emit cost stays below 5ms on average', () => {
|
|
71
|
+
const concepts = makeConcepts(100);
|
|
72
|
+
const start = performance.now();
|
|
73
|
+
for (const { concept, uri } of concepts) {
|
|
74
|
+
const { graph } = emitConceptGraph(concept, uri);
|
|
75
|
+
writeTurtle(graph);
|
|
76
|
+
}
|
|
77
|
+
const elapsed = performance.now() - start;
|
|
78
|
+
const perConcept = elapsed / 100;
|
|
79
|
+
expect(perConcept).toBeLessThan(5);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe('Layer 7 — scale stress (P4: 10 000 concepts)', () => {
|
|
84
|
+
it(`emits ${SCALE_CONCEPT_COUNT} concepts to Turtle under ${SCALE_TIME_BUDGET_MS}ms`, () => {
|
|
85
|
+
const concepts = makeConcepts(SCALE_CONCEPT_COUNT);
|
|
86
|
+
const start = performance.now();
|
|
87
|
+
for (const { concept, uri } of concepts) {
|
|
88
|
+
const { graph } = emitConceptGraph(concept, uri);
|
|
89
|
+
writeTurtle(graph);
|
|
90
|
+
}
|
|
91
|
+
const elapsed = performance.now() - start;
|
|
92
|
+
// eslint-disable-next-line no-console
|
|
93
|
+
console.log(`P4 scale: emitted ${SCALE_CONCEPT_COUNT} concepts to Turtle in ${elapsed.toFixed(0)}ms`);
|
|
94
|
+
expect(elapsed).toBeLessThan(SCALE_TIME_BUDGET_MS);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('emission cost scales linearly (slope within 3x of the 500-concept baseline)', () => {
|
|
98
|
+
const small = makeConcepts(500);
|
|
99
|
+
const large = makeConcepts(SCALE_CONCEPT_COUNT);
|
|
100
|
+
|
|
101
|
+
const tSmall = timeTurtle(small);
|
|
102
|
+
const tLarge = timeTurtle(large);
|
|
103
|
+
const ratio = tLarge / tSmall;
|
|
104
|
+
const expectedRatio = SCALE_CONCEPT_COUNT / 500;
|
|
105
|
+
const overhead = ratio / expectedRatio;
|
|
106
|
+
|
|
107
|
+
// eslint-disable-next-line no-console
|
|
108
|
+
console.log(`P4 scale: 500→${SCALE_CONCEPT_COUNT} ratio=${ratio.toFixed(1)}x (expected ~${expectedRatio}x, overhead=${overhead.toFixed(2)}x)`);
|
|
109
|
+
|
|
110
|
+
expect(overhead).toBeLessThan(3);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
function timeTurtle(concepts: { concept: Concept; uri: string }[]): number {
|
|
115
|
+
const start = performance.now();
|
|
116
|
+
for (const { concept, uri } of concepts) {
|
|
117
|
+
const { graph } = emitConceptGraph(concept, uri);
|
|
118
|
+
writeTurtle(graph);
|
|
119
|
+
}
|
|
120
|
+
return performance.now() - start;
|
|
121
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { Parser, Store } from 'n3';
|
|
3
|
+
import { buildAgentsTurtle } from '../../../scripts/lib/agents-turtle.mjs';
|
|
4
|
+
|
|
5
|
+
function parse(turtle: string): Store {
|
|
6
|
+
const parser = new Parser({ format: 'Turtle' });
|
|
7
|
+
const store = new Store();
|
|
8
|
+
store.addQuads(parser.parse(turtle));
|
|
9
|
+
return store;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const FOAF = 'http://xmlns.com/foaf/0.1/';
|
|
13
|
+
const PROV = 'http://www.w3.org/ns/prov#';
|
|
14
|
+
const DCTERMS = 'http://purl.org/dc/terms/';
|
|
15
|
+
const RDF_TYPE = 'http://www.w3.org/1999/02/22-rdf-syntax-ns#type';
|
|
16
|
+
|
|
17
|
+
describe('buildAgentsTurtle (mjs)', () => {
|
|
18
|
+
it('parses without errors', () => {
|
|
19
|
+
const ttl = buildAgentsTurtle([{ name: 'Ada Lovelace' }]);
|
|
20
|
+
const store = parse(ttl);
|
|
21
|
+
expect(store.size).toBeGreaterThan(0);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('types each person as foaf:Person, prov:Person, prov:Agent', () => {
|
|
25
|
+
const ttl = buildAgentsTurtle([{ name: 'Ada Lovelace' }]);
|
|
26
|
+
const store = parse(ttl);
|
|
27
|
+
const types = store.getObjects('https://glossarist.org/agent/ada-lovelace', RDF_TYPE, null).map(q => q.value);
|
|
28
|
+
expect(types).toContain(`${FOAF}Person`);
|
|
29
|
+
expect(types).toContain(`${PROV}Person`);
|
|
30
|
+
expect(types).toContain(`${PROV}Agent`);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('records name, mailto mbox, role, and seeAlso', () => {
|
|
34
|
+
const ttl = buildAgentsTurtle([
|
|
35
|
+
{ name: 'Ada Lovelace', email: 'ada@example.org', role: 'Editor', url: 'https://example.org/ada' },
|
|
36
|
+
]);
|
|
37
|
+
const store = parse(ttl);
|
|
38
|
+
const iri = 'https://glossarist.org/agent/ada-lovelace';
|
|
39
|
+
expect(store.getObjects(iri, `${FOAF}name`, null).map(q => q.value)).toContain('Ada Lovelace');
|
|
40
|
+
expect(store.getObjects(iri, `${FOAF}mbox`, null).map(q => q.value)).toContain('mailto:ada@example.org');
|
|
41
|
+
expect(store.getObjects(iri, `${DCTERMS}description`, null).map(q => q.value)).toContain('Editor');
|
|
42
|
+
expect(store.getObjects(iri, 'http://www.w3.org/2000/01/rdf-schema#seeAlso', null).map(q => q.value)).toContain('https://example.org/ada');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('creates a single prov:Organization per unique organization', () => {
|
|
46
|
+
const ttl = buildAgentsTurtle([
|
|
47
|
+
{ name: 'Alice', organization: 'ISO' },
|
|
48
|
+
{ name: 'Bob', organization: 'ISO' },
|
|
49
|
+
]);
|
|
50
|
+
const store = parse(ttl);
|
|
51
|
+
const types = store.getObjects('https://glossarist.org/org/iso', RDF_TYPE, null).map(q => q.value);
|
|
52
|
+
expect(types).toContain(`${FOAF}Organization`);
|
|
53
|
+
expect(types).toContain(`${PROV}Organization`);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('emits only prefix declarations for an empty input', () => {
|
|
57
|
+
const ttl = buildAgentsTurtle([]);
|
|
58
|
+
expect(ttl).toContain('@prefix foaf:');
|
|
59
|
+
expect(ttl).not.toContain('<https://glossarist.org/agent/');
|
|
60
|
+
});
|
|
61
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { Parser, Store } from 'n3';
|
|
3
|
+
import { buildBibliographyTurtle } from '../../../scripts/lib/bibliography-turtle.mjs';
|
|
4
|
+
|
|
5
|
+
const FOAF = 'http://xmlns.com/foaf/0.1/';
|
|
6
|
+
const DCTERMS = 'http://purl.org/dc/terms/';
|
|
7
|
+
const RDF_TYPE = 'http://www.w3.org/1999/02/22-rdf-syntax-ns#type';
|
|
8
|
+
|
|
9
|
+
function parse(turtle: string): Store {
|
|
10
|
+
const parser = new Parser({ format: 'Turtle' });
|
|
11
|
+
const store = new Store();
|
|
12
|
+
store.addQuads(parser.parse(turtle));
|
|
13
|
+
return store;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe('buildBibliographyTurtle (mjs)', () => {
|
|
17
|
+
it('parses without errors', () => {
|
|
18
|
+
const ttl = buildBibliographyTurtle('iso-geodetic', { iso704: { reference: 'ISO 704' } });
|
|
19
|
+
const store = parse(ttl);
|
|
20
|
+
expect(store.size).toBeGreaterThan(0);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('types each entry as dcterms:BibliographicResource', () => {
|
|
24
|
+
const ttl = buildBibliographyTurtle('iso-geodetic', { iso704: { reference: 'ISO 704' } });
|
|
25
|
+
const store = parse(ttl);
|
|
26
|
+
const types = store.getObjects('https://glossarist.org/iso-geodetic/bib/iso704', RDF_TYPE, null).map(q => q.value);
|
|
27
|
+
expect(types).toContain(`${DCTERMS}BibliographicResource`);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('emits identifier, bibliographicCitation, and title', () => {
|
|
31
|
+
const ttl = buildBibliographyTurtle('iso-geodetic', {
|
|
32
|
+
iso704: { reference: 'ISO 704', title: 'Terminology work — Principles and methods' },
|
|
33
|
+
});
|
|
34
|
+
const store = parse(ttl);
|
|
35
|
+
const iri = 'https://glossarist.org/iso-geodetic/bib/iso704';
|
|
36
|
+
expect(store.getObjects(iri, `${DCTERMS}identifier`, null).map(q => q.value)).toContain('iso704');
|
|
37
|
+
expect(store.getObjects(iri, `${DCTERMS}bibliographicCitation`, null).map(q => q.value)).toContain('ISO 704');
|
|
38
|
+
expect(store.getObjects(iri, `${DCTERMS}title`, null).map(q => q.value)).toContain('Terminology work — Principles and methods');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('emits foaf:page when link is provided', () => {
|
|
42
|
+
const ttl = buildBibliographyTurtle('iso-geodetic', {
|
|
43
|
+
ref: { reference: 'X', link: 'https://example.org/x' },
|
|
44
|
+
});
|
|
45
|
+
const store = parse(ttl);
|
|
46
|
+
const iri = 'https://glossarist.org/iso-geodetic/bib/ref';
|
|
47
|
+
expect(store.getObjects(iri, `${FOAF}page`, null).map(q => q.value)).toContain('https://example.org/x');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('emits one resource per bibliography entry', () => {
|
|
51
|
+
const ttl = buildBibliographyTurtle('iso-geodetic', {
|
|
52
|
+
a: { reference: 'A' },
|
|
53
|
+
b: { reference: 'B' },
|
|
54
|
+
});
|
|
55
|
+
const store = parse(ttl);
|
|
56
|
+
expect(store.getObjects('https://glossarist.org/iso-geodetic/bib/a', `${DCTERMS}bibliographicCitation`, null).map(q => q.value)).toContain('A');
|
|
57
|
+
expect(store.getObjects('https://glossarist.org/iso-geodetic/bib/b', `${DCTERMS}bibliographicCitation`, null).map(q => q.value)).toContain('B');
|
|
58
|
+
});
|
|
59
|
+
});
|