@glossarist/concept-browser 0.7.51 → 0.7.53

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 (159) 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/App.vue +2 -0
  21. package/src/__fixtures__/concept-shape.ttl +20 -0
  22. package/src/__fixtures__/shacl/bad/concept.ttl +7 -0
  23. package/src/__fixtures__/shacl/empty/.gitkeep +0 -0
  24. package/src/__fixtures__/shacl/good/concept.ttl +8 -0
  25. package/src/__tests__/__fixtures__/concepts.ts +221 -0
  26. package/src/__tests__/adapters/concept-identity.test.ts +76 -0
  27. package/src/__tests__/components/error-boundary.test.ts +109 -0
  28. package/src/__tests__/composables/use-dataset-series.test.ts +262 -0
  29. package/src/__tests__/concept-rdf/agents-emitter.test.ts +110 -0
  30. package/src/__tests__/concept-rdf/bibliography-emitter.test.ts +159 -0
  31. package/src/__tests__/concept-rdf/build-activity-emitter.test.ts +119 -0
  32. package/src/__tests__/concept-rdf/coerce-date.test.ts +97 -0
  33. package/src/__tests__/concept-rdf/concept-emitter.test.ts +258 -0
  34. package/src/__tests__/concept-rdf/dataset-emitter.test.ts +224 -0
  35. package/src/__tests__/concept-rdf/differential.test.ts +96 -0
  36. package/src/__tests__/concept-rdf/group-emitter.test.ts +109 -0
  37. package/src/__tests__/concept-rdf/image-variant-emitter.test.ts +135 -0
  38. package/src/__tests__/concept-rdf/jsonld-writer.test.ts +109 -0
  39. package/src/__tests__/concept-rdf/nonverbal-rep.test.ts +177 -0
  40. package/src/__tests__/concept-rdf/property-based.test.ts +179 -0
  41. package/src/__tests__/concept-rdf/provenance.test.ts +110 -0
  42. package/src/__tests__/concept-rdf/quad-isomorphism.test.ts +43 -0
  43. package/src/__tests__/concept-rdf/quad-isomorphism.ts +47 -0
  44. package/src/__tests__/concept-rdf/rdf-components.test.ts +145 -0
  45. package/src/__tests__/concept-rdf/rdf-graph.test.ts +115 -0
  46. package/src/__tests__/concept-rdf/round-trip.test.ts +243 -0
  47. package/src/__tests__/concept-rdf/scoped-examples.test.ts +126 -0
  48. package/src/__tests__/concept-rdf/sections-builder.test.ts +94 -0
  49. package/src/__tests__/concept-rdf/shacl-conformance.test.ts +110 -0
  50. package/src/__tests__/concept-rdf/shape-consistency.test.ts +138 -0
  51. package/src/__tests__/concept-rdf/snapshot-generation.test.ts +75 -0
  52. package/src/__tests__/concept-rdf/table-formula-emitter.test.ts +142 -0
  53. package/src/__tests__/concept-rdf/turtle-writer.test.ts +114 -0
  54. package/src/__tests__/concept-rdf/use-rdf-document.test.ts +246 -0
  55. package/src/__tests__/concept-rdf/version-emitter.test.ts +120 -0
  56. package/src/__tests__/concept-rdf/vocabulary-ssot.test.ts +100 -0
  57. package/src/__tests__/concept-rdf-view.test.ts +136 -0
  58. package/src/__tests__/config/group-renderers.test.ts +35 -0
  59. package/src/__tests__/config/group-types.test.ts +76 -0
  60. package/src/__tests__/dataset-style.test.ts +12 -7
  61. package/src/__tests__/errors/errors.test.ts +142 -0
  62. package/src/__tests__/format-downloads.test.ts +47 -65
  63. package/src/__tests__/markdown-lite.test.ts +19 -0
  64. package/src/__tests__/perf/bundle-layout.test.ts +50 -0
  65. package/src/__tests__/perf/serialization-perf.test.ts +121 -0
  66. package/src/__tests__/scripts/agents-turtle.test.ts +61 -0
  67. package/src/__tests__/scripts/bibliography-turtle.test.ts +59 -0
  68. package/src/__tests__/scripts/build-activity-turtle.test.ts +75 -0
  69. package/src/__tests__/scripts/build-cache.test.ts +78 -0
  70. package/src/__tests__/scripts/build-integration.test.ts +134 -0
  71. package/src/__tests__/scripts/dataset-turtle.test.ts +94 -0
  72. package/src/__tests__/scripts/normalize-yaml.test.ts +98 -0
  73. package/src/__tests__/scripts/stryker-config.test.ts +33 -0
  74. package/src/__tests__/scripts/turtle-escape.test.ts +63 -0
  75. package/src/__tests__/scripts/version-turtle.test.ts +72 -0
  76. package/src/__tests__/scripts/vocab-turtle.test.ts +63 -0
  77. package/src/__tests__/use-format-registry.test.ts +125 -0
  78. package/src/__tests__/utils/bcp47.test.ts +166 -0
  79. package/src/__tests__/utils/color-theme.test.ts +143 -0
  80. package/src/__tests__/utils/url-safety.test.ts +65 -0
  81. package/src/__tests__/validate-shacl.test.ts +100 -0
  82. package/src/adapters/DatasetAdapter.ts +11 -5
  83. package/src/adapters/GraphDataSource.ts +2 -1
  84. package/src/adapters/UriRouter.ts +2 -1
  85. package/src/adapters/concept-identity.ts +69 -0
  86. package/src/adapters/factory.ts +3 -2
  87. package/src/adapters/model-bridge.ts +2 -1
  88. package/src/adapters/non-verbal/glossarist-augment.d.ts +7 -0
  89. package/src/adapters/non-verbal-resolver.ts +2 -1
  90. package/src/components/AppSidebar.vue +189 -93
  91. package/src/components/ConceptDetail.vue +8 -0
  92. package/src/components/ConceptEditionRail.vue +222 -0
  93. package/src/components/ConceptRdfView.vue +37 -377
  94. package/src/components/DatasetSeriesCard.vue +270 -0
  95. package/src/components/ErrorBoundary.vue +95 -0
  96. package/src/components/FormatDownloads.vue +17 -13
  97. package/src/components/HomeSeriesSection.vue +277 -0
  98. package/src/components/RelationSphere.vue +1672 -0
  99. package/src/components/SidebarSeriesSection.vue +239 -0
  100. package/src/components/concept-rdf/RdfInstanceHeader.vue +47 -0
  101. package/src/components/concept-rdf/RdfInstanceSection.vue +54 -0
  102. package/src/components/concept-rdf/RdfPrefixLegend.vue +27 -0
  103. package/src/components/concept-rdf/RdfSourcePanel.vue +72 -0
  104. package/src/components/concept-rdf/agents-emitter.ts +82 -0
  105. package/src/components/concept-rdf/bibliography-emitter.ts +83 -0
  106. package/src/components/concept-rdf/build-activity-emitter.ts +89 -0
  107. package/src/components/concept-rdf/concept-emitter.ts +443 -0
  108. package/src/components/concept-rdf/dataset-emitter.ts +95 -0
  109. package/src/components/concept-rdf/group-emitter.ts +69 -0
  110. package/src/components/concept-rdf/image-variant-emitter.ts +46 -0
  111. package/src/components/concept-rdf/jsonld-writer.ts +82 -0
  112. package/src/components/concept-rdf/predicates.ts +261 -0
  113. package/src/components/concept-rdf/provenance.ts +80 -0
  114. package/src/components/concept-rdf/rdf-graph.ts +211 -0
  115. package/src/components/concept-rdf/rdf-prefixes.ts +23 -0
  116. package/src/components/concept-rdf/sections-builder.ts +62 -0
  117. package/src/components/concept-rdf/table-formula-emitter.ts +101 -0
  118. package/src/components/concept-rdf/turtle-writer.ts +116 -0
  119. package/src/components/concept-rdf/use-rdf-document.ts +72 -0
  120. package/src/components/concept-rdf/version-emitter.ts +65 -0
  121. package/src/components/concept-rdf/vocabulary-emitter.ts +62 -0
  122. package/src/components/groups/DatasetGroupRenderer.vue +32 -0
  123. package/src/components/groups/DefaultGroupSidebar.vue +50 -0
  124. package/src/components/groups/LineageGroupSidebar.vue +75 -0
  125. package/src/composables/use-color-theme.ts +82 -0
  126. package/src/composables/use-format-registry.ts +42 -0
  127. package/src/composables/useDatasetSeries.ts +258 -0
  128. package/src/composables/useSphereProjection.ts +125 -0
  129. package/src/config/group-renderers.ts +27 -0
  130. package/src/config/group-types.ts +92 -0
  131. package/src/config/types.ts +81 -2
  132. package/src/config/use-site-config.ts +2 -1
  133. package/src/errors.ts +136 -0
  134. package/src/i18n/locales/eng.yml +24 -0
  135. package/src/i18n/locales/fra.yml +24 -0
  136. package/src/stores/vocabulary.ts +3 -1
  137. package/src/style.css +17 -2
  138. package/src/types/agents-version-turtle.d.ts +27 -0
  139. package/src/types/bibliography-turtle.d.ts +12 -0
  140. package/src/types/build-activity-turtle.d.ts +16 -0
  141. package/src/types/build-cache.d.ts +20 -0
  142. package/src/types/dataset-turtle.d.ts +32 -0
  143. package/src/types/normalize-yaml.d.ts +16 -0
  144. package/src/types/turtle-escape.d.ts +6 -0
  145. package/src/types/vocab-turtle.d.ts +13 -0
  146. package/src/utils/asciidoc-lite.ts +11 -6
  147. package/src/utils/bcp47.ts +141 -0
  148. package/src/utils/color-theme-integration.ts +11 -0
  149. package/src/utils/color-theme.ts +129 -0
  150. package/src/utils/dataset-style.ts +31 -6
  151. package/src/utils/locale.ts +6 -14
  152. package/src/utils/markdown-lite.ts +6 -1
  153. package/src/utils/relation-sphere-styling.ts +63 -0
  154. package/src/utils/relationship-categories.ts +30 -0
  155. package/src/utils/url-safety.ts +30 -0
  156. package/src/views/ConceptView.vue +187 -9
  157. package/src/views/DatasetView.vue +6 -0
  158. package/src/views/HomeView.vue +5 -0
  159. package/vite.config.ts +7 -0
@@ -0,0 +1,72 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { Parser, Store } from 'n3';
3
+ import { buildVersionTurtle, buildVersionHistoryTurtle } from '../../../scripts/lib/version-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 PROV = 'http://www.w3.org/ns/prov#';
13
+ const DCTERMS = 'http://purl.org/dc/terms/';
14
+ const RDF_TYPE = 'http://www.w3.org/1999/02/22-rdf-syntax-ns#type';
15
+
16
+ describe('buildVersionTurtle (mjs)', () => {
17
+ it('parses without errors', () => {
18
+ const ttl = buildVersionTurtle({
19
+ registerId: 'test',
20
+ version: '1.0',
21
+ versionIri: 'https://glossarist.org/test/versions/1.0',
22
+ datasetIri: 'https://glossarist.org/test/',
23
+ generatedAt: '2026-06-28T12:00:00Z',
24
+ });
25
+ const store = parse(ttl);
26
+ expect(store.size).toBeGreaterThan(0);
27
+ });
28
+
29
+ it('types the version as prov:Entity', () => {
30
+ const ttl = buildVersionTurtle({
31
+ registerId: 'test',
32
+ version: '1.0',
33
+ versionIri: 'https://glossarist.org/test/versions/1.0',
34
+ datasetIri: 'https://glossarist.org/test/',
35
+ generatedAt: '2026-06-28T12:00:00Z',
36
+ });
37
+ const store = parse(ttl);
38
+ const types = store.getObjects('https://glossarist.org/test/versions/1.0', RDF_TYPE, null).map(q => q.value);
39
+ expect(types).toContain(`${PROV}Entity`);
40
+ });
41
+
42
+ it('emits prov:wasRevisionOf when a previous version is provided', () => {
43
+ const ttl = buildVersionTurtle({
44
+ registerId: 'test',
45
+ version: '1.0',
46
+ versionIri: 'https://glossarist.org/test/versions/1.0',
47
+ datasetIri: 'https://glossarist.org/test/',
48
+ generatedAt: '2026-06-28T12:00:00Z',
49
+ previousVersionIri: 'https://glossarist.org/test/versions/0.9',
50
+ });
51
+ const store = parse(ttl);
52
+ const prev = store.getObjects('https://glossarist.org/test/versions/1.0', `${PROV}wasRevisionOf`, null).map(q => q.value);
53
+ expect(prev).toContain('https://glossarist.org/test/versions/0.9');
54
+ });
55
+ });
56
+
57
+ describe('buildVersionHistoryTurtle (mjs)', () => {
58
+ it('emits a chain with revision links', () => {
59
+ const ttl = buildVersionHistoryTurtle({
60
+ registerId: 'test',
61
+ datasetIri: 'https://glossarist.org/test/',
62
+ versions: [
63
+ { version: '1.0', generatedAt: '2024-01-01T00:00:00Z' },
64
+ { version: '1.1', generatedAt: '2024-06-01T00:00:00Z' },
65
+ ],
66
+ });
67
+ const store = parse(ttl);
68
+ const iri11 = 'https://glossarist.org/test/versions/1.1';
69
+ const prev = store.getObjects(iri11, `${PROV}wasRevisionOf`, null).map(q => q.value);
70
+ expect(prev).toContain('https://glossarist.org/test/versions/1.0');
71
+ });
72
+ });
@@ -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
+ });