@glossarist/concept-browser 0.7.9 → 0.7.11

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.9",
3
+ "version": "0.7.11",
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": {
@@ -773,6 +773,7 @@ function processDataset(dir, register, opts) {
773
773
  };
774
774
  if (opts.languageOrder) manifest.languageOrder = opts.languageOrder;
775
775
  if (opts.ref) manifest.ref = opts.ref;
776
+ if (opts.refAliases) manifest.refAliases = opts.refAliases;
776
777
  writeJson(path.join(DATA, register, 'manifest.json'), manifest);
777
778
 
778
779
  // Copy bibliography.yaml → bibliography.json
@@ -838,6 +839,7 @@ for (let i = 0; i < config.datasets.length; i++) {
838
839
  sourceRepo: ds.sourceRepo,
839
840
  languageOrder: ds.languageOrder,
840
841
  ref: ds.ref,
842
+ refAliases: ds.refAliases,
841
843
  tags: ds.tags,
842
844
  color: ds.color || DS_PALETTE[i % DS_PALETTE.length],
843
845
  datasetUri: ds.uri,
@@ -1112,6 +1114,30 @@ function processPages(config) {
1112
1114
 
1113
1115
  const processedPages = processPages(config);
1114
1116
 
1117
+ // Auto-generate dataset about pages from {localPath}/about.md
1118
+ const _pagesDir = path.join(PUBLIC, 'pages');
1119
+ for (const ds of config.datasets || []) {
1120
+ if (!ds.localPath) continue;
1121
+ const aboutSrc = path.resolve(ROOT, ds.localPath, 'about.md');
1122
+ if (!fs.existsSync(aboutSrc)) continue;
1123
+ const raw = fs.readFileSync(aboutSrc, 'utf8');
1124
+ const html = renderMarkdown(stripFrontmatter(raw));
1125
+ const route = `${ds.id}-about`;
1126
+ writeJson(path.join(_pagesDir, `${route}.json`), { title: 'About', html });
1127
+ console.log(` Auto-generated dataset about page: ${route}`);
1128
+ const uiLangs = (config.uiLanguages || []).map(l => l.code).filter(l => l !== 'eng');
1129
+ const dsTranslations = ds.translations || {};
1130
+ for (const lang of uiLangs) {
1131
+ const trAboutSrc = path.resolve(ROOT, ds.localPath, `about-${lang}.md`);
1132
+ if (!fs.existsSync(trAboutSrc)) continue;
1133
+ const trRaw = fs.readFileSync(trAboutSrc, 'utf8');
1134
+ const trHtml = renderMarkdown(stripFrontmatter(trRaw));
1135
+ const trTitle = dsTranslations[lang]?.title ? `About ${dsTranslations[lang].title}` : 'About';
1136
+ writeJson(path.join(_pagesDir, `${route}.${lang}.json`), { title: trTitle, html: trHtml });
1137
+ console.log(` Auto-generated dataset about page: ${route}.${lang}`);
1138
+ }
1139
+ }
1140
+
1115
1141
  // Generate site-config.json from site config
1116
1142
  const siteBranding = { ...config.branding };
1117
1143
  // Rewrite logo paths to destination filenames and strip build-time fields
@@ -37,8 +37,8 @@ describe('renderMath', () => {
37
37
  const input = 'Intro text\n\n|===\n| a | b | c\n| d | e | f\n|===';
38
38
  const result = renderMath(input);
39
39
  expect(result).toContain('<table class="concept-table">');
40
- expect(result).toContain('<tr><td>a</td><td>b</td><td>c</td></tr>');
41
- expect(result).toContain('<tr><td>d</td><td>e</td><td>f</td></tr>');
40
+ expect(result).toContain('<thead><tr><th>a</th><th>b</th><th>c</th></tr></thead>');
41
+ expect(result).toContain('<tbody><tr><td>d</td><td>e</td><td>f</td></tr></tbody>');
42
42
  expect(result).not.toContain('|===');
43
43
  });
44
44
 
@@ -30,11 +30,16 @@ function matchUriPattern(uri: string, pattern: string): boolean {
30
30
  export class ReferenceResolver {
31
31
  private datasets: DatasetEntry[] = [];
32
32
  private routing: RoutingEntry[] = [];
33
+ private sourceRefs = new Map<string, { datasetId: string; uriPrefix: string }>();
33
34
 
34
35
  registerDataset(id: string, uriPatterns: string[]): void {
35
36
  this.datasets.push({ id, uriPatterns });
36
37
  }
37
38
 
39
+ registerSourceRef(sourceRef: string, datasetId: string, uriPrefix: string): void {
40
+ this.sourceRefs.set(sourceRef, { datasetId, uriPrefix });
41
+ }
42
+
38
43
  loadRouting(entries: RoutingEntry[]): void {
39
44
  this.routing = entries;
40
45
  }
@@ -82,6 +87,29 @@ export class ReferenceResolver {
82
87
  return { type: 'unresolved', uri };
83
88
  }
84
89
 
90
+ resolveCitation(source: string, referenceFrom: string, sourceDatasetId?: string): Resolution | null {
91
+ const entry = this.sourceRefs.get(source);
92
+ if (!entry) {
93
+ if (!source.startsWith('urn:')) return null;
94
+ return this.tryResolveCitationUri(source, referenceFrom, sourceDatasetId);
95
+ }
96
+ return this.tryResolveCitationUri(entry.uriPrefix, referenceFrom, sourceDatasetId);
97
+ }
98
+
99
+ private tryResolveCitationUri(uriPrefix: string, referenceFrom: string, sourceDatasetId?: string): Resolution | null {
100
+ const uri = `${uriPrefix}/${referenceFrom}`;
101
+ const result = this.resolveReference(uri, sourceDatasetId);
102
+ if (result.type === 'internal') {
103
+ return { ...result, conceptId: result.conceptId.replace(/^\//, '') };
104
+ }
105
+ const directUri = uriPrefix + referenceFrom;
106
+ const directResult = this.resolveReference(directUri, sourceDatasetId);
107
+ if (directResult.type === 'internal') {
108
+ return { ...directResult, conceptId: directResult.conceptId.replace(/^\//, '') };
109
+ }
110
+ return null;
111
+ }
112
+
85
113
  private extractConceptIdFromRouting(uri: string, pattern: string): string | null {
86
114
  for (const ds of this.datasets) {
87
115
  for (const dsPattern of ds.uriPatterns) {
@@ -55,6 +55,13 @@ export class AdapterFactory {
55
55
  ].filter(Boolean) as string[];
56
56
  this.resolver.registerDataset(registerId, uriPatterns);
57
57
 
58
+ if (manifest.ref) {
59
+ this.resolver.registerSourceRef(manifest.ref, registerId, manifest.datasetUri);
60
+ }
61
+ for (const alias of manifest.refAliases ?? []) {
62
+ this.resolver.registerSourceRef(alias, registerId, manifest.datasetUri);
63
+ }
64
+
58
65
  return adapter;
59
66
  }
60
67
 
@@ -65,6 +72,10 @@ export class AdapterFactory {
65
72
  resolve(uri: string, sourceDatasetId?: string): Resolution {
66
73
  return this.resolver.resolveReference(uri, sourceDatasetId);
67
74
  }
75
+
76
+ resolveCitation(source: string, referenceFrom: string, sourceDatasetId?: string): Resolution | null {
77
+ return this.resolver.resolveCitation(source, referenceFrom, sourceDatasetId);
78
+ }
68
79
  }
69
80
 
70
81
  let _instance: AdapterFactory | null = null;
@@ -58,6 +58,7 @@ export interface Manifest {
58
58
  shortname?: string;
59
59
  languageOrder?: string[];
60
60
  ref?: string;
61
+ refAliases?: string[];
61
62
  languageStats?: Record<string, { terms: number; definitions: number }>;
62
63
  availableFormats?: string[];
63
64
  bulkFormats?: { file: string; format: string; size: number }[];
@@ -83,6 +83,21 @@ const datasetEntries = computed(() => {
83
83
  return entries;
84
84
  });
85
85
 
86
+ const datasetIds = computed(() => new Set(datasetEntries.value.map(d => d.id)));
87
+
88
+ // Hide dataset-prefixed pages (e.g. "viml-about") from global nav
89
+ const filteredGlobalPages = computed(() =>
90
+ globalPages.value.filter(p => {
91
+ const r = p.route || '';
92
+ return !Array.from(datasetIds.value).some(dsId => r.startsWith(dsId + '-'));
93
+ })
94
+ );
95
+
96
+ // Show only standard dataset pages (Concepts, Statistics, About)
97
+ const filteredDatasetPages = computed(() =>
98
+ datasetPages.value.filter(p => ['', 'stats', 'about'].includes(p.route || ''))
99
+ );
100
+
86
101
  const currentManifest = computed(() => store.manifests.get(currentDataset.value));
87
102
  const showDatasetNav = computed(() => !!currentManifest.value || !!siteConfig.value?.defaultDataset);
88
103
 
@@ -169,7 +184,7 @@ function navTitle(page: { route: string }): string {
169
184
  <!-- Navigation -->
170
185
  <div class="section-label">{{ t('nav.navigation') }}</div>
171
186
  <nav class="space-y-0.5 mb-6">
172
- <template v-for="page in globalPages" :key="page.route || 'home'">
187
+ <template v-for="page in filteredGlobalPages" :key="page.route || 'home'">
173
188
  <router-link
174
189
  :to="pageRoute(page)"
175
190
  class="btn-ghost w-full text-left flex items-center gap-2"
@@ -456,84 +471,67 @@ function navTitle(page: { route: string }): string {
456
471
  </template>
457
472
  </nav>
458
473
 
459
- <!-- Dataset-level navigation (shown when viewing a dataset) -->
460
- <div v-if="showDatasetNav" class="mb-6">
461
- <div class="section-label">{{ localizedDatasetField(currentDataset, 'title', currentManifest?.title || siteConfig?.title || 'Dataset') }}</div>
462
- <nav class="space-y-0.5">
463
- <router-link
464
- v-for="page in datasetPages"
465
- :key="page.route || 'concepts'"
466
- :to="pageRoute(page)"
467
- class="btn-ghost w-full text-left flex items-center gap-2"
468
- :class="isActive(page) ? 'active' : ''"
469
- @click="closeMobile"
470
- >
471
- <NavIcon :name="page.icon" />
472
- {{ navTitle(page) }}
473
- </router-link>
474
- </nav>
475
- </div>
476
-
477
474
  <!-- Datasets -->
478
475
  <div class="section-label">{{ t('nav.datasets') }}</div>
479
476
  <nav class="space-y-1">
480
- <button
477
+ <div
481
478
  v-for="ds in datasetEntries"
482
479
  :key="ds.id"
483
- @click="goToDataset(ds.id)"
484
- class="w-full text-left px-3 py-2.5 rounded-lg text-sm transition-all duration-150 border-l-2"
485
- :class="[
486
- currentDataset === ds.id
487
- ? 'bg-surface text-ink-800'
488
- : 'border-transparent text-ink-600 hover:bg-ink-50 hover:text-ink-800'
489
- ]"
490
- :style="currentDataset === ds.id ? { borderLeftColor: getColor(ds.id), borderLeftWidth: '2px' } : {}"
480
+ class="rounded-lg transition-all duration-150"
481
+ :class="currentDataset === ds.id ? 'bg-surface' : ''"
491
482
  >
492
- <div class="font-medium truncate leading-snug">{{ localizedDatasetField(ds.id, 'title', ds.title) }}</div>
493
- <div v-if="ds.loaded" class="text-xs mt-0.5" :class="currentDataset === ds.id ? 'text-ink-400' : 'text-ink-300'">
494
- {{ ds.conceptCount.toLocaleString() }} {{ t('home.concepts').toLowerCase() }}
495
- </div>
496
- </button>
497
- </nav>
498
-
499
- <!-- Dataset provenance -->
500
- <div v-if="provenance.owner" class="mt-6 pt-4 border-t border-ink-100/60">
501
- <div class="text-[11px] text-ink-300 space-y-1.5">
502
- <div class="font-medium text-ink-400">{{ t('sidebar.provenance') }}</div>
503
-
504
- <div v-if="provenance.ref" class="text-xs font-semibold text-ink-700">
505
- {{ provenance.ref }}
506
- </div>
507
-
508
- <div class="flex items-center gap-1">
509
- <span class="text-ink-400">{{ t('sidebar.publishedBy') }}</span>
510
- <a v-if="provenance.ownerUrl" :href="provenance.ownerUrl" target="_blank" rel="noopener" class="concept-link font-medium">{{ provenance.owner }}</a>
511
- <span v-else class="text-ink-600 font-medium">{{ provenance.owner }}</span>
512
- </div>
513
-
514
- <div v-if="provenance.status" class="flex items-center gap-1.5">
515
- <span class="inline-block w-1.5 h-1.5 rounded-full" :class="provenance.status === 'valid' ? 'bg-emerald-500' : 'bg-amber-400'"></span>
516
- <span class="text-[10px] uppercase tracking-wide font-medium" :class="provenance.status === 'valid' ? 'text-emerald-600' : 'text-amber-600'">
517
- {{ provenance.status }}
518
- </span>
519
- </div>
520
-
521
- <div v-if="provenance.lastUpdated" class="text-ink-300">
522
- {{ t('sidebar.updated') }} {{ provenance.lastUpdated }}
523
- </div>
524
-
525
- <div v-if="provenance.conceptCount" class="text-ink-400">
526
- {{ provenance.conceptCount.toLocaleString() }} {{ t('sidebar.concepts').toLowerCase() }}
527
- <template v-if="provenance.languageCount">
528
- · {{ provenance.languageCount }} {{ t('sidebar.languages').toLowerCase() }}
529
- </template>
530
- </div>
531
-
532
- <div v-if="provenance.sourceRepo">
533
- <a :href="provenance.sourceRepo" target="_blank" rel="noopener" class="concept-link">{{ t('sidebar.viewSource') }}</a>
483
+ <button
484
+ @click="goToDataset(ds.id)"
485
+ class="w-full text-left px-3 py-2.5 rounded-lg text-sm border-l-2"
486
+ :class="[
487
+ currentDataset === ds.id
488
+ ? 'text-ink-800'
489
+ : 'border-transparent text-ink-600 hover:bg-ink-50 hover:text-ink-800'
490
+ ]"
491
+ :style="currentDataset === ds.id ? { borderLeftColor: getColor(ds.id), borderLeftWidth: '2px' } : {}"
492
+ >
493
+ <div class="font-medium truncate leading-snug">{{ localizedDatasetField(ds.id, 'title', ds.title) }}</div>
494
+ <div v-if="ds.loaded" class="text-xs mt-0.5" :class="currentDataset === ds.id ? 'text-ink-400' : 'text-ink-300'">
495
+ {{ ds.conceptCount.toLocaleString() }} {{ t('home.concepts').toLowerCase() }}
496
+ </div>
497
+ </button>
498
+
499
+ <!-- Expanded dataset: sub-pages + provenance -->
500
+ <div v-if="currentDataset === ds.id && (filteredDatasetPages.length || provenance.owner)" class="px-2 pb-2">
501
+ <!-- Sub-pages -->
502
+ <nav v-if="filteredDatasetPages.length" class="space-y-0.5 mt-1">
503
+ <router-link
504
+ v-for="page in filteredDatasetPages"
505
+ :key="page.route || 'concepts'"
506
+ :to="pageRoute(page)"
507
+ class="btn-ghost w-full text-left flex items-center gap-2 text-sm"
508
+ :class="isActive(page) ? 'active' : ''"
509
+ @click="closeMobile"
510
+ >
511
+ <NavIcon :name="page.icon" />
512
+ {{ navTitle(page) }}
513
+ </router-link>
514
+ </nav>
515
+
516
+ <!-- Provenance -->
517
+ <div v-if="provenance.owner" class="mt-3 pt-3 border-t border-ink-100/60">
518
+ <div class="text-[11px] text-ink-300 space-y-1.5 px-1">
519
+ <div v-if="provenance.ref" class="text-xs font-semibold text-ink-700">
520
+ {{ provenance.ref }}
521
+ </div>
522
+ <div class="flex items-center gap-1">
523
+ <span class="text-ink-400">{{ t('sidebar.publishedBy') }}</span>
524
+ <a v-if="provenance.ownerUrl" :href="provenance.ownerUrl" target="_blank" rel="noopener" class="concept-link font-medium">{{ provenance.owner }}</a>
525
+ <span v-else class="text-ink-600 font-medium">{{ provenance.owner }}</span>
526
+ </div>
527
+ <div v-if="provenance.sourceRepo">
528
+ <a :href="provenance.sourceRepo" target="_blank" rel="noopener" class="concept-link">{{ t('sidebar.viewSource') }}</a>
529
+ </div>
530
+ </div>
531
+ </div>
534
532
  </div>
535
533
  </div>
536
- </div>
534
+ </nav>
537
535
  </div>
538
536
  </aside>
539
537
  </template>
@@ -1,10 +1,19 @@
1
1
  <script setup lang="ts">
2
2
  import type { Citation } from 'glossarist';
3
+ import { computed } from 'vue';
4
+ import { getFactory } from '../adapters/factory';
5
+ import { useRouter } from 'vue-router';
6
+ import { useVocabularyStore } from '../stores/vocabulary';
3
7
 
4
8
  const props = defineProps<{
5
9
  citation: Citation;
10
+ registerId?: string;
6
11
  }>();
7
12
 
13
+ const router = useRouter();
14
+ const store = useVocabularyStore();
15
+ const factory = getFactory();
16
+
8
17
  function formatRef(c: Citation): string {
9
18
  const ref = c.ref;
10
19
  if (!ref) return '';
@@ -14,22 +23,52 @@ function formatRef(c: Citation): string {
14
23
  if (ref.version) parts.push(`(${ref.version})`);
15
24
  return parts.join(' ');
16
25
  }
26
+
27
+ function resolveCitation(): { registerId: string; conceptId: string } | null {
28
+ const ref = props.citation.ref;
29
+ const locality = props.citation.locality;
30
+ if (!ref?.source || !locality?.referenceFrom) return null;
31
+
32
+ const resolution = factory.resolveCitation(ref.source, locality.referenceFrom, props.registerId);
33
+ if (!resolution || resolution.type !== 'internal') return null;
34
+
35
+ return { registerId: resolution.registerId, conceptId: resolution.conceptId };
36
+ }
37
+
38
+ const resolvedTarget = computed(() => resolveCitation());
39
+
40
+ async function navigateToCitation() {
41
+ if (!resolvedTarget.value) return;
42
+ const { registerId, conceptId } = resolvedTarget.value;
43
+ await store.viewConcept(registerId, conceptId);
44
+ router.push({ name: 'concept', params: { registerId, conceptId } });
45
+ }
17
46
  </script>
18
47
 
19
48
  <template>
20
49
  <span class="inline">
21
50
  <template v-if="citation.ref">
22
- <span v-if="citation.ref.source" class="font-medium">{{ citation.ref.source }}</span>
51
+ <button v-if="resolvedTarget" @click="navigateToCitation" class="concept-link font-medium">{{ citation.ref.source }}</button>
52
+ <span v-else-if="citation.ref.source" class="font-medium">{{ citation.ref.source }}</span>
23
53
  <span v-if="citation.ref.id"> {{ citation.ref.id }}</span>
24
54
  <span v-if="citation.ref.version" class="text-ink-400"> ({{ citation.ref.version }})</span>
25
55
  </template>
26
56
  <template v-if="citation.locality">
27
- <span v-if="citation.locality.type" class="text-ink-400">, {{ citation.locality.type }}</span>
28
- <span v-if="citation.locality.referenceFrom" class="text-ink-400">
29
- {{ citation.locality.referenceTo ? ` ${citation.locality.referenceFrom}–${citation.locality.referenceTo}` : ` ${citation.locality.referenceFrom}` }}
30
- </span>
57
+ <button v-if="resolvedTarget" @click="navigateToCitation" class="concept-link">
58
+ <span v-if="citation.locality.type" class="text-ink-400">, {{ citation.locality.type }}</span>
59
+ <span v-if="citation.locality.referenceFrom" class="text-ink-400">
60
+ {{ citation.locality.referenceTo ? ` ${citation.locality.referenceFrom}–${citation.locality.referenceTo}` : ` ${citation.locality.referenceFrom}` }}
61
+ </span>
62
+ </button>
63
+ <template v-else>
64
+ <span v-if="citation.locality.type" class="text-ink-400">, {{ citation.locality.type }}</span>
65
+ <span v-if="citation.locality.referenceFrom" class="text-ink-400">
66
+ {{ citation.locality.referenceTo ? ` ${citation.locality.referenceFrom}–${citation.locality.referenceTo}` : ` ${citation.locality.referenceFrom}` }}
67
+ </span>
68
+ </template>
31
69
  </template>
32
70
  <a v-if="citation.link" :href="citation.link" target="_blank" rel="noopener" class="concept-link ml-1">[link]</a>
33
71
  <span v-if="citation.original" class="text-xs text-ink-300 ml-1">(orig: {{ citation.original }})</span>
72
+ <span v-if="resolvedTarget" class="text-[9px] text-ink-300 ml-1">→ {{ resolvedTarget.registerId }}/{{ resolvedTarget.conceptId }}</span>
34
73
  </span>
35
74
  </template>
@@ -97,6 +97,34 @@ const conceptSources = computed(() => props.concept.sources);
97
97
  // Managed concept tags
98
98
  const conceptTags = computed(() => props.concept.tags ?? []);
99
99
 
100
+ // Managed concept related (concept-level cross-references)
101
+ const conceptRelated = computed(() => props.concept.relatedConcepts ?? []);
102
+
103
+ function resolveRelatedRef(ref: { source: string | null; id: string | null } | null): { registerId: string; conceptId: string } | null {
104
+ if (!ref?.source || !ref?.id) return null;
105
+ const uri = `${ref.source}/${ref.id}`;
106
+ const resolution = factory.resolve(uri, props.registerId);
107
+ if (resolution.type === 'internal') {
108
+ const conceptId = resolution.conceptId.replace(/^\//, '');
109
+ return { registerId: resolution.registerId, conceptId };
110
+ }
111
+ if (ref.source.startsWith('urn:')) {
112
+ const directUri = ref.source + ref.id;
113
+ const directRes = factory.resolve(directUri, props.registerId);
114
+ if (directRes.type === 'internal') {
115
+ return { registerId: directRes.registerId, conceptId: directRes.conceptId.replace(/^\//, '') };
116
+ }
117
+ }
118
+ return null;
119
+ }
120
+
121
+ async function navigateRelated(ref: { source: string | null; id: string | null }) {
122
+ const target = resolveRelatedRef(ref);
123
+ if (!target) return;
124
+ await store.viewConcept(target.registerId, target.conceptId);
125
+ router.push({ name: 'concept', params: { registerId: target.registerId, conceptId: target.conceptId } });
126
+ }
127
+
100
128
  // Cross-reference resolver: generates clickable links for inline refs
101
129
 
102
130
  const { ensureBibLoaded, bibResolver, figResolver } = useRenderOptions(() => props.registerId);
@@ -115,6 +143,9 @@ const renderOpts: RenderOptions = {
115
143
  }
116
144
  return escapeAttr(term);
117
145
  },
146
+ conceptRefResolver: (conceptId, term) => {
147
+ return `<a href="#" class="xref-link" data-register="${escapeAttr(props.registerId)}" data-concept="${escapeAttr(conceptId)}">${escapeAttr(term)}</a>`;
148
+ },
118
149
  bibResolver,
119
150
  figResolver,
120
151
  };
@@ -552,7 +583,7 @@ const nonVerbalReps = computed(() => {
552
583
  <div v-if="d.sources?.length" class="mt-1 space-y-0.5">
553
584
  <div v-for="(ds, dsi) in d.sources" :key="'ds'+dsi" class="text-xs text-ink-400 flex items-center gap-1.5">
554
585
  <span v-if="ds.type" class="badge text-[9px]" :class="sourceTypeInfo(ds.type).color">{{ sourceTypeInfo(ds.type).label }}</span>
555
- <CitationDisplay v-if="ds.origin" :citation="ds.origin" />
586
+ <CitationDisplay v-if="ds.origin" :citation="ds.origin" :register-id="registerId" />
556
587
  <span v-else-if="ds.modification" class="text-ink-300">{{ ds.modification }}</span>
557
588
  </div>
558
589
  </div>
@@ -560,7 +591,8 @@ const nonVerbalReps = computed(() => {
560
591
  <div v-if="d.related?.length" class="mt-0.5 space-y-0.5">
561
592
  <div v-for="(dr, dri) in d.related" :key="'dr'+dri" class="text-xs text-ink-400 flex items-center gap-1.5">
562
593
  <span class="badge text-[9px] bg-gray-50 text-gray-600">{{ relationshipLabel(dr.type) }}</span>
563
- <span>{{ dr.content || (dr.ref ? `${dr.ref.source || ''} ${dr.ref.id || ''}`.trim() : '') }}</span>
594
+ <button v-if="resolveRelatedRef(dr.ref)" @click="navigateRelated(dr.ref!)" class="concept-link">{{ dr.content || (dr.ref ? `${dr.ref.source || ''} ${dr.ref.id || ''}`.trim() : '') }}</button>
595
+ <span v-else>{{ dr.content || (dr.ref ? `${dr.ref.source || ''} ${dr.ref.id || ''}`.trim() : '') }}</span>
564
596
  </div>
565
597
  </div>
566
598
  </div>
@@ -682,6 +714,24 @@ const nonVerbalReps = computed(() => {
682
714
  </div>
683
715
  </div>
684
716
 
717
+ <!-- Cross-references (concept-level related) -->
718
+ <div v-if="conceptRelated.length" class="card p-5">
719
+ <div class="section-label">{{ t('concept.relations') }}</div>
720
+ <div class="mt-3 space-y-1">
721
+ <button
722
+ v-for="(cr, cri) in conceptRelated"
723
+ :key="'cr'+cri"
724
+ @click="navigateRelated(cr.ref!)"
725
+ class="text-sm concept-link block truncate w-full text-left flex items-center gap-1.5"
726
+ >
727
+ <span class="badge text-[9px] bg-gray-50 text-gray-600">{{ relationshipLabel(cr.type) }}</span>
728
+ <span v-if="resolveRelatedRef(cr.ref)" class="text-ink-600">{{ resolveRelatedRef(cr.ref)!.conceptId }}</span>
729
+ <span v-else class="text-ink-400">{{ cr.content || (cr.ref ? `${cr.ref.source || ''} ${cr.ref.id || ''}`.trim() : '') }}</span>
730
+ <span v-if="resolveRelatedRef(cr.ref) && resolveRelatedRef(cr.ref)!.registerId !== manifest.id" class="badge badge-gray text-[9px] flex-shrink-0">{{ resolveRelatedRef(cr.ref)!.registerId }}</span>
731
+ </button>
732
+ </div>
733
+ </div>
734
+
685
735
  <!-- Domains -->
686
736
  <div v-if="conceptDomains.length" class="card p-5">
687
737
  <div class="section-label">{{ t('concept.domains') }}</div>
@@ -735,7 +785,7 @@ const nonVerbalReps = computed(() => {
735
785
 
736
786
  <!-- Language quick-jump -->
737
787
  <div class="card p-5">
738
- <div class="section-label">{{ t('concept.languagesSidebar', { count: languages.length }) }}</div>
788
+ <div class="section-label">{{ t('concept.languagesSidebar', { count: String(languages.length) }) }}</div>
739
789
  <div class="space-y-1 mt-3 max-h-80 overflow-y-auto">
740
790
  <button
741
791
  v-for="lang in languages"
@@ -1,18 +1,15 @@
1
1
  <script setup lang="ts">
2
- import { useRouter, useRoute } from 'vue-router';
2
+ import { useRouter } from 'vue-router';
3
3
  import { useUiStore } from '../stores/ui';
4
4
  import { useVocabularyStore } from '../stores/vocabulary';
5
5
  import { ref, watch, onMounted, computed, nextTick } from 'vue';
6
6
  import type { SearchHit } from '../adapters/types';
7
- import { langLabel, langName } from '../utils/lang';
8
- import { useDsStyle } from '../utils/dataset-style';
9
7
  import { useI18n } from '../i18n';
8
+ import SearchResults from './SearchResults.vue';
10
9
 
11
10
  const router = useRouter();
12
- const route = useRoute();
13
11
  const ui = useUiStore();
14
12
  const store = useVocabularyStore();
15
- const { getStyle } = useDsStyle();
16
13
  const { t } = useI18n();
17
14
  const query = ref('');
18
15
  const results = ref<SearchHit[]>([]);
@@ -38,9 +35,7 @@ async function doSearch() {
38
35
  }
39
36
  searched.value = true;
40
37
  selectedIdx.value = -1;
41
- if (route.query.q !== q) {
42
- router.replace({ query: { q } });
43
- }
38
+ router.replace({ query: { q } });
44
39
  }
45
40
 
46
41
  function onInput() {
@@ -65,11 +60,9 @@ function clearSearch() {
65
60
  router.replace({ query: {} });
66
61
  }
67
62
 
68
- // Group results by dataset
69
63
  interface GroupedResults {
70
64
  registerId: string;
71
65
  title: string;
72
- style: { light: string; dark: string; color: string };
73
66
  hits: SearchHit[];
74
67
  }
75
68
 
@@ -84,23 +77,12 @@ const groupedResults = computed(() => {
84
77
  const groups: GroupedResults[] = [];
85
78
  for (const [registerId, hits] of map) {
86
79
  const m = store.manifests.get(registerId);
87
- groups.push({
88
- registerId,
89
- title: m?.title ?? registerId,
90
- style: getStyle(registerId),
91
- hits,
92
- });
80
+ groups.push({ registerId, title: m?.title ?? registerId, hits });
93
81
  }
94
82
  return groups;
95
83
  });
96
84
 
97
- // Flatten for keyboard navigation
98
85
  const flatHits = computed(() => groupedResults.value.flatMap(g => g.hits));
99
- const hitIndexMap = computed(() => {
100
- const map = new Map<SearchHit, number>();
101
- flatHits.value.forEach((hit, i) => map.set(hit, i));
102
- return map;
103
- });
104
86
 
105
87
  function goToHit(hit: SearchHit) {
106
88
  router.push({
@@ -132,7 +114,6 @@ function scrollToSelected() {
132
114
  });
133
115
  }
134
116
 
135
- // Sync with UI store search query
136
117
  watch(() => ui.searchQuery, (q) => {
137
118
  if (q && q !== query.value) {
138
119
  query.value = q;
@@ -183,65 +164,16 @@ onMounted(() => {
183
164
  </div>
184
165
  </form>
185
166
 
186
- <div v-if="loading" class="text-center py-16">
187
- <svg class="w-8 h-8 text-ink-300 animate-spin mx-auto mb-4" fill="none" viewBox="0 0 24 24">
188
- <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
189
- <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
190
- </svg>
191
- <p class="text-sm text-ink-400">{{ t('search.searching') }}</p>
192
- </div>
193
-
194
- <div v-else-if="searchError" class="text-center py-16">
195
- <div class="card p-8 border-red-200 bg-red-50/50 max-w-md mx-auto">
196
- <p class="text-red-700 font-medium mb-1">{{ t('search.failed') }}</p>
197
- <p class="text-sm text-red-600/80 mb-4">{{ searchError }}</p>
198
- <button @click="doSearch" class="btn-primary">{{ t('search.retry') }}</button>
199
- </div>
200
- </div>
201
-
202
- <div v-else-if="searched">
203
- <p class="text-sm text-ink-400 mb-4">{{ results.length === 1 ? t('search.oneResultFor', { query: ui.searchQuery }) : t('search.manyResultsFor', { count: String(results.length), query: ui.searchQuery }) }}</p>
204
- <div v-if="results.length === 0" class="text-center py-16">
205
- <div class="text-ink-200 text-5xl mb-4 font-serif">&empty;</div>
206
- <p class="text-ink-500 font-medium">{{ t('search.noResults') }}</p>
207
- <p class="text-sm text-ink-300 mt-1">{{ t('search.tryDifferent') }}</p>
208
- </div>
209
-
210
- <!-- Grouped results -->
211
- <div v-else class="space-y-6">
212
- <div v-for="group in groupedResults" :key="group.registerId">
213
- <!-- Dataset header -->
214
- <div class="flex items-center gap-2 mb-2">
215
- <span class="w-2 h-2 rounded-full flex-shrink-0" :style="{ backgroundColor: group.style.color }"></span>
216
- <span class="text-xs font-semibold text-ink-500 uppercase tracking-wide">{{ group.title }}</span>
217
- <span class="text-xs text-ink-300">{{ group.hits.length }} {{ group.hits.length === 1 ? t('search.result') : t('search.results') }}</span>
218
- </div>
219
- <!-- Hits -->
220
- <div class="space-y-1.5">
221
- <button
222
- v-for="hit in group.hits"
223
- :key="hit.conceptId + hit.language"
224
- @click="goToHit(hit)"
225
- :class="selectedIdx === hitIndexMap.get(hit) ? 'bg-ink-50 border-ink-200 search-hit-selected' : ''"
226
- class="card-hover p-3 w-full text-left flex items-center justify-between group"
227
- >
228
- <div class="min-w-0">
229
- <span class="font-medium text-ink-800 group-hover:text-ink-900 transition-colors">{{ hit.designation }}</span>
230
- <span class="text-xs text-ink-300 ml-2 font-mono">{{ hit.conceptId }}</span>
231
- <span v-if="hit.snippet" class="block text-xs text-ink-300 mt-0.5 truncate">{{ hit.snippet }}</span>
232
- </div>
233
- <div class="flex items-center gap-2 flex-shrink-0">
234
- <span v-if="hit.matchField === 'id'" class="badge badge-gray text-[10px]">{{ t('search.idMatch') }}</span>
235
- <span class="text-xs font-semibold text-ink-500 bg-ink-50 px-1.5 py-0.5 rounded">{{ langName(hit.language) }}</span>
236
- </div>
237
- </button>
238
- </div>
239
- </div>
240
- </div>
241
-
242
- <div v-if="results.length > 100" class="text-center text-sm text-ink-300 mt-6 pt-4 border-t border-ink-100/60">
243
- {{ t('search.showingFirst', { max: '100', total: String(results.length) }) }}
244
- </div>
245
- </div>
167
+ <SearchResults
168
+ :loading="loading"
169
+ :search-error="searchError"
170
+ :searched="searched"
171
+ :search-query="ui.searchQuery"
172
+ :results="results"
173
+ :grouped-results="groupedResults"
174
+ :selected-idx="selectedIdx"
175
+ @retry="doSearch"
176
+ @go-hit="goToHit"
177
+ />
246
178
  </div>
247
179
  </template>
@@ -0,0 +1,98 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue';
3
+ import type { SearchHit } from '../adapters/types';
4
+ import { langName } from '../utils/lang';
5
+ import { useDsStyle } from '../utils/dataset-style';
6
+ import { useI18n } from '../i18n';
7
+
8
+ interface GroupedResults {
9
+ registerId: string;
10
+ title: string;
11
+ hits: SearchHit[];
12
+ }
13
+
14
+ const props = defineProps<{
15
+ loading: boolean;
16
+ searchError: string | null;
17
+ searched: boolean;
18
+ searchQuery: string;
19
+ results: SearchHit[];
20
+ groupedResults: GroupedResults[];
21
+ selectedIdx: number;
22
+ }>();
23
+
24
+ const emit = defineEmits<{
25
+ retry: [];
26
+ goHit: [hit: SearchHit];
27
+ }>();
28
+
29
+ const { getStyle } = useDsStyle();
30
+ const { t } = useI18n();
31
+
32
+ const flatHits = computed(() => props.groupedResults.flatMap(g => g.hits));
33
+ const hitIndexMap = computed(() => {
34
+ const map = new Map<SearchHit, number>();
35
+ flatHits.value.forEach((hit, i) => map.set(hit, i));
36
+ return map;
37
+ });
38
+ </script>
39
+
40
+ <template>
41
+ <div v-if="loading" class="text-center py-16">
42
+ <svg class="w-8 h-8 text-ink-300 animate-spin mx-auto mb-4" fill="none" viewBox="0 0 24 24">
43
+ <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
44
+ <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
45
+ </svg>
46
+ <p class="text-sm text-ink-400">{{ t('search.searching') }}</p>
47
+ </div>
48
+
49
+ <div v-else-if="searchError" class="text-center py-16">
50
+ <div class="card p-8 border-red-200 bg-red-50/50 max-w-md mx-auto">
51
+ <p class="text-red-700 font-medium mb-1">{{ t('search.failed') }}</p>
52
+ <p class="text-sm text-red-600/80 mb-4">{{ searchError }}</p>
53
+ <button @click="emit('retry')" class="btn-primary">{{ t('search.retry') }}</button>
54
+ </div>
55
+ </div>
56
+
57
+ <div v-else-if="searched" class="search-results">
58
+ <p class="text-sm text-ink-400 mb-4">{{ results.length === 1 ? t('search.oneResultFor', { query: searchQuery }) : t('search.manyResultsFor', { count: String(results.length), query: searchQuery }) }}</p>
59
+ <div v-if="results.length === 0" class="text-center py-16">
60
+ <div class="text-ink-200 text-5xl mb-4 font-serif">&empty;</div>
61
+ <p class="text-ink-500 font-medium">{{ t('search.noResults') }}</p>
62
+ <p class="text-sm text-ink-300 mt-1">{{ t('search.tryDifferent') }}</p>
63
+ </div>
64
+
65
+ <div v-else class="space-y-6">
66
+ <div v-for="group in groupedResults" :key="group.registerId">
67
+ <div class="flex items-center gap-2 mb-2">
68
+ <span class="w-2 h-2 rounded-full flex-shrink-0" :style="{ backgroundColor: getStyle(group.registerId).color }"></span>
69
+ <span class="text-xs font-semibold text-ink-500 uppercase tracking-wide">{{ group.title }}</span>
70
+ <span class="text-xs text-ink-300">{{ group.hits.length }} {{ group.hits.length === 1 ? t('search.result') : t('search.results') }}</span>
71
+ </div>
72
+ <div class="space-y-1.5">
73
+ <button
74
+ v-for="hit in group.hits"
75
+ :key="hit.conceptId + hit.language"
76
+ @click="emit('goHit', hit)"
77
+ :class="selectedIdx === hitIndexMap.get(hit) ? 'bg-ink-50 border-ink-200 search-hit-selected' : ''"
78
+ class="card-hover p-3 w-full text-left flex items-center justify-between group"
79
+ >
80
+ <div class="min-w-0">
81
+ <span class="font-medium text-ink-800 group-hover:text-ink-900 transition-colors">{{ hit.designation }}</span>
82
+ <span class="text-xs text-ink-300 ml-2 font-mono">{{ hit.conceptId }}</span>
83
+ <span v-if="hit.snippet" class="block text-xs text-ink-300 mt-0.5 truncate">{{ hit.snippet }}</span>
84
+ </div>
85
+ <div class="flex items-center gap-2 flex-shrink-0">
86
+ <span v-if="hit.matchField === 'id'" class="badge badge-gray text-[10px]">{{ t('search.idMatch') }}</span>
87
+ <span class="text-xs font-semibold text-ink-500 bg-ink-50 px-1.5 py-0.5 rounded">{{ langName(hit.language) }}</span>
88
+ </div>
89
+ </button>
90
+ </div>
91
+ </div>
92
+ </div>
93
+
94
+ <div v-if="results.length > 100" class="text-center text-sm text-ink-300 mt-6 pt-4 border-t border-ink-100/60">
95
+ {{ t('search.showingFirst', { max: '100', total: String(results.length) }) }}
96
+ </div>
97
+ </div>
98
+ </template>
@@ -0,0 +1,47 @@
1
+ <script setup lang="ts">
2
+ import { useI18n } from '../i18n';
3
+
4
+ const { t } = useI18n();
5
+
6
+ defineEmits<{ close: [] }>();
7
+ </script>
8
+
9
+ <template>
10
+ <Teleport to="body">
11
+ <div class="fixed inset-0 z-50 flex items-center justify-center bg-black/30" @click.self="$emit('close')">
12
+ <div class="bg-white rounded-xl shadow-2xl p-6 max-w-sm w-full mx-4 border border-ink-100">
13
+ <div class="flex items-center justify-between mb-4">
14
+ <h3 class="text-lg font-semibold text-ink-800">{{ t('shortcuts.title') }}</h3>
15
+ <button @click="$emit('close')" class="p-1 rounded hover:bg-ink-50 text-ink-400 hover:text-ink-600">
16
+ <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
17
+ </button>
18
+ </div>
19
+ <div class="space-y-3 text-sm">
20
+ <div class="flex items-center justify-between">
21
+ <span class="text-ink-600">{{ t('shortcuts.previous') }}</span>
22
+ <kbd class="px-2 py-1 bg-ink-50 border border-ink-200 rounded text-xs font-mono">J</kbd>
23
+ </div>
24
+ <div class="flex items-center justify-between">
25
+ <span class="text-ink-600">{{ t('shortcuts.next') }}</span>
26
+ <kbd class="px-2 py-1 bg-ink-50 border border-ink-200 rounded text-xs font-mono">K</kbd>
27
+ </div>
28
+ <div class="flex items-center justify-between">
29
+ <span class="text-ink-600">{{ t('shortcuts.search') }}</span>
30
+ <kbd class="px-2 py-1 bg-ink-50 border border-ink-200 rounded text-xs font-mono">/</kbd>
31
+ </div>
32
+ <div class="flex items-center justify-between">
33
+ <span class="text-ink-600">{{ t('shortcuts.showShortcuts') }}</span>
34
+ <kbd class="px-2 py-1 bg-ink-50 border border-ink-200 rounded text-xs font-mono">?</kbd>
35
+ </div>
36
+ <div class="flex items-center justify-between">
37
+ <span class="text-ink-600">{{ t('shortcuts.closeDialog') }}</span>
38
+ <kbd class="px-2 py-1 bg-ink-50 border border-ink-200 rounded text-xs font-mono">Esc</kbd>
39
+ </div>
40
+ </div>
41
+ <div class="mt-4 pt-3 border-t border-ink-100 text-xs text-ink-300 text-center">
42
+ {{ t('shortcuts.hint') }}
43
+ </div>
44
+ </div>
45
+ </div>
46
+ </Teleport>
47
+ </template>
@@ -96,6 +96,7 @@ export interface DatasetConfig {
96
96
  tags?: string[];
97
97
  languageOrder?: string[];
98
98
  ref?: string;
99
+ refAliases?: string[];
99
100
  downloads?: string[];
100
101
  translations?: Record<string, { title?: string; description?: string }>;
101
102
  }
@@ -165,3 +165,12 @@ sidebar.updated: Updated
165
165
  sidebar.concepts: concepts
166
166
  sidebar.languages: languages
167
167
  sidebar.viewSource: View source
168
+
169
+ # Keyboard shortcuts
170
+ shortcuts.title: Keyboard shortcuts
171
+ shortcuts.previous: Previous concept
172
+ shortcuts.next: Next concept
173
+ shortcuts.search: Search
174
+ shortcuts.showShortcuts: Show shortcuts
175
+ shortcuts.closeDialog: Close dialog
176
+ shortcuts.hint: Shortcuts only work when no input field is focused
@@ -165,3 +165,12 @@ sidebar.updated: Mis à jour
165
165
  sidebar.concepts: concepts
166
166
  sidebar.languages: langues
167
167
  sidebar.viewSource: Voir la source
168
+
169
+ # Keyboard shortcuts
170
+ shortcuts.title: Raccourcis clavier
171
+ shortcuts.previous: Concept précédent
172
+ shortcuts.next: Concept suivant
173
+ shortcuts.search: Recherche
174
+ shortcuts.showShortcuts: Afficher les raccourcis
175
+ shortcuts.closeDialog: Fermer
176
+ shortcuts.hint: Les raccourcis ne fonctionnent que si aucun champ de saisie n'a le focus
package/src/utils/math.ts CHANGED
@@ -4,10 +4,13 @@ export type XrefResolver = (uri: string, term: string) => string;
4
4
  export type BibResolver = (refId: string, title: string) => string;
5
5
  export type FigResolver = (figId: string) => string;
6
6
 
7
+ export type ConceptRefResolver = (conceptId: string, term: string) => string;
8
+
7
9
  export interface RenderOptions {
8
10
  xrefResolver?: XrefResolver;
9
11
  bibResolver?: BibResolver;
10
12
  figResolver?: FigResolver;
13
+ conceptRefResolver?: ConceptRefResolver;
11
14
  }
12
15
 
13
16
  function replaceBracketed(text: string, prefix: string, handler: (content: string, bold: boolean) => string): string {
@@ -53,21 +56,36 @@ function mathPlaceholder(expr: string, format: string, bold: boolean): string {
53
56
  return `<span class="math-pending${bold ? ' math-bold' : ''}" data-expr="${escapeAttr(expr)}" data-format="${format}">${escapeAttr(expr)}</span>`;
54
57
  }
55
58
 
56
- function convertLists(text: string): string {
57
- // AsciiDoc pipe-delimited tables: |=== ... |===
58
- let result = text.replace(/\|===\n([\s\S]*?)\n\|===/g, (_, body) => {
59
- const rows = body.split('\n').filter(line => line.trim().length > 0);
60
- const htmlRows = rows.map(row => {
61
- // Each row starts with "| " — strip the leading pipe then split on " | "
62
- const cells = row.replace(/^\| */, '').split(/ \| /);
63
- const htmlCells = cells.map(c => `<td>${c.trim()}</td>`).join('');
64
- return `<tr>${htmlCells}</tr>`;
59
+ function convertAsciiDocTables(text: string): string {
60
+ return text.replace(/\n?\|===\n([\s\S]*?)\n\|===/g, (_: string, body: string) => {
61
+ const rows: string[] = body.split('\n').filter((line: string) => line.trim() !== '');
62
+ if (!rows.length) return '';
63
+
64
+ const parsedRows: string[][] = rows.map((row: string) => {
65
+ const cellText = row.replace(/^\s*\|/, '').trim();
66
+ const cells = cellText.split(/\s*\|\s*/).map((c: string) => c.trim()).filter((c: string) => c !== '');
67
+ return cells;
68
+ }).filter((r: string[]) => r.length > 0);
69
+
70
+ if (!parsedRows.length) return '';
71
+
72
+ const maxCols = Math.max(...parsedRows.map((r: string[]) => r.length));
73
+ const normalized = parsedRows.map((r: string[]) => {
74
+ while (r.length < maxCols) r.push('');
75
+ return r;
65
76
  });
66
- return `<table class="concept-table">${htmlRows.join('')}</table>`;
77
+
78
+ const thead = normalized[0].map((c: string) => `<th>${escapeHtml(c)}</th>`).join('');
79
+ const tbody = normalized.slice(1).map((r: string[]) =>
80
+ `<tr>${r.map((c: string) => `<td>${escapeHtml(c)}</td>`).join('')}</tr>`
81
+ ).join('');
82
+
83
+ return `\n<table class="concept-table"><thead><tr>${thead}</tr></thead><tbody>${tbody}</tbody></table>`;
67
84
  });
85
+ }
68
86
 
69
- // Bullet lists: * item
70
- result = result.replace(/(?:^|\n)((?:[ \t]*\* [^\n]+)(?:\n[ \t]*\* [^\n]+)*)/g, (_, block) => {
87
+ function convertLists(text: string): string {
88
+ let result = text.replace(/(?:^|\n)((?:[ \t]*\* [^\n]+)(?:\n[ \t]*\* [^\n]+)*)/g, (_, block) => {
71
89
  if (/^\*stem:\[/.test(block.trimStart())) return _;
72
90
  const items: string[] = [];
73
91
  const re = /[ \t]*\* ([^\n]+)/g;
@@ -108,6 +126,7 @@ export function renderMath(text: string, xrefResolverOrOpts?: XrefResolver | Ren
108
126
  result = replaceBracketed(result, 'stem:', (expr, bold) => mathPlaceholder(expr, 'asciimath', bold));
109
127
  result = replaceBracketed(result, 'latexmath:', (expr, bold) => mathPlaceholder(expr, 'latex', bold));
110
128
 
129
+ result = convertAsciiDocTables(result);
111
130
  result = convertLists(result);
112
131
  result = result.replace(/\*([^*]+)\*/g, '<em>$1</em>');
113
132
  result = result.replace(/~([^~]+)~/g, '<sub>$1</sub>');
@@ -142,7 +161,12 @@ export function renderMath(text: string, xrefResolverOrOpts?: XrefResolver | Ren
142
161
  return t;
143
162
  });
144
163
 
145
- result = result.replace(/\{\{([^,}]+)(?:,\s*[^}]+)?\}\}/g, '$1');
164
+ result = result.replace(/\{\{([^,}]+),\s*([^}]+)\}\}/g, (_, term, id) => {
165
+ if (opts.conceptRefResolver) {
166
+ return opts.conceptRefResolver(id.trim(), term.trim());
167
+ }
168
+ return term.trim();
169
+ });
146
170
 
147
171
  return result;
148
172
  }
@@ -3,6 +3,7 @@ import { computed, watch, ref, onMounted, onUnmounted } from 'vue';
3
3
  import { useRouter } from 'vue-router';
4
4
  import { useVocabularyStore } from '../stores/vocabulary';
5
5
  import ConceptDetail from '../components/ConceptDetail.vue';
6
+ import ShortcutsModal from '../components/ShortcutsModal.vue';
6
7
  import { useI18n } from '../i18n';
7
8
 
8
9
  const { t } = useI18n();
@@ -16,6 +17,7 @@ const store = useVocabularyStore();
16
17
  const router = useRouter();
17
18
  const conceptLoading = ref(false);
18
19
  const localError = ref<string | null>(null);
20
+ const showShortcuts = ref(false);
19
21
 
20
22
  async function loadConcept(regId: string, cId: string) {
21
23
  conceptLoading.value = true;
@@ -68,10 +70,20 @@ function goAdjacent(id: string) {
68
70
 
69
71
  function onKeydown(e: KeyboardEvent) {
70
72
  if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
71
- if (e.key === 'ArrowLeft' && adjacent.value.prev) {
73
+
74
+ if (e.key === '?') {
75
+ e.preventDefault();
76
+ showShortcuts.value = !showShortcuts.value;
77
+ return;
78
+ }
79
+ if (e.key === 'Escape' && showShortcuts.value) {
80
+ showShortcuts.value = false;
81
+ return;
82
+ }
83
+ if (e.key === 'j' && adjacent.value.prev) {
72
84
  e.preventDefault();
73
85
  goAdjacent(adjacent.value.prev);
74
- } else if (e.key === 'ArrowRight' && adjacent.value.next) {
86
+ } else if (e.key === 'k' && adjacent.value.next) {
75
87
  e.preventDefault();
76
88
  goAdjacent(adjacent.value.next);
77
89
  }
@@ -139,5 +151,7 @@ onUnmounted(() => window.removeEventListener('keydown', onKeydown));
139
151
  :adjacent="adjacent"
140
152
  :register-id="registerId"
141
153
  />
154
+
155
+ <ShortcutsModal v-if="showShortcuts" @close="showShortcuts = false" />
142
156
  </div>
143
157
  </template>
@@ -71,10 +71,10 @@ function onGlobalKeydown(e: KeyboardEvent) {
71
71
  e.preventDefault();
72
72
  filterInput.value?.focus();
73
73
  }
74
- if (e.key === 'ArrowRight' && document.activeElement?.tagName !== 'INPUT' && page.value < totalPages.value) {
75
- goToPage(page.value + 1);
76
- } else if (e.key === 'ArrowLeft' && document.activeElement?.tagName !== 'INPUT' && page.value > 1) {
74
+ if (e.key === 'j' && document.activeElement?.tagName !== 'INPUT' && document.activeElement?.tagName !== 'TEXTAREA' && page.value > 1) {
77
75
  goToPage(page.value - 1);
76
+ } else if (e.key === 'k' && document.activeElement?.tagName !== 'INPUT' && document.activeElement?.tagName !== 'TEXTAREA' && page.value < totalPages.value) {
77
+ goToPage(page.value + 1);
78
78
  }
79
79
  }
80
80