@glossarist/concept-browser 0.7.8 → 0.7.10
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 +24 -0
- package/src/__tests__/math.test.ts +9 -0
- package/src/components/AppSidebar.vue +69 -71
- package/src/components/SearchBar.vue +15 -83
- package/src/components/SearchResults.vue +98 -0
- package/src/style.css +13 -0
- package/src/utils/math.ts +15 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@glossarist/concept-browser",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.10",
|
|
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": {
|
|
@@ -1112,6 +1112,30 @@ function processPages(config) {
|
|
|
1112
1112
|
|
|
1113
1113
|
const processedPages = processPages(config);
|
|
1114
1114
|
|
|
1115
|
+
// Auto-generate dataset about pages from {localPath}/about.md
|
|
1116
|
+
const _pagesDir = path.join(PUBLIC, 'pages');
|
|
1117
|
+
for (const ds of config.datasets || []) {
|
|
1118
|
+
if (!ds.localPath) continue;
|
|
1119
|
+
const aboutSrc = path.resolve(ROOT, ds.localPath, 'about.md');
|
|
1120
|
+
if (!fs.existsSync(aboutSrc)) continue;
|
|
1121
|
+
const raw = fs.readFileSync(aboutSrc, 'utf8');
|
|
1122
|
+
const html = renderMarkdown(stripFrontmatter(raw));
|
|
1123
|
+
const route = `${ds.id}-about`;
|
|
1124
|
+
writeJson(path.join(_pagesDir, `${route}.json`), { title: 'About', html });
|
|
1125
|
+
console.log(` Auto-generated dataset about page: ${route}`);
|
|
1126
|
+
const uiLangs = (config.uiLanguages || []).map(l => l.code).filter(l => l !== 'eng');
|
|
1127
|
+
const dsTranslations = ds.translations || {};
|
|
1128
|
+
for (const lang of uiLangs) {
|
|
1129
|
+
const trAboutSrc = path.resolve(ROOT, ds.localPath, `about-${lang}.md`);
|
|
1130
|
+
if (!fs.existsSync(trAboutSrc)) continue;
|
|
1131
|
+
const trRaw = fs.readFileSync(trAboutSrc, 'utf8');
|
|
1132
|
+
const trHtml = renderMarkdown(stripFrontmatter(trRaw));
|
|
1133
|
+
const trTitle = dsTranslations[lang]?.title ? `About ${dsTranslations[lang].title}` : 'About';
|
|
1134
|
+
writeJson(path.join(_pagesDir, `${route}.${lang}.json`), { title: trTitle, html: trHtml });
|
|
1135
|
+
console.log(` Auto-generated dataset about page: ${route}.${lang}`);
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1115
1139
|
// Generate site-config.json from site config
|
|
1116
1140
|
const siteBranding = { ...config.branding };
|
|
1117
1141
|
// Rewrite logo paths to destination filenames and strip build-time fields
|
|
@@ -33,6 +33,15 @@ describe('renderMath', () => {
|
|
|
33
33
|
expect(result).toContain('<li>second item</li>');
|
|
34
34
|
});
|
|
35
35
|
|
|
36
|
+
it('converts AsciiDoc pipe-delimited tables to <table>', () => {
|
|
37
|
+
const input = 'Intro text\n\n|===\n| a | b | c\n| d | e | f\n|===';
|
|
38
|
+
const result = renderMath(input);
|
|
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>');
|
|
42
|
+
expect(result).not.toContain('|===');
|
|
43
|
+
});
|
|
44
|
+
|
|
36
45
|
it('resolves URN inline refs via xrefResolver', () => {
|
|
37
46
|
const resolver = (uri: string, term: string) => `[${term}→${uri}]`;
|
|
38
47
|
const result = renderMath(
|
|
@@ -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,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>
|
package/src/style.css
CHANGED
|
@@ -146,6 +146,19 @@
|
|
|
146
146
|
margin: 0.25rem 0;
|
|
147
147
|
}
|
|
148
148
|
|
|
149
|
+
.concept-table {
|
|
150
|
+
border-collapse: collapse;
|
|
151
|
+
margin: 0.75rem 0;
|
|
152
|
+
font-size: 0.875rem;
|
|
153
|
+
width: 100%;
|
|
154
|
+
}
|
|
155
|
+
.concept-table td {
|
|
156
|
+
border: 1px solid var(--ink-200, #d1d5db);
|
|
157
|
+
padding: 0.375rem 0.625rem;
|
|
158
|
+
vertical-align: top;
|
|
159
|
+
text-align: left;
|
|
160
|
+
}
|
|
161
|
+
|
|
149
162
|
/* Smooth collapse for language sections */
|
|
150
163
|
.lang-content {
|
|
151
164
|
overflow: hidden;
|
package/src/utils/math.ts
CHANGED
|
@@ -54,7 +54,20 @@ function mathPlaceholder(expr: string, format: string, bold: boolean): string {
|
|
|
54
54
|
}
|
|
55
55
|
|
|
56
56
|
function convertLists(text: string): string {
|
|
57
|
-
|
|
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>`;
|
|
65
|
+
});
|
|
66
|
+
return `<table class="concept-table">${htmlRows.join('')}</table>`;
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// Bullet lists: * item
|
|
70
|
+
result = result.replace(/(?:^|\n)((?:[ \t]*\* [^\n]+)(?:\n[ \t]*\* [^\n]+)*)/g, (_, block) => {
|
|
58
71
|
if (/^\*stem:\[/.test(block.trimStart())) return _;
|
|
59
72
|
const items: string[] = [];
|
|
60
73
|
const re = /[ \t]*\* ([^\n]+)/g;
|
|
@@ -67,6 +80,7 @@ function convertLists(text: string): string {
|
|
|
67
80
|
return `\n<ul class="concept-list">${lis}</ul>`;
|
|
68
81
|
});
|
|
69
82
|
|
|
83
|
+
// Numbered lists: 1) item or 1. item
|
|
70
84
|
result = result.replace(/(?:^|\n)((?:[ \t]*\d+[).][ \t]+[^\n]+)(?:\n[ \t]*\d+[).][ \t]+[^\n]+)*)/g, (_, block) => {
|
|
71
85
|
const items: string[] = [];
|
|
72
86
|
const re = /[ \t]*\d+[).][ \t]+([^\n]+)/g;
|