@glossarist/concept-browser 0.7.34 → 0.7.37

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 (37) hide show
  1. package/package.json +2 -2
  2. package/scripts/build-edges.js +16 -8
  3. package/scripts/generate-data.mjs +284 -86
  4. package/src/__tests__/citation-display.test.ts +165 -3
  5. package/src/__tests__/cite-ref.test.ts +112 -0
  6. package/src/__tests__/concept-detail-interaction.test.ts +1 -5
  7. package/src/__tests__/{math.test.ts → content-renderer.test.ts} +113 -29
  8. package/src/__tests__/escape.test.ts +76 -0
  9. package/src/__tests__/graph-data-source.test.ts +155 -0
  10. package/src/__tests__/model-bridge-bridges.test.ts +150 -0
  11. package/src/__tests__/model-bridge-citation.test.ts +163 -0
  12. package/src/__tests__/reference-resolver-cite.test.ts +122 -0
  13. package/src/__tests__/reference-resolver.test.ts +12 -7
  14. package/src/__tests__/resolve-view.test.ts +1 -1
  15. package/src/__tests__/sidebar-nav-highlighting.test.ts +178 -0
  16. package/src/__tests__/source-refs.test.ts +9 -6
  17. package/src/__tests__/test-helpers.ts +20 -0
  18. package/src/__tests__/uri-router.test.ts +39 -12
  19. package/src/adapters/DatasetAdapter.ts +35 -143
  20. package/src/adapters/GraphDataSource.ts +178 -0
  21. package/src/adapters/ReferenceResolver.ts +101 -47
  22. package/src/adapters/UriRouter.ts +82 -10
  23. package/src/adapters/factory.ts +35 -28
  24. package/src/adapters/model-bridge.ts +121 -71
  25. package/src/adapters/types.ts +3 -0
  26. package/src/components/AppSidebar.vue +7 -4
  27. package/src/components/CitationDisplay.vue +86 -30
  28. package/src/components/ConceptDetail.vue +24 -126
  29. package/src/components/LanguageDetail.vue +6 -6
  30. package/src/composables/use-concept-content.ts +8 -8
  31. package/src/composables/use-ontology-nav.ts +129 -130
  32. package/src/composables/use-render-options.ts +1 -1
  33. package/src/graph/GraphEngine.ts +65 -0
  34. package/src/stores/vocabulary.ts +12 -73
  35. package/src/utils/content-renderer.ts +312 -0
  36. package/src/utils/markdown-lite.ts +2 -2
  37. package/src/utils/math.ts +0 -189
@@ -0,0 +1,178 @@
1
+ import type { GraphEdge, GraphNode, SectionNode } from './types';
2
+ import type { Concept, RelatedConcept } from 'glossarist';
3
+ import type { DatasetAdapter } from './DatasetAdapter';
4
+ import { UriRouter } from './UriRouter';
5
+ import { slugify } from '../utils/slugify';
6
+
7
+ interface DomainNodeJson {
8
+ uri?: string;
9
+ id?: string;
10
+ registerId?: string;
11
+ label?: string;
12
+ names?: Record<string, string>;
13
+ conceptCount?: number;
14
+ children?: DomainNodeJson[];
15
+ }
16
+
17
+ interface SectionJson {
18
+ id: string;
19
+ names?: Record<string, string>;
20
+ children?: SectionJson[];
21
+ }
22
+
23
+ function resolveRefTarget(rc: RelatedConcept, uriBase: string, registerId: string, urnMap?: ReadonlyMap<string, string>): string {
24
+ if (!rc.ref) return '';
25
+ const ref = rc.ref;
26
+ if (ref.id) {
27
+ let reg = registerId;
28
+ if (ref.source && !ref.source.startsWith('http')) {
29
+ reg = urnMap?.get(ref.source) ?? ref.source;
30
+ }
31
+ return `${uriBase}/${reg}/concept/${ref.id}`;
32
+ }
33
+ if (ref.source && ref.source.startsWith('http')) return ref.source;
34
+ return ref.source || '';
35
+ }
36
+
37
+ export class GraphDataSource {
38
+ constructor(private adapter: DatasetAdapter) {}
39
+
40
+ private get baseUrl(): string {
41
+ return this.adapter.dataUrl;
42
+ }
43
+
44
+ private get registerId(): string {
45
+ return this.adapter.registerId;
46
+ }
47
+
48
+ private get uriBase(): string {
49
+ return this.adapter.manifest?.uriBase || 'https://glossarist.org';
50
+ }
51
+
52
+ private get urnMap(): ReadonlyMap<string, string> {
53
+ return this.adapter.urnMap;
54
+ }
55
+
56
+ async loadEdgeIndex(): Promise<GraphEdge[]> {
57
+ const resp = await fetch(`${this.baseUrl}/edges.json`);
58
+ if (!resp.ok) return [];
59
+ const data = await resp.json();
60
+ return data.edges ?? [];
61
+ }
62
+
63
+ async loadGraphNodes(): Promise<{ uriPrefix: string; nodes: [string, Record<string, string>, string][] }> {
64
+ const resp = await fetch(`${this.baseUrl}/graph-nodes.json`);
65
+ if (!resp.ok) return { uriPrefix: '', nodes: [] };
66
+ return await resp.json();
67
+ }
68
+
69
+ async loadDomainNodes(): Promise<GraphNode[]> {
70
+ const resp = await fetch(`${this.baseUrl}/domain-nodes.json`);
71
+ if (!resp.ok) return [];
72
+ const data = await resp.json();
73
+ return (data.domainNodes || []).map((dn: DomainNodeJson) => this.mapDomainNode(dn));
74
+ }
75
+
76
+ extractEdges(concept: Concept): GraphEdge[] {
77
+ const edges: GraphEdge[] = [];
78
+ const sourceUri = concept.uri || `${this.uriBase}/${this.registerId}/concept/${concept.id}`;
79
+
80
+ for (const rc of concept.relatedConcepts) {
81
+ const target = resolveRefTarget(rc, this.uriBase, this.registerId, this.urnMap);
82
+ if (target && target !== sourceUri) {
83
+ const parsed = UriRouter.parseUri(target);
84
+ edges.push({
85
+ source: sourceUri,
86
+ target,
87
+ type: rc.type || 'references',
88
+ label: rc.content || undefined,
89
+ register: parsed?.registerId ?? this.registerId,
90
+ });
91
+ }
92
+ }
93
+
94
+ for (const lang of concept.languages) {
95
+ const lc = concept.localization(lang);
96
+ if (!lc) continue;
97
+ for (const rc of lc.related) {
98
+ const target = resolveRefTarget(rc, this.uriBase, this.registerId, this.urnMap);
99
+ if (target && target !== sourceUri) {
100
+ const parsed = UriRouter.parseUri(target);
101
+ edges.push({
102
+ source: sourceUri,
103
+ target,
104
+ type: rc.type || 'references',
105
+ label: rc.content || undefined,
106
+ register: parsed?.registerId ?? this.registerId,
107
+ lang,
108
+ });
109
+ }
110
+ }
111
+ }
112
+
113
+ return edges;
114
+ }
115
+
116
+ extractDomainEdges(concept: Concept): GraphEdge[] {
117
+ const edges: GraphEdge[] = [];
118
+ const sourceUri = concept.uri || `${this.uriBase}/${this.registerId}/concept/${concept.id}`;
119
+
120
+ for (const lang of concept.languages) {
121
+ const lc = concept.localization(lang);
122
+ if (lc?.domain) {
123
+ edges.push({
124
+ source: sourceUri,
125
+ target: `${this.uriBase}/${this.registerId}/domain/${slugify(lc.domain)}`,
126
+ type: 'domain',
127
+ label: lc.domain,
128
+ register: this.registerId,
129
+ lang,
130
+ });
131
+ }
132
+ }
133
+ return edges;
134
+ }
135
+
136
+ getSectionTree(): SectionNode[] {
137
+ const nodes = this.adapter.manifest?.sections;
138
+ if (!nodes || nodes.length === 0) return [];
139
+ return nodes.map(s => this.mapManifestSection(s));
140
+ }
141
+
142
+ private mapDomainNode(dn: DomainNodeJson): GraphNode {
143
+ const node: GraphNode = {
144
+ uri: dn.uri ?? '',
145
+ register: dn.registerId ?? '',
146
+ conceptId: dn.uri?.split('/domain/')[1] || dn.id || '',
147
+ designations: dn.names || (dn.label ? { eng: dn.label } : {}),
148
+ status: 'domain',
149
+ loaded: true,
150
+ nodeType: 'domain' as const,
151
+ conceptCount: dn.conceptCount || 0,
152
+ };
153
+ if (dn.children && dn.children.length > 0) {
154
+ node.children = dn.children.map((c) => this.mapSectionNode(c));
155
+ }
156
+ return node;
157
+ }
158
+
159
+ private mapSectionNode(dn: DomainNodeJson): SectionNode {
160
+ const node: SectionNode = {
161
+ id: dn.id ?? '',
162
+ names: dn.names || (dn.label ? { eng: dn.label } : {}),
163
+ conceptCount: dn.conceptCount || 0,
164
+ };
165
+ if (dn.children && dn.children.length > 0) {
166
+ node.children = dn.children.map((c) => this.mapSectionNode(c));
167
+ }
168
+ return node;
169
+ }
170
+
171
+ private mapManifestSection(s: SectionJson): SectionNode {
172
+ const node: SectionNode = { id: s.id, names: s.names || {}, conceptCount: 0 };
173
+ if (s.children && s.children.length > 0) {
174
+ node.children = s.children.map(c => this.mapManifestSection(c));
175
+ }
176
+ return node;
177
+ }
178
+ }
@@ -1,39 +1,45 @@
1
1
  import type { Resolution } from './types';
2
2
  import type { RoutingEntry } from '../config/types';
3
+ import { UriRouter } from './UriRouter';
3
4
 
4
- interface DatasetEntry {
5
- id: string;
6
- uriPatterns: string[];
7
- }
5
+ // ── Citation classification ────────────────────────────────────────────────
8
6
 
9
- function extractConceptId(uri: string, pattern: string): string | null {
10
- if (!pattern.endsWith('*')) return null;
11
- const base = pattern.slice(0, -1);
12
- if (!uri.startsWith(base)) return null;
13
- const remainder = uri.slice(base.length);
7
+ export type CitationClassification =
8
+ | 'internal-citation'
9
+ | 'self-contained-citation'
10
+ | 'external-citation'
11
+ | 'unresolved-citation';
14
12
 
15
- if (uri.startsWith('https://') || uri.startsWith('http://')) {
16
- const match = remainder.match(/^\/?concept\/([^/?#]+)/);
17
- return match ? match[1] : null;
18
- }
19
- if (uri.startsWith('urn:')) {
20
- return remainder || null;
21
- }
22
- return null;
13
+ export interface CiteResolution {
14
+ classification: CitationClassification;
15
+ resolved: { registerId: string; conceptId: string } | null;
23
16
  }
24
17
 
25
- function matchUriPattern(uri: string, pattern: string): boolean {
26
- if (!pattern.endsWith('*')) return uri === pattern;
27
- return uri.startsWith(pattern.slice(0, -1));
18
+ /**
19
+ * Lightweight citation shape used for classification.
20
+ * Uses snake_case to match glossarist's Citation model conventions.
21
+ */
22
+ interface CitationInput {
23
+ ref?: { source?: string | null; id?: string | null; version?: string | null } | null;
24
+ locality?: { type?: string | null; reference_from?: string | null; reference_to?: string | null; referenceFrom?: string | null; referenceTo?: string | null } | null;
25
+ link?: string | null;
28
26
  }
29
27
 
28
+ // ── ReferenceResolver ──────────────────────────────────────────────────────
29
+
30
+ /**
31
+ * Resolves references (citations, source refs, URNs) to concepts.
32
+ *
33
+ * Delegates URI pattern matching to UriRouter (the single authority for URI routing).
34
+ * Adds its own concerns on top: source-ref mapping, routing table, citation classification.
35
+ */
30
36
  export class ReferenceResolver {
31
- private datasets: DatasetEntry[] = [];
32
37
  private routing: RoutingEntry[] = [];
33
38
  private sourceRefs = new Map<string, { datasetId: string; uriPrefix: string }>();
39
+ private readonly uriRouter: UriRouter;
34
40
 
35
- registerDataset(id: string, uriPatterns: string[]): void {
36
- this.datasets.push({ id, uriPatterns });
41
+ constructor(uriRouter: UriRouter) {
42
+ this.uriRouter = uriRouter;
37
43
  }
38
44
 
39
45
  registerSourceRef(sourceRef: string, datasetId: string, uriPrefix: string): void {
@@ -49,26 +55,20 @@ export class ReferenceResolver {
49
55
  }
50
56
 
51
57
  resolveReference(uri: string, sourceDatasetId?: string): Resolution {
52
- // Step 1: Check provided datasets
53
- for (const ds of this.datasets) {
54
- for (const pattern of ds.uriPatterns) {
55
- if (matchUriPattern(uri, pattern)) {
56
- const conceptId = extractConceptId(uri, pattern);
57
- if (conceptId) {
58
- return {
59
- type: 'internal',
60
- registerId: ds.id,
61
- conceptId,
62
- crossDataset: sourceDatasetId != null && sourceDatasetId !== ds.id,
63
- };
64
- }
65
- }
66
- }
58
+ // Step 1: Check registered datasets via UriRouter
59
+ const resolved = this.uriRouter.resolveUri(uri);
60
+ if (resolved) {
61
+ return {
62
+ type: 'internal',
63
+ registerId: resolved.registerId,
64
+ conceptId: resolved.conceptId,
65
+ crossDataset: sourceDatasetId != null && sourceDatasetId !== resolved.registerId,
66
+ };
67
67
  }
68
68
 
69
69
  // Step 2: Check routing table
70
70
  for (const entry of this.routing) {
71
- if (matchUriPattern(uri, entry.uri)) {
71
+ if (this.matchesRoutingPattern(uri, entry.uri)) {
72
72
  if (entry.type === 'site') {
73
73
  return {
74
74
  type: 'site',
@@ -79,7 +79,7 @@ export class ReferenceResolver {
79
79
  }
80
80
  if (entry.type === 'url') {
81
81
  const template = entry.url!;
82
- const conceptId = this.extractConceptIdFromRouting(uri, entry.uri);
82
+ const conceptId = this.extractConceptIdFromRouting(uri);
83
83
  const url = template.includes('{conceptId}') && conceptId
84
84
  ? template.replace('{conceptId}', conceptId)
85
85
  : template;
@@ -115,14 +115,68 @@ export class ReferenceResolver {
115
115
  return null;
116
116
  }
117
117
 
118
- private extractConceptIdFromRouting(uri: string, pattern: string): string | null {
119
- for (const ds of this.datasets) {
120
- for (const dsPattern of ds.uriPatterns) {
121
- if (matchUriPattern(uri, dsPattern)) {
122
- return extractConceptId(uri, dsPattern);
123
- }
118
+ /**
119
+ * Classify a citation and resolve it to a concept if possible.
120
+ * Single source of truth for citation resolution — both classification
121
+ * and navigation target come from this one method.
122
+ */
123
+ resolveCite(citation: CitationInput | null | undefined, sourceDatasetId?: string): CiteResolution {
124
+ if (!citation?.ref?.source) {
125
+ return { classification: 'unresolved-citation', resolved: null };
126
+ }
127
+
128
+ const referenceFrom = citation.locality?.reference_from ?? citation.locality?.referenceFrom ?? '';
129
+ const resolution = this.resolveCitation(citation.ref.source, referenceFrom, sourceDatasetId);
130
+ if (resolution?.type === 'internal') {
131
+ return {
132
+ classification: 'internal-citation',
133
+ resolved: { registerId: resolution.registerId, conceptId: resolution.conceptId },
134
+ };
135
+ }
136
+
137
+ if (citation.link) {
138
+ return { classification: 'self-contained-citation', resolved: null };
139
+ }
140
+
141
+ return { classification: 'external-citation', resolved: null };
142
+ }
143
+
144
+ resolveRelatedRef(ref: { source: string | null; id: string | null } | null, sourceDatasetId?: string): { registerId: string; conceptId: string } | null {
145
+ if (!ref?.source || !ref?.id) return null;
146
+ const uri = `${ref.source}/${ref.id}`;
147
+ const resolution = this.resolveReference(uri, sourceDatasetId);
148
+ if (resolution.type === 'internal') {
149
+ return { registerId: resolution.registerId, conceptId: resolution.conceptId.replace(/^\//, '') };
150
+ }
151
+ if (ref.source.startsWith('urn:')) {
152
+ const directUri = ref.source + ref.id;
153
+ const directRes = this.resolveReference(directUri, sourceDatasetId);
154
+ if (directRes.type === 'internal') {
155
+ return { registerId: directRes.registerId, conceptId: directRes.conceptId.replace(/^\//, '') };
124
156
  }
125
157
  }
126
- return extractConceptId(uri, pattern);
158
+ return null;
159
+ }
160
+
161
+ // ── Routing table helpers ────────────────────────────────────────────────
162
+
163
+ private matchesRoutingPattern(uri: string, pattern: string): boolean {
164
+ if (!pattern.endsWith('*')) return uri === pattern;
165
+ return uri.startsWith(pattern.slice(0, -1));
166
+ }
167
+
168
+ private extractConceptIdFromRouting(uri: string): string | null {
169
+ const resolved = this.uriRouter.resolveUri(uri);
170
+ if (resolved) return resolved.conceptId;
171
+ // Fallback: extract from URI structure
172
+ // HTTP: /concept/{id}
173
+ const httpMatch = uri.match(/\/concept\/([^/?#]+)/);
174
+ if (httpMatch) return httpMatch[1];
175
+ // URN: last colon-separated segment
176
+ if (uri.startsWith('urn:')) {
177
+ const parts = uri.split(':');
178
+ return parts.length > 0 ? parts[parts.length - 1] : null;
179
+ }
180
+ return null;
127
181
  }
128
182
  }
@@ -1,34 +1,106 @@
1
1
  import type { Manifest } from './types';
2
2
 
3
+ // ── URI pattern matching ────────────────────────────────────────────────────
4
+
5
+ function matchUriPattern(uri: string, pattern: string): boolean {
6
+ if (!pattern.endsWith('*')) return uri === pattern;
7
+ return uri.startsWith(pattern.slice(0, -1));
8
+ }
9
+
10
+ function extractConceptId(uri: string, pattern: string): string | null {
11
+ if (!pattern.endsWith('*')) return null;
12
+ const base = pattern.slice(0, -1);
13
+ if (!uri.startsWith(base)) return null;
14
+ const remainder = uri.slice(base.length);
15
+
16
+ if (uri.startsWith('https://') || uri.startsWith('http://')) {
17
+ const match = remainder.match(/^\/?concept\/([^/?#]+)/);
18
+ return match ? match[1] : null;
19
+ }
20
+ if (uri.startsWith('urn:')) {
21
+ return remainder || null;
22
+ }
23
+ return null;
24
+ }
25
+
26
+ // ── Static parse regex ──────────────────────────────────────────────────────
27
+
3
28
  const URI_REGISTER_RE = /\/([^/]+)\/concept\/([^/]+)$/;
4
29
 
30
+ // ── UriRouter ───────────────────────────────────────────────────────────────
31
+
32
+ /**
33
+ * Single source of truth for URI routing.
34
+ *
35
+ * Maps URIs to {registerId, conceptId} pairs using dataset-registered URI
36
+ * patterns. Supports wildcard patterns, URN prefix mapping, and URI construction.
37
+ *
38
+ * ReferenceResolver delegates URI matching here and adds its own concerns
39
+ * (routing table, source refs, citation classification) on top.
40
+ */
5
41
  export class UriRouter {
6
- private registerMap = new Map<string, { baseUrl: string; manifest: Manifest | null; uriBase: string }>();
42
+ /** registerId { baseUrl, uriBase, uriPatterns } */
43
+ private registerMap = new Map<string, { baseUrl: string; uriBase: string; uriPatterns: string[] }>();
7
44
 
8
- registerDataset(registerId: string, baseUrl: string, manifest?: Manifest) {
9
- this.registerMap.set(registerId, {
10
- baseUrl,
11
- manifest: manifest ?? null,
12
- uriBase: manifest?.uriBase ?? '',
13
- });
45
+ /** URN prefix → registerId (extracted from uriPatterns at registration time) */
46
+ private urnMap = new Map<string, string>();
47
+
48
+ /**
49
+ * Register a dataset's URI patterns for routing.
50
+ * URN-prefixed patterns are also indexed for fast URN → registerId lookup.
51
+ */
52
+ registerDataset(registerId: string, baseUrl: string, uriBase: string, uriPatterns: string[]): void {
53
+ this.registerMap.set(registerId, { baseUrl, uriBase, uriPatterns });
54
+
55
+ for (const pattern of uriPatterns) {
56
+ const base = pattern.endsWith('*') ? pattern.slice(0, -1) : pattern;
57
+ if (base.startsWith('urn:')) {
58
+ // Store without trailing colon so prefix matching works naturally
59
+ const clean = base.endsWith(':') ? base.slice(0, -1) : base;
60
+ this.urnMap.set(clean, registerId);
61
+ }
62
+ }
14
63
  }
15
64
 
65
+ /**
66
+ * Resolve a URI to {registerId, conceptId} using registered patterns.
67
+ * Returns null if no registered dataset matches.
68
+ */
16
69
  resolveUri(uri: string): { registerId: string; conceptId: string } | null {
17
70
  for (const [registerId, info] of this.registerMap) {
18
- const prefix = `${info.uriBase}/${registerId}/concept/`;
19
- if (uri.startsWith(prefix)) {
20
- return { registerId, conceptId: uri.slice(prefix.length) };
71
+ for (const pattern of info.uriPatterns) {
72
+ if (matchUriPattern(uri, pattern)) {
73
+ const conceptId = extractConceptId(uri, pattern);
74
+ if (conceptId) return { registerId, conceptId };
75
+ }
21
76
  }
22
77
  }
23
78
  return null;
24
79
  }
25
80
 
81
+ /** Resolve a URN prefix to a registerId. Matches by longest prefix. Returns null if unknown. */
82
+ resolveUrn(urn: string): string | null {
83
+ // Try exact match first, then progressively shorter prefixes
84
+ for (let len = urn.length; len > 0; len--) {
85
+ const prefix = urn.slice(0, len);
86
+ const match = this.urnMap.get(prefix);
87
+ if (match) return match;
88
+ }
89
+ return null;
90
+ }
91
+
92
+ /** Get the uriBase for a register. Returns empty string if unknown. */
93
+ getUriBase(registerId: string): string {
94
+ return this.registerMap.get(registerId)?.uriBase ?? '';
95
+ }
96
+
26
97
  /** Extract registerId and conceptId from any glossarist URI (no registration needed). */
27
98
  static parseUri(uri: string): { registerId: string; conceptId: string } | null {
28
99
  const m = uri.match(URI_REGISTER_RE);
29
100
  return m ? { registerId: m[1], conceptId: m[2] } : null;
30
101
  }
31
102
 
103
+ /** Construct a canonical URI for a concept. */
32
104
  buildUri(registerId: string, conceptId: string): string {
33
105
  const info = this.registerMap.get(registerId);
34
106
  const uriBase = info?.uriBase ?? '';
@@ -1,18 +1,18 @@
1
1
  import type { DatasetRegistry, Manifest, Resolution } from './types';
2
2
  import type { RoutingEntry as ConfigRoutingEntry } from '../config/types';
3
3
  import { DatasetAdapter } from './DatasetAdapter';
4
- import { UriRouter } from './UriRouter';
5
4
  import { ReferenceResolver } from './ReferenceResolver';
5
+ import { UriRouter } from './UriRouter';
6
6
 
7
7
  export class AdapterFactory {
8
8
  private adapters = new Map<string, DatasetAdapter>();
9
- private urnMap = new Map<string, string>();
10
- readonly router = new UriRouter();
11
- readonly resolver: ReferenceResolver;
12
9
  private crossRefIndex: Record<string, string[]> | null = null;
10
+ readonly uriRouter: UriRouter;
11
+ readonly resolver: ReferenceResolver;
13
12
 
14
13
  constructor() {
15
- this.resolver = new ReferenceResolver();
14
+ this.uriRouter = new UriRouter();
15
+ this.resolver = new ReferenceResolver(this.uriRouter);
16
16
  }
17
17
 
18
18
  async discoverDatasets(datasetsUrl: string): Promise<DatasetAdapter[]> {
@@ -76,23 +76,43 @@ export class AdapterFactory {
76
76
  }
77
77
 
78
78
  private registerDataset(registerId: string, manifest: Manifest): void {
79
- this.router.registerDataset(registerId, `${import.meta.env.BASE_URL}data/${registerId}`, manifest);
80
-
81
79
  const uriPatterns = [
82
80
  manifest.datasetUri,
83
81
  ...(manifest.uriAliases ?? []),
84
82
  manifest.uriBase ? `${manifest.uriBase}/${registerId}/*` : undefined,
85
83
  ].filter(Boolean) as string[];
86
- this.resolver.registerDataset(registerId, uriPatterns);
87
84
 
88
- if (manifest.datasetUri) this.urnMap.set(manifest.datasetUri, registerId);
89
- for (const alias of manifest.uriAliases ?? []) {
90
- const base = alias.endsWith('*') ? alias.slice(0, -1) : alias;
91
- if (base.startsWith('urn:')) this.urnMap.set(base, registerId);
85
+ this.uriRouter.registerDataset(
86
+ registerId,
87
+ manifest.baseUrl,
88
+ manifest.uriBase,
89
+ uriPatterns,
90
+ );
91
+
92
+ // Propagate URN map to all adapters for ref-target resolution
93
+ const urnMap = new Map<string, string>();
94
+ for (const id of this.uriRouter.getRegisteredIds()) {
95
+ const uriBase = this.uriRouter.getUriBase(id);
96
+ if (!uriBase) continue;
97
+ // Reconstruct URN map from uriRouter registrations
98
+ for (const pattern of [manifest.datasetUri, ...(manifest.uriAliases ?? [])]) {
99
+ if (!pattern) continue;
100
+ const base = pattern.endsWith('*') ? pattern.slice(0, -1) : pattern;
101
+ if (base.startsWith('urn:')) urnMap.set(base, registerId);
102
+ }
92
103
  }
93
-
104
+ // Include URNs from all previously registered datasets
94
105
  for (const adapter of this.adapters.values()) {
95
- adapter.setUrnMap(this.urnMap);
106
+ const m = adapter.manifest;
107
+ if (!m) continue;
108
+ for (const pattern of [m.datasetUri, ...(m.uriAliases ?? [])]) {
109
+ if (!pattern) continue;
110
+ const base = pattern.endsWith('*') ? pattern.slice(0, -1) : pattern;
111
+ if (base.startsWith('urn:')) urnMap.set(base, adapter.registerId);
112
+ }
113
+ }
114
+ for (const adapter of this.adapters.values()) {
115
+ adapter.setUrnMap(urnMap);
96
116
  }
97
117
  }
98
118
 
@@ -117,20 +137,7 @@ export class AdapterFactory {
117
137
  }
118
138
 
119
139
  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;
140
+ return this.resolver.resolveRelatedRef(ref, sourceDatasetId);
134
141
  }
135
142
 
136
143
  resolveCitation(source: string, referenceFrom: string, sourceDatasetId?: string): Resolution | null {