@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 +1 -1
- package/scripts/generate-data.mjs +2 -0
- package/src/__tests__/math.test.ts +2 -2
- package/src/adapters/ReferenceResolver.ts +28 -0
- package/src/adapters/factory.ts +11 -0
- package/src/adapters/types.ts +1 -0
- package/src/components/CitationDisplay.vue +44 -5
- package/src/components/ConceptDetail.vue +53 -3
- package/src/components/ShortcutsModal.vue +47 -0
- package/src/config/types.ts +1 -0
- package/src/i18n/locales/eng.yml +9 -0
- package/src/i18n/locales/fra.yml +9 -0
- package/src/utils/math.ts +37 -13
- package/src/views/ConceptView.vue +16 -2
- package/src/views/DatasetView.vue +3 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@glossarist/concept-browser",
|
|
3
|
-
"version": "0.7.
|
|
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><
|
|
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) {
|
package/src/adapters/factory.ts
CHANGED
|
@@ -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;
|
package/src/adapters/types.ts
CHANGED
|
@@ -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
|
-
<
|
|
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
|
-
<
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
<
|
|
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>
|
package/src/config/types.ts
CHANGED
package/src/i18n/locales/eng.yml
CHANGED
|
@@ -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
|
package/src/i18n/locales/fra.yml
CHANGED
|
@@ -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
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
const
|
|
63
|
-
const
|
|
64
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
70
|
-
result =
|
|
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(/\{\{([^,}]+)
|
|
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
|
-
|
|
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 === '
|
|
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 === '
|
|
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
|
|