@glossarist/concept-browser 0.7.51 → 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.
Files changed (152) hide show
  1. package/cli/index.mjs +32 -0
  2. package/env.d.ts +15 -0
  3. package/package.json +12 -2
  4. package/scripts/__tests__/doctor.test.mjs +147 -0
  5. package/scripts/doctor.mjs +327 -0
  6. package/scripts/generate-data.mjs +136 -0
  7. package/scripts/generate-ontology-data.mjs +3 -3
  8. package/scripts/generate-ontology-schema.mjs +3 -3
  9. package/scripts/lib/agents-turtle.mjs +64 -0
  10. package/scripts/lib/bibliography-turtle.mjs +54 -0
  11. package/scripts/lib/build-activity-turtle.mjs +92 -0
  12. package/scripts/lib/build-cache.mjs +70 -0
  13. package/scripts/lib/dataset-turtle.mjs +79 -0
  14. package/scripts/lib/turtle-escape.mjs +0 -0
  15. package/scripts/lib/version-turtle.mjs +56 -0
  16. package/scripts/lib/vocab-turtle.mjs +64 -0
  17. package/scripts/normalize-yaml.mjs +99 -0
  18. package/scripts/sync-concept-model.mjs +86 -0
  19. package/scripts/validate-shacl.mjs +194 -0
  20. package/src/__fixtures__/concept-shape.ttl +20 -0
  21. package/src/__fixtures__/shacl/bad/concept.ttl +7 -0
  22. package/src/__fixtures__/shacl/empty/.gitkeep +0 -0
  23. package/src/__fixtures__/shacl/good/concept.ttl +8 -0
  24. package/src/__tests__/__fixtures__/concepts.ts +221 -0
  25. package/src/__tests__/adapters/concept-identity.test.ts +76 -0
  26. package/src/__tests__/components/error-boundary.test.ts +109 -0
  27. package/src/__tests__/composables/use-dataset-series.test.ts +262 -0
  28. package/src/__tests__/concept-rdf/agents-emitter.test.ts +110 -0
  29. package/src/__tests__/concept-rdf/bibliography-emitter.test.ts +159 -0
  30. package/src/__tests__/concept-rdf/build-activity-emitter.test.ts +119 -0
  31. package/src/__tests__/concept-rdf/coerce-date.test.ts +97 -0
  32. package/src/__tests__/concept-rdf/concept-emitter.test.ts +258 -0
  33. package/src/__tests__/concept-rdf/dataset-emitter.test.ts +224 -0
  34. package/src/__tests__/concept-rdf/differential.test.ts +96 -0
  35. package/src/__tests__/concept-rdf/group-emitter.test.ts +109 -0
  36. package/src/__tests__/concept-rdf/image-variant-emitter.test.ts +135 -0
  37. package/src/__tests__/concept-rdf/jsonld-writer.test.ts +109 -0
  38. package/src/__tests__/concept-rdf/nonverbal-rep.test.ts +177 -0
  39. package/src/__tests__/concept-rdf/property-based.test.ts +179 -0
  40. package/src/__tests__/concept-rdf/provenance.test.ts +110 -0
  41. package/src/__tests__/concept-rdf/quad-isomorphism.test.ts +43 -0
  42. package/src/__tests__/concept-rdf/quad-isomorphism.ts +47 -0
  43. package/src/__tests__/concept-rdf/rdf-components.test.ts +145 -0
  44. package/src/__tests__/concept-rdf/rdf-graph.test.ts +115 -0
  45. package/src/__tests__/concept-rdf/round-trip.test.ts +243 -0
  46. package/src/__tests__/concept-rdf/scoped-examples.test.ts +126 -0
  47. package/src/__tests__/concept-rdf/sections-builder.test.ts +94 -0
  48. package/src/__tests__/concept-rdf/shacl-conformance.test.ts +110 -0
  49. package/src/__tests__/concept-rdf/shape-consistency.test.ts +138 -0
  50. package/src/__tests__/concept-rdf/snapshot-generation.test.ts +75 -0
  51. package/src/__tests__/concept-rdf/table-formula-emitter.test.ts +142 -0
  52. package/src/__tests__/concept-rdf/turtle-writer.test.ts +114 -0
  53. package/src/__tests__/concept-rdf/use-rdf-document.test.ts +246 -0
  54. package/src/__tests__/concept-rdf/version-emitter.test.ts +120 -0
  55. package/src/__tests__/concept-rdf/vocabulary-ssot.test.ts +100 -0
  56. package/src/__tests__/concept-rdf-view.test.ts +136 -0
  57. package/src/__tests__/dataset-style.test.ts +12 -7
  58. package/src/__tests__/errors/errors.test.ts +142 -0
  59. package/src/__tests__/format-downloads.test.ts +47 -65
  60. package/src/__tests__/markdown-lite.test.ts +19 -0
  61. package/src/__tests__/perf/bundle-layout.test.ts +50 -0
  62. package/src/__tests__/perf/serialization-perf.test.ts +121 -0
  63. package/src/__tests__/scripts/agents-turtle.test.ts +61 -0
  64. package/src/__tests__/scripts/bibliography-turtle.test.ts +59 -0
  65. package/src/__tests__/scripts/build-activity-turtle.test.ts +75 -0
  66. package/src/__tests__/scripts/build-cache.test.ts +78 -0
  67. package/src/__tests__/scripts/build-integration.test.ts +134 -0
  68. package/src/__tests__/scripts/dataset-turtle.test.ts +94 -0
  69. package/src/__tests__/scripts/normalize-yaml.test.ts +98 -0
  70. package/src/__tests__/scripts/stryker-config.test.ts +33 -0
  71. package/src/__tests__/scripts/turtle-escape.test.ts +63 -0
  72. package/src/__tests__/scripts/version-turtle.test.ts +72 -0
  73. package/src/__tests__/scripts/vocab-turtle.test.ts +63 -0
  74. package/src/__tests__/use-format-registry.test.ts +125 -0
  75. package/src/__tests__/utils/bcp47.test.ts +166 -0
  76. package/src/__tests__/utils/color-theme.test.ts +143 -0
  77. package/src/__tests__/utils/url-safety.test.ts +65 -0
  78. package/src/__tests__/validate-shacl.test.ts +100 -0
  79. package/src/adapters/DatasetAdapter.ts +11 -5
  80. package/src/adapters/GraphDataSource.ts +2 -1
  81. package/src/adapters/UriRouter.ts +2 -1
  82. package/src/adapters/concept-identity.ts +69 -0
  83. package/src/adapters/factory.ts +3 -2
  84. package/src/adapters/model-bridge.ts +2 -1
  85. package/src/adapters/non-verbal/glossarist-augment.d.ts +7 -0
  86. package/src/adapters/non-verbal-resolver.ts +2 -1
  87. package/src/components/AppSidebar.vue +189 -93
  88. package/src/components/ConceptDetail.vue +8 -0
  89. package/src/components/ConceptEditionRail.vue +222 -0
  90. package/src/components/ConceptRdfView.vue +37 -377
  91. package/src/components/DatasetSeriesCard.vue +270 -0
  92. package/src/components/ErrorBoundary.vue +95 -0
  93. package/src/components/FormatDownloads.vue +17 -13
  94. package/src/components/HomeSeriesSection.vue +277 -0
  95. package/src/components/RelationSphere.vue +1672 -0
  96. package/src/components/SidebarSeriesSection.vue +239 -0
  97. package/src/components/concept-rdf/RdfInstanceHeader.vue +47 -0
  98. package/src/components/concept-rdf/RdfInstanceSection.vue +54 -0
  99. package/src/components/concept-rdf/RdfPrefixLegend.vue +27 -0
  100. package/src/components/concept-rdf/RdfSourcePanel.vue +72 -0
  101. package/src/components/concept-rdf/agents-emitter.ts +82 -0
  102. package/src/components/concept-rdf/bibliography-emitter.ts +83 -0
  103. package/src/components/concept-rdf/build-activity-emitter.ts +89 -0
  104. package/src/components/concept-rdf/concept-emitter.ts +443 -0
  105. package/src/components/concept-rdf/dataset-emitter.ts +95 -0
  106. package/src/components/concept-rdf/group-emitter.ts +69 -0
  107. package/src/components/concept-rdf/image-variant-emitter.ts +46 -0
  108. package/src/components/concept-rdf/jsonld-writer.ts +82 -0
  109. package/src/components/concept-rdf/predicates.ts +261 -0
  110. package/src/components/concept-rdf/provenance.ts +80 -0
  111. package/src/components/concept-rdf/rdf-graph.ts +211 -0
  112. package/src/components/concept-rdf/rdf-prefixes.ts +23 -0
  113. package/src/components/concept-rdf/sections-builder.ts +62 -0
  114. package/src/components/concept-rdf/table-formula-emitter.ts +101 -0
  115. package/src/components/concept-rdf/turtle-writer.ts +116 -0
  116. package/src/components/concept-rdf/use-rdf-document.ts +72 -0
  117. package/src/components/concept-rdf/version-emitter.ts +65 -0
  118. package/src/components/concept-rdf/vocabulary-emitter.ts +62 -0
  119. package/src/composables/use-color-theme.ts +82 -0
  120. package/src/composables/use-format-registry.ts +42 -0
  121. package/src/composables/useDatasetSeries.ts +258 -0
  122. package/src/composables/useSphereProjection.ts +125 -0
  123. package/src/config/group-types.ts +92 -0
  124. package/src/config/types.ts +81 -2
  125. package/src/config/use-site-config.ts +2 -1
  126. package/src/errors.ts +136 -0
  127. package/src/i18n/locales/eng.yml +24 -0
  128. package/src/i18n/locales/fra.yml +24 -0
  129. package/src/stores/vocabulary.ts +3 -1
  130. package/src/style.css +17 -2
  131. package/src/types/agents-version-turtle.d.ts +27 -0
  132. package/src/types/bibliography-turtle.d.ts +12 -0
  133. package/src/types/build-activity-turtle.d.ts +16 -0
  134. package/src/types/build-cache.d.ts +20 -0
  135. package/src/types/dataset-turtle.d.ts +32 -0
  136. package/src/types/normalize-yaml.d.ts +16 -0
  137. package/src/types/turtle-escape.d.ts +6 -0
  138. package/src/types/vocab-turtle.d.ts +13 -0
  139. package/src/utils/asciidoc-lite.ts +11 -6
  140. package/src/utils/bcp47.ts +141 -0
  141. package/src/utils/color-theme-integration.ts +11 -0
  142. package/src/utils/color-theme.ts +129 -0
  143. package/src/utils/dataset-style.ts +31 -6
  144. package/src/utils/locale.ts +6 -14
  145. package/src/utils/markdown-lite.ts +6 -1
  146. package/src/utils/relation-sphere-styling.ts +63 -0
  147. package/src/utils/relationship-categories.ts +30 -0
  148. package/src/utils/url-safety.ts +30 -0
  149. package/src/views/ConceptView.vue +183 -9
  150. package/src/views/DatasetView.vue +6 -0
  151. package/src/views/HomeView.vue +5 -0
  152. package/vite.config.ts +7 -0
@@ -0,0 +1,63 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { Parser, Store } from 'n3';
3
+ import { buildVocabularyTurtle, listVocabSchemes } from '../../../scripts/lib/vocab-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 SKOS = 'http://www.w3.org/2004/02/skos/core#';
13
+ const RDF_TYPE = 'http://www.w3.org/1999/02/22-rdf-syntax-ns#type';
14
+
15
+ describe('buildVocabularyTurtle (mjs)', () => {
16
+ it('parses without errors and produces a non-empty graph', () => {
17
+ const ttl = buildVocabularyTurtle();
18
+ const store = parse(ttl);
19
+ expect(store.size).toBeGreaterThan(0);
20
+ });
21
+
22
+ it('declares at least one skos:ConceptScheme', () => {
23
+ const store = parse(buildVocabularyTurtle());
24
+ const schemes = [...store].filter(q =>
25
+ q.predicate.value === RDF_TYPE && q.object.value === `${SKOS}ConceptScheme`,
26
+ );
27
+ expect(schemes.length).toBeGreaterThan(0);
28
+ });
29
+
30
+ it('declares enumeration IRIs as skos:Concept instances', () => {
31
+ const store = parse(buildVocabularyTurtle());
32
+ const concepts = [...store].filter(q =>
33
+ q.predicate.value === RDF_TYPE && q.object.value === `${SKOS}Concept`,
34
+ );
35
+ expect(concepts.length).toBeGreaterThan(10);
36
+
37
+ const conceptIris = new Set(concepts.map(q => q.subject.value));
38
+ expect(conceptIris.has('https://www.glossarist.org/ontologies/status/valid')).toBe(true);
39
+ expect(conceptIris.has('https://www.glossarist.org/ontologies/norm/preferred')).toBe(true);
40
+ expect(conceptIris.has('https://www.glossarist.org/ontologies/datetype/accepted')).toBe(true);
41
+ });
42
+
43
+ it('listVocabSchemes returns the seven canonical schemes', () => {
44
+ const schemes = listVocabSchemes();
45
+ const ids = schemes.map(s => s.schemeIri);
46
+ expect(ids).toContain('gloss:status-scheme');
47
+ expect(ids).toContain('gloss:entstatus-scheme');
48
+ expect(ids).toContain('gloss:norm-scheme');
49
+ expect(ids).toContain('gloss:srcstatus-scheme');
50
+ expect(ids).toContain('gloss:srctype-scheme');
51
+ expect(ids).toContain('gloss:datetype-scheme');
52
+ expect(ids).toContain('gloss:rel-scheme');
53
+ expect(schemes.length).toBe(7);
54
+ });
55
+
56
+ it('emits skos:hasTopConcept and skos:inScheme bidirectionally', () => {
57
+ const store = parse(buildVocabularyTurtle());
58
+ const hasTopConcept = [...store].filter(q => q.predicate.value === `${SKOS}hasTopConcept`);
59
+ const inScheme = [...store].filter(q => q.predicate.value === `${SKOS}inScheme`);
60
+ expect(hasTopConcept.length).toBeGreaterThan(0);
61
+ expect(inScheme.length).toBe(hasTopConcept.length);
62
+ });
63
+ });
@@ -0,0 +1,125 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import {
3
+ registerFormat,
4
+ unregisterFormat,
5
+ getFormat,
6
+ listFormats,
7
+ clearFormats,
8
+ type FormatDescriptor,
9
+ } from '../composables/use-format-registry';
10
+
11
+ const BUILTIN_DESCRIPTORS: FormatDescriptor[] = [
12
+ { id: 'ttl', extension: 'ttl', mediaType: 'text/turtle', label: 'Turtle', available: 'both', serialize: 'build' },
13
+ { id: 'jsonld', extension: 'jsonld', mediaType: 'application/ld+json', label: 'JSON-LD', available: 'both', serialize: 'build' },
14
+ { id: 'yaml', extension: 'yaml', mediaType: 'application/yaml', label: 'YAML', available: 'per-concept', serialize: 'build' },
15
+ { id: 'tbx', extension: 'tbx.xml', mediaType: 'application/x-tbx', label: 'TBX', available: 'aggregate', serialize: 'build' },
16
+ { id: 'jsonl', extension: 'jsonl', mediaType: 'application/jsonl+json', label: 'JSON-Lines', available: 'aggregate', serialize: 'build' },
17
+ ];
18
+
19
+ function resetToBuiltins() {
20
+ clearFormats();
21
+ for (const desc of BUILTIN_DESCRIPTORS) {
22
+ registerFormat(desc);
23
+ }
24
+ }
25
+
26
+ describe('FormatRegistry', () => {
27
+ beforeEach(resetToBuiltins);
28
+ afterEach(resetToBuiltins);
29
+
30
+ describe('registerFormat / getFormat', () => {
31
+ it('registers and retrieves a format by id', () => {
32
+ registerFormat({ id: 'csv', extension: 'csv', mediaType: 'text/csv', label: 'CSV', available: 'both', serialize: 'build' });
33
+ const got = getFormat('csv');
34
+ expect(got).toBeDefined();
35
+ expect(got?.extension).toBe('csv');
36
+ expect(got?.label).toBe('CSV');
37
+ });
38
+
39
+ it('overwrites an existing format with the same id', () => {
40
+ registerFormat({ id: 'csv', extension: 'csv', mediaType: 'text/csv', label: 'Old', available: 'both', serialize: 'build' });
41
+ registerFormat({ id: 'csv', extension: 'csv', mediaType: 'text/csv', label: 'New', available: 'both', serialize: 'build' });
42
+ expect(getFormat('csv')?.label).toBe('New');
43
+ });
44
+
45
+ it('returns undefined for unknown ids', () => {
46
+ expect(getFormat('does-not-exist')).toBeUndefined();
47
+ });
48
+ });
49
+
50
+ describe('unregisterFormat', () => {
51
+ it('removes a format from the registry', () => {
52
+ registerFormat({ id: 'csv', extension: 'csv', mediaType: 'text/csv', label: 'CSV', available: 'both', serialize: 'build' });
53
+ unregisterFormat('csv');
54
+ expect(getFormat('csv')).toBeUndefined();
55
+ });
56
+
57
+ it('is a no-op for unknown ids', () => {
58
+ expect(() => unregisterFormat('never-registered')).not.toThrow();
59
+ });
60
+ });
61
+
62
+ describe('listFormats', () => {
63
+ it('returns all registered formats sorted by label', () => {
64
+ const all = listFormats();
65
+ const labels = all.map(f => f.label);
66
+ const sorted = [...labels].sort((a, b) => a.localeCompare(b));
67
+ expect(labels).toEqual(sorted);
68
+ });
69
+
70
+ it('returns formats whose availability matches the filter or is "both"', () => {
71
+ const perConcept = listFormats({ availability: 'per-concept' });
72
+ for (const f of perConcept) {
73
+ expect(['per-concept', 'both']).toContain(f.available);
74
+ }
75
+ });
76
+
77
+ it('returns formats whose availability matches aggregate filter or is "both"', () => {
78
+ const aggregate = listFormats({ availability: 'aggregate' });
79
+ for (const f of aggregate) {
80
+ expect(['aggregate', 'both']).toContain(f.available);
81
+ }
82
+ });
83
+
84
+ it('includes both-availability formats in per-concept listings', () => {
85
+ const perConcept = listFormats({ availability: 'per-concept' });
86
+ const ids = perConcept.map(f => f.id);
87
+ expect(ids).toContain('ttl');
88
+ expect(ids).toContain('jsonld');
89
+ expect(ids).toContain('yaml');
90
+ });
91
+
92
+ it('excludes per-concept-only formats from aggregate listings', () => {
93
+ const aggregate = listFormats({ availability: 'aggregate' });
94
+ const ids = aggregate.map(f => f.id);
95
+ expect(ids).not.toContain('yaml');
96
+ });
97
+ });
98
+
99
+ describe('built-in registrations', () => {
100
+ it('registers ttl, jsonld, yaml, tbx, jsonl on module load', () => {
101
+ for (const id of ['ttl', 'jsonld', 'yaml', 'tbx', 'jsonl']) {
102
+ expect(getFormat(id)).toBeDefined();
103
+ }
104
+ });
105
+
106
+ it('gives ttl and jsonld a per-concept-and-aggregate availability', () => {
107
+ expect(getFormat('ttl')?.available).toBe('both');
108
+ expect(getFormat('jsonld')?.available).toBe('both');
109
+ });
110
+
111
+ it('gives yaml a per-concept-only availability', () => {
112
+ expect(getFormat('yaml')?.available).toBe('per-concept');
113
+ });
114
+
115
+ it('gives tbx and jsonl an aggregate-only availability', () => {
116
+ expect(getFormat('tbx')?.available).toBe('aggregate');
117
+ expect(getFormat('jsonl')?.available).toBe('aggregate');
118
+ });
119
+
120
+ it('declares media types distinct from extensions where appropriate', () => {
121
+ expect(getFormat('ttl')?.mediaType).toBe('text/turtle');
122
+ expect(getFormat('tbx')?.extension).toBe('tbx.xml');
123
+ });
124
+ });
125
+ });
@@ -0,0 +1,166 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import {
3
+ parseLangTag,
4
+ formatLangTag,
5
+ canonicalLangTag,
6
+ isValidLangTag,
7
+ mapIso6393To6391,
8
+ mapIso6391To6393,
9
+ isNfc,
10
+ toNfc,
11
+ } from '../../utils/bcp47';
12
+ import { InvalidLangTagError } from '../../errors';
13
+
14
+ describe('parseLangTag', () => {
15
+ it('maps ISO 639-3 codes to ISO 639-1', () => {
16
+ expect(parseLangTag('eng').primary).toBe('en');
17
+ expect(parseLangTag('fra').primary).toBe('fr');
18
+ expect(parseLangTag('deu').primary).toBe('de');
19
+ });
20
+
21
+ it('passes through unknown 3-letter codes unchanged', () => {
22
+ expect(parseLangTag('xxx').primary).toBe('xxx');
23
+ });
24
+
25
+ it('preserves ISO 639-1 codes', () => {
26
+ expect(parseLangTag('en').primary).toBe('en');
27
+ expect(parseLangTag('fr').primary).toBe('fr');
28
+ });
29
+
30
+ it('captures the script subtag', () => {
31
+ const tag = parseLangTag('zh-Hant');
32
+ expect(tag.script).toBe('Hant');
33
+ });
34
+
35
+ it('captures the region subtag (alpha-2)', () => {
36
+ const tag = parseLangTag('en-US');
37
+ expect(tag.region).toBe('US');
38
+ });
39
+
40
+ it('captures the region subtag (UN M.49 numeric)', () => {
41
+ const tag = parseLangTag('zh-156');
42
+ expect(tag.region).toBe('156');
43
+ });
44
+
45
+ it('captures variants', () => {
46
+ const tag = parseLangTag('ca-ES-valencia');
47
+ expect(tag.variants).toEqual(['valencia']);
48
+ });
49
+
50
+ it('captures private-use', () => {
51
+ const tag = parseLangTag('en-x-foo-bar');
52
+ expect(tag.privateUse).toEqual(['foo', 'bar']);
53
+ });
54
+
55
+ it('defaults multi-script languages to their primary script', () => {
56
+ expect(parseLangTag('zho').script).toBe('Hans');
57
+ expect(parseLangTag('srp').script).toBe('Cyrl');
58
+ expect(parseLangTag('uzb').script).toBe('Latn');
59
+ });
60
+
61
+ it('keeps an explicitly-provided script over the default', () => {
62
+ expect(parseLangTag('zho-Hant').script).toBe('Hant');
63
+ });
64
+
65
+ it('throws on invalid primary subtags', () => {
66
+ expect(() => parseLangTag('')).toThrow(InvalidLangTagError);
67
+ expect(() => parseLangTag('!@#')).toThrow(InvalidLangTagError);
68
+ });
69
+
70
+ it('throws on unrecognized subtag shapes', () => {
71
+ expect(() => parseLangTag('en-***')).toThrow(InvalidLangTagError);
72
+ });
73
+
74
+ it('preserves the original input on the tag', () => {
75
+ const tag = parseLangTag('eng-US');
76
+ expect(tag.raw).toBe('eng-US');
77
+ });
78
+ });
79
+
80
+ describe('formatLangTag', () => {
81
+ it('joins subtags with hyphens', () => {
82
+ const tag = parseLangTag('eng-Hant-US');
83
+ expect(formatLangTag(tag)).toBe('en-Hant-US');
84
+ });
85
+
86
+ it('omits undefined subtags', () => {
87
+ expect(formatLangTag(parseLangTag('eng'))).toBe('en');
88
+ });
89
+
90
+ it('emits private-use prefix', () => {
91
+ expect(formatLangTag(parseLangTag('en-x-foo'))).toBe('en-x-foo');
92
+ });
93
+
94
+ it('emits variants inline', () => {
95
+ expect(formatLangTag(parseLangTag('ca-valencia'))).toBe('ca-valencia');
96
+ });
97
+ });
98
+
99
+ describe('canonicalLangTag', () => {
100
+ it('canonicalizes ISO 639-3 codes to ISO 639-1', () => {
101
+ expect(canonicalLangTag('eng')).toBe('en');
102
+ expect(canonicalLangTag('fra')).toBe('fr');
103
+ expect(canonicalLangTag('zho')).toBe('zh-Hans');
104
+ });
105
+
106
+ it('preserves script+region on canonical form', () => {
107
+ expect(canonicalLangTag('zho-Hant-HK')).toBe('zh-Hant-HK');
108
+ });
109
+
110
+ it('is idempotent', () => {
111
+ const once = canonicalLangTag('eng-Hant-US');
112
+ expect(canonicalLangTag(once)).toBe(once);
113
+ });
114
+ });
115
+
116
+ describe('isValidLangTag', () => {
117
+ it('accepts well-formed tags', () => {
118
+ expect(isValidLangTag('en')).toBe(true);
119
+ expect(isValidLangTag('eng')).toBe(true);
120
+ expect(isValidLangTag('zh-Hans-CN')).toBe(true);
121
+ expect(isValidLangTag('en-x-foo')).toBe(true);
122
+ });
123
+
124
+ it('rejects malformed tags', () => {
125
+ expect(isValidLangTag('')).toBe(false);
126
+ expect(isValidLangTag('!!!')).toBe(false);
127
+ });
128
+ });
129
+
130
+ describe('ISO 639 mapping', () => {
131
+ it('maps 639-3 → 639-1', () => {
132
+ expect(mapIso6393To6391('eng')).toBe('en');
133
+ expect(mapIso6393To6391('fra')).toBe('fr');
134
+ expect(mapIso6393To6391('xxx')).toBeNull();
135
+ });
136
+
137
+ it('maps 639-1 → 639-3', () => {
138
+ expect(mapIso6391To6393('en')).toBe('eng');
139
+ expect(mapIso6391To6393('fr')).toBe('fra');
140
+ expect(mapIso6391To6393('xx')).toBeNull();
141
+ });
142
+
143
+ it('round-trips through both maps', () => {
144
+ for (const code of ['en', 'fr', 'de', 'ja', 'ar', 'ru']) {
145
+ const three = mapIso6391To6393(code)!;
146
+ expect(mapIso6393To6391(three)).toBe(code);
147
+ }
148
+ });
149
+ });
150
+
151
+ describe('NFC utilities', () => {
152
+ it('detects already-NFC strings', () => {
153
+ expect(isNfc('hello')).toBe(true);
154
+ expect(isNfc('atomic data unit')).toBe(true);
155
+ });
156
+
157
+ it('detects non-NFC strings', () => {
158
+ const nfd = 'café'.normalize('NFD');
159
+ expect(isNfc(nfd)).toBe(false);
160
+ });
161
+
162
+ it('normalizes non-NFC strings', () => {
163
+ const nfd = 'café'.normalize('NFD');
164
+ expect(toNfc(nfd)).toBe('café'.normalize('NFC'));
165
+ });
166
+ });
@@ -0,0 +1,143 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { readFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { createColorTheme, type ColorPair } from '../../utils/color-theme';
5
+ import type { SiteColors } from '../../config/types';
6
+
7
+ const COLORS_JSON = JSON.parse(readFileSync(join(process.cwd(), 'data', 'colors.json'), 'utf8'));
8
+
9
+ describe('color system v2 — defaults', () => {
10
+ it('every relationship category has both light and dark variants', () => {
11
+ const entries = COLORS_JSON.relationshipCategory;
12
+ for (const [key, pair] of Object.entries(entries)) {
13
+ const p = pair as ColorPair;
14
+ expect(p.light, `${key}.light missing`).toMatch(/^#[0-9A-Fa-f]{6}$/);
15
+ expect(p.dark, `${key}.dark missing`).toMatch(/^#[0-9A-Fa-f]{6}$/);
16
+ expect(p.light).not.toBe(p.dark);
17
+ }
18
+ });
19
+
20
+ it('every group-kind has both light and dark variants', () => {
21
+ const entries = COLORS_JSON.groupKind;
22
+ for (const [key, pair] of Object.entries(entries)) {
23
+ const p = pair as ColorPair;
24
+ expect(p.light, `${key}.light missing`).toMatch(/^#[0-9A-Fa-f]{6}$/);
25
+ expect(p.dark, `${key}.dark missing`).toMatch(/^#[0-9A-Fa-f]{6}$/);
26
+ }
27
+ });
28
+
29
+ it('every concept-status has both light and dark variants', () => {
30
+ const entries = COLORS_JSON.conceptStatus;
31
+ for (const [key, pair] of Object.entries(entries)) {
32
+ const p = pair as ColorPair;
33
+ expect(p.light, `${key}.light missing`).toMatch(/^#[0-9A-Fa-f]{6}$/);
34
+ expect(p.dark, `${key}.dark missing`).toMatch(/^#[0-9A-Fa-f]{6}$/);
35
+ }
36
+ });
37
+ });
38
+
39
+ describe('color system v2 — resolution', () => {
40
+ it('falls back to category default when type has no override', () => {
41
+ const theme = createColorTheme(undefined);
42
+ const supersedes = theme.relationshipTypeColor('supersedes');
43
+ const lifecycle = theme.relationshipCategoryColor('lifecycle');
44
+ expect(supersedes).toEqual(lifecycle);
45
+ });
46
+
47
+ it('uses explicit type override when provided', () => {
48
+ const overrides: SiteColors = {
49
+ relationshipType: {
50
+ supersedes: { light: '#FF0000', dark: '#FF5555' },
51
+ },
52
+ };
53
+ const theme = createColorTheme(overrides);
54
+ const result = theme.relationshipTypeColor('supersedes');
55
+ expect(result).toEqual({ light: '#FF0000', dark: '#FF5555' });
56
+ });
57
+
58
+ it('uses string-form override (single hex applied to both modes)', () => {
59
+ const overrides: SiteColors = {
60
+ relationshipType: {
61
+ supersedes: '#ABCDEF',
62
+ },
63
+ };
64
+ const theme = createColorTheme(overrides);
65
+ const result = theme.relationshipTypeColor('supersedes');
66
+ expect(result).toEqual({ light: '#ABCDEF', dark: '#ABCDEF' });
67
+ });
68
+
69
+ it('falls back to associative category for unknown type', () => {
70
+ const theme = createColorTheme(undefined);
71
+ const result = theme.relationshipTypeColor('nonexistent_type');
72
+ expect(result).toEqual(theme.relationshipCategoryColor('associative'));
73
+ });
74
+
75
+ it('dataset override takes precedence over declared color', () => {
76
+ const overrides: SiteColors = {
77
+ dataset: {
78
+ 'viml-2022': { light: '#FF0000', dark: '#FF5555' },
79
+ },
80
+ };
81
+ const theme = createColorTheme(overrides);
82
+ const declared = { light: '#0000FF', dark: '#00FF00' };
83
+ const result = theme.datasetColor('viml-2022', declared);
84
+ expect(result).toEqual({ light: '#FF0000', dark: '#FF5555' });
85
+ });
86
+
87
+ it('declared dataset color is used when no override', () => {
88
+ const theme = createColorTheme(undefined);
89
+ const declared = { light: '#0000FF', dark: '#00FF00' };
90
+ const result = theme.datasetColor('viml-2022', declared);
91
+ expect(result).toEqual(declared);
92
+ });
93
+
94
+ it('legacy single-hex declared color is applied to both modes', () => {
95
+ const theme = createColorTheme(undefined);
96
+ const result = theme.datasetColor('viml-2022', '#3366ff');
97
+ expect(result.light).toBe('#3366ff');
98
+ expect(result.dark).toBe('#3366ff');
99
+ });
100
+
101
+ it('concept status override works', () => {
102
+ const overrides: SiteColors = {
103
+ conceptStatus: {
104
+ valid: '#00FF00',
105
+ },
106
+ };
107
+ const theme = createColorTheme(overrides);
108
+ const result = theme.conceptStatusColor('valid');
109
+ expect(result).toEqual({ light: '#00FF00', dark: '#00FF00' });
110
+ });
111
+
112
+ it('group kind override works', () => {
113
+ const overrides: SiteColors = {
114
+ groupKind: {
115
+ lineage: { light: '#111111', dark: '#222222' },
116
+ },
117
+ };
118
+ const theme = createColorTheme(overrides);
119
+ const result = theme.groupKindColor('lineage');
120
+ expect(result).toEqual({ light: '#111111', dark: '#222222' });
121
+ });
122
+ });
123
+
124
+ describe('color system v2 — README contract', () => {
125
+ it('relationshipCategory defaults cover all 9 categories', () => {
126
+ const expected = [
127
+ 'lifecycle', 'mapping', 'hierarchical', 'associative',
128
+ 'comparative', 'definitional', 'spatiotemporal', 'lexical', 'designation',
129
+ ];
130
+ const actual = Object.keys(COLORS_JSON.relationshipCategory);
131
+ for (const e of expected) {
132
+ expect(actual).toContain(e);
133
+ }
134
+ });
135
+
136
+ it('groupKind defaults cover all 5 DatasetGroupKind values', () => {
137
+ const expected = ['lineage', 'topic', 'family', 'collection', 'default'];
138
+ const actual = Object.keys(COLORS_JSON.groupKind);
139
+ for (const e of expected) {
140
+ expect(actual).toContain(e);
141
+ }
142
+ });
143
+ });
@@ -0,0 +1,65 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { isSafeUrl, sanitizeUrl } from '../../utils/url-safety';
3
+
4
+ describe('isSafeUrl', () => {
5
+ it('accepts http and https URLs', () => {
6
+ expect(isSafeUrl('https://example.com')).toBe(true);
7
+ expect(isSafeUrl('http://example.com')).toBe(true);
8
+ expect(isSafeUrl('https://example.com/path?q=1')).toBe(true);
9
+ });
10
+
11
+ it('accepts mailto and tel URLs', () => {
12
+ expect(isSafeUrl('mailto:foo@example.org')).toBe(true);
13
+ expect(isSafeUrl('tel:+15551234567')).toBe(true);
14
+ });
15
+
16
+ it('accepts same-document and relative URLs', () => {
17
+ expect(isSafeUrl('#anchor')).toBe(true);
18
+ expect(isSafeUrl('/page')).toBe(true);
19
+ expect(isSafeUrl('./page')).toBe(true);
20
+ expect(isSafeUrl('../page')).toBe(true);
21
+ });
22
+
23
+ it('rejects javascript: URLs', () => {
24
+ expect(isSafeUrl('javascript:alert(1)')).toBe(false);
25
+ expect(isSafeUrl('javascript:/* */alert(1)')).toBe(false);
26
+ });
27
+
28
+ it('rejects data: URLs', () => {
29
+ expect(isSafeUrl('data:text/html,<script>alert(1)</script>')).toBe(false);
30
+ expect(isSafeUrl('data:image/png;base64,AAA')).toBe(false);
31
+ });
32
+
33
+ it('rejects file: URLs', () => {
34
+ expect(isSafeUrl('file:///etc/passwd')).toBe(false);
35
+ });
36
+
37
+ it('rejects URLs with whitespace, angle brackets, or quotes', () => {
38
+ expect(isSafeUrl('https://example.com/ foo')).toBe(false);
39
+ expect(isSafeUrl('https://example.com/<script>')).toBe(false);
40
+ expect(isSafeUrl('https://example.com/" onload="alert(1)')).toBe(false);
41
+ });
42
+
43
+ it('rejects malformed inputs', () => {
44
+ expect(isSafeUrl('')).toBe(false);
45
+ expect(isSafeUrl(' ')).toBe(false);
46
+ expect(isSafeUrl(null as unknown as string)).toBe(false);
47
+ expect(isSafeUrl(undefined as unknown as string)).toBe(false);
48
+ });
49
+
50
+ it('rejects unknown protocol schemes', () => {
51
+ expect(isSafeUrl('vbscript:msgbox(1)')).toBe(false);
52
+ expect(isSafeUrl('about:blank')).toBe(false);
53
+ });
54
+ });
55
+
56
+ describe('sanitizeUrl', () => {
57
+ it('returns the URL if safe', () => {
58
+ expect(sanitizeUrl('https://example.com')).toBe('https://example.com');
59
+ });
60
+
61
+ it('returns empty string for unsafe URLs', () => {
62
+ expect(sanitizeUrl('javascript:alert(1)')).toBe('');
63
+ expect(sanitizeUrl('data:text/html,<script>')).toBe('');
64
+ });
65
+ });
@@ -0,0 +1,100 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { execFileSync } from 'node:child_process';
3
+ import { join, dirname } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+
6
+ const __dirname = dirname(fileURLToPath(import.meta.url));
7
+ const SCRIPT = join(__dirname, '..', '..', 'scripts', 'validate-shacl.mjs');
8
+ const FIXTURES = join(__dirname, '..', '__fixtures__', 'shacl');
9
+ const SHAPES = join(__dirname, '..', '__fixtures__', 'concept-shape.ttl');
10
+
11
+ interface RunResult { code: number; stdout: string; stderr: string }
12
+ interface RunOptions { env?: NodeJS.ProcessEnv; shapes?: string }
13
+ interface ExecException extends Error {
14
+ status?: number;
15
+ stdout?: Buffer | string;
16
+ stderr?: Buffer | string;
17
+ }
18
+
19
+ function execCaught(cmd: string, args: string[], opts: Parameters<typeof execFileSync>[2] = {}): RunResult {
20
+ try {
21
+ const stdout = execFileSync(cmd, args, { encoding: 'utf8', ...opts }) as string;
22
+ return { code: 0, stdout, stderr: '' };
23
+ } catch (e) {
24
+ const err = e as ExecException;
25
+ return {
26
+ code: err.status ?? 1,
27
+ stdout: typeof err.stdout === 'string' ? err.stdout : (err.stdout?.toString() ?? ''),
28
+ stderr: typeof err.stderr === 'string' ? err.stderr : (err.stderr?.toString() ?? ''),
29
+ };
30
+ }
31
+ }
32
+
33
+ function runValidate(dataDir: string, { env, shapes }: RunOptions = {}): RunResult {
34
+ return execCaught('node', [SCRIPT, '--shapes', shapes ?? SHAPES, dataDir], {
35
+ env: { ...process.env, ...env },
36
+ });
37
+ }
38
+
39
+ function runValidateRaw(args: string[], { env }: RunOptions = {}): RunResult {
40
+ return execCaught('node', [SCRIPT, ...args], {
41
+ env: { ...process.env, ...env },
42
+ stdio: ['ignore', 'pipe', 'pipe'],
43
+ });
44
+ }
45
+
46
+ describe('validate-shacl.mjs', () => {
47
+ it('passes when a fixture conforms to the shapes', () => {
48
+ const goodDir = join(FIXTURES, 'good');
49
+ const result = runValidate(goodDir);
50
+ expect(result.code).toBe(0);
51
+ expect(result.stdout).toContain('SHACL validation passed');
52
+ });
53
+
54
+ it('fails when a fixture has missing language tags', () => {
55
+ const badDir = join(FIXTURES, 'bad');
56
+ const result = runValidate(badDir);
57
+ expect(result.code).not.toBe(0);
58
+ expect(result.stderr).toContain('SHACL validation FAILED');
59
+ expect(result.stderr).toContain('concept.ttl');
60
+ expect(result.stderr.toLowerCase()).toContain('langstring');
61
+ });
62
+
63
+ it('aggregates violations across multiple files', () => {
64
+ const result = runValidate(FIXTURES);
65
+ expect(result.code).not.toBe(0);
66
+ expect(result.stderr).not.toContain('good/concept.ttl');
67
+ expect(result.stderr).toContain('bad/concept.ttl');
68
+ });
69
+
70
+ it('exits cleanly when no .ttl files are found', () => {
71
+ const emptyDir = join(FIXTURES, 'empty');
72
+ const result = runValidate(emptyDir);
73
+ expect(result.code).toBe(0);
74
+ expect(result.stdout).toContain('No .ttl files');
75
+ });
76
+
77
+ it('errors clearly when --shapes path does not exist', () => {
78
+ const result = runValidate(FIXTURES, { shapes: '/does/not/exist.ttl' });
79
+ expect(result.code).not.toBe(0);
80
+ expect(result.stderr).toContain('Failed to load SHACL shapes');
81
+ });
82
+
83
+ it('accepts SHAPES_PATH env var as fallback when --shapes is omitted', () => {
84
+ const result = runValidate(join(FIXTURES, 'good'), {
85
+ env: { SHAPES_PATH: SHAPES },
86
+ });
87
+ expect(result.code).toBe(0);
88
+ expect(result.stdout).toContain('SHACL validation passed');
89
+ });
90
+
91
+ it('uses the vendored shapes by default when no override is given', () => {
92
+ const result = runValidateRaw([join(FIXTURES, 'good')], {
93
+ env: { SHAPES_PATH: '' },
94
+ });
95
+ // The vendored shapes live at data/concept-model/shapes/glossarist.shacl.ttl
96
+ // and ship with the repo. The good fixture conforms to them.
97
+ expect(result.code).toBe(0);
98
+ expect(result.stdout).toContain('SHACL validation passed');
99
+ });
100
+ });