@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 { 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
- resolver = new ReferenceResolver();
17
+ uriRouter = new UriRouter();
18
+ resolver = new ReferenceResolver(uriRouter);
16
19
  // 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
+ 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
- resolver.registerDataset('iso-17000', ['urn:iso:std:iso:iec:17000*']);
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
- resolver.registerDataset('vim-1993', ['urn:oiml:pub:v:2:1993*']);
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.registerDataset('iev', '/data/iev', MOCK_MANIFEST);
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.registerDataset('isotc204', '/data/isotc204', MOCK_MANIFEST);
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.registerDataset('iev', '/data/iev', MOCK_MANIFEST);
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.registerDataset('iev', '/data/iev', MOCK_MANIFEST);
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.registerDataset('iev', '/data/iev', MOCK_MANIFEST);
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.registerDataset('iev', '/data/iev', MOCK_MANIFEST);
46
- router.registerDataset('isotc211', '/data/isotc211', MOCK_MANIFEST);
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.registerDataset('iev', '/data/iev', MOCK_MANIFEST);
54
- router.registerDataset('isotc211', '/data/isotc211', MOCK_MANIFEST);
55
- router.registerDataset('isotc204', '/data/isotc204', MOCK_MANIFEST);
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({
@@ -9,10 +9,9 @@ import type {
9
9
  SectionNode,
10
10
  DatasetSummary,
11
11
  } from './types';
12
- import type { Concept, LocalizedConcept, Designation, RelatedConcept } from 'glossarist';
13
- import { conceptFromJson, conceptUri } from './model-bridge';
14
- import { UriRouter } from './UriRouter';
15
- import { slugify } from '../utils/slugify';
12
+ import type { Concept } from 'glossarist';
13
+ import { conceptFromJson } from './model-bridge';
14
+ import { GraphDataSource } from './GraphDataSource';
16
15
 
17
16
  // ── Wire-format types for JSON responses ────────────────────────────────────
18
17
 
@@ -33,36 +32,6 @@ interface IndexConceptJson {
33
32
  groups?: string[];
34
33
  }
35
34
 
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[];
50
- }
51
-
52
- function resolveRefTarget(rc: RelatedConcept, uriBase: string, registerId: string, urnMap?: ReadonlyMap<string, string>): string {
53
- if (!rc.ref) return '';
54
- const ref = rc.ref;
55
- if (ref.id) {
56
- let reg = registerId;
57
- if (ref.source && !ref.source.startsWith('http')) {
58
- reg = urnMap?.get(ref.source) ?? ref.source;
59
- }
60
- return `${uriBase}/${reg}/concept/${ref.id}`;
61
- }
62
- if (ref.source && ref.source.startsWith('http')) return ref.source;
63
- return ref.source || '';
64
- }
65
-
66
35
  export class DatasetAdapter {
67
36
  private positionIndex = new Map<string, number>();
68
37
  private _urnMap: ReadonlyMap<string, string> = new Map();
@@ -75,6 +44,7 @@ export class DatasetAdapter {
75
44
  private conceptCache = new Map<string, Concept>();
76
45
  private static MAX_CACHE = 100;
77
46
  private summaryMap = new Map<string, ConceptSummary>();
47
+ private designationMap = new Map<string, string>();
78
48
  private loadedChunks = new Set<number>();
79
49
  private indexMeta: { conceptCount: number; chunkSize: number; chunks: { file: string; count: number }[] } | null = null;
80
50
 
@@ -158,11 +128,17 @@ export class DatasetAdapter {
158
128
  private buildSummaryIndex() {
159
129
  this.summaryMap.clear();
160
130
  this.positionIndex.clear();
131
+ this.designationMap.clear();
161
132
  for (let i = 0; i < this.index!.concepts.length; i++) {
162
133
  const entry = this.index!.concepts[i];
163
134
  if (entry) {
164
135
  this.summaryMap.set(entry.id, entry);
165
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
+ }
166
142
  }
167
143
  }
168
144
  }
@@ -294,6 +270,11 @@ export class DatasetAdapter {
294
270
  return this.summaryMap.get(conceptId);
295
271
  }
296
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
+
297
278
  getConcepts(): (ConceptSummary | undefined)[] {
298
279
  return this.index?.concepts ?? [];
299
280
  }
@@ -376,131 +357,42 @@ export class DatasetAdapter {
376
357
  this._urnMap = map;
377
358
  }
378
359
 
379
- extractEdges(concept: Concept): GraphEdge[] {
380
- const edges: GraphEdge[] = [];
381
- const uriBase = this.manifest?.uriBase || 'https://glossarist.org';
382
- const sourceUri = concept.uri || `${uriBase}/${this.registerId}/concept/${concept.id}`;
383
-
384
- // Managed concept level relationships
385
- for (const rc of concept.relatedConcepts) {
386
- const target = resolveRefTarget(rc, uriBase, this.registerId, this._urnMap);
387
- if (target && target !== sourceUri) {
388
- const parsed = UriRouter.parseUri(target);
389
- edges.push({
390
- source: sourceUri,
391
- target,
392
- type: rc.type || 'references',
393
- label: rc.content || undefined,
394
- register: parsed?.registerId ?? this.registerId,
395
- });
396
- }
397
- }
398
-
399
- // Per-localization references (from inline extraction in generate-data)
400
- for (const lang of concept.languages) {
401
- const lc = concept.localization(lang);
402
- if (!lc) continue;
403
- for (const rc of lc.related) {
404
- const target = resolveRefTarget(rc, uriBase, this.registerId, this._urnMap);
405
- if (target && target !== sourceUri) {
406
- const parsed = UriRouter.parseUri(target);
407
- edges.push({
408
- source: sourceUri,
409
- target,
410
- type: rc.type || 'references',
411
- label: rc.content || undefined,
412
- register: parsed?.registerId ?? this.registerId,
413
- lang,
414
- });
415
- }
416
- }
417
- }
418
-
419
- return edges;
360
+ get dataUrl(): string {
361
+ return this.baseUrl;
420
362
  }
421
363
 
422
- extractDomainEdges(concept: Concept): GraphEdge[] {
423
- const edges: GraphEdge[] = [];
424
- const uriBase = this.manifest?.uriBase || 'https://glossarist.org';
425
- const sourceUri = concept.uri || `${uriBase}/${this.registerId}/concept/${concept.id}`;
426
-
427
- for (const lang of concept.languages) {
428
- const lc = concept.localization(lang);
429
- if (lc?.domain) {
430
- edges.push({
431
- source: sourceUri,
432
- target: `${uriBase}/${this.registerId}/domain/${slugify(lc.domain)}`,
433
- type: 'domain',
434
- label: lc.domain,
435
- register: this.registerId,
436
- lang,
437
- });
438
- }
439
- }
440
- return edges;
364
+ get urnMap(): ReadonlyMap<string, string> {
365
+ return this._urnMap;
441
366
  }
442
367
 
443
- async loadDomainNodes(): Promise<GraphNode[]> {
444
- const resp = await fetch(`${this.baseUrl}/domain-nodes.json`);
445
- if (!resp.ok) return [];
446
- const data = await resp.json();
447
- return (data.domainNodes || []).map((dn: DomainNodeJson) => this.mapDomainNode(dn));
368
+ private _graphDataSource: GraphDataSource | null = null;
369
+ get graphDataSource(): GraphDataSource {
370
+ if (!this._graphDataSource) this._graphDataSource = new GraphDataSource(this);
371
+ return this._graphDataSource;
448
372
  }
449
373
 
450
- private mapDomainNode(dn: DomainNodeJson): GraphNode {
451
- const node: GraphNode = {
452
- uri: dn.uri ?? '',
453
- register: dn.registerId ?? '',
454
- conceptId: dn.uri?.split('/domain/')[1] || dn.id || '',
455
- designations: dn.names || (dn.label ? { eng: dn.label } : {}),
456
- status: 'domain',
457
- loaded: true,
458
- nodeType: 'domain' as const,
459
- conceptCount: dn.conceptCount || 0,
460
- };
461
- if (dn.children && dn.children.length > 0) {
462
- node.children = dn.children.map((c) => this.mapSectionNode(c));
463
- }
464
- return node;
465
- }
466
-
467
- private mapSectionNode(dn: DomainNodeJson): SectionNode {
468
- const node: SectionNode = {
469
- id: dn.id ?? '',
470
- names: dn.names || (dn.label ? { eng: dn.label } : {}),
471
- conceptCount: dn.conceptCount || 0,
472
- };
473
- if (dn.children && dn.children.length > 0) {
474
- node.children = dn.children.map((c) => this.mapSectionNode(c));
475
- }
476
- return node;
374
+ extractEdges(concept: Concept): GraphEdge[] {
375
+ return this.graphDataSource.extractEdges(concept);
477
376
  }
478
377
 
479
- getSectionTree(): SectionNode[] {
480
- const nodes = this.manifest?.sections;
481
- if (!nodes || nodes.length === 0) return [];
482
- return nodes.map(s => this.mapManifestSection(s));
378
+ extractDomainEdges(concept: Concept): GraphEdge[] {
379
+ return this.graphDataSource.extractDomainEdges(concept);
483
380
  }
484
381
 
485
- private mapManifestSection(s: SectionJson): SectionNode {
486
- const node: SectionNode = { id: s.id, names: s.names || {}, conceptCount: 0 };
487
- if (s.children && s.children.length > 0) {
488
- node.children = s.children.map(c => this.mapManifestSection(c));
489
- }
490
- return node;
382
+ async loadDomainNodes(): Promise<GraphNode[]> {
383
+ return this.graphDataSource.loadDomainNodes();
491
384
  }
492
385
 
493
386
  async loadEdgeIndex(): Promise<GraphEdge[]> {
494
- const resp = await fetch(`${this.baseUrl}/edges.json`);
495
- if (!resp.ok) return [];
496
- const data = await resp.json();
497
- return data.edges ?? [];
387
+ return this.graphDataSource.loadEdgeIndex();
498
388
  }
499
389
 
500
390
  async loadGraphNodes(): Promise<{ uriPrefix: string; nodes: [string, Record<string, string>, string][] }> {
501
- const resp = await fetch(`${this.baseUrl}/graph-nodes.json`);
502
- if (!resp.ok) return { uriPrefix: '', nodes: [] };
503
- return await resp.json();
391
+ return this.graphDataSource.loadGraphNodes();
392
+ }
393
+
394
+ getSectionTree(): SectionNode[] {
395
+ return this.graphDataSource.getSectionTree();
504
396
  }
505
397
 
506
398
  getLanguages(): string[] {