@glossarist/concept-browser 0.7.35 → 0.7.41

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 (38) 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 -1
  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 +12 -0
  20. package/src/adapters/GraphDataSource.ts +3 -3
  21. package/src/adapters/ReferenceResolver.ts +85 -55
  22. package/src/adapters/UriRouter.ts +82 -10
  23. package/src/adapters/factory.ts +34 -10
  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 +56 -20
  29. package/src/components/LanguageDetail.vue +6 -6
  30. package/src/composables/use-concept-content.ts +8 -8
  31. package/src/composables/use-concept-edges.ts +2 -1
  32. package/src/composables/use-render-options.ts +1 -1
  33. package/src/graph/GraphEngine.ts +3 -3
  34. package/src/stores/vocabulary.ts +2 -2
  35. package/src/style.css +29 -0
  36. package/src/utils/content-renderer.ts +312 -0
  37. package/src/utils/markdown-lite.ts +2 -2
  38. 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({
@@ -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 { ReferenceResolver } from './ReferenceResolver';
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 = ReferenceResolver.parseUri(target);
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 = ReferenceResolver.parseUri(target);
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
- 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
- private static readonly URI_REGISTER_RE = /\/([^/]+)\/concept\/([^/]+)$/;
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 provided datasets
60
- for (const ds of this.datasets) {
61
- for (const pattern of ds.uriPatterns) {
62
- if (matchUriPattern(uri, pattern)) {
63
- const conceptId = extractConceptId(uri, pattern);
64
- if (conceptId) {
65
- return {
66
- type: 'internal',
67
- registerId: ds.id,
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 (matchUriPattern(uri, entry.uri)) {
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, entry.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
- private extractConceptIdFromRouting(uri: string, pattern: string): string | null {
143
- for (const ds of this.datasets) {
144
- for (const dsPattern of ds.uriPatterns) {
145
- if (matchUriPattern(uri, dsPattern)) {
146
- return extractConceptId(uri, dsPattern);
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 extractConceptId(uri, pattern);
180
+ return null;
151
181
  }
152
182
  }