@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
@@ -73,18 +73,18 @@ interface JsonLdRef {
73
73
  'gl:id'?: string;
74
74
  'gl:version'?: string;
75
75
  'gl:text'?: string;
76
- 'source'?: string;
77
- 'id'?: string;
78
- 'version'?: string;
76
+ source?: string;
77
+ id?: string;
78
+ version?: string;
79
79
  }
80
80
 
81
81
  interface JsonLdLocality {
82
82
  'gl:localityType'?: string;
83
83
  'gl:referenceFrom'?: string;
84
84
  'gl:referenceTo'?: string;
85
- 'type'?: string;
86
- 'reference_from'?: string;
87
- 'reference_to'?: string;
85
+ type?: string;
86
+ reference_from?: string;
87
+ reference_to?: string;
88
88
  }
89
89
 
90
90
  interface JsonLdOrigin {
@@ -97,6 +97,7 @@ interface JsonLdOrigin {
97
97
  }
98
98
 
99
99
  interface JsonLdSource {
100
+ 'gl:id'?: string;
100
101
  'gl:sourceType'?: string;
101
102
  'gl:sourceStatus'?: string;
102
103
  'gl:modification'?: string;
@@ -109,6 +110,8 @@ interface JsonLdRelated {
109
110
  '@id'?: string;
110
111
  'gl:term'?: string;
111
112
  'gl:target'?: string;
113
+ 'gl:sourceId'?: string;
114
+ 'gl:citation'?: JsonLdOrigin;
112
115
  }
113
116
 
114
117
  interface JsonLdDesignation {
@@ -192,10 +195,24 @@ export function getRefText(ref: ConceptRef): string | null {
192
195
  return refTexts.get(ref) ?? null;
193
196
  }
194
197
 
198
+ // RelatedConcept.sourceId: links a citation reference back to its source entry
199
+ const relatedSourceIds = new WeakMap<object, string>();
200
+
201
+ export function getRelatedSourceId(rc: object): string | null {
202
+ return relatedSourceIds.get(rc) ?? null;
203
+ }
204
+
205
+ // RelatedConcept.citation: embedded citation data for cite-ref references
206
+ const relatedCitations = new WeakMap<object, Record<string, unknown>>();
207
+
208
+ export function getRelatedCitation(rc: object): Record<string, unknown> | null {
209
+ return relatedCitations.get(rc) ?? null;
210
+ }
211
+
195
212
  // Relationship types whose target is a designation string, not a concept ref.
196
213
  const DESIGNATION_REL_TYPES = new Set(['abbreviated_form_for', 'short_form_for']);
197
214
 
198
- function attachAnnotations(concept: Concept, localizations: Record<string, unknown>): void {
215
+ function attachBridges(concept: Concept, localizations: Record<string, unknown>): void {
199
216
  for (const lang of concept.languages) {
200
217
  const lc = concept.localization(lang);
201
218
  const raw = localizations[lang];
@@ -210,7 +227,7 @@ function attachAnnotations(concept: Concept, localizations: Record<string, unkno
210
227
  ));
211
228
  }
212
229
 
213
- // Designation-level relationship targets and ref text
230
+ // Designation-level relationship targets, ref text, sourceId, citation
214
231
  const rawTerms = rawObj.terms;
215
232
  if (Array.isArray(rawTerms)) {
216
233
  for (const rawTerm of rawTerms) {
@@ -222,40 +239,56 @@ function attachAnnotations(concept: Concept, localizations: Record<string, unkno
222
239
  if (!designation) continue;
223
240
  const rawRelated = rawT.related;
224
241
  if (!Array.isArray(rawRelated)) continue;
225
- for (const rawRel of rawRelated) {
226
- if (!rawRel || typeof rawRel !== 'object') continue;
227
- const rel = rawRel as Record<string, unknown>;
228
- const relType = rel.type as string | undefined;
229
- const rc = designation.related.find(r => r.type === relType);
230
- if (!rc) continue;
231
- if (rel.target && typeof rel.target === 'string') {
232
- designationTargets.set(rc as object, rel.target);
233
- }
234
- if ('ref' in rc && rc.ref) {
235
- const rawRef = rel.ref as Record<string, unknown> | undefined;
236
- if (rawRef?.text && typeof rawRef.text === 'string') {
237
- refTexts.set(rc.ref, rawRef.text);
238
- }
239
- }
240
- }
242
+ attachRelatedBridges(designation.related, rawRelated);
241
243
  }
242
244
  }
243
245
 
244
- // Localization-level ref text
246
+ // Localization-level related concepts
245
247
  const rawRelated = rawObj.related;
246
248
  if (Array.isArray(rawRelated)) {
247
- for (const rawRel of rawRelated) {
248
- if (!rawRel || typeof rawRel !== 'object') continue;
249
- const rel = rawRel as Record<string, unknown>;
250
- const relType = rel.type as string | undefined;
251
- const rc = relType ? lc.related.find(r => r.type === relType) : undefined;
252
- if (!rc || !rc.ref) continue;
253
- const rawRef = rel.ref as Record<string, unknown> | undefined;
254
- if (rawRef?.text && typeof rawRef.text === 'string') {
255
- refTexts.set(rc.ref, rawRef.text);
256
- }
249
+ attachRelatedBridges(lc.related, rawRelated);
250
+ }
251
+ }
252
+ }
253
+
254
+ /**
255
+ * Attach bridged fields (ref text, sourceId, citation) to RelatedConcept instances
256
+ * from the raw deserialized data. Called after Concept.fromJSON creates the model
257
+ * instances, since RelatedConcept.fromJSON only reads type/content/ref.
258
+ */
259
+ function attachRelatedBridges(
260
+ modelRelated: Array<{ type?: string | null; content?: string | null; ref?: any; related?: any[] }>,
261
+ rawRelated: unknown[],
262
+ ): void {
263
+ for (const rawRel of rawRelated) {
264
+ if (!rawRel || typeof rawRel !== 'object') continue;
265
+ const rel = rawRel as Record<string, unknown>;
266
+ const relType = rel.type as string | undefined;
267
+ const rc = relType ? modelRelated.find(r => r.type === relType) : undefined;
268
+ if (!rc) continue;
269
+
270
+ // Ref text
271
+ if (rc.ref) {
272
+ const rawRef = rel.ref as Record<string, unknown> | undefined;
273
+ if (rawRef?.text && typeof rawRef.text === 'string') {
274
+ refTexts.set(rc.ref, rawRef.text);
257
275
  }
258
276
  }
277
+
278
+ // Designation target
279
+ if (rel.target && typeof rel.target === 'string') {
280
+ designationTargets.set(rc, rel.target);
281
+ }
282
+
283
+ // Source ID — links citation reference back to the ConceptSource entry
284
+ if (rel.sourceId && typeof rel.sourceId === 'string') {
285
+ relatedSourceIds.set(rc, rel.sourceId);
286
+ }
287
+
288
+ // Citation — embedded origin data for cite-ref references
289
+ if (rel.citation && typeof rel.citation === 'object') {
290
+ relatedCitations.set(rc, rel.citation as Record<string, unknown>);
291
+ }
259
292
  }
260
293
  }
261
294
 
@@ -335,8 +368,37 @@ function mapDesignationFromJsonLd(d: JsonLdDesignation): Record<string, unknown>
335
368
  return result;
336
369
  }
337
370
 
371
+ function mapRefFromJsonLd(rawRef: JsonLdRef | string | undefined): Record<string, unknown> | null {
372
+ if (!rawRef) return null;
373
+ if (typeof rawRef === 'string') return { source: rawRef };
374
+ const refObj: Record<string, unknown> = {};
375
+ // gl:-prefixed keys take precedence over unprefixed keys
376
+ refObj.source = rawRef['gl:source'] ?? rawRef.source;
377
+ refObj.id = rawRef['gl:id'] ?? rawRef.id;
378
+ refObj.version = rawRef['gl:version'] ?? rawRef.version;
379
+ if (rawRef['gl:text']) refObj.text = rawRef['gl:text'];
380
+ return (refObj.source ?? refObj.id ?? refObj.version ?? refObj.text) != null
381
+ ? refObj : null;
382
+ }
383
+
384
+ /**
385
+ * Map JSON-LD locality to glossarist's snake_case format.
386
+ * Always uses snake_case (reference_from/reference_to) for consistency
387
+ * with the glossarist model.
388
+ */
389
+ function mapLocalityFromJsonLd(rawLoc: JsonLdLocality | undefined): Record<string, unknown> | null {
390
+ if (!rawLoc) return null;
391
+ const locObj: Record<string, unknown> = {};
392
+ locObj.type = rawLoc['gl:localityType'] ?? rawLoc.type;
393
+ locObj.reference_from = rawLoc['gl:referenceFrom'] ?? rawLoc.reference_from;
394
+ locObj.reference_to = rawLoc['gl:referenceTo'] ?? rawLoc.reference_to;
395
+ return (locObj.type ?? locObj.reference_from ?? locObj.reference_to) != null
396
+ ? locObj : null;
397
+ }
398
+
338
399
  function mapSourceFromJsonLd(s: JsonLdSource): Record<string, unknown> {
339
400
  const result: Record<string, unknown> = {};
401
+ if (s['gl:id']) result.id = s['gl:id'];
340
402
  if (s['gl:sourceType']) result.type = s['gl:sourceType'];
341
403
  if (s['gl:sourceStatus']) result.status = s['gl:sourceStatus'];
342
404
  if (s['gl:modification']) result.modification = s['gl:modification'];
@@ -344,32 +406,10 @@ function mapSourceFromJsonLd(s: JsonLdSource): Record<string, unknown> {
344
406
  if (s['gl:origin']) {
345
407
  const origin: Record<string, unknown> = {};
346
408
  const o = s['gl:origin'];
347
- if (o['gl:ref']) {
348
- const rawRef = o['gl:ref'];
349
- if (typeof rawRef === 'string') {
350
- origin.ref = { source: rawRef };
351
- } else {
352
- const refObj: Record<string, unknown> = {};
353
- if (rawRef['gl:source']) refObj.source = rawRef['gl:source'];
354
- if (rawRef['gl:id']) refObj.id = rawRef['gl:id'];
355
- if (rawRef['gl:version']) refObj.version = rawRef['gl:version'];
356
- if (rawRef['source']) refObj.source = rawRef['source'];
357
- if (rawRef['id']) refObj.id = rawRef['id'];
358
- if (rawRef['version']) refObj.version = rawRef['version'];
359
- if (Object.keys(refObj).length > 0) origin.ref = refObj;
360
- }
361
- }
362
- if (o['gl:locality']) {
363
- const loc: Record<string, unknown> = {};
364
- const rawLoc = o['gl:locality'];
365
- if (rawLoc['gl:localityType']) loc.type = rawLoc['gl:localityType'];
366
- if (rawLoc['gl:referenceFrom']) loc.reference_from = rawLoc['gl:referenceFrom'];
367
- if (rawLoc['gl:referenceTo']) loc.reference_to = rawLoc['gl:referenceTo'];
368
- if (rawLoc['type']) loc.type = rawLoc['type'];
369
- if (rawLoc['reference_from']) loc.reference_from = rawLoc['reference_from'];
370
- if (rawLoc['reference_to']) loc.reference_to = rawLoc['reference_to'];
371
- origin.locality = loc;
372
- }
409
+ const ref = mapRefFromJsonLd(o['gl:ref']);
410
+ if (ref) origin.ref = ref;
411
+ const loc = mapLocalityFromJsonLd(o['gl:locality']);
412
+ if (loc) origin.locality = loc;
373
413
  if (o['gl:link']) origin.link = o['gl:link'];
374
414
  if (o['gl:id']) origin.id = o['gl:id'];
375
415
  if (o['gl:version']) origin.version = o['gl:version'];
@@ -388,14 +428,8 @@ function mapRelatedFromJsonLd(r: JsonLdRelated): Record<string, unknown> {
388
428
  }
389
429
 
390
430
  if (r['gl:ref']) {
391
- const ref = r['gl:ref'];
392
- const refObj: Record<string, unknown> = {};
393
- if (ref['gl:source']) refObj.source = ref['gl:source'];
394
- if (ref['gl:id']) refObj.id = ref['gl:id'];
395
- if (ref['source']) refObj.source = ref['source'];
396
- if (ref['id']) refObj.id = ref['id'];
397
- if (ref['gl:text']) refObj.text = ref['gl:text'];
398
- if (Object.keys(refObj).length > 0) result.ref = refObj;
431
+ const ref = mapRefFromJsonLd(r['gl:ref']);
432
+ if (ref) result.ref = ref;
399
433
  }
400
434
 
401
435
  if (!result.ref && r['@id']) {
@@ -406,6 +440,22 @@ function mapRelatedFromJsonLd(r: JsonLdRelated): Record<string, unknown> {
406
440
  : { source: uri, id: null };
407
441
  }
408
442
  if (r['gl:term']) result.content = r['gl:term'];
443
+
444
+ // Bridged fields — stored in raw dict, extracted by attachBridges()
445
+ if (r['gl:sourceId']) result.sourceId = r['gl:sourceId'];
446
+ if (r['gl:citation']) {
447
+ const c = r['gl:citation'];
448
+ const citation: Record<string, unknown> = {};
449
+ if (c['gl:ref']) {
450
+ const cr = mapRefFromJsonLd(c['gl:ref']);
451
+ if (cr) citation.ref = cr;
452
+ }
453
+ const loc = mapLocalityFromJsonLd(c['gl:locality']);
454
+ if (loc) citation.locality = loc;
455
+ if (c['gl:link']) citation.link = c['gl:link'];
456
+ if (Object.keys(citation).length > 0) result.citation = citation;
457
+ }
458
+
409
459
  return result;
410
460
  }
411
461
 
@@ -493,7 +543,7 @@ function conceptFromJsonLd(doc: JsonLdConcept): Concept {
493
543
  status: null,
494
544
  });
495
545
 
496
- attachAnnotations(concept, localizations);
546
+ attachBridges(concept, localizations);
497
547
  return concept;
498
548
  }
499
549
 
@@ -505,7 +555,7 @@ export function conceptFromJson(doc: Record<string, unknown>): Concept {
505
555
  }
506
556
  const concept = Concept.fromJSON(doc);
507
557
  const locs = (doc as Record<string, unknown>).localizations as Record<string, unknown> | undefined;
508
- if (locs) attachAnnotations(concept, locs);
558
+ if (locs) attachBridges(concept, locs);
509
559
  return concept;
510
560
  }
511
561
 
@@ -32,6 +32,9 @@ export type {
32
32
  export { RELATIONSHIP_TYPES, DATE_TYPES } from 'glossarist';
33
33
  export { GRAMMAR_GENDERS, GRAMMAR_NUMBERS, GRAMMAR_PARTS_OF_SPEECH } from 'glossarist/models';
34
34
 
35
+ // Re-export citation classification from ReferenceResolver (single definition site)
36
+ export type { CitationClassification, CiteResolution } from './ReferenceResolver';
37
+
35
38
  // ── Dataset metadata ──────────────────────────────────────────────────────
36
39
 
37
40
  export interface ManifestSection {
@@ -114,8 +114,8 @@ const showDatasetNav = computed(() => !!currentManifest.value || !!siteConfig.va
114
114
  const provenance = computed(() => {
115
115
  const manifest = currentManifest.value;
116
116
  return {
117
- owner: manifest?.owner || (siteConfig.value as any)?.branding?.ownerName,
118
- ownerUrl: (siteConfig.value as any)?.branding?.ownerUrl,
117
+ owner: manifest?.owner || siteConfig.value?.branding?.ownerName,
118
+ ownerUrl: siteConfig.value?.branding?.ownerUrl,
119
119
  ref: manifest?.ref,
120
120
  status: manifest?.status,
121
121
  lastUpdated: manifest?.lastUpdated,
@@ -149,14 +149,17 @@ function isActive(page: { route: string; datasetScoped?: boolean }): boolean {
149
149
  const target = pageRoute(page);
150
150
  if (route.path === target) return true;
151
151
  if (page.datasetScoped) return route.name === page.route;
152
+ // Non-dataset-scoped page: only match if we're NOT inside a dataset route
153
+ const inDataset = 'registerId' in route.params;
154
+ if (inDataset) return false;
152
155
  return route.name === page.route || route.name === `${page.route}-global`;
153
156
  }
154
157
 
155
- function navTitle(page: { route: string }): string {
158
+ function navTitle(page: { route: string; title?: string }): string {
156
159
  const route = page.route || 'home';
157
160
  const key = `nav.${route}`;
158
161
  const translated = t(key);
159
- return translated === key ? (page as any).title : translated;
162
+ return translated === key ? (page.title ?? route) : translated;
160
163
  }
161
164
 
162
165
  const expandedSectionNodes = ref<Set<string>>(new Set());
@@ -1,5 +1,6 @@
1
1
  <script setup lang="ts">
2
2
  import type { Citation } from 'glossarist';
3
+ import type { CitationClassification, CiteResolution } from '../adapters/types';
3
4
  import { computed, ref } from 'vue';
4
5
  import { getFactory } from '../adapters/factory';
5
6
  import { useRouter } from 'vue-router';
@@ -14,22 +15,26 @@ const router = useRouter();
14
15
  const store = useVocabularyStore();
15
16
  const factory = getFactory();
16
17
 
17
- function resolveCitation(): { registerId: string; conceptId: string } | null {
18
- const ref = props.citation.ref;
19
- const locality = props.citation.locality;
20
- if (!ref?.source || !locality?.referenceFrom) return null;
18
+ // ── Single source of truth for citation resolution ────────────────────────
19
+ // Both classification and navigation target come from the same resolveCite()
20
+ // call, so they can never disagree.
21
21
 
22
- const resolution = factory.resolveCitation(ref.source, locality.referenceFrom, props.registerId);
23
- if (!resolution || resolution.type !== 'internal') return null;
22
+ const citeResolution = computed<CiteResolution>(() =>
23
+ factory.resolver.resolveCite(props.citation, props.registerId),
24
+ );
24
25
 
25
- return { registerId: resolution.registerId, conceptId: resolution.conceptId };
26
- }
26
+ const classification = computed<CitationClassification>(() =>
27
+ citeResolution.value.classification,
28
+ );
29
+
30
+ const resolvedTarget = computed(() => citeResolution.value.resolved);
27
31
 
28
- const resolvedTarget = computed(() => resolveCitation());
29
32
  const isCrossDataset = computed(() =>
30
33
  resolvedTarget.value != null && resolvedTarget.value.registerId !== props.registerId,
31
34
  );
32
35
 
36
+ // ── Navigation ────────────────────────────────────────────────────────────
37
+
33
38
  async function navigateToCitation() {
34
39
  if (!resolvedTarget.value) return;
35
40
  const { registerId, conceptId } = resolvedTarget.value;
@@ -37,7 +42,37 @@ async function navigateToCitation() {
37
42
  router.push({ name: 'concept', params: { registerId, conceptId } });
38
43
  }
39
44
 
40
- // --- Hover preview ---
45
+ // ── Wrapper element determined by classification ──────────────────────────
46
+ // Internal → button (navigates to concept)
47
+ // Self-contained with link → anchor (external link)
48
+ // Everything else → span (plain text)
49
+
50
+ const sourceTag = computed(() => {
51
+ if (classification.value === 'internal-citation') return 'button';
52
+ if (classification.value === 'self-contained-citation' && props.citation.link) return 'a';
53
+ return 'span';
54
+ });
55
+
56
+ const sourceAttrs = computed(() => {
57
+ if (sourceTag.value === 'button') {
58
+ return { class: 'concept-link font-medium inline-flex items-center gap-0.5' };
59
+ }
60
+ if (sourceTag.value === 'a') {
61
+ return { href: props.citation.link!, target: '_blank', rel: 'noopener', class: 'concept-link font-medium' };
62
+ }
63
+ return { class: 'font-medium' };
64
+ });
65
+
66
+ const sourceEvents = computed(() => {
67
+ if (sourceTag.value !== 'button') return {};
68
+ return {
69
+ onClick: navigateToCitation,
70
+ onMouseenter: schedulePreview,
71
+ };
72
+ });
73
+
74
+ // ── Hover preview ─────────────────────────────────────────────────────────
75
+
41
76
  const triggerEl = ref<HTMLElement | null>(null);
42
77
  const preview = ref<{ designation: string; definition: string } | null>(null);
43
78
  const previewVisible = ref(false);
@@ -90,39 +125,60 @@ function hidePreview() {
90
125
  if (previewTimer) { clearTimeout(previewTimer); previewTimer = null; }
91
126
  previewVisible.value = false;
92
127
  }
128
+
129
+ // ── Locality formatting ───────────────────────────────────────────────────
130
+
131
+ function formatLocality(loc: NonNullable<Citation['locality']>): string {
132
+ const parts: string[] = [];
133
+ const lType = (loc as unknown as Record<string, unknown>).type as string | null;
134
+ const from = (loc as unknown as Record<string, unknown>).referenceFrom ?? (loc as unknown as Record<string, unknown>).reference_from;
135
+ const to = (loc as unknown as Record<string, unknown>).referenceTo ?? (loc as unknown as Record<string, unknown>).reference_to;
136
+ if (lType) parts.push(`, ${lType}`);
137
+ if (from) parts.push(to ? ` ${from}–${to}` : ` ${from}`);
138
+ return parts.join('');
139
+ }
93
140
  </script>
94
141
 
95
142
  <template>
96
143
  <span class="inline" @mouseleave="hidePreview">
97
- <template v-if="citation.ref">
98
- <button
99
- v-if="resolvedTarget"
100
- @click="navigateToCitation"
101
- @mouseenter="schedulePreview"
102
- class="concept-link font-medium inline-flex items-center gap-0.5"
144
+ <!-- Source reference: dynamic wrapper (button/a/span) -->
145
+ <template v-if="citation.ref?.source">
146
+ <component
147
+ :is="sourceTag"
148
+ v-bind="sourceAttrs"
149
+ v-on="sourceEvents"
103
150
  >
104
151
  {{ citation.ref.source }}
105
152
  <span v-if="isCrossDataset" class="text-[10px] opacity-60 leading-none">↗</span>
106
- </button>
107
- <span v-else-if="citation.ref.source" class="font-medium">{{ citation.ref.source }}</span>
153
+ </component>
108
154
  <span v-if="citation.ref.id"> {{ citation.ref.id }}</span>
109
155
  <span v-if="citation.ref.version" class="text-ink-400"> ({{ citation.ref.version }})</span>
110
156
  </template>
157
+
158
+ <!-- Locality: same formatting across all classifications -->
111
159
  <template v-if="citation.locality">
112
- <button v-if="resolvedTarget" @click="navigateToCitation" @mouseenter="schedulePreview" class="concept-link">
113
- <span v-if="citation.locality.type" class="text-ink-400">, {{ citation.locality.type }}</span>
114
- <span v-if="citation.locality.referenceFrom" class="text-ink-400">
115
- {{ citation.locality.referenceTo ? ` ${citation.locality.referenceFrom}–${citation.locality.referenceTo}` : ` ${citation.locality.referenceFrom}` }}
116
- </span>
160
+ <button
161
+ v-if="classification === 'internal-citation'"
162
+ @click="navigateToCitation"
163
+ @mouseenter="schedulePreview"
164
+ class="concept-link"
165
+ >
166
+ {{ formatLocality(citation.locality) }}
117
167
  </button>
118
- <template v-else>
119
- <span v-if="citation.locality.type" class="text-ink-400">, {{ citation.locality.type }}</span>
120
- <span v-if="citation.locality.referenceFrom" class="text-ink-400">
121
- {{ citation.locality.referenceTo ? ` ${citation.locality.referenceFrom}–${citation.locality.referenceTo}` : ` ${citation.locality.referenceFrom}` }}
122
- </span>
123
- </template>
168
+ <span v-else class="text-ink-400">
169
+ {{ formatLocality(citation.locality) }}
170
+ </span>
124
171
  </template>
125
- <a v-if="citation.link" :href="citation.link" target="_blank" rel="noopener" class="concept-link ml-1">[link]</a>
172
+
173
+ <!-- External link badge (only for non-self-contained citations that have a link) -->
174
+ <a
175
+ v-if="citation.link && classification !== 'self-contained-citation'"
176
+ :href="citation.link"
177
+ target="_blank"
178
+ rel="noopener"
179
+ class="concept-link ml-1"
180
+ >[link]</a>
181
+
126
182
  <span v-if="citation.original" class="text-xs text-ink-300 ml-1">(orig: {{ citation.original }})</span>
127
183
  <span v-if="resolvedTarget" class="text-[9px] text-ink-300 ml-1">→ {{ resolvedTarget.registerId }}/{{ resolvedTarget.conceptId }}</span>
128
184