@glossarist/concept-browser 0.7.10 → 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.10",
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,
@@ -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 }[];
@@ -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"
@@ -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