@glossarist/concept-browser 0.1.8 → 0.1.9

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.1.8",
3
+ "version": "0.1.9",
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": {
@@ -257,6 +257,105 @@ function conceptJsonToSkosJsonLd(concept) {
257
257
  return JSON.stringify(doc, null, 2);
258
258
  }
259
259
 
260
+ function escapeXml(s) {
261
+ return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
262
+ }
263
+
264
+ function conceptJsonToTbx(concept) {
265
+ const id = concept['gl:identifier'] || '';
266
+ const uri = concept['@id'] || '';
267
+ const localized = concept['gl:localizedConcept'] || {};
268
+
269
+ const langSections = [];
270
+ for (const [lang, lc] of Object.entries(localized)) {
271
+ const descs = lc['gl:designation'] || [];
272
+ const definitions = (lc['gl:definition'] || []).filter(d => d['gl:content']);
273
+ const notes = (lc['gl:notes'] || []).filter(d => d['gl:content']);
274
+ const examples = (lc['gl:examples'] || []).filter(d => d['gl:content']);
275
+ const sources = lc['gl:source'] || [];
276
+ const entryStatus = lc['gl:entryStatus'] || '';
277
+
278
+ if (!descs.length && !definitions.length) continue;
279
+
280
+ const termEntries = [];
281
+ for (const d of descs) {
282
+ const term = d['gl:term'];
283
+ if (!term) continue;
284
+ const status = d['gl:normativeStatus'] || '';
285
+ const type = d['@type'] || '';
286
+ let gramGrp = '';
287
+ if (d['gl:gender']) gramGrp = `\n <grammaticalGender>${escapeXml(d['gl:gender'])}</grammaticalGender>`;
288
+ let partOfSpeech = '';
289
+ if (type.includes('Abbreviation')) partOfSpeech = '\n <partOfSpeech>abbreviation</partOfSpeech>';
290
+ if (type.includes('Symbol')) partOfSpeech = '\n <partOfSpeech>symbol</partOfSpeech>';
291
+
292
+ termEntries.push(` <termEntry>
293
+ <langSet xml:lang="${lang}">
294
+ <tig>
295
+ <term>${escapeXml(term)}</term>${gramGrp}${partOfSpeech}
296
+ </tig>
297
+ </langSet>
298
+ </termEntry>`);
299
+ }
300
+
301
+ let defBlock = '';
302
+ if (definitions.length) {
303
+ const defParts = definitions.map(d => ` <p>${escapeXml(d['gl:content'])}</p>`).join('\n');
304
+ defBlock = `\n <descrip type="definition">\n${defParts}\n </descrip>`;
305
+ }
306
+
307
+ let noteBlock = '';
308
+ for (let i = 0; i < notes.length; i++) {
309
+ noteBlock += `\n <note type="note">${escapeXml(notes[i]['gl:content'])}</note>`;
310
+ }
311
+ for (let i = 0; i < examples.length; i++) {
312
+ noteBlock += `\n <note type="example">${escapeXml(examples[i]['gl:content'])}</note>`;
313
+ }
314
+
315
+ let sourceBlock = '';
316
+ for (const src of sources) {
317
+ const origin = src['gl:origin'] || {};
318
+ const parts = [];
319
+ if (origin['gl:ref']) parts.push(origin['gl:ref']);
320
+ if (origin['gl:clause']) parts.push(origin['gl:clause']);
321
+ if (parts.length) {
322
+ sourceBlock += `\n <ref>${escapeXml(parts.join(', '))}</ref>`;
323
+ }
324
+ }
325
+
326
+ let statusBlock = '';
327
+ if (entryStatus) {
328
+ statusBlock += `\n <descrip type="entryStatus">${escapeXml(entryStatus)}</descrip>`;
329
+ }
330
+
331
+ const termEntriesBlock = termEntries.length ? '\n' + termEntries.join('\n') : '';
332
+ langSections.push({ lang, termEntries, blocks: [defBlock, noteBlock, sourceBlock, statusBlock].filter(b => b).join('') });
333
+ }
334
+
335
+ if (!langSections.length) return '';
336
+
337
+ const bodyEntries = langSections.map(ls => {
338
+ return ` <languageSection xml:lang="${ls.lang}">${ls.blocks}\n </languageSection>`;
339
+ }).join('\n');
340
+
341
+ return `<?xml version="1.0" encoding="UTF-8"?>
342
+ <tbx style="dca" type="TBX-Basic" xml:lang="en" xmlns="urn:iso:std:iso:30042:ed-2">
343
+ <tbxHeader>
344
+ <fileDesc>
345
+ <source>${escapeXml(uri)}</source>
346
+ </fileDesc>
347
+ </tbxHeader>
348
+ <text>
349
+ <body>
350
+ <conceptEntry id="${escapeXml(id)}">
351
+ ${bodyEntries}
352
+ </conceptEntry>
353
+ </body>
354
+ </text>
355
+ </tbx>
356
+ `;
357
+ }
358
+
260
359
  function processDataset(dir, register, opts) {
261
360
  const files = fs.readdirSync(dir).filter(f => f.endsWith('.yaml')).sort((a, b) => naturalSort(a.replace('.yaml', ''), b.replace('.yaml', '')));
262
361
 
@@ -266,7 +365,7 @@ function processDataset(dir, register, opts) {
266
365
  const concepts = [];
267
366
  const langTermCounts = {};
268
367
  const langDefCounts = {};
269
- const availableFormats = ['ttl', 'jsonld', 'yaml'];
368
+ const availableFormats = ['ttl', 'jsonld', 'yaml', 'tbx'];
270
369
 
271
370
  for (let i = 0; i < files.length; i++) {
272
371
  const file = files[i];
@@ -286,6 +385,12 @@ function processDataset(dir, register, opts) {
286
385
  const skosJsonLd = conceptJsonToSkosJsonLd(jsonld);
287
386
  fs.writeFileSync(path.join(conceptsDir, `${termid}.jsonld`), skosJsonLd);
288
387
 
388
+ // Generate TBX-XML format
389
+ const tbxContent = conceptJsonToTbx(jsonld);
390
+ if (tbxContent) {
391
+ fs.writeFileSync(path.join(conceptsDir, `${termid}.tbx`), tbxContent);
392
+ }
393
+
289
394
  // Copy source YAML
290
395
  fs.copyFileSync(path.join(dir, file), path.join(conceptsDir, `${termid}.yaml`));
291
396
 
package/src/App.vue CHANGED
@@ -6,6 +6,7 @@ import { buildPageRoutes } from './router/page-routes';
6
6
  import router from './router';
7
7
  import AppHeader from './components/AppHeader.vue';
8
8
  import AppSidebar from './components/AppSidebar.vue';
9
+ import AppFooter from './components/AppFooter.vue';
9
10
 
10
11
  const store = useVocabularyStore();
11
12
  const { loadConfig, config, globalPages, datasetPages } = useSiteConfig();
@@ -48,7 +49,7 @@ onUnmounted(() => {
48
49
  <AppHeader />
49
50
  <div class="flex flex-1 overflow-hidden">
50
51
  <AppSidebar />
51
- <main class="flex-1 overflow-y-auto bg-surface">
52
+ <main class="flex-1 overflow-y-auto bg-surface flex flex-col">
52
53
  <div v-if="!appReady" class="flex items-center justify-center h-[70vh]">
53
54
  <div class="w-full max-w-md px-6 space-y-6">
54
55
  <!-- Title skeleton -->
@@ -85,6 +86,7 @@ onUnmounted(() => {
85
86
  </transition>
86
87
  </router-view>
87
88
  </template>
89
+ <AppFooter />
88
90
  </main>
89
91
  </div>
90
92
  <!-- Scroll-to-top -->
@@ -214,13 +214,21 @@ export class DatasetAdapter {
214
214
  for (const entry of arr) {
215
215
  if (!entry) continue;
216
216
  const term = entry.eng || '';
217
- if (term.toLowerCase().includes(q) || entry.id.toLowerCase().includes(q)) {
217
+ const termMatch = term.toLowerCase().includes(q);
218
+ const idMatch = entry.id.toLowerCase().includes(q);
219
+ if (termMatch || idMatch) {
220
+ const matchField = termMatch ? 'designation' as const : 'id' as const;
221
+ let snippet: string | undefined;
222
+ if (!termMatch && idMatch) {
223
+ snippet = `ID: ${entry.id}`;
224
+ }
218
225
  hits.push({
219
226
  conceptId: entry.id,
220
227
  registerId: this.registerId,
221
228
  designation: term,
222
229
  language: lang,
223
- matchField: 'designation',
230
+ matchField,
231
+ snippet,
224
232
  });
225
233
  }
226
234
  }
@@ -143,7 +143,7 @@ export interface SearchHit {
143
143
  registerId: string;
144
144
  designation: string;
145
145
  language: string;
146
- matchField: 'designation' | 'definition';
146
+ matchField: 'designation' | 'id';
147
147
  snippet?: string;
148
148
  }
149
149
 
@@ -0,0 +1,60 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue';
3
+ import { useSiteConfig } from '../config/use-site-config';
4
+
5
+ const { config } = useSiteConfig();
6
+
7
+ const poweredBy = computed(() => {
8
+ const pb = config.value?.features?.poweredBy as { title?: string; url?: string } | undefined;
9
+ return { title: pb?.title || 'Glossarist', url: pb?.url || 'https://glossarist.org' };
10
+ });
11
+
12
+ const socialLinks = computed(() => {
13
+ const s = config.value?.social;
14
+ if (!s) return [];
15
+ const links: { key: string; label: string; url: string }[] = [];
16
+ if (s.github) links.push({ key: 'github', label: 'GitHub', url: s.github });
17
+ if (s.twitter) links.push({ key: 'twitter', label: 'Twitter', url: s.twitter });
18
+ return links;
19
+ });
20
+
21
+ const footerNav = computed(() => config.value?.footerNav ?? []);
22
+ const ownerName = computed(() => config.value?.branding?.ownerName || config.value?.title || '');
23
+ const ownerUrl = computed(() => config.value?.branding?.ownerUrl || '/');
24
+ </script>
25
+
26
+ <template>
27
+ <footer class="border-t border-ink-100/60 bg-surface-raised mt-auto">
28
+ <div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
29
+ <div class="flex flex-col sm:flex-row items-center justify-between gap-4 text-sm text-ink-400">
30
+ <div class="flex items-center gap-3">
31
+ <span v-if="ownerName">
32
+ &copy; {{ new Date().getFullYear() }}
33
+ <a v-if="ownerUrl" :href="ownerUrl" target="_blank" rel="noopener" class="concept-link">{{ ownerName }}</a>
34
+ <span v-else>{{ ownerName }}</span>
35
+ </span>
36
+ </div>
37
+ <div class="flex items-center gap-4">
38
+ <router-link
39
+ v-for="item in footerNav"
40
+ :key="item.route"
41
+ :to="item.route ? `/${item.route}` : '/'"
42
+ class="hover:text-ink-700 transition-colors"
43
+ >{{ item.label }}</router-link>
44
+ <a
45
+ v-for="link in socialLinks"
46
+ :key="link.key"
47
+ :href="link.url"
48
+ target="_blank"
49
+ rel="noopener"
50
+ class="hover:text-ink-700 transition-colors"
51
+ >{{ link.label }}</a>
52
+ <span class="text-ink-200">|</span>
53
+ <span class="text-xs">
54
+ Powered by <a :href="poweredBy.url" target="_blank" rel="noopener" class="concept-link">{{ poweredBy.title }}</a>
55
+ </span>
56
+ </div>
57
+ </div>
58
+ </div>
59
+ </footer>
60
+ </template>
@@ -12,7 +12,7 @@ const ui = useUiStore();
12
12
  const router = useRouter();
13
13
  const route = useRoute();
14
14
  const { getColor } = useDsStyle();
15
- const { globalPages, datasetPages } = useSiteConfig();
15
+ const { globalPages, datasetPages, config: siteConfig } = useSiteConfig();
16
16
 
17
17
  const currentDataset = computed(() => (route.params as any).registerId ?? '');
18
18
 
@@ -121,11 +121,16 @@ function isActive(page: { route: string; datasetScoped?: boolean }): boolean {
121
121
  </button>
122
122
  </nav>
123
123
 
124
- <!-- Graph stats -->
124
+ <!-- Powered by -->
125
125
  <div class="mt-6 pt-4 border-t border-ink-100/60">
126
- <div class="text-[11px] text-ink-300 space-y-0.5">
127
- <div>{{ store.graph.nodeCount.toLocaleString() }} graph nodes</div>
128
- <div>{{ store.graph.edgeCount.toLocaleString() }} edges</div>
126
+ <div class="text-[11px] text-ink-300">
127
+ Powered by
128
+ <a
129
+ :href="(siteConfig?.features?.poweredBy as any)?.url || 'https://glossarist.org'"
130
+ target="_blank"
131
+ rel="noopener"
132
+ class="concept-link"
133
+ >{{ (siteConfig?.features?.poweredBy as any)?.title || 'Glossarist' }}</a>
129
134
  </div>
130
135
  </div>
131
136
  </div>
@@ -35,6 +35,7 @@ const manifestLanguages = computed(() => store.manifests.get(props.registerId)?.
35
35
  <button
36
36
  @click="viewConcept"
37
37
  class="card-hover p-4 text-left w-full border-l-2 group"
38
+ :class="(entry.status === 'superseded' || entry.status === 'withdrawn') ? 'opacity-70' : ''"
38
39
  :style="{ borderLeftColor: getColor(registerId) }"
39
40
  >
40
41
  <div class="flex items-start justify-between gap-2">
@@ -51,15 +52,18 @@ const manifestLanguages = computed(() => store.manifests.get(props.registerId)?.
51
52
  {{ entry.status === 'Standard' ? 'valid' : entry.status }}
52
53
  </span>
53
54
  </div>
54
- <!-- Language coverage dots -->
55
- <div class="flex gap-0.5 mt-2.5" :aria-label="`${manifestLanguages.length} languages`" role="img">
56
- <span
57
- v-for="lang in manifestLanguages"
58
- :key="lang"
59
- class="w-1.5 h-1.5 rounded-full"
60
- :style="{ backgroundColor: getColor(registerId) + '60' }"
61
- :aria-label="lang"
62
- ></span>
55
+ <!-- Language coverage -->
56
+ <div class="flex items-center gap-1.5 mt-2.5" :aria-label="`${manifestLanguages.length} languages`">
57
+ <span class="text-[11px] text-ink-300">{{ manifestLanguages.length }} lang</span>
58
+ <div class="flex gap-0.5">
59
+ <span
60
+ v-for="lang in manifestLanguages"
61
+ :key="lang"
62
+ class="w-1.5 h-1.5 rounded-full"
63
+ :style="{ backgroundColor: getColor(registerId) + '40' }"
64
+ :aria-label="lang"
65
+ ></span>
66
+ </div>
63
67
  </div>
64
68
  </button>
65
69
  </template>
@@ -1,7 +1,7 @@
1
1
  <script setup lang="ts">
2
2
  import type { ConceptDocument, LocalizedConcept, GraphEdge } from '../adapters/types';
3
3
  import type { Manifest } from '../adapters/types';
4
- import { computed, ref, nextTick } from 'vue';
4
+ import { computed, ref, nextTick, watch } from 'vue';
5
5
  import { langName, langLabel } from '../utils/lang';
6
6
  import { renderMath, cleanContent } from '../utils/math';
7
7
  import type { XrefResolver } from '../utils/math';
@@ -65,6 +65,9 @@ const languages = computed(() => {
65
65
  });
66
66
  });
67
67
 
68
+ // Initialize collapsed state when languages change
69
+ watch(languages, (langs) => { initCollapsed(langs); }, { immediate: true });
70
+
68
71
  const engConcept = computed((): LocalizedConcept | null => {
69
72
  return props.concept['gl:localizedConcept']?.['eng'] ?? null;
70
73
  });
@@ -146,9 +149,15 @@ const allLangContent = computed(() => {
146
149
  return result;
147
150
  });
148
151
 
149
- // Collapsible language sections — all expanded by default
152
+ // Collapsible language sections — auto-collapse non-eng when 6+ languages
150
153
  const collapsedLangs = ref(new Set<string>());
151
154
 
155
+ function initCollapsed(langs: string[]) {
156
+ if (langs.length >= 6) {
157
+ collapsedLangs.value = new Set(langs.filter(l => l !== 'eng'));
158
+ }
159
+ }
160
+
152
161
  function hasContent(lc: LangContent): boolean {
153
162
  return !!(lc.definition || lc.notes.length || lc.examples.length || lc.sources.length);
154
163
  }
@@ -186,6 +195,17 @@ function scrollToLang(lang: string) {
186
195
  const outgoingEdges = computed(() => props.edges.filter(e => e.source === props.concept['@id']));
187
196
  const incomingEdges = computed(() => props.edges.filter(e => e.target === props.concept['@id']));
188
197
 
198
+ function edgeDatasetBadge(uri: string): { id: string; title: string } | null {
199
+ const resolution = factory.resolve(uri, props.registerId);
200
+ if (resolution.type === 'internal' && resolution.registerId !== props.registerId) {
201
+ const m = store.manifests.get(resolution.registerId);
202
+ return { id: resolution.registerId, title: m?.shortname || m?.title || resolution.registerId };
203
+ }
204
+ if (resolution.type === 'site') return { id: '', title: resolution.label };
205
+ if (resolution.type === 'url') return { id: '', title: resolution.label };
206
+ return null;
207
+ }
208
+
189
209
  async function navigateEdge(edge: GraphEdge) {
190
210
  const uri = edge.source === props.concept['@id'] ? edge.target : edge.source;
191
211
  const resolution = factory.resolve(uri);
@@ -299,7 +319,7 @@ function plainTruncate(html: string, max: number = 120): string {
299
319
  </button>
300
320
  </div>
301
321
  </div>
302
- <h1 class="font-serif text-2xl sm:text-3xl text-ink-800 leading-snug mb-3" v-html="renderMath(cleanContent(primaryTerm))"></h1>
322
+ <h1 class="font-serif text-2xl sm:text-3xl text-ink-800 leading-snug mb-3" v-html="renderMath(primaryTerm)"></h1>
303
323
  <div class="flex flex-wrap gap-2">
304
324
  <span class="badge badge-blue font-mono">{{ conceptId }}</span>
305
325
  <span class="badge" :class="entryStatusColor(engConcept?.['gl:entryStatus'] ?? '')" v-if="engConcept?.['gl:entryStatus']">
@@ -369,12 +389,17 @@ function plainTruncate(html: string, max: number = 120): string {
369
389
  <div v-else class="w-full flex items-center gap-2.5 px-3 sm:px-4 py-3">
370
390
  <span class="text-xs font-semibold text-ink-500 bg-ink-50 px-1.5 py-0.5 rounded">{{ langName(lc.lang) }}</span>
371
391
  <span class="font-medium text-ink-800 text-sm" v-html="renderMath(getTermForLang(lc.lang))"></span>
372
- <span class="text-xs text-ink-200 ml-2">No definition provided</span>
392
+ <span class="text-xs text-ink-200 ml-2 italic">designation only</span>
373
393
  <span v-if="lc.entryStatus" class="badge text-[10px] ml-auto" :class="entryStatusColor(lc.entryStatus)">{{ lc.entryStatus }}</span>
374
394
  </div>
375
395
  <!-- Collapsed preview -->
376
- <div v-if="hasContent(lc) && collapsedLangs.has(lc.lang) && lc.definition" class="px-3 sm:px-4 pb-3 -mt-0.5">
377
- <p class="text-xs text-ink-300 leading-relaxed pl-[22px]">{{ plainTruncate(lc.definition) }}</p>
396
+ <div v-if="hasContent(lc) && collapsedLangs.has(lc.lang)" class="px-3 sm:px-4 pb-3 -mt-0.5">
397
+ <p v-if="lc.definition" class="text-xs text-ink-300 leading-relaxed pl-[22px]">{{ plainTruncate(lc.definition) }}</p>
398
+ <p v-else class="text-xs text-ink-200 leading-relaxed pl-[22px]">
399
+ <template v-if="lc.notes.length">{{ lc.notes.length }} note{{ lc.notes.length > 1 ? 's' : '' }}</template>
400
+ <template v-if="lc.notes.length && lc.examples.length"> &middot; </template>
401
+ <template v-if="lc.examples.length">{{ lc.examples.length }} example{{ lc.examples.length > 1 ? 's' : '' }}</template>
402
+ </p>
378
403
  </div>
379
404
 
380
405
  <!-- Expandable content -->
@@ -440,9 +465,10 @@ function plainTruncate(html: string, max: number = 120): string {
440
465
  v-for="edge in outgoingEdges"
441
466
  :key="edge.target"
442
467
  @click="navigateEdge(edge)"
443
- class="text-sm concept-link block truncate w-full text-left"
468
+ class="text-sm concept-link block truncate w-full text-left flex items-center gap-1.5"
444
469
  >
445
470
  {{ edge.label || edge.target.split('/').pop() }}
471
+ <span v-if="edgeDatasetBadge(edge.target)" class="badge badge-gray text-[9px] flex-shrink-0 truncate max-w-[100px]">{{ edgeDatasetBadge(edge.target)!.title }}</span>
446
472
  </button>
447
473
  </div>
448
474
  </div>
@@ -453,9 +479,10 @@ function plainTruncate(html: string, max: number = 120): string {
453
479
  v-for="edge in incomingEdges"
454
480
  :key="edge.source"
455
481
  @click="navigateEdge(edge)"
456
- class="text-sm concept-link block truncate w-full text-left"
482
+ class="text-sm concept-link block truncate w-full text-left flex items-center gap-1.5"
457
483
  >
458
484
  {{ edge.label || edge.source.split('/').pop() }}
485
+ <span v-if="edgeDatasetBadge(edge.source)" class="badge badge-gray text-[9px] flex-shrink-0 truncate max-w-[100px]">{{ edgeDatasetBadge(edge.source)!.title }}</span>
459
486
  </button>
460
487
  </div>
461
488
  </div>
@@ -224,8 +224,10 @@ onMounted(() => {
224
224
  <div class="min-w-0">
225
225
  <span class="font-medium text-ink-800 group-hover:text-ink-900 transition-colors">{{ hit.designation }}</span>
226
226
  <span class="text-xs text-ink-300 ml-2 font-mono">{{ hit.conceptId }}</span>
227
+ <span v-if="hit.snippet" class="block text-xs text-ink-300 mt-0.5 truncate">{{ hit.snippet }}</span>
227
228
  </div>
228
229
  <div class="flex items-center gap-2 flex-shrink-0">
230
+ <span v-if="hit.matchField === 'id'" class="badge badge-gray text-[10px]">ID match</span>
229
231
  <span class="text-xs font-semibold text-ink-500 bg-ink-50 px-1.5 py-0.5 rounded">{{ langName(hit.language) }}</span>
230
232
  </div>
231
233
  </button>
package/src/style.css CHANGED
@@ -127,6 +127,9 @@
127
127
  padding-left: 1.25rem;
128
128
  margin: 0.5rem 0;
129
129
  }
130
+ .concept-list-ordered {
131
+ list-style: decimal;
132
+ }
130
133
  .concept-list li {
131
134
  margin: 0.25rem 0;
132
135
  }
@@ -9,6 +9,7 @@ export interface FormatDescriptor {
9
9
  export const FORMAT_REGISTRY: Record<string, FormatDescriptor> = {
10
10
  ttl: { extension: 'ttl', label: 'Turtle RDF', mediaType: 'text/turtle' },
11
11
  jsonld: { extension: 'jsonld', label: 'JSON-LD', mediaType: 'application/ld+json' },
12
+ tbx: { extension: 'tbx', label: 'TBX-XML', mediaType: 'application/xml' },
12
13
  yaml: { extension: 'yaml', label: 'YAML', mediaType: 'text/yaml' },
13
14
  };
14
15
 
package/src/utils/math.ts CHANGED
@@ -4,9 +4,11 @@ export type XrefResolver = (uri: string, term: string) => string;
4
4
 
5
5
  /**
6
6
  * Convert `* item` lines into <ul><li> blocks.
7
+ * Also handles `1)` and `1.` numbered items into ordered lists.
7
8
  */
8
9
  function convertLists(text: string): string {
9
- return text.replace(/(?:^|\n\n)((?:[ \t]*\* [^\n]+)(?:\n\n[ \t]*\* [^\n]+)*)/g, (_, block) => {
10
+ // Unordered: * item (separated by \n or \n\n)
11
+ let result = text.replace(/(?:^|\n)((?:[ \t]*\* [^\n]+)(?:\n[ \t]*\* [^\n]+)*)/g, (_, block) => {
10
12
  if (/^\*stem:\[/.test(block.trimStart())) return _;
11
13
  const items: string[] = [];
12
14
  const re = /[ \t]*\* ([^\n]+)/g;
@@ -16,8 +18,23 @@ function convertLists(text: string): string {
16
18
  }
17
19
  if (!items.length) return _;
18
20
  const lis = items.map(item => `<li>${item}</li>`).join('');
19
- return `<ul class="concept-list">${lis}</ul>`;
21
+ return `\n<ul class="concept-list">${lis}</ul>`;
20
22
  });
23
+
24
+ // Ordered: 1) item or 1. item (numbered items)
25
+ result = result.replace(/(?:^|\n)((?:[ \t]*\d+[).][ \t]+[^\n]+)(?:\n[ \t]*\d+[).][ \t]+[^\n]+)*)/g, (_, block) => {
26
+ const items: string[] = [];
27
+ const re = /[ \t]*\d+[).][ \t]+([^\n]+)/g;
28
+ let m;
29
+ while ((m = re.exec(block)) !== null) {
30
+ items.push(m[1].trim());
31
+ }
32
+ if (!items.length) return _;
33
+ const lis = items.map(item => `<li>${item}</li>`).join('');
34
+ return `\n<ol class="concept-list concept-list-ordered">${lis}</ol>`;
35
+ });
36
+
37
+ return result;
21
38
  }
22
39
 
23
40
  /**
@@ -37,6 +37,11 @@ onMounted(async () => {
37
37
 
38
38
  <template>
39
39
  <div class="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
40
+ <nav aria-label="Breadcrumb" class="flex items-center gap-1.5 text-sm text-ink-400 mb-6">
41
+ <router-link :to="{ name: 'home' }" class="hover:text-ink-700 transition-colors">Home</router-link>
42
+ <span class="text-ink-200">/</span>
43
+ <span class="text-ink-700">Contributors</span>
44
+ </nav>
40
45
  <h1 class="font-serif text-3xl text-ink-800 mb-2">Contributors</h1>
41
46
  <p class="text-ink-400 mb-8">
42
47
  Organizations and individuals contributing to {{ config?.branding?.ownerName || config?.title || 'this glossary' }}.
@@ -224,10 +224,15 @@ function goToPage(p: number) {
224
224
  </svg>
225
225
  </div>
226
226
  <span class="text-sm text-ink-400">
227
- {{ filter.trim()
228
- ? `${filtered.length.toLocaleString()} of ${totalConceptCount.toLocaleString()} concepts`
229
- : `${totalConceptCount.toLocaleString()} concepts`
230
- }}
227
+ <template v-if="filter.trim()">
228
+ {{ filtered.length.toLocaleString() }} of {{ totalConceptCount.toLocaleString() }} concepts
229
+ </template>
230
+ <template v-else-if="totalPages > 1">
231
+ {{ ((page - 1) * perPage + 1).toLocaleString() }}–{{ Math.min(page * perPage, totalConceptCount).toLocaleString() }} of {{ totalConceptCount.toLocaleString() }} concepts
232
+ </template>
233
+ <template v-else>
234
+ {{ totalConceptCount.toLocaleString() }} concepts
235
+ </template>
231
236
  </span>
232
237
  </div>
233
238
 
@@ -43,10 +43,10 @@ onMounted(async () => {
43
43
  <template>
44
44
  <div class="flex flex-col" style="height: calc(100vh - 56px)">
45
45
  <div class="px-4 sm:px-6 py-3 border-b border-ink-100/60 bg-surface-raised flex items-center gap-3 flex-shrink-0">
46
- <nav aria-label="Breadcrumb" class="flex items-center gap-1.5 text-sm">
47
- <router-link :to="{ name: 'home' }" class="text-ink-400 hover:text-ink-700 transition-colors">Home</router-link>
46
+ <nav aria-label="Breadcrumb" class="flex items-center gap-1.5 text-sm text-ink-400">
47
+ <router-link :to="{ name: 'home' }" class="hover:text-ink-700 transition-colors">Home</router-link>
48
48
  <span class="text-ink-200">/</span>
49
- <span class="text-ink-700 font-medium">Graph View</span>
49
+ <span class="text-ink-700">Graph View</span>
50
50
  </nav>
51
51
  <span class="text-xs text-ink-300 ml-1">
52
52
  {{ graphEdges.length.toLocaleString() }} edges
@@ -80,8 +80,7 @@ function goToGraph() { router.push({ name: 'graph' }); }
80
80
  <template v-if="!siteConfig?.subtitle">Terminology<br class="hidden sm:block" /> Register</template>
81
81
  </h1>
82
82
  <p class="text-base text-ink-400 max-w-lg leading-relaxed">
83
- Explore standardized terminology datasets from ISO and IEC technical committees.
84
- Browse concepts, definitions, and cross-references across multilingual vocabularies.
83
+ {{ siteConfig?.description || 'Explore standardized terminology datasets from ISO and IEC technical committees. Browse concepts, definitions, and cross-references across multilingual vocabularies.' }}
85
84
  </p>
86
85
  <div class="flex flex-wrap gap-3 mt-7">
87
86
  <button @click="goToSearch" class="btn-primary flex items-center gap-2">
@@ -121,7 +120,12 @@ function goToGraph() { router.push({ name: 'graph' }); }
121
120
  <div class="section-label mb-0">Available Datasets</div>
122
121
  <span class="text-xs text-ink-300">Click to browse</span>
123
122
  </div>
124
- <div class="grid grid-cols-1 md:grid-cols-3 gap-4">
123
+ <div :class="[
124
+ filteredDatasets.length === 1 ? 'max-w-md' : '',
125
+ filteredDatasets.length === 2 ? 'max-w-3xl' : '',
126
+ 'grid gap-4',
127
+ filteredDatasets.length === 1 ? 'grid-cols-1' : 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3'
128
+ ]">
125
129
  <button
126
130
  v-for="(ds, idx) in filteredDatasets"
127
131
  :key="ds.id"
@@ -75,6 +75,11 @@ function formatDate(dateStr: string) {
75
75
 
76
76
  <template>
77
77
  <div class="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
78
+ <nav aria-label="Breadcrumb" class="flex items-center gap-1.5 text-sm text-ink-400 mb-6">
79
+ <router-link :to="{ name: 'home' }" class="hover:text-ink-700 transition-colors">Home</router-link>
80
+ <span class="text-ink-200">/</span>
81
+ <span class="text-ink-700">News</span>
82
+ </nav>
78
83
  <h1 class="font-serif text-3xl text-ink-800 mb-2">News</h1>
79
84
  <p class="text-ink-400 mb-8">
80
85
  Updates from {{ config?.branding?.ownerName || config?.title || 'Glossarist' }}.
@@ -53,7 +53,7 @@ onMounted(async () => {
53
53
  <p class="text-ink-500 mb-2">The following concept URI could not be resolved:</p>
54
54
  <code class="text-sm text-ink-600 break-all bg-ink-50 px-3 py-2 rounded">{{ uri }}</code>
55
55
  <div class="mt-8">
56
- <router-link :to="{ name: 'home' }" class="text-blue-600 hover:underline">Return to home</router-link>
56
+ <router-link :to="{ name: 'home' }" class="concept-link">Return to home</router-link>
57
57
  </div>
58
58
  </template>
59
59
  <template v-else>