@glossarist/concept-browser 0.7.35 → 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 -1
- 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 +12 -0
- package/src/adapters/GraphDataSource.ts +3 -3
- package/src/adapters/ReferenceResolver.ts +85 -55
- package/src/adapters/UriRouter.ts +82 -10
- package/src/adapters/factory.ts +34 -10
- 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 +6 -4
- package/src/components/LanguageDetail.vue +6 -6
- package/src/composables/use-concept-content.ts +8 -8
- package/src/composables/use-render-options.ts +1 -1
- package/src/graph/GraphEngine.ts +3 -3
- package/src/stores/vocabulary.ts +2 -2
- 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 { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import { mount } from '@vue/test-utils';
|
|
3
|
+
import { createPinia, setActivePinia } from 'pinia';
|
|
4
|
+
import { createRouter, createMemoryHistory } from 'vue-router';
|
|
5
|
+
import AppSidebar from '../components/AppSidebar.vue';
|
|
6
|
+
import { useVocabularyStore } from '../stores/vocabulary';
|
|
7
|
+
|
|
8
|
+
function makeManifest(id = 'test') {
|
|
9
|
+
return { id, datasetUri: `https://glossarist.org/${id}/concept`, title: `Test ${id}`, description: 'A test dataset',
|
|
10
|
+
owner: 'ISO', baseUrl: `/data/${id}`, languages: ['eng'], conceptCount: 50,
|
|
11
|
+
conceptUrlTemplate: `/data/${id}/concepts/{id}.json`, indexUrl: `/data/${id}/index.json`,
|
|
12
|
+
contextUrl: `/data/${id}/context.json`, uriBase: 'https://glossarist.org', status: 'published',
|
|
13
|
+
schemaVersion: '1.0', tags: [], lastUpdated: '2025-01-01', sourceRepo: 'https://example.com/repo',
|
|
14
|
+
chunkSize: 1000, color: '#3366ff' } as any;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function createTestRouter(initialPath = '/') {
|
|
18
|
+
return createRouter({
|
|
19
|
+
history: createMemoryHistory(),
|
|
20
|
+
routes: [
|
|
21
|
+
{ path: '/', name: 'home', component: { template: '<div/>' } },
|
|
22
|
+
{ path: '/dataset/:registerId', name: 'dataset', component: { template: '<div/>' } },
|
|
23
|
+
{ path: '/dataset/:registerId/concept/:conceptId', name: 'concept', component: { template: '<div/>' } },
|
|
24
|
+
{ path: '/dataset/:registerId/stats', name: 'stats', component: { template: '<div/>' } },
|
|
25
|
+
{ path: '/dataset/:registerId/about', name: 'about', component: { template: '<div/>' } },
|
|
26
|
+
{ path: '/search', name: 'search', component: { template: '<div/>' } },
|
|
27
|
+
{ path: '/about', name: 'about-global', component: { template: '<div/>' } },
|
|
28
|
+
],
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function seedStore(datasets: string[] = ['test']) {
|
|
33
|
+
const store = useVocabularyStore();
|
|
34
|
+
for (const id of datasets) {
|
|
35
|
+
store.manifests.set(id, makeManifest(id));
|
|
36
|
+
store.datasets.set(id, { index: [], getConceptCount: () => 0, getConcepts: () => [] } as any);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Returns all <a> elements with the custom 'active' class.
|
|
42
|
+
* This is the class added by the isActive() function in AppSidebar,
|
|
43
|
+
* distinct from Vue Router's built-in router-link-active.
|
|
44
|
+
*/
|
|
45
|
+
function getActiveHrefs(wrapper: ReturnType<typeof mount>): string[] {
|
|
46
|
+
return wrapper.findAll('a.active').map(l => l.attributes('href') || '');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
describe('AppSidebar — nav highlighting (isActive)', () => {
|
|
50
|
+
let pinia: ReturnType<typeof createPinia>;
|
|
51
|
+
|
|
52
|
+
beforeEach(() => {
|
|
53
|
+
pinia = createPinia();
|
|
54
|
+
setActivePinia(pinia);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
async function mountSidebar(initialPath = '/') {
|
|
58
|
+
const router = createTestRouter(initialPath);
|
|
59
|
+
router.push(initialPath);
|
|
60
|
+
await router.isReady();
|
|
61
|
+
return mount(AppSidebar, {
|
|
62
|
+
global: { plugins: [pinia, router], stubs: { NavIcon: true } },
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ── The bug: dataset About should NOT activate global About ─────────
|
|
67
|
+
|
|
68
|
+
it('on /dataset/test/about: ONLY dataset about link is active', async () => {
|
|
69
|
+
seedStore();
|
|
70
|
+
const wrapper = await mountSidebar('/dataset/test/about');
|
|
71
|
+
const activeHrefs = getActiveHrefs(wrapper);
|
|
72
|
+
// Dataset about link IS active
|
|
73
|
+
expect(activeHrefs).toContain('/dataset/test/about');
|
|
74
|
+
// Global /about link is NOT active (this was the bug)
|
|
75
|
+
expect(activeHrefs).not.toContain('/about');
|
|
76
|
+
// Only one active link total
|
|
77
|
+
expect(activeHrefs).toEqual(['/dataset/test/about']);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('on /dataset/test/about: no link to /about (global) has active class', async () => {
|
|
81
|
+
seedStore();
|
|
82
|
+
const wrapper = await mountSidebar('/dataset/test/about');
|
|
83
|
+
const allLinks = wrapper.findAll('a');
|
|
84
|
+
const globalAboutLinks = allLinks.filter(l => l.attributes('href') === '/about');
|
|
85
|
+
// If global about link exists, it must NOT have custom active class
|
|
86
|
+
for (const link of globalAboutLinks) {
|
|
87
|
+
expect(link.classes()).not.toContain('active');
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// ── Global About page still works ───────────────────────────────────
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
it('on /about: dataset about links are NOT active', async () => {
|
|
95
|
+
seedStore();
|
|
96
|
+
const wrapper = await mountSidebar('/about');
|
|
97
|
+
const allLinks = wrapper.findAll('a');
|
|
98
|
+
const datasetAboutActive = allLinks.filter(
|
|
99
|
+
l => (l.attributes('href') || '').includes('/dataset/') &&
|
|
100
|
+
(l.attributes('href') || '').includes('/about') &&
|
|
101
|
+
l.classes().includes('active')
|
|
102
|
+
);
|
|
103
|
+
expect(datasetAboutActive).toEqual([]);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// ── Dataset root: dataset Concepts active, global Home not ──────────
|
|
107
|
+
|
|
108
|
+
it('on /dataset/test: dataset sub-nav is active', async () => {
|
|
109
|
+
seedStore();
|
|
110
|
+
const wrapper = await mountSidebar('/dataset/test');
|
|
111
|
+
// Something should be active (the dataset concepts sub-page)
|
|
112
|
+
const activeHrefs = getActiveHrefs(wrapper);
|
|
113
|
+
expect(activeHrefs.length).toBeGreaterThanOrEqual(1);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('on /dataset/test: global nav links are NOT active', async () => {
|
|
117
|
+
seedStore();
|
|
118
|
+
const wrapper = await mountSidebar('/dataset/test');
|
|
119
|
+
const allLinks = wrapper.findAll('a');
|
|
120
|
+
// Global Search link must NOT be active
|
|
121
|
+
const searchActive = allLinks.filter(
|
|
122
|
+
l => l.attributes('href') === '/search' && l.classes().includes('active')
|
|
123
|
+
);
|
|
124
|
+
expect(searchActive).toEqual([]);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
// ── Concept page: dataset Concepts still active ─────────────────────
|
|
128
|
+
|
|
129
|
+
it('on /dataset/test/concept/1.2: dataset sub-nav is active', async () => {
|
|
130
|
+
seedStore();
|
|
131
|
+
const wrapper = await mountSidebar('/dataset/test/concept/1.2');
|
|
132
|
+
const activeHrefs = getActiveHrefs(wrapper);
|
|
133
|
+
expect(activeHrefs.length).toBeGreaterThanOrEqual(1);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// ── Cross-dataset isolation ─────────────────────────────────────────
|
|
137
|
+
|
|
138
|
+
it('on /dataset/ds-a/about: ds-b about is NOT active', async () => {
|
|
139
|
+
seedStore(['ds-a', 'ds-b']);
|
|
140
|
+
const wrapper = await mountSidebar('/dataset/ds-a/about');
|
|
141
|
+
const activeHrefs = getActiveHrefs(wrapper);
|
|
142
|
+
expect(activeHrefs).toContain('/dataset/ds-a/about');
|
|
143
|
+
expect(activeHrefs).not.toContain('/dataset/ds-b/about');
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
// ── Root route ──────────────────────────────────────────────────────
|
|
147
|
+
|
|
148
|
+
it('on /: Home link is active', async () => {
|
|
149
|
+
seedStore();
|
|
150
|
+
const wrapper = await mountSidebar('/');
|
|
151
|
+
const activeHrefs = getActiveHrefs(wrapper);
|
|
152
|
+
expect(activeHrefs).toContain('/');
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('on /: search and about are NOT active', async () => {
|
|
156
|
+
seedStore();
|
|
157
|
+
const wrapper = await mountSidebar('/');
|
|
158
|
+
const activeHrefs = getActiveHrefs(wrapper);
|
|
159
|
+
expect(activeHrefs).not.toContain('/search');
|
|
160
|
+
expect(activeHrefs).not.toContain('/about');
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
// ── Dataset stats page ──────────────────────────────────────────────
|
|
164
|
+
|
|
165
|
+
it('on /dataset/test/stats: stats sub-page is active', async () => {
|
|
166
|
+
seedStore();
|
|
167
|
+
const wrapper = await mountSidebar('/dataset/test/stats');
|
|
168
|
+
const activeHrefs = getActiveHrefs(wrapper);
|
|
169
|
+
expect(activeHrefs).toContain('/dataset/test/stats');
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('on /dataset/test/stats: about sub-page is NOT active', async () => {
|
|
173
|
+
seedStore();
|
|
174
|
+
const wrapper = await mountSidebar('/dataset/test/stats');
|
|
175
|
+
const activeHrefs = getActiveHrefs(wrapper);
|
|
176
|
+
expect(activeHrefs).not.toContain('/dataset/test/about');
|
|
177
|
+
});
|
|
178
|
+
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
2
|
import { ReferenceResolver } from '../adapters/ReferenceResolver';
|
|
3
|
+
import { UriRouter } from '../adapters/UriRouter';
|
|
3
4
|
import type { Resolution } from '../adapters/types';
|
|
4
5
|
|
|
5
6
|
type InternalResolution = Extract<Resolution, { type: 'internal' }>;
|
|
@@ -10,13 +11,15 @@ function asInternal(r: Resolution | null): InternalResolution | null {
|
|
|
10
11
|
|
|
11
12
|
describe('Source reference resolution (citation linking)', () => {
|
|
12
13
|
let resolver: ReferenceResolver;
|
|
14
|
+
let uriRouter: UriRouter;
|
|
13
15
|
|
|
14
16
|
beforeEach(() => {
|
|
15
|
-
|
|
17
|
+
uriRouter = new UriRouter();
|
|
18
|
+
resolver = new ReferenceResolver(uriRouter);
|
|
16
19
|
// Register datasets with URI patterns
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
+
uriRouter.registerDataset('vim-2012', '', '', ['urn:oiml:pub:v:2:2012*']);
|
|
21
|
+
uriRouter.registerDataset('viml-2022', '', '', ['urn:oiml:pub:v:1:2022*']);
|
|
22
|
+
uriRouter.registerDataset('vim-2007', '', '', ['urn:oiml:pub:v:2:2007*']);
|
|
20
23
|
});
|
|
21
24
|
|
|
22
25
|
describe('hasSourceRef', () => {
|
|
@@ -132,7 +135,7 @@ describe('Source reference resolution (citation linking)', () => {
|
|
|
132
135
|
|
|
133
136
|
describe('ISO source references', () => {
|
|
134
137
|
it('resolves ISO/IEC references when registered', () => {
|
|
135
|
-
|
|
138
|
+
uriRouter.registerDataset('iso-17000', '', '', ['urn:iso:std:iso:iec:17000*']);
|
|
136
139
|
resolver.registerSourceRef('ISO/IEC 17000:2020', 'iso-17000', 'urn:iso:std:iso:iec:17000');
|
|
137
140
|
|
|
138
141
|
const result = resolver.resolveCitation('ISO/IEC 17000:2020', '3.1', 'viml-2022');
|
|
@@ -171,7 +174,7 @@ describe('Source reference resolution (citation linking)', () => {
|
|
|
171
174
|
});
|
|
172
175
|
|
|
173
176
|
it('handles source strings with special characters', () => {
|
|
174
|
-
|
|
177
|
+
uriRouter.registerDataset('vim-1993', '', '', ['urn:oiml:pub:v:2:1993*']);
|
|
175
178
|
resolver.registerSourceRef('OIML V 2:1993', 'vim-1993', 'urn:oiml:pub:v:2:1993');
|
|
176
179
|
const result = resolver.resolveCitation('OIML V 2:1993', '3.6');
|
|
177
180
|
expect(asInternal(result)?.registerId).toBe('vim-1993');
|
|
@@ -2,6 +2,8 @@ import { createPinia, setActivePinia } from 'pinia';
|
|
|
2
2
|
import { createRouter, createMemoryHistory } from 'vue-router';
|
|
3
3
|
import { LocalizedConcept } from 'glossarist';
|
|
4
4
|
import type { Manifest, ConceptSummary, SearchHit } from '../adapters/types';
|
|
5
|
+
import { ReferenceResolver } from '../adapters/ReferenceResolver';
|
|
6
|
+
import { UriRouter } from '../adapters/UriRouter';
|
|
5
7
|
|
|
6
8
|
// ── Manifest Factory ──────────────────────────────────────────────────
|
|
7
9
|
|
|
@@ -173,3 +175,21 @@ export function setupPinia() {
|
|
|
173
175
|
setActivePinia(pinia);
|
|
174
176
|
return pinia;
|
|
175
177
|
}
|
|
178
|
+
|
|
179
|
+
// ── ReferenceResolver Setup ──────────────────────────────────────────
|
|
180
|
+
|
|
181
|
+
export interface ResolverPair {
|
|
182
|
+
uriRouter: UriRouter;
|
|
183
|
+
resolver: ReferenceResolver;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Create a UriRouter + ReferenceResolver pair for testing.
|
|
188
|
+
* Use `pair.uriRouter.registerDataset()` to register URI patterns,
|
|
189
|
+
* then use `pair.resolver` for reference resolution.
|
|
190
|
+
*/
|
|
191
|
+
export function createResolverPair(): ResolverPair {
|
|
192
|
+
const uriRouter = new UriRouter();
|
|
193
|
+
const resolver = new ReferenceResolver(uriRouter);
|
|
194
|
+
return { uriRouter, resolver };
|
|
195
|
+
}
|
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest';
|
|
2
2
|
import { UriRouter } from '../adapters/UriRouter';
|
|
3
3
|
|
|
4
|
-
const MOCK_MANIFEST = { uriBase: 'https://glossarist.org' } as any;
|
|
5
|
-
|
|
6
4
|
describe('UriRouter', () => {
|
|
5
|
+
const URI_BASE = 'https://glossarist.org';
|
|
6
|
+
|
|
7
|
+
function register(router: UriRouter, registerId: string, baseUrl: string = `/data/${registerId}`) {
|
|
8
|
+
router.registerDataset(registerId, baseUrl, URI_BASE, [`${URI_BASE}/${registerId}/*`]);
|
|
9
|
+
}
|
|
10
|
+
|
|
7
11
|
it('resolves URIs for registered datasets', () => {
|
|
8
12
|
const router = new UriRouter();
|
|
9
|
-
router
|
|
13
|
+
register(router, 'iev');
|
|
10
14
|
|
|
11
15
|
const resolved = router.resolveUri('https://glossarist.org/iev/concept/103-01-02');
|
|
12
16
|
expect(resolved).toEqual({ registerId: 'iev', conceptId: '103-01-02' });
|
|
@@ -14,7 +18,7 @@ describe('UriRouter', () => {
|
|
|
14
18
|
|
|
15
19
|
it('resolves URIs with multi-part concept IDs', () => {
|
|
16
20
|
const router = new UriRouter();
|
|
17
|
-
router
|
|
21
|
+
register(router, 'isotc204');
|
|
18
22
|
|
|
19
23
|
const resolved = router.resolveUri('https://glossarist.org/isotc204/concept/3.1.1.1');
|
|
20
24
|
expect(resolved).toEqual({ registerId: 'isotc204', conceptId: '3.1.1.1' });
|
|
@@ -22,43 +26,66 @@ describe('UriRouter', () => {
|
|
|
22
26
|
|
|
23
27
|
it('returns null for unknown register', () => {
|
|
24
28
|
const router = new UriRouter();
|
|
25
|
-
router
|
|
29
|
+
register(router, 'iev');
|
|
26
30
|
|
|
27
31
|
expect(router.resolveUri('https://glossarist.org/unknown/concept/123')).toBeNull();
|
|
28
32
|
});
|
|
29
33
|
|
|
30
34
|
it('returns null for non-matching URI pattern', () => {
|
|
31
35
|
const router = new UriRouter();
|
|
32
|
-
router
|
|
36
|
+
register(router, 'iev');
|
|
33
37
|
|
|
34
38
|
expect(router.resolveUri('https://example.com/other')).toBeNull();
|
|
35
39
|
});
|
|
36
40
|
|
|
37
41
|
it('builds URIs from register and concept ID', () => {
|
|
38
42
|
const router = new UriRouter();
|
|
39
|
-
router
|
|
43
|
+
register(router, 'iev');
|
|
40
44
|
expect(router.buildUri('iev', '103-01-02')).toBe('https://glossarist.org/iev/concept/103-01-02');
|
|
41
45
|
});
|
|
42
46
|
|
|
43
47
|
it('lists all registered IDs', () => {
|
|
44
48
|
const router = new UriRouter();
|
|
45
|
-
router
|
|
46
|
-
router
|
|
49
|
+
register(router, 'iev');
|
|
50
|
+
register(router, 'isotc211');
|
|
47
51
|
|
|
48
52
|
expect(router.getRegisteredIds()).toEqual(['iev', 'isotc211']);
|
|
49
53
|
});
|
|
50
54
|
|
|
51
55
|
it('resolves across multiple registers', () => {
|
|
52
56
|
const router = new UriRouter();
|
|
53
|
-
router
|
|
54
|
-
router
|
|
55
|
-
router
|
|
57
|
+
register(router, 'iev');
|
|
58
|
+
register(router, 'isotc211');
|
|
59
|
+
register(router, 'isotc204');
|
|
56
60
|
|
|
57
61
|
expect(router.resolveUri('https://glossarist.org/iev/concept/102-01-01')?.registerId).toBe('iev');
|
|
58
62
|
expect(router.resolveUri('https://glossarist.org/isotc211/concept/10')?.registerId).toBe('isotc211');
|
|
59
63
|
expect(router.resolveUri('https://glossarist.org/isotc204/concept/3.1.1.1')?.registerId).toBe('isotc204');
|
|
60
64
|
});
|
|
61
65
|
|
|
66
|
+
it('resolves URN patterns with wildcard', () => {
|
|
67
|
+
const router = new UriRouter();
|
|
68
|
+
router.registerDataset('iso-10303', '', '', ['urn:iso:std:iso:10303:*']);
|
|
69
|
+
|
|
70
|
+
const resolved = router.resolveUri('urn:iso:std:iso:10303:3.1.1.1');
|
|
71
|
+
expect(resolved).toEqual({ registerId: 'iso-10303', conceptId: '3.1.1.1' });
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('resolves URN prefix to registerId', () => {
|
|
75
|
+
const router = new UriRouter();
|
|
76
|
+
router.registerDataset('iso-10303', '', '', ['urn:iso:std:iso:10303:*']);
|
|
77
|
+
|
|
78
|
+
expect(router.resolveUrn('urn:iso:std:iso:10303')).toBe('iso-10303');
|
|
79
|
+
expect(router.resolveUrn('urn:unknown:prefix')).toBeNull();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('returns uriBase for registered dataset', () => {
|
|
83
|
+
const router = new UriRouter();
|
|
84
|
+
register(router, 'iev');
|
|
85
|
+
expect(router.getUriBase('iev')).toBe(URI_BASE);
|
|
86
|
+
expect(router.getUriBase('unknown')).toBe('');
|
|
87
|
+
});
|
|
88
|
+
|
|
62
89
|
describe('parseUri (static)', () => {
|
|
63
90
|
it('extracts register and concept from any glossarist URI', () => {
|
|
64
91
|
expect(UriRouter.parseUri('https://glossarist.org/iev/concept/103-01-02')).toEqual({
|
|
@@ -44,6 +44,7 @@ export class DatasetAdapter {
|
|
|
44
44
|
private conceptCache = new Map<string, Concept>();
|
|
45
45
|
private static MAX_CACHE = 100;
|
|
46
46
|
private summaryMap = new Map<string, ConceptSummary>();
|
|
47
|
+
private designationMap = new Map<string, string>();
|
|
47
48
|
private loadedChunks = new Set<number>();
|
|
48
49
|
private indexMeta: { conceptCount: number; chunkSize: number; chunks: { file: string; count: number }[] } | null = null;
|
|
49
50
|
|
|
@@ -127,11 +128,17 @@ export class DatasetAdapter {
|
|
|
127
128
|
private buildSummaryIndex() {
|
|
128
129
|
this.summaryMap.clear();
|
|
129
130
|
this.positionIndex.clear();
|
|
131
|
+
this.designationMap.clear();
|
|
130
132
|
for (let i = 0; i < this.index!.concepts.length; i++) {
|
|
131
133
|
const entry = this.index!.concepts[i];
|
|
132
134
|
if (entry) {
|
|
133
135
|
this.summaryMap.set(entry.id, entry);
|
|
134
136
|
this.positionIndex.set(entry.id, i);
|
|
137
|
+
for (const term of Object.values(entry.designations)) {
|
|
138
|
+
if (term && !this.designationMap.has(term.toLowerCase())) {
|
|
139
|
+
this.designationMap.set(term.toLowerCase(), entry.id);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
135
142
|
}
|
|
136
143
|
}
|
|
137
144
|
}
|
|
@@ -263,6 +270,11 @@ export class DatasetAdapter {
|
|
|
263
270
|
return this.summaryMap.get(conceptId);
|
|
264
271
|
}
|
|
265
272
|
|
|
273
|
+
/** Look up a concept ID by its designation string (case-insensitive). */
|
|
274
|
+
lookupByDesignation(designation: string): string | undefined {
|
|
275
|
+
return this.designationMap.get(designation.toLowerCase());
|
|
276
|
+
}
|
|
277
|
+
|
|
266
278
|
getConcepts(): (ConceptSummary | undefined)[] {
|
|
267
279
|
return this.index?.concepts ?? [];
|
|
268
280
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { GraphEdge, GraphNode, SectionNode } from './types';
|
|
2
2
|
import type { Concept, RelatedConcept } from 'glossarist';
|
|
3
3
|
import type { DatasetAdapter } from './DatasetAdapter';
|
|
4
|
-
import {
|
|
4
|
+
import { UriRouter } from './UriRouter';
|
|
5
5
|
import { slugify } from '../utils/slugify';
|
|
6
6
|
|
|
7
7
|
interface DomainNodeJson {
|
|
@@ -80,7 +80,7 @@ export class GraphDataSource {
|
|
|
80
80
|
for (const rc of concept.relatedConcepts) {
|
|
81
81
|
const target = resolveRefTarget(rc, this.uriBase, this.registerId, this.urnMap);
|
|
82
82
|
if (target && target !== sourceUri) {
|
|
83
|
-
const parsed =
|
|
83
|
+
const parsed = UriRouter.parseUri(target);
|
|
84
84
|
edges.push({
|
|
85
85
|
source: sourceUri,
|
|
86
86
|
target,
|
|
@@ -97,7 +97,7 @@ export class GraphDataSource {
|
|
|
97
97
|
for (const rc of lc.related) {
|
|
98
98
|
const target = resolveRefTarget(rc, this.uriBase, this.registerId, this.urnMap);
|
|
99
99
|
if (target && target !== sourceUri) {
|
|
100
|
-
const parsed =
|
|
100
|
+
const parsed = UriRouter.parseUri(target);
|
|
101
101
|
edges.push({
|
|
102
102
|
source: sourceUri,
|
|
103
103
|
target,
|
|
@@ -1,46 +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
|
-
|
|
37
|
-
static parseUri(uri: string): { registerId: string; conceptId: string } | null {
|
|
38
|
-
const m = uri.match(ReferenceResolver.URI_REGISTER_RE);
|
|
39
|
-
return m ? { registerId: m[1], conceptId: m[2] } : null;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
registerDataset(id: string, uriPatterns: string[]): void {
|
|
43
|
-
this.datasets.push({ id, uriPatterns });
|
|
41
|
+
constructor(uriRouter: UriRouter) {
|
|
42
|
+
this.uriRouter = uriRouter;
|
|
44
43
|
}
|
|
45
44
|
|
|
46
45
|
registerSourceRef(sourceRef: string, datasetId: string, uriPrefix: string): void {
|
|
@@ -56,26 +55,20 @@ export class ReferenceResolver {
|
|
|
56
55
|
}
|
|
57
56
|
|
|
58
57
|
resolveReference(uri: string, sourceDatasetId?: string): Resolution {
|
|
59
|
-
// Step 1: Check
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
conceptId,
|
|
69
|
-
crossDataset: sourceDatasetId != null && sourceDatasetId !== ds.id,
|
|
70
|
-
};
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
}
|
|
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
|
+
};
|
|
74
67
|
}
|
|
75
68
|
|
|
76
69
|
// Step 2: Check routing table
|
|
77
70
|
for (const entry of this.routing) {
|
|
78
|
-
if (
|
|
71
|
+
if (this.matchesRoutingPattern(uri, entry.uri)) {
|
|
79
72
|
if (entry.type === 'site') {
|
|
80
73
|
return {
|
|
81
74
|
type: 'site',
|
|
@@ -86,7 +79,7 @@ export class ReferenceResolver {
|
|
|
86
79
|
}
|
|
87
80
|
if (entry.type === 'url') {
|
|
88
81
|
const template = entry.url!;
|
|
89
|
-
const conceptId = this.extractConceptIdFromRouting(uri
|
|
82
|
+
const conceptId = this.extractConceptIdFromRouting(uri);
|
|
90
83
|
const url = template.includes('{conceptId}') && conceptId
|
|
91
84
|
? template.replace('{conceptId}', conceptId)
|
|
92
85
|
: template;
|
|
@@ -122,6 +115,32 @@ export class ReferenceResolver {
|
|
|
122
115
|
return null;
|
|
123
116
|
}
|
|
124
117
|
|
|
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
|
+
|
|
125
144
|
resolveRelatedRef(ref: { source: string | null; id: string | null } | null, sourceDatasetId?: string): { registerId: string; conceptId: string } | null {
|
|
126
145
|
if (!ref?.source || !ref?.id) return null;
|
|
127
146
|
const uri = `${ref.source}/${ref.id}`;
|
|
@@ -139,14 +158,25 @@ export class ReferenceResolver {
|
|
|
139
158
|
return null;
|
|
140
159
|
}
|
|
141
160
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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;
|
|
149
179
|
}
|
|
150
|
-
return
|
|
180
|
+
return null;
|
|
151
181
|
}
|
|
152
182
|
}
|