@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 +1 -1
- package/scripts/generate-data.mjs +26 -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/AppSidebar.vue +69 -71
- package/src/components/CitationDisplay.vue +44 -5
- package/src/components/ConceptDetail.vue +53 -3
- package/src/components/SearchBar.vue +15 -83
- package/src/components/SearchResults.vue +98 -0
- 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,
|
|
@@ -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><
|
|
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 }[];
|
|
@@ -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
|
|
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
|
-
<
|
|
477
|
+
<div
|
|
481
478
|
v-for="ds in datasetEntries"
|
|
482
479
|
:key="ds.id"
|
|
483
|
-
|
|
484
|
-
class="
|
|
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
|
-
<
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
</
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
<
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
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
|
-
</
|
|
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
|
-
<
|
|
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"
|
|
@@ -1,18 +1,15 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import { useRouter
|
|
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
|
-
|
|
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
|
-
<
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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">∅</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">∅</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>
|
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
|
|