@glossarist/concept-browser 0.7.30 → 0.7.32

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glossarist/concept-browser",
3
- "version": "0.7.30",
3
+ "version": "0.7.32",
4
4
  "description": "Vue SPA for browsing Glossarist terminology datasets with cross-reference resolution, graph visualization, and multi-language support",
5
5
  "type": "module",
6
6
  "bin": {
@@ -23,6 +23,32 @@ function slugify(text) {
23
23
 
24
24
  // --- Extractors (open/closed: add new extractors to EXTRACTORS array) ---
25
25
 
26
+ function extractSourceRefs(concept, registerId) {
27
+ const refs = new Set();
28
+
29
+ // Managed concept-level sources
30
+ for (const src of concept['gl:source'] || []) {
31
+ const origin = src['gl:origin'];
32
+ if (origin) {
33
+ const ref = origin['gl:ref'];
34
+ if (ref?.['gl:source']) refs.add(ref['gl:source']);
35
+ }
36
+ }
37
+
38
+ // Localized concept-level sources
39
+ for (const lc of Object.values(concept['gl:localizedConcept'] || {})) {
40
+ for (const src of lc['gl:source'] || []) {
41
+ const origin = src['gl:origin'];
42
+ if (origin) {
43
+ const ref = origin['gl:ref'];
44
+ if (ref?.['gl:source']) refs.add(ref['gl:source']);
45
+ }
46
+ }
47
+ }
48
+
49
+ return [...refs].map(source => ({ source, registerId }));
50
+ }
51
+
26
52
  function extractReferences(concept, registerId) {
27
53
  const edges = [];
28
54
  const sourceUri = concept['@id'];
@@ -129,13 +155,14 @@ function buildEdgesForDataset(datasetDir, registerId, uriBase, urnMap, manifest)
129
155
  const conceptsDir = join(datasetDir, 'concepts');
130
156
  if (!existsSync(conceptsDir)) {
131
157
  console.log(` Skipping ${registerId}: no concepts directory`);
132
- return [];
158
+ return { edges: [], sourceRefs: [] };
133
159
  }
134
160
 
135
161
  const files = readdirSync(conceptsDir).filter(f => f.endsWith('.json'));
136
162
  console.log(` Processing ${files.length} concepts...`);
137
163
 
138
164
  const allEdges = [];
165
+ const allSourceRefs = [];
139
166
  const domainConceptCount = new Map();
140
167
  let processed = 0;
141
168
 
@@ -144,6 +171,7 @@ function buildEdgesForDataset(datasetDir, registerId, uriBase, urnMap, manifest)
144
171
  const data = JSON.parse(readFileSync(join(conceptsDir, file), 'utf-8'));
145
172
  const edges = extractAllEdges(data, registerId, uriBase, urnMap);
146
173
  allEdges.push(...edges);
174
+ allSourceRefs.push(...extractSourceRefs(data, registerId));
147
175
 
148
176
  for (const edge of edges) {
149
177
  if (edge.type === 'domain' || edge.type === 'section') {
@@ -241,7 +269,7 @@ function buildEdgesForDataset(datasetDir, registerId, uriBase, urnMap, manifest)
241
269
  console.log(` Written ${domainNodes.length} edge-derived domain nodes to domain-nodes.json`);
242
270
  }
243
271
 
244
- return deduped;
272
+ return { edges: deduped, sourceRefs: allSourceRefs };
245
273
  }
246
274
 
247
275
  // Main
@@ -278,6 +306,7 @@ for (const ds of datasets) {
278
306
  console.log(`URN resolution map: ${[...urnMap.entries()].map(([k,v]) => `${k}→${v}`).join(', ')}\n`);
279
307
 
280
308
  const allDatasetEdges = new Map();
309
+ const allSourceRefs = [];
281
310
 
282
311
  for (const ds of datasets) {
283
312
  const manifest = manifestCache.get(ds);
@@ -285,14 +314,44 @@ for (const ds of datasets) {
285
314
  try {
286
315
  console.log(`${manifest.title} (${ds}):`);
287
316
  const uriBase = manifest.uriBase || 'https://glossarist.org';
288
- const edges = buildEdgesForDataset(join(DATA_DIR, ds), ds, uriBase, urnMap, manifest);
289
- allDatasetEdges.set(ds, edges);
317
+ const result = buildEdgesForDataset(join(DATA_DIR, ds), ds, uriBase, urnMap, manifest);
318
+ allDatasetEdges.set(ds, result.edges);
319
+ allSourceRefs.push(...result.sourceRefs);
290
320
  } catch (e) {
291
321
  console.error(`Error reading manifest for ${ds}: ${e.message}`);
292
322
  }
293
323
  console.log();
294
324
  }
295
325
 
326
+ // Build source-refs index: maps every source string to its dataset ID.
327
+ // Uses manifest ref/refAliases as authoritative keys, augmented by
328
+ // actual source strings found in concept data.
329
+ const sourceRefMap = {};
330
+
331
+ // Seed from manifests (authoritative)
332
+ for (const [ds, manifest] of manifestCache) {
333
+ if (manifest.ref) sourceRefMap[manifest.ref] = ds;
334
+ for (const alias of manifest.refAliases ?? []) {
335
+ sourceRefMap[alias] = ds;
336
+ }
337
+ if (manifest.datasetUri) sourceRefMap[manifest.datasetUri] = ds;
338
+ for (const alias of manifest.uriAliases ?? []) {
339
+ const base = alias.endsWith('*') ? alias.slice(0, -1) : alias;
340
+ sourceRefMap[base] = ds;
341
+ }
342
+ }
343
+
344
+ // Augment with actual source strings from concepts
345
+ for (const { source, registerId } of allSourceRefs) {
346
+ if (!sourceRefMap[source]) {
347
+ sourceRefMap[source] = registerId;
348
+ }
349
+ }
350
+
351
+ const sourceRefPath = join(DATA_DIR, 'source-refs.json');
352
+ writeFileSync(sourceRefPath, JSON.stringify(sourceRefMap));
353
+ console.log(`Written source-refs.json (${Object.keys(sourceRefMap).length} source references across ${datasets.length} datasets)\n`);
354
+
296
355
  // Build cross-reference index: for each dataset, which other datasets'
297
356
  // edges.json contains edges targeting that dataset's URIs.
298
357
  const datasetUriPrefixes = new Map();
@@ -42,7 +42,7 @@ describe('designation relationship bridge', () => {
42
42
 
43
43
  const rc = term.related[0];
44
44
  expect(rc.type).toBe('abbreviated_form_for');
45
- expect(getDesignationTarget(rc)).toBe('Portable Document Format');
45
+ expect(getDesignationTarget(rc as any)).toBe('Portable Document Format');
46
46
  });
47
47
 
48
48
  it('returns null for concept-level relationships (no target)', () => {
@@ -50,7 +50,7 @@ describe('designation relationship bridge', () => {
50
50
  const concept = conceptFromJson(doc);
51
51
  const rc = concept.localization('eng')!.terms[0].related[0];
52
52
  expect(rc.type).toBe('abbreviated_form_for');
53
- expect(getDesignationTarget(rc)).toBeNull();
53
+ expect(getDesignationTarget(rc as any)).toBeNull();
54
54
  });
55
55
 
56
56
  it('handles short_form_for designation target', () => {
@@ -58,7 +58,7 @@ describe('designation relationship bridge', () => {
58
58
  const concept = conceptFromJson(doc);
59
59
  const rc = concept.localization('eng')!.terms[0].related[0];
60
60
  expect(rc.type).toBe('short_form_for');
61
- expect(getDesignationTarget(rc)).toBe('kilogram');
61
+ expect(getDesignationTarget(rc as any)).toBe('kilogram');
62
62
  });
63
63
 
64
64
  it('preserves designation target from glossarist native format', () => {
@@ -78,7 +78,7 @@ describe('designation relationship bridge', () => {
78
78
  };
79
79
  const concept = conceptFromJson(doc);
80
80
  const rc = concept.localization('eng')!.terms[0].related[0];
81
- expect(getDesignationTarget(rc)).toBe('Portable Document Format');
81
+ expect(getDesignationTarget(rc as any)).toBe('Portable Document Format');
82
82
  });
83
83
 
84
84
  it('designation without related returns empty related array', () => {
@@ -56,8 +56,8 @@ describe('AdapterFactory — lazy discovery', () => {
56
56
  expect(adapters[0].manifest).not.toBeNull();
57
57
  expect(adapters[0].manifest!.title).toBe('Dataset 1');
58
58
  expect(adapters[0].manifest!.conceptCount).toBe(100);
59
- // Should NOT fetch manifest.json — only datasets.json
60
- expect(mockFetch).toHaveBeenCalledTimes(1);
59
+ // Should NOT fetch manifest.json — only datasets.json + source-refs.json
60
+ expect(mockFetch).toHaveBeenCalledTimes(2);
61
61
  expect(mockFetch).toHaveBeenCalledWith('/datasets.json');
62
62
  });
63
63
 
@@ -89,8 +89,8 @@ describe('AdapterFactory — lazy discovery', () => {
89
89
 
90
90
  expect(adapters.length).toBe(1);
91
91
  expect(adapters[0].manifest!.title).toBe('Full Dataset');
92
- // Should fetch both datasets.json and manifest.json
93
- expect(mockFetch).toHaveBeenCalledTimes(2);
92
+ // Should fetch datasets.json, manifest.json, and source-refs.json
93
+ expect(mockFetch).toHaveBeenCalledTimes(3);
94
94
  });
95
95
 
96
96
  it('loads full manifest in loadDataset after summary discovery', async () => {
@@ -140,10 +140,10 @@ describe('AdapterFactory — lazy discovery', () => {
140
140
  return Promise.resolve({ ok: false, status: 404 } as Response);
141
141
  });
142
142
 
143
- // Discover with summary — no manifest fetch
143
+ // Discover with summary — no manifest fetch (datasets.json + source-refs.json)
144
144
  const adapters = await factory.discoverDatasets('/datasets.json');
145
145
  expect(adapters[0].manifest!.title).toBe('Summary Title');
146
- expect(mockFetch).toHaveBeenCalledTimes(1);
146
+ expect(mockFetch).toHaveBeenCalledTimes(2);
147
147
 
148
148
  // Load full dataset — fetches full manifest + index
149
149
  const loaded = await factory.loadDataset('ds1');
@@ -0,0 +1,180 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
+ import { ReferenceResolver } from '../adapters/ReferenceResolver';
3
+ import type { Resolution } from '../adapters/types';
4
+
5
+ type InternalResolution = Extract<Resolution, { type: 'internal' }>;
6
+
7
+ function asInternal(r: Resolution | null): InternalResolution | null {
8
+ return r?.type === 'internal' ? (r as InternalResolution) : null;
9
+ }
10
+
11
+ describe('Source reference resolution (citation linking)', () => {
12
+ let resolver: ReferenceResolver;
13
+
14
+ beforeEach(() => {
15
+ resolver = new ReferenceResolver();
16
+ // 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
+ });
21
+
22
+ describe('registerSourceRef', () => {
23
+ it('resolves citation when source ref matches exactly', () => {
24
+ resolver.registerSourceRef('OIML V 2-200:2012', 'vim-2012', 'urn:oiml:pub:v:2:2012');
25
+ const result = resolver.resolveCitation('OIML V 2-200:2012', '2.2', 'viml-2022');
26
+ expect(result).toEqual({
27
+ type: 'internal',
28
+ registerId: 'vim-2012',
29
+ conceptId: '2.2',
30
+ crossDataset: true,
31
+ });
32
+ });
33
+
34
+ it('resolves citation using variant source string', () => {
35
+ // Manifest has "OIML V 2-200:2012" but concepts use "OIML V2-200:2012"
36
+ resolver.registerSourceRef('OIML V 2-200:2012', 'vim-2012', 'urn:oiml:pub:v:2:2012');
37
+ resolver.registerSourceRef('OIML V2-200:2012', 'vim-2012', 'urn:oiml:pub:v:2:2012');
38
+
39
+ const result = resolver.resolveCitation('OIML V2-200:2012', '2.2', 'viml-2022');
40
+ expect(result).toEqual({
41
+ type: 'internal',
42
+ registerId: 'vim-2012',
43
+ conceptId: '2.2',
44
+ crossDataset: true,
45
+ });
46
+ });
47
+
48
+ it('resolves citation using URN as source', () => {
49
+ resolver.registerSourceRef('urn:oiml:pub:v:2:2012', 'vim-2012', 'urn:oiml:pub:v:2:2012');
50
+ const result = resolver.resolveCitation('urn:oiml:pub:v:2:2012', '2.2', 'viml-2022');
51
+ expect(result).toEqual({
52
+ type: 'internal',
53
+ registerId: 'vim-2012',
54
+ conceptId: '2.2',
55
+ crossDataset: true,
56
+ });
57
+ });
58
+
59
+ it('resolves citation using ref alias (e.g. "VIM")', () => {
60
+ resolver.registerSourceRef('VIM', 'vim-2012', 'urn:oiml:pub:v:2:2012');
61
+ const result = resolver.resolveCitation('VIM', '2.2', 'viml-2022');
62
+ expect(result).toEqual({
63
+ type: 'internal',
64
+ registerId: 'vim-2012',
65
+ conceptId: '2.2',
66
+ crossDataset: true,
67
+ });
68
+ });
69
+
70
+ it('returns null when source ref is not registered', () => {
71
+ const result = resolver.resolveCitation('Unknown Source', '2.2', 'viml-2022');
72
+ expect(result).toBeNull();
73
+ });
74
+
75
+ it('returns null when source ref matches but concept does not exist in dataset', () => {
76
+ resolver.registerSourceRef('OIML V 2-200:2012', 'vim-2012', 'urn:oiml:pub:v:2:2012');
77
+ // The resolver can only check if the dataset exists, not if the concept exists
78
+ // It will still return internal if the dataset is registered
79
+ const result = resolver.resolveCitation('OIML V 2-200:2012', '99.99', 'viml-2022');
80
+ expect(result?.type).toBe('internal');
81
+ });
82
+
83
+ it('handles same-dataset citation (crossDataset=false)', () => {
84
+ resolver.registerSourceRef('OIML V 2-200:2012', 'vim-2012', 'urn:oiml:pub:v:2:2012');
85
+ const result = resolver.resolveCitation('OIML V 2-200:2012', '2.2', 'vim-2012');
86
+ expect(result).toEqual({
87
+ type: 'internal',
88
+ registerId: 'vim-2012',
89
+ conceptId: '2.2',
90
+ crossDataset: false,
91
+ });
92
+ });
93
+ });
94
+
95
+ describe('resolveCitation fallback to URN', () => {
96
+ it('resolves URN-based source without registerSourceRef', () => {
97
+ // When source starts with "urn:", tryResolveCitationUri handles it
98
+ const result = resolver.resolveCitation('urn:oiml:pub:v:2:2012', '2.2', 'viml-2022');
99
+ expect(result).toEqual({
100
+ type: 'internal',
101
+ registerId: 'vim-2012',
102
+ conceptId: '2.2',
103
+ crossDataset: true,
104
+ });
105
+ });
106
+ });
107
+
108
+ describe('multiple source strings for same dataset', () => {
109
+ it('registers multiple source refs pointing to the same dataset', () => {
110
+ resolver.registerSourceRef('OIML V 2-200:2012', 'vim-2012', 'urn:oiml:pub:v:2:2012');
111
+ resolver.registerSourceRef('OIML V2-200:2012', 'vim-2012', 'urn:oiml:pub:v:2:2012');
112
+ resolver.registerSourceRef('VIM', 'vim-2012', 'urn:oiml:pub:v:2:2012');
113
+ resolver.registerSourceRef('ISO Guide 99:2007', 'vim-2012', 'urn:oiml:pub:v:2:2012');
114
+
115
+ expect(asInternal(resolver.resolveCitation('OIML V 2-200:2012', '2.2'))?.registerId).toBe('vim-2012');
116
+ expect(asInternal(resolver.resolveCitation('OIML V2-200:2012', '2.2'))?.registerId).toBe('vim-2012');
117
+ expect(asInternal(resolver.resolveCitation('VIM', '2.2'))?.registerId).toBe('vim-2012');
118
+ expect(asInternal(resolver.resolveCitation('ISO Guide 99:2007', '2.2'))?.registerId).toBe('vim-2012');
119
+ });
120
+ });
121
+
122
+ describe('ISO source references', () => {
123
+ it('resolves ISO/IEC references when registered', () => {
124
+ resolver.registerDataset('iso-17000', ['urn:iso:std:iso:iec:17000*']);
125
+ resolver.registerSourceRef('ISO/IEC 17000:2020', 'iso-17000', 'urn:iso:std:iso:iec:17000');
126
+
127
+ const result = resolver.resolveCitation('ISO/IEC 17000:2020', '3.1', 'viml-2022');
128
+ expect(result).toEqual({
129
+ type: 'internal',
130
+ registerId: 'iso-17000',
131
+ conceptId: '3.1',
132
+ crossDataset: true,
133
+ });
134
+ });
135
+
136
+ it('returns null for unresolvable ISO references', () => {
137
+ // ISO dataset not loaded — no registerSourceRef or dataset pattern match
138
+ const result = resolver.resolveCitation('ISO/IEC 17000:2020', '3.1', 'viml-2022');
139
+ expect(result).toBeNull();
140
+ });
141
+ });
142
+
143
+ describe('edge cases', () => {
144
+ it('returns internal with empty conceptId when referenceFrom is empty', () => {
145
+ resolver.registerSourceRef('OIML V 2-200:2012', 'vim-2012', 'urn:oiml:pub:v:2:2012');
146
+ const result = resolver.resolveCitation('OIML V 2-200:2012', '');
147
+ // Resolver still resolves but with empty conceptId — UI handles this
148
+ expect(result?.type).toBe('internal');
149
+ });
150
+
151
+ it('handles concept IDs with dots', () => {
152
+ resolver.registerSourceRef('OIML V 2-200:2012', 'vim-2012', 'urn:oiml:pub:v:2:2012');
153
+ const result = resolver.resolveCitation('OIML V 2-200:2012', '5.12.3', 'viml-2022');
154
+ expect(result).toEqual({
155
+ type: 'internal',
156
+ registerId: 'vim-2012',
157
+ conceptId: '5.12.3',
158
+ crossDataset: true,
159
+ });
160
+ });
161
+
162
+ it('handles source strings with special characters', () => {
163
+ resolver.registerDataset('vim-1993', ['urn:oiml:pub:v:2:1993*']);
164
+ resolver.registerSourceRef('OIML V 2:1993', 'vim-1993', 'urn:oiml:pub:v:2:1993');
165
+ const result = resolver.resolveCitation('OIML V 2:1993', '3.6');
166
+ expect(asInternal(result)?.registerId).toBe('vim-1993');
167
+ });
168
+
169
+ it('does not confuse similar source strings', () => {
170
+ resolver.registerSourceRef('OIML V 2-200:2007', 'vim-2007', 'urn:oiml:pub:v:2:2007');
171
+ resolver.registerSourceRef('OIML V 2-200:2012', 'vim-2012', 'urn:oiml:pub:v:2:2012');
172
+
173
+ const result2007 = resolver.resolveCitation('OIML V 2-200:2007', '2.2');
174
+ const result2012 = resolver.resolveCitation('OIML V 2-200:2012', '2.2');
175
+
176
+ expect(asInternal(result2007)?.registerId).toBe('vim-2007');
177
+ expect(asInternal(result2012)?.registerId).toBe('vim-2012');
178
+ });
179
+ });
180
+ });
@@ -56,6 +56,9 @@ export class AdapterFactory {
56
56
  adapter.setUrnMap(this.urnMap);
57
57
  }
58
58
 
59
+ // Load source-refs index for citation resolution
60
+ await this.loadSourceRefs();
61
+
59
62
  return adapters;
60
63
 
61
64
  }
@@ -154,6 +157,21 @@ export class AdapterFactory {
154
157
  this.crossRefIndex = this.crossRefIndex || {};
155
158
  return this.crossRefIndex;
156
159
  }
160
+
161
+ async loadSourceRefs(): Promise<void> {
162
+ try {
163
+ const resp = await fetch(`${import.meta.env.BASE_URL}data/source-refs.json`);
164
+ if (!resp.ok) return;
165
+ const refs: Record<string, string> = await resp.json();
166
+ for (const [source, datasetId] of Object.entries(refs)) {
167
+ const adapter = this.adapters.get(datasetId);
168
+ if (!adapter?.manifest) continue;
169
+ this.resolver.registerSourceRef(source, datasetId, adapter.manifest.datasetUri);
170
+ }
171
+ } catch {
172
+ // source-refs.json is optional
173
+ }
174
+ }
157
175
  }
158
176
 
159
177
  let _instance: AdapterFactory | null = null;
@@ -221,13 +221,15 @@ function attachAnnotations(concept: Concept, localizations: Record<string, unkno
221
221
  for (let j = 0; j < designation.related.length && j < rawRelated.length; j++) {
222
222
  const rawRel = rawRelated[j] as Record<string, unknown>;
223
223
  const rc = designation.related[j];
224
- if (rawRel.target && typeof rawRel.target === 'string') {
225
- designationTargets.set(rc, rawRel.target);
226
- }
227
- if (rc.ref) {
228
- const rawRef = rawRel.ref as Record<string, unknown> | undefined;
229
- if (rawRef?.text && typeof rawRef.text === 'string') {
230
- refTexts.set(rc.ref, rawRef.text);
224
+ if ('type' in rc) {
225
+ if (rawRel.target && typeof rawRel.target === 'string') {
226
+ designationTargets.set(rc as RelatedConcept, rawRel.target);
227
+ }
228
+ if ('ref' in rc && rc.ref) {
229
+ const rawRef = rawRel.ref as Record<string, unknown> | undefined;
230
+ if (rawRef?.text && typeof rawRef.text === 'string') {
231
+ refTexts.set((rc as RelatedConcept).ref!, rawRef.text);
232
+ }
231
233
  }
232
234
  }
233
235
  }
@@ -314,8 +314,18 @@ const conceptUriValue = computed(() =>
314
314
  conceptUri(props.concept, props.registerId, props.manifest.uriBase)
315
315
  );
316
316
 
317
- const outgoingEdges = computed(() => props.edges.filter(e => e.source === conceptUriValue.value && e.type !== 'domain' && e.type !== 'section'));
318
- const incomingEdges = computed(() => props.edges.filter(e => e.target === conceptUriValue.value && e.type !== 'domain' && e.type !== 'section'));
317
+ const outgoingEdges = computed(() => dedupeEdges(props.edges.filter(e => e.source === conceptUriValue.value && e.type !== 'domain' && e.type !== 'section'), 'target'));
318
+ const incomingEdges = computed(() => dedupeEdges(props.edges.filter(e => e.target === conceptUriValue.value && e.type !== 'domain' && e.type !== 'section'), 'source'));
319
+
320
+ function dedupeEdges(edges: GraphEdge[], direction: 'source' | 'target'): GraphEdge[] {
321
+ const seen = new Set<string>();
322
+ return edges.filter(e => {
323
+ const key = `${e[direction]}\0${e.type}`;
324
+ if (seen.has(key)) return false;
325
+ seen.add(key);
326
+ return true;
327
+ });
328
+ }
319
329
 
320
330
  function inverseEdgeType(type: string): string {
321
331
  return INVERSE_RELATIONSHIPS[type] || type;
@@ -1,5 +1,5 @@
1
1
  <script setup lang="ts">
2
- import type { Designation, Expression, ConceptSource } from 'glossarist';
2
+ import type { Designation, Expression, ConceptSource, RelatedConcept } from 'glossarist';
3
3
  import { designationTypeInfo, normativeStatusInfo, abbreviationDetails, termTypeInfo, grammarBadges, pronunciationLabel, pronunciationTooltip, sourceTypeInfo } from '../utils/designation-registry';
4
4
  import { relationshipLabel } from '../utils/relationship-categories';
5
5
  import { langName } from '../utils/lang';
@@ -17,6 +17,10 @@ const emit = defineEmits<{
17
17
  (e: 'navigate-related', ref: { source: string | null; id: string | null }): void;
18
18
  }>();
19
19
 
20
+ function asRelated(dr: unknown): RelatedConcept | null {
21
+ return dr && typeof dr === 'object' && 'ref' in dr ? dr as RelatedConcept : null;
22
+ }
23
+
20
24
  function resolvedLabel(dr: { content: string | null; ref: { source: string | null; id: string | null } | null }): string {
21
25
  if (dr.content) return dr.content;
22
26
  if (dr.ref?.source && dr.ref?.id) return `${dr.ref.source}/${dr.ref.id}`;
@@ -65,12 +69,12 @@ function resolvedLabel(dr: { content: string | null; ref: { source: string | nul
65
69
  </div>
66
70
  <div v-if="d.related?.length" class="mt-0.5 space-y-0.5">
67
71
  <div v-for="(dr, dri) in d.related" :key="'dr'+dri" class="text-xs text-ink-400 flex items-center gap-1.5">
68
- <span class="badge text-[9px] bg-gray-50 text-gray-600">{{ relationshipLabel(dr.type) }}</span>
69
- <template v-if="getDesignationTarget(dr)">
70
- <span class="italic">{{ getDesignationTarget(dr) }}</span>
72
+ <span class="badge text-[9px] bg-gray-50 text-gray-600">{{ relationshipLabel(dr.type as string) }}</span>
73
+ <template v-if="getDesignationTarget(dr as any)">
74
+ <span class="italic">{{ getDesignationTarget(dr as any) }}</span>
71
75
  </template>
72
- <button v-else-if="dr.ref" @click="emit('navigate-related', dr.ref)" class="concept-link">{{ resolvedLabel(dr) }}</button>
73
- <span v-else>{{ resolvedLabel(dr) }}</span>
76
+ <button v-else-if="asRelated(dr)?.ref" @click="emit('navigate-related', asRelated(dr)!.ref!)" class="concept-link">{{ resolvedLabel(asRelated(dr)!) }}</button>
77
+ <span v-else>{{ resolvedLabel(dr as any) }}</span>
74
78
  </div>
75
79
  </div>
76
80
  </div>