@glossarist/concept-browser 0.7.31 → 0.7.33

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
+ });
@@ -0,0 +1,191 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
+ import { ReferenceResolver } from '../adapters/ReferenceResolver';
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('Source reference resolution (citation linking)', () => {
12
+ let resolver: ReferenceResolver;
13
+
14
+ beforeEach(() => {
15
+ resolver = new ReferenceResolver();
16
+ // Register datasets with URI patterns
17
+ resolver.registerDataset('vim-2012', ['urn:oiml:pub:v:2:2012*']);
18
+ resolver.registerDataset('viml-2022', ['urn:oiml:pub:v:1:2022*']);
19
+ resolver.registerDataset('vim-2007', ['urn:oiml:pub:v:2:2007*']);
20
+ });
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
+
33
+ describe('registerSourceRef', () => {
34
+ it('resolves citation when source ref matches exactly', () => {
35
+ resolver.registerSourceRef('OIML V 2-200:2012', 'vim-2012', 'urn:oiml:pub:v:2:2012');
36
+ const result = resolver.resolveCitation('OIML V 2-200:2012', '2.2', 'viml-2022');
37
+ expect(result).toEqual({
38
+ type: 'internal',
39
+ registerId: 'vim-2012',
40
+ conceptId: '2.2',
41
+ crossDataset: true,
42
+ });
43
+ });
44
+
45
+ it('resolves citation using variant source string', () => {
46
+ // Manifest has "OIML V 2-200:2012" but concepts use "OIML V2-200:2012"
47
+ resolver.registerSourceRef('OIML V 2-200:2012', 'vim-2012', 'urn:oiml:pub:v:2:2012');
48
+ resolver.registerSourceRef('OIML V2-200:2012', 'vim-2012', 'urn:oiml:pub:v:2:2012');
49
+
50
+ const result = resolver.resolveCitation('OIML V2-200:2012', '2.2', 'viml-2022');
51
+ expect(result).toEqual({
52
+ type: 'internal',
53
+ registerId: 'vim-2012',
54
+ conceptId: '2.2',
55
+ crossDataset: true,
56
+ });
57
+ });
58
+
59
+ it('resolves citation using URN as source', () => {
60
+ resolver.registerSourceRef('urn:oiml:pub:v:2:2012', 'vim-2012', 'urn:oiml:pub:v:2:2012');
61
+ const result = resolver.resolveCitation('urn:oiml:pub:v:2:2012', '2.2', 'viml-2022');
62
+ expect(result).toEqual({
63
+ type: 'internal',
64
+ registerId: 'vim-2012',
65
+ conceptId: '2.2',
66
+ crossDataset: true,
67
+ });
68
+ });
69
+
70
+ it('resolves citation using ref alias (e.g. "VIM")', () => {
71
+ resolver.registerSourceRef('VIM', 'vim-2012', 'urn:oiml:pub:v:2:2012');
72
+ const result = resolver.resolveCitation('VIM', '2.2', 'viml-2022');
73
+ expect(result).toEqual({
74
+ type: 'internal',
75
+ registerId: 'vim-2012',
76
+ conceptId: '2.2',
77
+ crossDataset: true,
78
+ });
79
+ });
80
+
81
+ it('returns null when source ref is not registered', () => {
82
+ const result = resolver.resolveCitation('Unknown Source', '2.2', 'viml-2022');
83
+ expect(result).toBeNull();
84
+ });
85
+
86
+ it('returns null when source ref matches but concept does not exist in dataset', () => {
87
+ resolver.registerSourceRef('OIML V 2-200:2012', 'vim-2012', 'urn:oiml:pub:v:2:2012');
88
+ // The resolver can only check if the dataset exists, not if the concept exists
89
+ // It will still return internal if the dataset is registered
90
+ const result = resolver.resolveCitation('OIML V 2-200:2012', '99.99', 'viml-2022');
91
+ expect(result?.type).toBe('internal');
92
+ });
93
+
94
+ it('handles same-dataset citation (crossDataset=false)', () => {
95
+ resolver.registerSourceRef('OIML V 2-200:2012', 'vim-2012', 'urn:oiml:pub:v:2:2012');
96
+ const result = resolver.resolveCitation('OIML V 2-200:2012', '2.2', 'vim-2012');
97
+ expect(result).toEqual({
98
+ type: 'internal',
99
+ registerId: 'vim-2012',
100
+ conceptId: '2.2',
101
+ crossDataset: false,
102
+ });
103
+ });
104
+ });
105
+
106
+ describe('resolveCitation fallback to URN', () => {
107
+ it('resolves URN-based source without registerSourceRef', () => {
108
+ // When source starts with "urn:", tryResolveCitationUri handles it
109
+ const result = resolver.resolveCitation('urn:oiml:pub:v:2:2012', '2.2', 'viml-2022');
110
+ expect(result).toEqual({
111
+ type: 'internal',
112
+ registerId: 'vim-2012',
113
+ conceptId: '2.2',
114
+ crossDataset: true,
115
+ });
116
+ });
117
+ });
118
+
119
+ describe('multiple source strings for same dataset', () => {
120
+ it('registers multiple source refs pointing to the same dataset', () => {
121
+ resolver.registerSourceRef('OIML V 2-200:2012', 'vim-2012', 'urn:oiml:pub:v:2:2012');
122
+ resolver.registerSourceRef('OIML V2-200:2012', 'vim-2012', 'urn:oiml:pub:v:2:2012');
123
+ resolver.registerSourceRef('VIM', 'vim-2012', 'urn:oiml:pub:v:2:2012');
124
+ resolver.registerSourceRef('ISO Guide 99:2007', 'vim-2012', 'urn:oiml:pub:v:2:2012');
125
+
126
+ expect(asInternal(resolver.resolveCitation('OIML V 2-200:2012', '2.2'))?.registerId).toBe('vim-2012');
127
+ expect(asInternal(resolver.resolveCitation('OIML V2-200:2012', '2.2'))?.registerId).toBe('vim-2012');
128
+ expect(asInternal(resolver.resolveCitation('VIM', '2.2'))?.registerId).toBe('vim-2012');
129
+ expect(asInternal(resolver.resolveCitation('ISO Guide 99:2007', '2.2'))?.registerId).toBe('vim-2012');
130
+ });
131
+ });
132
+
133
+ describe('ISO source references', () => {
134
+ it('resolves ISO/IEC references when registered', () => {
135
+ resolver.registerDataset('iso-17000', ['urn:iso:std:iso:iec:17000*']);
136
+ resolver.registerSourceRef('ISO/IEC 17000:2020', 'iso-17000', 'urn:iso:std:iso:iec:17000');
137
+
138
+ const result = resolver.resolveCitation('ISO/IEC 17000:2020', '3.1', 'viml-2022');
139
+ expect(result).toEqual({
140
+ type: 'internal',
141
+ registerId: 'iso-17000',
142
+ conceptId: '3.1',
143
+ crossDataset: true,
144
+ });
145
+ });
146
+
147
+ it('returns null for unresolvable ISO references', () => {
148
+ // ISO dataset not loaded — no registerSourceRef or dataset pattern match
149
+ const result = resolver.resolveCitation('ISO/IEC 17000:2020', '3.1', 'viml-2022');
150
+ expect(result).toBeNull();
151
+ });
152
+ });
153
+
154
+ describe('edge cases', () => {
155
+ it('returns internal with empty conceptId when referenceFrom is empty', () => {
156
+ resolver.registerSourceRef('OIML V 2-200:2012', 'vim-2012', 'urn:oiml:pub:v:2:2012');
157
+ const result = resolver.resolveCitation('OIML V 2-200:2012', '');
158
+ // Resolver still resolves but with empty conceptId — UI handles this
159
+ expect(result?.type).toBe('internal');
160
+ });
161
+
162
+ it('handles concept IDs with dots', () => {
163
+ resolver.registerSourceRef('OIML V 2-200:2012', 'vim-2012', 'urn:oiml:pub:v:2:2012');
164
+ const result = resolver.resolveCitation('OIML V 2-200:2012', '5.12.3', 'viml-2022');
165
+ expect(result).toEqual({
166
+ type: 'internal',
167
+ registerId: 'vim-2012',
168
+ conceptId: '5.12.3',
169
+ crossDataset: true,
170
+ });
171
+ });
172
+
173
+ it('handles source strings with special characters', () => {
174
+ resolver.registerDataset('vim-1993', ['urn:oiml:pub:v:2:1993*']);
175
+ resolver.registerSourceRef('OIML V 2:1993', 'vim-1993', 'urn:oiml:pub:v:2:1993');
176
+ const result = resolver.resolveCitation('OIML V 2:1993', '3.6');
177
+ expect(asInternal(result)?.registerId).toBe('vim-1993');
178
+ });
179
+
180
+ it('does not confuse similar source strings', () => {
181
+ resolver.registerSourceRef('OIML V 2-200:2007', 'vim-2007', 'urn:oiml:pub:v:2:2007');
182
+ resolver.registerSourceRef('OIML V 2-200:2012', 'vim-2012', 'urn:oiml:pub:v:2:2012');
183
+
184
+ const result2007 = resolver.resolveCitation('OIML V 2-200:2007', '2.2');
185
+ const result2012 = resolver.resolveCitation('OIML V 2-200:2012', '2.2');
186
+
187
+ expect(asInternal(result2007)?.registerId).toBe('vim-2007');
188
+ expect(asInternal(result2012)?.registerId).toBe('vim-2012');
189
+ });
190
+ });
191
+ });
@@ -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,18 +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);
54
+
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
+ }
57
65
  }
58
66
 
59
67
  return adapters;
60
-
61
68
  }
62
69
 
63
70
  getAdapter(registerId: string): DatasetAdapter | undefined {
@@ -68,8 +75,9 @@ export class AdapterFactory {
68
75
  return [...this.adapters.values()];
69
76
  }
70
77
 
78
+ private registerDataset(registerId: string, manifest: Manifest): void {
79
+ this.router.registerDataset(registerId, `${import.meta.env.BASE_URL}data/${registerId}`, manifest);
71
80
 
72
- private registerUriPatterns(registerId: string, manifest: Manifest): void {
73
81
  const uriPatterns = [
74
82
  manifest.datasetUri,
75
83
  ...(manifest.uriAliases ?? []),
@@ -77,18 +85,15 @@ export class AdapterFactory {
77
85
  ].filter(Boolean) as string[];
78
86
  this.resolver.registerDataset(registerId, uriPatterns);
79
87
 
80
- if (manifest.ref) {
81
- this.resolver.registerSourceRef(manifest.ref, registerId, manifest.datasetUri);
82
- }
83
- for (const alias of manifest.refAliases ?? []) {
84
- this.resolver.registerSourceRef(alias, registerId, manifest.datasetUri);
85
- }
86
-
87
88
  if (manifest.datasetUri) this.urnMap.set(manifest.datasetUri, registerId);
88
89
  for (const alias of manifest.uriAliases ?? []) {
89
90
  const base = alias.endsWith('*') ? alias.slice(0, -1) : alias;
90
91
  if (base.startsWith('urn:')) this.urnMap.set(base, registerId);
91
92
  }
93
+
94
+ for (const adapter of this.adapters.values()) {
95
+ adapter.setUrnMap(this.urnMap);
96
+ }
92
97
  }
93
98
 
94
99
  async loadDataset(registerId: string): Promise<DatasetAdapter> {
@@ -98,33 +103,7 @@ export class AdapterFactory {
98
103
  const manifest = await adapter.loadManifest();
99
104
  await adapter.loadIndex();
100
105
 
101
- this.router.registerDataset(registerId, `${import.meta.env.BASE_URL}data/${registerId}`, manifest);
102
-
103
- const uriPatterns = [
104
- manifest.datasetUri,
105
- ...(manifest.uriAliases ?? []),
106
- manifest.uriBase ? `${manifest.uriBase}/${registerId}/*` : undefined,
107
- ].filter(Boolean) as string[];
108
- this.resolver.registerDataset(registerId, uriPatterns);
109
-
110
- if (manifest.ref) {
111
- this.resolver.registerSourceRef(manifest.ref, registerId, manifest.datasetUri);
112
- }
113
- for (const alias of manifest.refAliases ?? []) {
114
- this.resolver.registerSourceRef(alias, registerId, manifest.datasetUri);
115
- }
116
-
117
- // Build URN→datasetId map from manifest
118
- if (manifest.datasetUri) this.urnMap.set(manifest.datasetUri, registerId);
119
- for (const alias of manifest.uriAliases ?? []) {
120
- const base = alias.endsWith('*') ? alias.slice(0, -1) : alias;
121
- if (base.startsWith('urn:')) this.urnMap.set(base, registerId);
122
- }
123
-
124
- // Distribute the updated URN map to all loaded adapters
125
- for (const adapter of this.adapters.values()) {
126
- adapter.setUrnMap(this.urnMap);
127
- }
106
+ this.registerDataset(registerId, manifest);
128
107
 
129
108
  return adapter;
130
109
  }
@@ -137,6 +116,23 @@ export class AdapterFactory {
137
116
  return this.resolver.resolveReference(uri, sourceDatasetId);
138
117
  }
139
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
+
140
136
  resolveCitation(source: string, referenceFrom: string, sourceDatasetId?: string): Resolution | null {
141
137
  return this.resolver.resolveCitation(source, referenceFrom, sourceDatasetId);
142
138
  }