@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.
- package/package.json +2 -2
- package/scripts/build-edges.js +16 -8
- package/scripts/generate-data.mjs +284 -86
- package/src/__tests__/citation-display.test.ts +165 -3
- package/src/__tests__/cite-ref.test.ts +112 -0
- package/src/__tests__/concept-detail-interaction.test.ts +1 -5
- package/src/__tests__/{math.test.ts → content-renderer.test.ts} +113 -29
- package/src/__tests__/escape.test.ts +76 -0
- package/src/__tests__/graph-data-source.test.ts +155 -0
- package/src/__tests__/model-bridge-bridges.test.ts +150 -0
- package/src/__tests__/model-bridge-citation.test.ts +163 -0
- package/src/__tests__/reference-resolver-cite.test.ts +122 -0
- package/src/__tests__/reference-resolver.test.ts +12 -7
- package/src/__tests__/resolve-view.test.ts +1 -1
- package/src/__tests__/sidebar-nav-highlighting.test.ts +178 -0
- package/src/__tests__/source-refs.test.ts +9 -6
- package/src/__tests__/test-helpers.ts +20 -0
- package/src/__tests__/uri-router.test.ts +39 -12
- package/src/adapters/DatasetAdapter.ts +35 -143
- package/src/adapters/GraphDataSource.ts +178 -0
- package/src/adapters/ReferenceResolver.ts +101 -47
- package/src/adapters/UriRouter.ts +82 -10
- package/src/adapters/factory.ts +35 -28
- package/src/adapters/model-bridge.ts +121 -71
- package/src/adapters/types.ts +3 -0
- package/src/components/AppSidebar.vue +7 -4
- package/src/components/CitationDisplay.vue +86 -30
- package/src/components/ConceptDetail.vue +24 -126
- package/src/components/LanguageDetail.vue +6 -6
- package/src/composables/use-concept-content.ts +8 -8
- package/src/composables/use-ontology-nav.ts +129 -130
- package/src/composables/use-render-options.ts +1 -1
- package/src/graph/GraphEngine.ts +65 -0
- package/src/stores/vocabulary.ts +12 -73
- package/src/utils/content-renderer.ts +312 -0
- package/src/utils/markdown-lite.ts +2 -2
- 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
|
-
|
|
5
|
-
id: string;
|
|
6
|
-
uriPatterns: string[];
|
|
7
|
-
}
|
|
5
|
+
// ── Citation classification ────────────────────────────────────────────────
|
|
8
6
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
7
|
+
export type CitationClassification =
|
|
8
|
+
| 'internal-citation'
|
|
9
|
+
| 'self-contained-citation'
|
|
10
|
+
| 'external-citation'
|
|
11
|
+
| 'unresolved-citation';
|
|
14
12
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
36
|
-
this.
|
|
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
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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 (
|
|
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
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
|
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
|
-
|
|
42
|
+
/** registerId → { baseUrl, uriBase, uriPatterns } */
|
|
43
|
+
private registerMap = new Map<string, { baseUrl: string; uriBase: string; uriPatterns: string[] }>();
|
|
7
44
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
|
19
|
-
|
|
20
|
-
|
|
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 ?? '';
|
package/src/adapters/factory.ts
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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.
|
|
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
|
-
|
|
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 {
|