@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glossarist/concept-browser",
3
- "version": "0.7.8",
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 globalPages" :key="page.route || 'home'">
187
+ <template v-for="page in filteredGlobalPages" :key="page.route || 'home'">
173
188
  <router-link
174
189
  :to="pageRoute(page)"
175
190
  class="btn-ghost w-full text-left flex items-center gap-2"
@@ -456,84 +471,67 @@ function navTitle(page: { route: string }): string {
456
471
  </template>
457
472
  </nav>
458
473
 
459
- <!-- Dataset-level navigation (shown when viewing a dataset) -->
460
- <div v-if="showDatasetNav" class="mb-6">
461
- <div class="section-label">{{ localizedDatasetField(currentDataset, 'title', currentManifest?.title || siteConfig?.title || 'Dataset') }}</div>
462
- <nav class="space-y-0.5">
463
- <router-link
464
- v-for="page in datasetPages"
465
- :key="page.route || 'concepts'"
466
- :to="pageRoute(page)"
467
- class="btn-ghost w-full text-left flex items-center gap-2"
468
- :class="isActive(page) ? 'active' : ''"
469
- @click="closeMobile"
470
- >
471
- <NavIcon :name="page.icon" />
472
- {{ navTitle(page) }}
473
- </router-link>
474
- </nav>
475
- </div>
476
-
477
474
  <!-- Datasets -->
478
475
  <div class="section-label">{{ t('nav.datasets') }}</div>
479
476
  <nav class="space-y-1">
480
- <button
477
+ <div
481
478
  v-for="ds in datasetEntries"
482
479
  :key="ds.id"
483
- @click="goToDataset(ds.id)"
484
- class="w-full text-left px-3 py-2.5 rounded-lg text-sm transition-all duration-150 border-l-2"
485
- :class="[
486
- currentDataset === ds.id
487
- ? 'bg-surface text-ink-800'
488
- : 'border-transparent text-ink-600 hover:bg-ink-50 hover:text-ink-800'
489
- ]"
490
- :style="currentDataset === ds.id ? { borderLeftColor: getColor(ds.id), borderLeftWidth: '2px' } : {}"
480
+ class="rounded-lg transition-all duration-150"
481
+ :class="currentDataset === ds.id ? 'bg-surface' : ''"
491
482
  >
492
- <div class="font-medium truncate leading-snug">{{ localizedDatasetField(ds.id, 'title', ds.title) }}</div>
493
- <div v-if="ds.loaded" class="text-xs mt-0.5" :class="currentDataset === ds.id ? 'text-ink-400' : 'text-ink-300'">
494
- {{ ds.conceptCount.toLocaleString() }} {{ t('home.concepts').toLowerCase() }}
495
- </div>
496
- </button>
497
- </nav>
498
-
499
- <!-- Dataset provenance -->
500
- <div v-if="provenance.owner" class="mt-6 pt-4 border-t border-ink-100/60">
501
- <div class="text-[11px] text-ink-300 space-y-1.5">
502
- <div class="font-medium text-ink-400">{{ t('sidebar.provenance') }}</div>
503
-
504
- <div v-if="provenance.ref" class="text-xs font-semibold text-ink-700">
505
- {{ provenance.ref }}
506
- </div>
507
-
508
- <div class="flex items-center gap-1">
509
- <span class="text-ink-400">{{ t('sidebar.publishedBy') }}</span>
510
- <a v-if="provenance.ownerUrl" :href="provenance.ownerUrl" target="_blank" rel="noopener" class="concept-link font-medium">{{ provenance.owner }}</a>
511
- <span v-else class="text-ink-600 font-medium">{{ provenance.owner }}</span>
512
- </div>
513
-
514
- <div v-if="provenance.status" class="flex items-center gap-1.5">
515
- <span class="inline-block w-1.5 h-1.5 rounded-full" :class="provenance.status === 'valid' ? 'bg-emerald-500' : 'bg-amber-400'"></span>
516
- <span class="text-[10px] uppercase tracking-wide font-medium" :class="provenance.status === 'valid' ? 'text-emerald-600' : 'text-amber-600'">
517
- {{ provenance.status }}
518
- </span>
519
- </div>
520
-
521
- <div v-if="provenance.lastUpdated" class="text-ink-300">
522
- {{ t('sidebar.updated') }} {{ provenance.lastUpdated }}
523
- </div>
524
-
525
- <div v-if="provenance.conceptCount" class="text-ink-400">
526
- {{ provenance.conceptCount.toLocaleString() }} {{ t('sidebar.concepts').toLowerCase() }}
527
- <template v-if="provenance.languageCount">
528
- · {{ provenance.languageCount }} {{ t('sidebar.languages').toLowerCase() }}
529
- </template>
530
- </div>
531
-
532
- <div v-if="provenance.sourceRepo">
533
- <a :href="provenance.sourceRepo" target="_blank" rel="noopener" class="concept-link">{{ t('sidebar.viewSource') }}</a>
483
+ <button
484
+ @click="goToDataset(ds.id)"
485
+ class="w-full text-left px-3 py-2.5 rounded-lg text-sm border-l-2"
486
+ :class="[
487
+ currentDataset === ds.id
488
+ ? 'text-ink-800'
489
+ : 'border-transparent text-ink-600 hover:bg-ink-50 hover:text-ink-800'
490
+ ]"
491
+ :style="currentDataset === ds.id ? { borderLeftColor: getColor(ds.id), borderLeftWidth: '2px' } : {}"
492
+ >
493
+ <div class="font-medium truncate leading-snug">{{ localizedDatasetField(ds.id, 'title', ds.title) }}</div>
494
+ <div v-if="ds.loaded" class="text-xs mt-0.5" :class="currentDataset === ds.id ? 'text-ink-400' : 'text-ink-300'">
495
+ {{ ds.conceptCount.toLocaleString() }} {{ t('home.concepts').toLowerCase() }}
496
+ </div>
497
+ </button>
498
+
499
+ <!-- Expanded dataset: sub-pages + provenance -->
500
+ <div v-if="currentDataset === ds.id && (filteredDatasetPages.length || provenance.owner)" class="px-2 pb-2">
501
+ <!-- Sub-pages -->
502
+ <nav v-if="filteredDatasetPages.length" class="space-y-0.5 mt-1">
503
+ <router-link
504
+ v-for="page in filteredDatasetPages"
505
+ :key="page.route || 'concepts'"
506
+ :to="pageRoute(page)"
507
+ class="btn-ghost w-full text-left flex items-center gap-2 text-sm"
508
+ :class="isActive(page) ? 'active' : ''"
509
+ @click="closeMobile"
510
+ >
511
+ <NavIcon :name="page.icon" />
512
+ {{ navTitle(page) }}
513
+ </router-link>
514
+ </nav>
515
+
516
+ <!-- Provenance -->
517
+ <div v-if="provenance.owner" class="mt-3 pt-3 border-t border-ink-100/60">
518
+ <div class="text-[11px] text-ink-300 space-y-1.5 px-1">
519
+ <div v-if="provenance.ref" class="text-xs font-semibold text-ink-700">
520
+ {{ provenance.ref }}
521
+ </div>
522
+ <div class="flex items-center gap-1">
523
+ <span class="text-ink-400">{{ t('sidebar.publishedBy') }}</span>
524
+ <a v-if="provenance.ownerUrl" :href="provenance.ownerUrl" target="_blank" rel="noopener" class="concept-link font-medium">{{ provenance.owner }}</a>
525
+ <span v-else class="text-ink-600 font-medium">{{ provenance.owner }}</span>
526
+ </div>
527
+ <div v-if="provenance.sourceRepo">
528
+ <a :href="provenance.sourceRepo" target="_blank" rel="noopener" class="concept-link">{{ t('sidebar.viewSource') }}</a>
529
+ </div>
530
+ </div>
531
+ </div>
534
532
  </div>
535
533
  </div>
536
- </div>
534
+ </nav>
537
535
  </div>
538
536
  </aside>
539
537
  </template>
@@ -1,18 +1,15 @@
1
1
  <script setup lang="ts">
2
- import { useRouter, useRoute } from 'vue-router';
2
+ import { useRouter } from 'vue-router';
3
3
  import { useUiStore } from '../stores/ui';
4
4
  import { useVocabularyStore } from '../stores/vocabulary';
5
5
  import { ref, watch, onMounted, computed, nextTick } from 'vue';
6
6
  import type { SearchHit } from '../adapters/types';
7
- import { langLabel, langName } from '../utils/lang';
8
- import { useDsStyle } from '../utils/dataset-style';
9
7
  import { useI18n } from '../i18n';
8
+ import SearchResults from './SearchResults.vue';
10
9
 
11
10
  const router = useRouter();
12
- const route = useRoute();
13
11
  const ui = useUiStore();
14
12
  const store = useVocabularyStore();
15
- const { getStyle } = useDsStyle();
16
13
  const { t } = useI18n();
17
14
  const query = ref('');
18
15
  const results = ref<SearchHit[]>([]);
@@ -38,9 +35,7 @@ async function doSearch() {
38
35
  }
39
36
  searched.value = true;
40
37
  selectedIdx.value = -1;
41
- if (route.query.q !== q) {
42
- router.replace({ query: { q } });
43
- }
38
+ router.replace({ query: { q } });
44
39
  }
45
40
 
46
41
  function onInput() {
@@ -65,11 +60,9 @@ function clearSearch() {
65
60
  router.replace({ query: {} });
66
61
  }
67
62
 
68
- // Group results by dataset
69
63
  interface GroupedResults {
70
64
  registerId: string;
71
65
  title: string;
72
- style: { light: string; dark: string; color: string };
73
66
  hits: SearchHit[];
74
67
  }
75
68
 
@@ -84,23 +77,12 @@ const groupedResults = computed(() => {
84
77
  const groups: GroupedResults[] = [];
85
78
  for (const [registerId, hits] of map) {
86
79
  const m = store.manifests.get(registerId);
87
- groups.push({
88
- registerId,
89
- title: m?.title ?? registerId,
90
- style: getStyle(registerId),
91
- hits,
92
- });
80
+ groups.push({ registerId, title: m?.title ?? registerId, hits });
93
81
  }
94
82
  return groups;
95
83
  });
96
84
 
97
- // Flatten for keyboard navigation
98
85
  const flatHits = computed(() => groupedResults.value.flatMap(g => g.hits));
99
- const hitIndexMap = computed(() => {
100
- const map = new Map<SearchHit, number>();
101
- flatHits.value.forEach((hit, i) => map.set(hit, i));
102
- return map;
103
- });
104
86
 
105
87
  function goToHit(hit: SearchHit) {
106
88
  router.push({
@@ -132,7 +114,6 @@ function scrollToSelected() {
132
114
  });
133
115
  }
134
116
 
135
- // Sync with UI store search query
136
117
  watch(() => ui.searchQuery, (q) => {
137
118
  if (q && q !== query.value) {
138
119
  query.value = q;
@@ -183,65 +164,16 @@ onMounted(() => {
183
164
  </div>
184
165
  </form>
185
166
 
186
- <div v-if="loading" class="text-center py-16">
187
- <svg class="w-8 h-8 text-ink-300 animate-spin mx-auto mb-4" fill="none" viewBox="0 0 24 24">
188
- <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
189
- <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
190
- </svg>
191
- <p class="text-sm text-ink-400">{{ t('search.searching') }}</p>
192
- </div>
193
-
194
- <div v-else-if="searchError" class="text-center py-16">
195
- <div class="card p-8 border-red-200 bg-red-50/50 max-w-md mx-auto">
196
- <p class="text-red-700 font-medium mb-1">{{ t('search.failed') }}</p>
197
- <p class="text-sm text-red-600/80 mb-4">{{ searchError }}</p>
198
- <button @click="doSearch" class="btn-primary">{{ t('search.retry') }}</button>
199
- </div>
200
- </div>
201
-
202
- <div v-else-if="searched">
203
- <p class="text-sm text-ink-400 mb-4">{{ results.length === 1 ? t('search.oneResultFor', { query: ui.searchQuery }) : t('search.manyResultsFor', { count: String(results.length), query: ui.searchQuery }) }}</p>
204
- <div v-if="results.length === 0" class="text-center py-16">
205
- <div class="text-ink-200 text-5xl mb-4 font-serif">&empty;</div>
206
- <p class="text-ink-500 font-medium">{{ t('search.noResults') }}</p>
207
- <p class="text-sm text-ink-300 mt-1">{{ t('search.tryDifferent') }}</p>
208
- </div>
209
-
210
- <!-- Grouped results -->
211
- <div v-else class="space-y-6">
212
- <div v-for="group in groupedResults" :key="group.registerId">
213
- <!-- Dataset header -->
214
- <div class="flex items-center gap-2 mb-2">
215
- <span class="w-2 h-2 rounded-full flex-shrink-0" :style="{ backgroundColor: group.style.color }"></span>
216
- <span class="text-xs font-semibold text-ink-500 uppercase tracking-wide">{{ group.title }}</span>
217
- <span class="text-xs text-ink-300">{{ group.hits.length }} {{ group.hits.length === 1 ? t('search.result') : t('search.results') }}</span>
218
- </div>
219
- <!-- Hits -->
220
- <div class="space-y-1.5">
221
- <button
222
- v-for="hit in group.hits"
223
- :key="hit.conceptId + hit.language"
224
- @click="goToHit(hit)"
225
- :class="selectedIdx === hitIndexMap.get(hit) ? 'bg-ink-50 border-ink-200 search-hit-selected' : ''"
226
- class="card-hover p-3 w-full text-left flex items-center justify-between group"
227
- >
228
- <div class="min-w-0">
229
- <span class="font-medium text-ink-800 group-hover:text-ink-900 transition-colors">{{ hit.designation }}</span>
230
- <span class="text-xs text-ink-300 ml-2 font-mono">{{ hit.conceptId }}</span>
231
- <span v-if="hit.snippet" class="block text-xs text-ink-300 mt-0.5 truncate">{{ hit.snippet }}</span>
232
- </div>
233
- <div class="flex items-center gap-2 flex-shrink-0">
234
- <span v-if="hit.matchField === 'id'" class="badge badge-gray text-[10px]">{{ t('search.idMatch') }}</span>
235
- <span class="text-xs font-semibold text-ink-500 bg-ink-50 px-1.5 py-0.5 rounded">{{ langName(hit.language) }}</span>
236
- </div>
237
- </button>
238
- </div>
239
- </div>
240
- </div>
241
-
242
- <div v-if="results.length > 100" class="text-center text-sm text-ink-300 mt-6 pt-4 border-t border-ink-100/60">
243
- {{ t('search.showingFirst', { max: '100', total: String(results.length) }) }}
244
- </div>
245
- </div>
167
+ <SearchResults
168
+ :loading="loading"
169
+ :search-error="searchError"
170
+ :searched="searched"
171
+ :search-query="ui.searchQuery"
172
+ :results="results"
173
+ :grouped-results="groupedResults"
174
+ :selected-idx="selectedIdx"
175
+ @retry="doSearch"
176
+ @go-hit="goToHit"
177
+ />
246
178
  </div>
247
179
  </template>
@@ -0,0 +1,98 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue';
3
+ import type { SearchHit } from '../adapters/types';
4
+ import { langName } from '../utils/lang';
5
+ import { useDsStyle } from '../utils/dataset-style';
6
+ import { useI18n } from '../i18n';
7
+
8
+ interface GroupedResults {
9
+ registerId: string;
10
+ title: string;
11
+ hits: SearchHit[];
12
+ }
13
+
14
+ const props = defineProps<{
15
+ loading: boolean;
16
+ searchError: string | null;
17
+ searched: boolean;
18
+ searchQuery: string;
19
+ results: SearchHit[];
20
+ groupedResults: GroupedResults[];
21
+ selectedIdx: number;
22
+ }>();
23
+
24
+ const emit = defineEmits<{
25
+ retry: [];
26
+ goHit: [hit: SearchHit];
27
+ }>();
28
+
29
+ const { getStyle } = useDsStyle();
30
+ const { t } = useI18n();
31
+
32
+ const flatHits = computed(() => props.groupedResults.flatMap(g => g.hits));
33
+ const hitIndexMap = computed(() => {
34
+ const map = new Map<SearchHit, number>();
35
+ flatHits.value.forEach((hit, i) => map.set(hit, i));
36
+ return map;
37
+ });
38
+ </script>
39
+
40
+ <template>
41
+ <div v-if="loading" class="text-center py-16">
42
+ <svg class="w-8 h-8 text-ink-300 animate-spin mx-auto mb-4" fill="none" viewBox="0 0 24 24">
43
+ <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
44
+ <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
45
+ </svg>
46
+ <p class="text-sm text-ink-400">{{ t('search.searching') }}</p>
47
+ </div>
48
+
49
+ <div v-else-if="searchError" class="text-center py-16">
50
+ <div class="card p-8 border-red-200 bg-red-50/50 max-w-md mx-auto">
51
+ <p class="text-red-700 font-medium mb-1">{{ t('search.failed') }}</p>
52
+ <p class="text-sm text-red-600/80 mb-4">{{ searchError }}</p>
53
+ <button @click="emit('retry')" class="btn-primary">{{ t('search.retry') }}</button>
54
+ </div>
55
+ </div>
56
+
57
+ <div v-else-if="searched" class="search-results">
58
+ <p class="text-sm text-ink-400 mb-4">{{ results.length === 1 ? t('search.oneResultFor', { query: searchQuery }) : t('search.manyResultsFor', { count: String(results.length), query: searchQuery }) }}</p>
59
+ <div v-if="results.length === 0" class="text-center py-16">
60
+ <div class="text-ink-200 text-5xl mb-4 font-serif">&empty;</div>
61
+ <p class="text-ink-500 font-medium">{{ t('search.noResults') }}</p>
62
+ <p class="text-sm text-ink-300 mt-1">{{ t('search.tryDifferent') }}</p>
63
+ </div>
64
+
65
+ <div v-else class="space-y-6">
66
+ <div v-for="group in groupedResults" :key="group.registerId">
67
+ <div class="flex items-center gap-2 mb-2">
68
+ <span class="w-2 h-2 rounded-full flex-shrink-0" :style="{ backgroundColor: getStyle(group.registerId).color }"></span>
69
+ <span class="text-xs font-semibold text-ink-500 uppercase tracking-wide">{{ group.title }}</span>
70
+ <span class="text-xs text-ink-300">{{ group.hits.length }} {{ group.hits.length === 1 ? t('search.result') : t('search.results') }}</span>
71
+ </div>
72
+ <div class="space-y-1.5">
73
+ <button
74
+ v-for="hit in group.hits"
75
+ :key="hit.conceptId + hit.language"
76
+ @click="emit('goHit', hit)"
77
+ :class="selectedIdx === hitIndexMap.get(hit) ? 'bg-ink-50 border-ink-200 search-hit-selected' : ''"
78
+ class="card-hover p-3 w-full text-left flex items-center justify-between group"
79
+ >
80
+ <div class="min-w-0">
81
+ <span class="font-medium text-ink-800 group-hover:text-ink-900 transition-colors">{{ hit.designation }}</span>
82
+ <span class="text-xs text-ink-300 ml-2 font-mono">{{ hit.conceptId }}</span>
83
+ <span v-if="hit.snippet" class="block text-xs text-ink-300 mt-0.5 truncate">{{ hit.snippet }}</span>
84
+ </div>
85
+ <div class="flex items-center gap-2 flex-shrink-0">
86
+ <span v-if="hit.matchField === 'id'" class="badge badge-gray text-[10px]">{{ t('search.idMatch') }}</span>
87
+ <span class="text-xs font-semibold text-ink-500 bg-ink-50 px-1.5 py-0.5 rounded">{{ langName(hit.language) }}</span>
88
+ </div>
89
+ </button>
90
+ </div>
91
+ </div>
92
+ </div>
93
+
94
+ <div v-if="results.length > 100" class="text-center text-sm text-ink-300 mt-6 pt-4 border-t border-ink-100/60">
95
+ {{ t('search.showingFirst', { max: '100', total: String(results.length) }) }}
96
+ </div>
97
+ </div>
98
+ </template>
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
- let result = text.replace(/(?:^|\n)((?:[ \t]*\* [^\n]+)(?:\n[ \t]*\* [^\n]+)*)/g, (_, block) => {
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;