@glossarist/concept-browser 0.7.32 → 0.7.34

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.
@@ -0,0 +1,128 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
2
+ import { getFactory, resetFactory } from '../adapters/factory';
3
+ import type { Resolution } from '../adapters/types';
4
+
5
+ type InternalResolution = Extract<Resolution, { type: 'internal' }>;
6
+
7
+ function asInternal(r: Resolution | null): InternalResolution | null {
8
+ return r?.type === 'internal' ? (r as InternalResolution) : null;
9
+ }
10
+
11
+ describe('AdapterFactory — bibliography resolution from registry', () => {
12
+ beforeEach(() => {
13
+ resetFactory();
14
+ vi.restoreAllMocks();
15
+ });
16
+
17
+ it('registers bibliography from registry ref/refAliases', async () => {
18
+ const factory = getFactory();
19
+ const mockFetch = vi.fn((url: string) => {
20
+ if (url.endsWith('datasets.json')) {
21
+ return Promise.resolve({
22
+ ok: true,
23
+ json: () => Promise.resolve([
24
+ {
25
+ id: 'vim-2012',
26
+ manifestUrl: '/data/vim-2012/manifest.json',
27
+ summary: {
28
+ title: 'VIM 2012',
29
+ description: 'Vocabulary of metrology',
30
+ conceptCount: 144,
31
+ languages: ['eng', 'fra'],
32
+ owner: 'OIML',
33
+ tags: [],
34
+ },
35
+ datasetUri: 'urn:oiml:pub:v:2:2012',
36
+ uriBase: 'https://glossarist.org',
37
+ uriAliases: ['urn:oiml:pub:v:2:2012*'],
38
+ ref: 'OIML V 2-200:2012',
39
+ refAliases: ['VIM'],
40
+ },
41
+ ]),
42
+ });
43
+ }
44
+ return Promise.resolve({ ok: false, status: 404 } as Response);
45
+ });
46
+ vi.stubGlobal('fetch', mockFetch);
47
+
48
+ await factory.discoverDatasets('/datasets.json');
49
+
50
+ // No source-refs.json fetch — bibliography comes from registry config
51
+ expect(mockFetch).not.toHaveBeenCalledWith(expect.stringContaining('source-refs'));
52
+
53
+ // ref resolves via bibliography config
54
+ const result = factory.resolveCitation('OIML V 2-200:2012', '2.2', 'viml-2022');
55
+ expect(result).toEqual({
56
+ type: 'internal',
57
+ registerId: 'vim-2012',
58
+ conceptId: '2.2',
59
+ crossDataset: true,
60
+ });
61
+
62
+ // refAlias resolves too
63
+ const alias = factory.resolveCitation('VIM', '2.2', 'viml-2022');
64
+ expect(asInternal(alias)?.registerId).toBe('vim-2012');
65
+
66
+ // URN resolves directly via URI pattern matching (no bibliography entry needed)
67
+ const urn = factory.resolveCitation('urn:oiml:pub:v:2:2012', '2.2', 'viml-2022');
68
+ expect(asInternal(urn)?.registerId).toBe('vim-2012');
69
+ });
70
+
71
+ it('resolves without ref/refAliases when only datasetUri is present', async () => {
72
+ const factory = getFactory();
73
+ const mockFetch = vi.fn((url: string) => {
74
+ if (url.endsWith('datasets.json')) {
75
+ return Promise.resolve({
76
+ ok: true,
77
+ json: () => Promise.resolve([
78
+ {
79
+ id: 'ds1',
80
+ manifestUrl: '/data/ds1/manifest.json',
81
+ summary: {
82
+ title: 'DS1',
83
+ description: 'Test',
84
+ conceptCount: 10,
85
+ languages: ['eng'],
86
+ owner: 'Test',
87
+ tags: [],
88
+ },
89
+ },
90
+ ]),
91
+ });
92
+ }
93
+ return Promise.resolve({ ok: false, status: 404 } as Response);
94
+ });
95
+ vi.stubGlobal('fetch', mockFetch);
96
+
97
+ const adapters = await factory.discoverDatasets('/datasets.json');
98
+ expect(adapters).toHaveLength(1);
99
+ });
100
+
101
+ it('skips registry entries without datasetUri for bibliography', async () => {
102
+ const factory = getFactory();
103
+ const mockFetch = vi.fn((url: string) => {
104
+ if (url.endsWith('datasets.json')) {
105
+ return Promise.resolve({
106
+ ok: true,
107
+ json: () => Promise.resolve([
108
+ {
109
+ id: 'ds1',
110
+ manifestUrl: '/data/ds1/manifest.json',
111
+ summary: { title: 'DS1', description: 'Test', conceptCount: 10, languages: ['eng'], owner: 'Test', tags: [] },
112
+ ref: 'Some Ref',
113
+ // No datasetUri — bibliography entry skipped
114
+ },
115
+ ]),
116
+ });
117
+ }
118
+ return Promise.resolve({ ok: false, status: 404 } as Response);
119
+ });
120
+ vi.stubGlobal('fetch', mockFetch);
121
+
122
+ await factory.discoverDatasets('/datasets.json');
123
+
124
+ // ref without URN can't resolve — no datasetUri to route to
125
+ const result = factory.resolveCitation('Some Ref', '1.1');
126
+ expect(result).toBeNull();
127
+ });
128
+ });
@@ -0,0 +1,28 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { slugify } from '../utils/slugify';
3
+
4
+ describe('slugify', () => {
5
+ it('lowercases text', () => {
6
+ expect(slugify('Hello World')).toBe('hello-world');
7
+ });
8
+
9
+ it('strips slashes and joins surrounding text', () => {
10
+ expect(slugify('foo bar/baz')).toBe('foo-barbaz');
11
+ });
12
+
13
+ it('strips non-word characters', () => {
14
+ expect(slugify('géométrie (2D)')).toBe('gomtrie-2d');
15
+ });
16
+
17
+ it('collapses multiple spaces into single hyphen', () => {
18
+ expect(slugify('a b')).toBe('a-b');
19
+ });
20
+
21
+ it('handles empty string', () => {
22
+ expect(slugify('')).toBe('');
23
+ });
24
+
25
+ it('preserves hyphens in input', () => {
26
+ expect(slugify('iso-19107')).toBe('iso-19107');
27
+ });
28
+ });
@@ -19,6 +19,17 @@ describe('Source reference resolution (citation linking)', () => {
19
19
  resolver.registerDataset('vim-2007', ['urn:oiml:pub:v:2:2007*']);
20
20
  });
21
21
 
22
+ describe('hasSourceRef', () => {
23
+ it('returns true for registered source ref', () => {
24
+ resolver.registerSourceRef('VIM', 'vim-2012', 'urn:oiml:pub:v:2:2012');
25
+ expect(resolver.hasSourceRef('VIM')).toBe(true);
26
+ });
27
+
28
+ it('returns false for unregistered source ref', () => {
29
+ expect(resolver.hasSourceRef('Unknown')).toBe(false);
30
+ });
31
+ });
32
+
22
33
  describe('registerSourceRef', () => {
23
34
  it('resolves citation when source ref matches exactly', () => {
24
35
  resolver.registerSourceRef('OIML V 2-200:2012', 'vim-2012', 'urn:oiml:pub:v:2:2012');
@@ -9,15 +9,47 @@ import type {
9
9
  SectionNode,
10
10
  DatasetSummary,
11
11
  } from './types';
12
- import type { Concept, LocalizedConcept, Designation } from 'glossarist';
12
+ import type { Concept, LocalizedConcept, Designation, RelatedConcept } from 'glossarist';
13
13
  import { conceptFromJson, conceptUri } from './model-bridge';
14
14
  import { UriRouter } from './UriRouter';
15
+ import { slugify } from '../utils/slugify';
15
16
 
16
- function slugify(text: string): string {
17
- return text.toLowerCase().replace(/[^\w\s-]/g, '').replace(/[\s/]+/g, '-');
17
+ // ── Wire-format types for JSON responses ────────────────────────────────────
18
+
19
+ interface IndexJson {
20
+ registerId: string;
21
+ schemaVersion: string;
22
+ conceptCount: number;
23
+ chunkSize: number;
24
+ chunks: { file: string; count: number }[];
25
+ concepts: IndexConceptJson[];
26
+ }
27
+
28
+ interface IndexConceptJson {
29
+ id: string;
30
+ designations?: Record<string, string>;
31
+ eng?: string;
32
+ status: string;
33
+ groups?: string[];
34
+ }
35
+
36
+ interface DomainNodeJson {
37
+ uri?: string;
38
+ id?: string;
39
+ registerId?: string;
40
+ label?: string;
41
+ names?: Record<string, string>;
42
+ conceptCount?: number;
43
+ children?: DomainNodeJson[];
44
+ }
45
+
46
+ interface SectionJson {
47
+ id: string;
48
+ names?: Record<string, string>;
49
+ children?: SectionJson[];
18
50
  }
19
51
 
20
- function resolveRefTarget(rc: any, uriBase: string, registerId: string, urnMap?: ReadonlyMap<string, string>): string {
52
+ function resolveRefTarget(rc: RelatedConcept, uriBase: string, registerId: string, urnMap?: ReadonlyMap<string, string>): string {
21
53
  if (!rc.ref) return '';
22
54
  const ref = rc.ref;
23
55
  if (ref.id) {
@@ -99,13 +131,13 @@ export class DatasetAdapter {
99
131
  const data = await resp.json();
100
132
 
101
133
  // Handle both old format (with eng/status fields) and new format (with designations map)
102
- this.index = this.normalizeIndex(data);
134
+ this.index = this.normalizeIndex(data as IndexJson);
103
135
  this.buildSummaryIndex();
104
136
  return this.index;
105
137
  }
106
138
 
107
- private normalizeIndex(data: any): ConceptIndex {
108
- const concepts: ConceptSummary[] = (data.concepts || []).map((c: any) => ({
139
+ private normalizeIndex(data: IndexJson): ConceptIndex {
140
+ const concepts: ConceptSummary[] = (data.concepts || []).map((c) => ({
109
141
  id: c.id,
110
142
  designations: c.designations || {},
111
143
  eng: c.eng || c.designations?.eng || Object.values(c.designations || {})[0] || '',
@@ -144,7 +176,7 @@ export class DatasetAdapter {
144
176
  const resp = await fetch(`${this.baseUrl}/index.json`);
145
177
  if (!resp.ok) throw new Error(`Failed to load index for ${this.registerId}`);
146
178
  const data = await resp.json();
147
- this.index = this.normalizeIndex(data);
179
+ this.index = this.normalizeIndex(data as IndexJson);
148
180
  this.buildSummaryIndex();
149
181
  return this.index;
150
182
  }
@@ -412,34 +444,34 @@ export class DatasetAdapter {
412
444
  const resp = await fetch(`${this.baseUrl}/domain-nodes.json`);
413
445
  if (!resp.ok) return [];
414
446
  const data = await resp.json();
415
- return (data.domainNodes || []).map((dn: any) => this.mapDomainNode(dn));
447
+ return (data.domainNodes || []).map((dn: DomainNodeJson) => this.mapDomainNode(dn));
416
448
  }
417
449
 
418
- private mapDomainNode(dn: any): GraphNode {
450
+ private mapDomainNode(dn: DomainNodeJson): GraphNode {
419
451
  const node: GraphNode = {
420
- uri: dn.uri,
421
- register: dn.registerId,
452
+ uri: dn.uri ?? '',
453
+ register: dn.registerId ?? '',
422
454
  conceptId: dn.uri?.split('/domain/')[1] || dn.id || '',
423
- designations: dn.names || { eng: dn.label },
455
+ designations: dn.names || (dn.label ? { eng: dn.label } : {}),
424
456
  status: 'domain',
425
457
  loaded: true,
426
458
  nodeType: 'domain' as const,
427
459
  conceptCount: dn.conceptCount || 0,
428
460
  };
429
461
  if (dn.children && dn.children.length > 0) {
430
- node.children = dn.children.map((c: any) => this.mapSectionNode(c));
462
+ node.children = dn.children.map((c) => this.mapSectionNode(c));
431
463
  }
432
464
  return node;
433
465
  }
434
466
 
435
- private mapSectionNode(dn: any): SectionNode {
467
+ private mapSectionNode(dn: DomainNodeJson): SectionNode {
436
468
  const node: SectionNode = {
437
- id: dn.id,
438
- names: dn.names || { eng: dn.label },
469
+ id: dn.id ?? '',
470
+ names: dn.names || (dn.label ? { eng: dn.label } : {}),
439
471
  conceptCount: dn.conceptCount || 0,
440
472
  };
441
473
  if (dn.children && dn.children.length > 0) {
442
- node.children = dn.children.map((c: any) => this.mapSectionNode(c));
474
+ node.children = dn.children.map((c) => this.mapSectionNode(c));
443
475
  }
444
476
  return node;
445
477
  }
@@ -450,7 +482,7 @@ export class DatasetAdapter {
450
482
  return nodes.map(s => this.mapManifestSection(s));
451
483
  }
452
484
 
453
- private mapManifestSection(s: { id: string; names: Record<string, string>; children?: any[] }): SectionNode {
485
+ private mapManifestSection(s: SectionJson): SectionNode {
454
486
  const node: SectionNode = { id: s.id, names: s.names || {}, conceptCount: 0 };
455
487
  if (s.children && s.children.length > 0) {
456
488
  node.children = s.children.map(c => this.mapManifestSection(c));
@@ -40,6 +40,10 @@ export class ReferenceResolver {
40
40
  this.sourceRefs.set(sourceRef, { datasetId, uriPrefix });
41
41
  }
42
42
 
43
+ hasSourceRef(sourceRef: string): boolean {
44
+ return this.sourceRefs.has(sourceRef);
45
+ }
46
+
43
47
  loadRouting(entries: RoutingEntry[]): void {
44
48
  this.routing = entries;
45
49
  }
@@ -90,6 +94,7 @@ export class ReferenceResolver {
90
94
  resolveCitation(source: string, referenceFrom: string, sourceDatasetId?: string): Resolution | null {
91
95
  const entry = this.sourceRefs.get(source);
92
96
  if (!entry) {
97
+ // URN-based source strings resolve directly via dataset URI patterns
93
98
  if (!source.startsWith('urn:')) return null;
94
99
  return this.tryResolveCitationUri(source, referenceFrom, sourceDatasetId);
95
100
  }
@@ -46,21 +46,25 @@ export class AdapterFactory {
46
46
  await Promise.all(needManifest.map(a => a.loadManifest().catch(() => {})));
47
47
  }
48
48
 
49
- // Register all datasets' URI patterns eagerly so cross-dataset refs resolve
50
49
  for (const adapter of adapters) {
51
50
  if (adapter.manifest) {
52
- this.registerUriPatterns(adapter.registerId, adapter.manifest);
51
+ this.registerDataset(adapter.registerId, adapter.manifest);
53
52
  }
54
53
  }
55
- for (const adapter of this.adapters.values()) {
56
- adapter.setUrnMap(this.urnMap);
57
- }
58
54
 
59
- // Load source-refs index for citation resolution
60
- await this.loadSourceRefs();
55
+ // Register bibliography from registry config (ref/refAliases → URN)
56
+ // This is the single source of truth — no separate source-refs file needed.
57
+ for (const reg of registry) {
58
+ if (!reg.datasetUri) continue;
59
+ if (reg.ref) {
60
+ this.resolver.registerSourceRef(reg.ref, reg.id, reg.datasetUri);
61
+ }
62
+ for (const alias of reg.refAliases ?? []) {
63
+ this.resolver.registerSourceRef(alias, reg.id, reg.datasetUri);
64
+ }
65
+ }
61
66
 
62
67
  return adapters;
63
-
64
68
  }
65
69
 
66
70
  getAdapter(registerId: string): DatasetAdapter | undefined {
@@ -71,8 +75,9 @@ export class AdapterFactory {
71
75
  return [...this.adapters.values()];
72
76
  }
73
77
 
78
+ private registerDataset(registerId: string, manifest: Manifest): void {
79
+ this.router.registerDataset(registerId, `${import.meta.env.BASE_URL}data/${registerId}`, manifest);
74
80
 
75
- private registerUriPatterns(registerId: string, manifest: Manifest): void {
76
81
  const uriPatterns = [
77
82
  manifest.datasetUri,
78
83
  ...(manifest.uriAliases ?? []),
@@ -80,18 +85,15 @@ export class AdapterFactory {
80
85
  ].filter(Boolean) as string[];
81
86
  this.resolver.registerDataset(registerId, uriPatterns);
82
87
 
83
- if (manifest.ref) {
84
- this.resolver.registerSourceRef(manifest.ref, registerId, manifest.datasetUri);
85
- }
86
- for (const alias of manifest.refAliases ?? []) {
87
- this.resolver.registerSourceRef(alias, registerId, manifest.datasetUri);
88
- }
89
-
90
88
  if (manifest.datasetUri) this.urnMap.set(manifest.datasetUri, registerId);
91
89
  for (const alias of manifest.uriAliases ?? []) {
92
90
  const base = alias.endsWith('*') ? alias.slice(0, -1) : alias;
93
91
  if (base.startsWith('urn:')) this.urnMap.set(base, registerId);
94
92
  }
93
+
94
+ for (const adapter of this.adapters.values()) {
95
+ adapter.setUrnMap(this.urnMap);
96
+ }
95
97
  }
96
98
 
97
99
  async loadDataset(registerId: string): Promise<DatasetAdapter> {
@@ -101,33 +103,7 @@ export class AdapterFactory {
101
103
  const manifest = await adapter.loadManifest();
102
104
  await adapter.loadIndex();
103
105
 
104
- this.router.registerDataset(registerId, `${import.meta.env.BASE_URL}data/${registerId}`, manifest);
105
-
106
- const uriPatterns = [
107
- manifest.datasetUri,
108
- ...(manifest.uriAliases ?? []),
109
- manifest.uriBase ? `${manifest.uriBase}/${registerId}/*` : undefined,
110
- ].filter(Boolean) as string[];
111
- this.resolver.registerDataset(registerId, uriPatterns);
112
-
113
- if (manifest.ref) {
114
- this.resolver.registerSourceRef(manifest.ref, registerId, manifest.datasetUri);
115
- }
116
- for (const alias of manifest.refAliases ?? []) {
117
- this.resolver.registerSourceRef(alias, registerId, manifest.datasetUri);
118
- }
119
-
120
- // Build URN→datasetId map from manifest
121
- if (manifest.datasetUri) this.urnMap.set(manifest.datasetUri, registerId);
122
- for (const alias of manifest.uriAliases ?? []) {
123
- const base = alias.endsWith('*') ? alias.slice(0, -1) : alias;
124
- if (base.startsWith('urn:')) this.urnMap.set(base, registerId);
125
- }
126
-
127
- // Distribute the updated URN map to all loaded adapters
128
- for (const adapter of this.adapters.values()) {
129
- adapter.setUrnMap(this.urnMap);
130
- }
106
+ this.registerDataset(registerId, manifest);
131
107
 
132
108
  return adapter;
133
109
  }
@@ -140,6 +116,23 @@ export class AdapterFactory {
140
116
  return this.resolver.resolveReference(uri, sourceDatasetId);
141
117
  }
142
118
 
119
+ resolveRelatedRef(ref: { source: string | null; id: string | null } | null, sourceDatasetId?: string): { registerId: string; conceptId: string } | null {
120
+ if (!ref?.source || !ref?.id) return null;
121
+ const uri = `${ref.source}/${ref.id}`;
122
+ const resolution = this.resolve(uri, sourceDatasetId);
123
+ if (resolution.type === 'internal') {
124
+ return { registerId: resolution.registerId, conceptId: resolution.conceptId.replace(/^\//, '') };
125
+ }
126
+ if (ref.source.startsWith('urn:')) {
127
+ const directUri = ref.source + ref.id;
128
+ const directRes = this.resolve(directUri, sourceDatasetId);
129
+ if (directRes.type === 'internal') {
130
+ return { registerId: directRes.registerId, conceptId: directRes.conceptId.replace(/^\//, '') };
131
+ }
132
+ }
133
+ return null;
134
+ }
135
+
143
136
  resolveCitation(source: string, referenceFrom: string, sourceDatasetId?: string): Resolution | null {
144
137
  return this.resolver.resolveCitation(source, referenceFrom, sourceDatasetId);
145
138
  }
@@ -157,21 +150,6 @@ export class AdapterFactory {
157
150
  this.crossRefIndex = this.crossRefIndex || {};
158
151
  return this.crossRefIndex;
159
152
  }
160
-
161
- async loadSourceRefs(): Promise<void> {
162
- try {
163
- const resp = await fetch(`${import.meta.env.BASE_URL}data/source-refs.json`);
164
- if (!resp.ok) return;
165
- const refs: Record<string, string> = await resp.json();
166
- for (const [source, datasetId] of Object.entries(refs)) {
167
- const adapter = this.adapters.get(datasetId);
168
- if (!adapter?.manifest) continue;
169
- this.resolver.registerSourceRef(source, datasetId, adapter.manifest.datasetUri);
170
- }
171
- } catch {
172
- // source-refs.json is optional
173
- }
174
- }
175
153
  }
176
154
 
177
155
  let _instance: AdapterFactory | null = null;
@@ -179,10 +179,10 @@ export function getAnnotations(lc: LocalizedConcept): DetailedDefinition[] {
179
179
  }
180
180
 
181
181
  // Designation relationship targets: RelatedConcept.target (string)
182
- const designationTargets = new WeakMap<RelatedConcept, string>();
182
+ const designationTargets = new WeakMap<object, string>();
183
183
 
184
- export function getDesignationTarget(rc: RelatedConcept): string | null {
185
- return designationTargets.get(rc) ?? null;
184
+ export function getDesignationTarget(rc: { type?: string | null; content?: string | null; target?: string | null; ref?: any }): string | null {
185
+ return designationTargets.get(rc) ?? rc.target ?? null;
186
186
  }
187
187
 
188
188
  // ConceptRef text: human-readable label alongside source/id
@@ -213,23 +213,28 @@ function attachAnnotations(concept: Concept, localizations: Record<string, unkno
213
213
  // Designation-level relationship targets and ref text
214
214
  const rawTerms = rawObj.terms;
215
215
  if (Array.isArray(rawTerms)) {
216
- for (let i = 0; i < lc.terms.length && i < rawTerms.length; i++) {
217
- const rawTerm = rawTerms[i] as Record<string, unknown>;
218
- const rawRelated = rawTerm.related;
216
+ for (const rawTerm of rawTerms) {
217
+ if (!rawTerm || typeof rawTerm !== 'object') continue;
218
+ const rawT = rawTerm as Record<string, unknown>;
219
+ const rawDesignation = rawT.designation as string | undefined;
220
+ if (!rawDesignation) continue;
221
+ const designation = lc.terms.find(d => d.designation === rawDesignation);
222
+ if (!designation) continue;
223
+ const rawRelated = rawT.related;
219
224
  if (!Array.isArray(rawRelated)) continue;
220
- const designation = lc.terms[i];
221
- for (let j = 0; j < designation.related.length && j < rawRelated.length; j++) {
222
- const rawRel = rawRelated[j] as Record<string, unknown>;
223
- const rc = designation.related[j];
224
- if ('type' in rc) {
225
- if (rawRel.target && typeof rawRel.target === 'string') {
226
- designationTargets.set(rc as RelatedConcept, rawRel.target);
227
- }
228
- if ('ref' in rc && rc.ref) {
229
- const rawRef = rawRel.ref as Record<string, unknown> | undefined;
230
- if (rawRef?.text && typeof rawRef.text === 'string') {
231
- refTexts.set((rc as RelatedConcept).ref!, rawRef.text);
232
- }
225
+ for (const rawRel of rawRelated) {
226
+ if (!rawRel || typeof rawRel !== 'object') continue;
227
+ const rel = rawRel as Record<string, unknown>;
228
+ const relType = rel.type as string | undefined;
229
+ const rc = designation.related.find(r => r.type === relType);
230
+ if (!rc) continue;
231
+ if (rel.target && typeof rel.target === 'string') {
232
+ designationTargets.set(rc as object, rel.target);
233
+ }
234
+ if ('ref' in rc && rc.ref) {
235
+ const rawRef = rel.ref as Record<string, unknown> | undefined;
236
+ if (rawRef?.text && typeof rawRef.text === 'string') {
237
+ refTexts.set(rc.ref, rawRef.text);
233
238
  }
234
239
  }
235
240
  }
@@ -239,11 +244,14 @@ function attachAnnotations(concept: Concept, localizations: Record<string, unkno
239
244
  // Localization-level ref text
240
245
  const rawRelated = rawObj.related;
241
246
  if (Array.isArray(rawRelated)) {
242
- for (let i = 0; i < lc.related.length && i < rawRelated.length; i++) {
243
- const rc = lc.related[i];
244
- const rawRel = rawRelated[i] as Record<string, unknown>;
245
- const rawRef = rawRel.ref as Record<string, unknown> | undefined;
246
- if (rc.ref && rawRef?.text && typeof rawRef.text === 'string') {
247
+ for (const rawRel of rawRelated) {
248
+ if (!rawRel || typeof rawRel !== 'object') continue;
249
+ const rel = rawRel as Record<string, unknown>;
250
+ const relType = rel.type as string | undefined;
251
+ const rc = relType ? lc.related.find(r => r.type === relType) : undefined;
252
+ if (!rc || !rc.ref) continue;
253
+ const rawRef = rel.ref as Record<string, unknown> | undefined;
254
+ if (rawRef?.text && typeof rawRef.text === 'string') {
247
255
  refTexts.set(rc.ref, rawRef.text);
248
256
  }
249
257
  }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Ontology Registry — taxonomy-driven labels and definitions for the browser.
2
+ * Ontology Registry — taxonomy-driven labels, definitions, and colors for the browser.
3
3
  *
4
4
  * All enumeration labels, definitions, and colors come from the SKOS taxonomy
5
5
  * data extracted at build time from concept-model/ontologies/taxonomies/*.ttl.
@@ -29,10 +29,19 @@ export interface Taxonomy {
29
29
  schemeDefinition: string | null;
30
30
  categories?: Record<string, TaxonomyCategory>;
31
31
  concepts: Record<string, TaxonomyConcept>;
32
+ colors?: Record<string, string>;
33
+ }
34
+
35
+ export interface TaxonomyDisplay {
36
+ label: string;
37
+ color: string;
38
+ definition?: string;
32
39
  }
33
40
 
34
41
  type TaxonomyKey = keyof typeof taxonomyData;
35
42
 
43
+ const DEFAULT_COLOR = 'badge-gray';
44
+
36
45
  export class OntologyRegistry {
37
46
  private data: Record<string, Taxonomy>;
38
47
 
@@ -94,9 +103,17 @@ export class OntologyRegistry {
94
103
  }
95
104
 
96
105
  getColor(taxonomy: TaxonomyKey, id: string): string | null {
97
- const entry = this.data[taxonomy] as unknown as Record<string, unknown>;
98
- const colors = entry?.colors as Record<string, string> | undefined;
99
- return colors?.[id] ?? null;
106
+ return this.data[taxonomy]?.colors?.[id] ?? null;
107
+ }
108
+
109
+ getDisplay(taxonomy: TaxonomyKey, id: string | null | undefined, colorFallback?: string): TaxonomyDisplay {
110
+ if (!id) return { label: '', color: colorFallback ?? DEFAULT_COLOR };
111
+ const concept = this.getConcept(taxonomy, id);
112
+ return {
113
+ label: concept?.prefLabel ?? id,
114
+ color: this.data[taxonomy]?.colors?.[id] ?? colorFallback ?? DEFAULT_COLOR,
115
+ definition: concept?.definition ?? undefined,
116
+ };
100
117
  }
101
118
  }
102
119
 
@@ -116,6 +116,8 @@ export interface DatasetRegistry {
116
116
  datasetUri?: string;
117
117
  uriBase?: string;
118
118
  uriAliases?: string[];
119
+ ref?: string;
120
+ refAliases?: string[];
119
121
  }
120
122
 
121
123
  // ── Graph types ────────────────────────────────────────────────────────────