@glossarist/concept-browser 0.6.0 → 0.7.0

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.6.0",
3
+ "version": "0.7.0",
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": {
@@ -1113,13 +1113,21 @@ for (const key of ['logo', 'footerLogo']) {
1113
1113
  }
1114
1114
  }
1115
1115
 
1116
+ // Build dataset translations map
1117
+ const datasetTranslations = {};
1118
+ for (const d of config.datasets) {
1119
+ if (d.translations) datasetTranslations[d.id] = d.translations;
1120
+ }
1121
+
1116
1122
  writeJson(path.join(PUBLIC, 'site-config.json'), {
1117
1123
  id: config.id,
1118
1124
  domain: config.domain,
1119
1125
  title: config.title,
1120
1126
  subtitle: config.subtitle,
1121
1127
  description: config.description,
1128
+ translations: config.translations || undefined,
1122
1129
  datasets: config.datasets.map(d => d.id),
1130
+ datasetTranslations: Object.keys(datasetTranslations).length ? datasetTranslations : undefined,
1123
1131
  defaultDataset: config.datasets.length === 1 ? config.datasets[0].id : undefined,
1124
1132
  uiLanguages: config.uiLanguages || undefined,
1125
1133
  branding: siteBranding,
@@ -9,7 +9,7 @@ import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
9
9
  const router = useRouter();
10
10
  const ui = useUiStore();
11
11
  const store = useVocabularyStore();
12
- const { config: siteConfig } = useSiteConfig();
12
+ const { config: siteConfig, localizedTitle } = useSiteConfig();
13
13
  const { locale, t, setLocale } = useI18n();
14
14
  const searchInput = ref('');
15
15
  const langOpen = ref(false);
@@ -77,7 +77,7 @@ onBeforeUnmount(() => document.removeEventListener('click', closeLangOnOutside))
77
77
  <line x1="9" y1="11" x2="15" y2="11"/>
78
78
  </svg>
79
79
  </div>
80
- <span class="font-serif text-lg text-ink-800 leading-none hidden sm:inline">{{ siteConfig?.title || 'Glossarist' }}</span>
80
+ <span class="font-serif text-lg text-ink-800 leading-none hidden sm:inline">{{ localizedTitle }}</span>
81
81
  </button>
82
82
 
83
83
  <!-- Search -->
@@ -14,7 +14,7 @@ const ui = useUiStore();
14
14
  const router = useRouter();
15
15
  const route = useRoute();
16
16
  const { getColor } = useDsStyle();
17
- const { globalPages, datasetPages, config: siteConfig } = useSiteConfig();
17
+ const { globalPages, datasetPages, config: siteConfig, localizedTitle, localizedDatasetField } = useSiteConfig();
18
18
  const { t } = useI18n();
19
19
 
20
20
  const currentDataset = computed(() => route.params.registerId as string ?? '');
@@ -127,6 +127,13 @@ function selectProperty(id: string) {
127
127
  }
128
128
 
129
129
  const isSearching = computed(() => !!searchQuery.value.trim());
130
+
131
+ function navTitle(page: { route: string }): string {
132
+ const route = page.route || 'home';
133
+ const key = `nav.${route}`;
134
+ const translated = t(key);
135
+ return translated === key ? (page as any).title : translated;
136
+ }
130
137
  </script>
131
138
 
132
139
  <template>
@@ -151,7 +158,7 @@ const isSearching = computed(() => !!searchQuery.value.trim());
151
158
  @click="closeMobile"
152
159
  >
153
160
  <NavIcon :name="page.icon" />
154
- {{ page.title }}
161
+ {{ navTitle(page) }}
155
162
  </router-link>
156
163
 
157
164
  <!-- Ontology entity sections nested under Ontology nav item -->
@@ -424,7 +431,7 @@ const isSearching = computed(() => !!searchQuery.value.trim());
424
431
 
425
432
  <!-- Dataset-level navigation (shown when viewing a dataset) -->
426
433
  <div v-if="showDatasetNav" class="mb-6">
427
- <div class="section-label">{{ currentManifest?.title || siteConfig?.title || 'Dataset' }}</div>
434
+ <div class="section-label">{{ localizedDatasetField(currentDataset, 'title', currentManifest?.title || siteConfig?.title || 'Dataset') }}</div>
428
435
  <nav class="space-y-0.5">
429
436
  <router-link
430
437
  v-for="page in datasetPages"
@@ -435,7 +442,7 @@ const isSearching = computed(() => !!searchQuery.value.trim());
435
442
  @click="closeMobile"
436
443
  >
437
444
  <NavIcon :name="page.icon" />
438
- {{ page.title }}
445
+ {{ navTitle(page) }}
439
446
  </router-link>
440
447
  </nav>
441
448
  </div>
@@ -455,7 +462,7 @@ const isSearching = computed(() => !!searchQuery.value.trim());
455
462
  ]"
456
463
  :style="currentDataset === ds.id ? { borderLeftColor: getColor(ds.id), borderLeftWidth: '2px' } : {}"
457
464
  >
458
- <div class="font-medium truncate leading-snug">{{ ds.title }}</div>
465
+ <div class="font-medium truncate leading-snug">{{ localizedDatasetField(ds.id, 'title', ds.title) }}</div>
459
466
  <div v-if="ds.loaded" class="text-xs mt-0.5" :class="currentDataset === ds.id ? 'text-ink-400' : 'text-ink-300'">
460
467
  {{ ds.conceptCount.toLocaleString() }} {{ t('home.concepts').toLowerCase() }}
461
468
  </div>
@@ -4,6 +4,7 @@ import { computed } from 'vue';
4
4
  import { useRouter } from 'vue-router';
5
5
  import { useDsStyle } from '../utils/dataset-style';
6
6
  import { useVocabularyStore } from '../stores/vocabulary';
7
+ import { useI18n } from '../i18n';
7
8
 
8
9
  const props = defineProps<{
9
10
  entry: ConceptSummary;
@@ -14,6 +15,7 @@ const props = defineProps<{
14
15
  const router = useRouter();
15
16
  const { getColor } = useDsStyle();
16
17
  const store = useVocabularyStore();
18
+ const { locale } = useI18n();
17
19
 
18
20
  function viewConcept() {
19
21
  router.push({
@@ -32,8 +34,9 @@ function statusColor(status: string): string {
32
34
  const manifestLanguages = computed(() => store.manifests.get(props.registerId)?.languages ?? []);
33
35
 
34
36
  const displayTitle = computed(() => {
35
- if (props.displayLang && props.entry.designations?.[props.displayLang]) {
36
- return props.entry.designations[props.displayLang];
37
+ const lang = props.displayLang || locale.value;
38
+ if (props.entry.designations?.[lang]) {
39
+ return props.entry.designations[lang];
37
40
  }
38
41
  return props.entry.eng || props.entry.id;
39
42
  });
@@ -366,7 +366,7 @@ const nonVerbalReps = computed(() => {
366
366
  <!-- Breadcrumb + nav row -->
367
367
  <div class="flex items-start gap-2 mb-3">
368
368
  <nav aria-label="Breadcrumb" class="flex items-center gap-1.5 text-sm text-ink-400 min-w-0 flex-1 flex-wrap">
369
- <router-link :to="{ name: 'home' }" class="hover:text-ink-700 transition-colors whitespace-nowrap">Home</router-link>
369
+ <router-link :to="{ name: 'home' }" class="hover:text-ink-700 transition-colors whitespace-nowrap">{{ t('nav.home') }}</router-link>
370
370
  <span class="text-ink-200">/</span>
371
371
  <router-link :to="{ name: 'dataset', params: { registerId: manifest.id }}" class="hover:text-ink-700 transition-colors truncate max-w-[180px]">
372
372
  {{ manifest.title }}
@@ -4,6 +4,7 @@ import type { GraphNode, GraphEdge } from '../adapters/types';
4
4
  import type { SimulationNodeDatum, SimulationLinkDatum } from 'd3';
5
5
  import { useDsStyle } from '../utils/dataset-style';
6
6
  import { useUiStore } from '../stores/ui';
7
+ import { useI18n } from '../i18n';
7
8
  import {
8
9
  forceSimulation,
9
10
  forceLink,
@@ -53,6 +54,7 @@ watch(() => props.registers, (regs) => {
53
54
 
54
55
  const { getColor } = useDsStyle();
55
56
  const uiStore = useUiStore();
57
+ const { t } = useI18n();
56
58
 
57
59
  const STUB_COLOR = '#b8b9cc'; // ink-200
58
60
  const HIGHLIGHT_COLOR = '#1a1b2e'; // ink-800
@@ -466,11 +468,11 @@ function selectedNodeColor(): string {
466
468
  <div class="bg-surface-raised/95 backdrop-blur rounded-xl border border-ink-100/60 overflow-hidden" style="box-shadow: 0 4px 12px rgba(26, 27, 46, 0.08);">
467
469
  <button
468
470
  @click="panelOpen = !panelOpen"
469
- :aria-label="panelOpen ? 'Collapse controls' : 'Expand controls'"
471
+ :aria-label="panelOpen ? t('graph.collapseControls') : t('graph.expandControls')"
470
472
  class="w-full px-4 py-2.5 flex items-center justify-between hover:bg-ink-50/50 transition-colors"
471
473
  >
472
474
  <span class="text-xs font-semibold text-ink-600 tracking-wide">
473
- {{ nodeCount.toLocaleString() }} nodes &middot; {{ edgeCount.toLocaleString() }} edges
475
+ {{ nodeCount.toLocaleString() }} {{ t('graph.nodes') }} &middot; {{ edgeCount.toLocaleString() }} {{ t('graph.edges') }}
474
476
  </span>
475
477
  <svg class="w-3.5 h-3.5 text-ink-300 transition-transform" :class="panelOpen ? 'rotate-180' : ''" fill="none" stroke="currentColor" viewBox="0 0 24 24">
476
478
  <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
@@ -481,9 +483,9 @@ function selectedNodeColor(): string {
481
483
  <!-- Dataset toggles -->
482
484
  <div class="mt-3 space-y-2">
483
485
  <div class="flex items-center gap-2 mb-3">
484
- <button @click="toggleAll(true)" class="text-[10px] font-semibold text-ink-500 hover:text-ink-700 uppercase tracking-wide transition-colors">All</button>
486
+ <button @click="toggleAll(true)" class="text-[10px] font-semibold text-ink-500 hover:text-ink-700 uppercase tracking-wide transition-colors">{{ t('graph.all') }}</button>
485
487
  <span class="text-ink-200 text-xs">|</span>
486
- <button @click="toggleAll(false)" class="text-[10px] font-semibold text-ink-500 hover:text-ink-700 uppercase tracking-wide transition-colors">None</button>
488
+ <button @click="toggleAll(false)" class="text-[10px] font-semibold text-ink-500 hover:text-ink-700 uppercase tracking-wide transition-colors">{{ t('graph.none') }}</button>
487
489
  </div>
488
490
  <div
489
491
  v-for="reg in registers"
@@ -515,32 +517,32 @@ function selectedNodeColor(): string {
515
517
 
516
518
  <!-- Actions -->
517
519
  <div v-if="isCapped" class="text-[10px] text-amber-600 mt-2 leading-relaxed">
518
- Rendering first {{ MAX_RENDER_NODES.toLocaleString() }} of {{ nodeCount.toLocaleString() }} nodes.
520
+ {{ t('graph.renderingWarning', { max: MAX_RENDER_NODES.toLocaleString(), total: nodeCount.toLocaleString() }) }}
519
521
  </div>
520
522
 
521
523
  <div v-if="nodeCount > 0" class="flex gap-4 mt-3 pt-3 border-t border-ink-100/40">
522
- <button @click="resetZoom" class="text-[10px] font-semibold text-ink-500 hover:text-ink-700 uppercase tracking-wide transition-colors">Reset zoom</button>
523
- <button @click="rebuildGraph" class="text-[10px] font-semibold text-ink-500 hover:text-ink-700 uppercase tracking-wide transition-colors">Re-layout</button>
524
+ <button @click="resetZoom" class="text-[10px] font-semibold text-ink-500 hover:text-ink-700 uppercase tracking-wide transition-colors">{{ t('graph.resetZoom') }}</button>
525
+ <button @click="rebuildGraph" class="text-[10px] font-semibold text-ink-500 hover:text-ink-700 uppercase tracking-wide transition-colors">{{ t('graph.reLayout') }}</button>
524
526
  </div>
525
527
 
526
528
  <div v-if="nodeCount > 0" class="mt-3 pt-3 border-t border-ink-100/40">
527
- <div class="text-[10px] font-semibold text-ink-400 uppercase tracking-wide mb-2">Node labels</div>
529
+ <div class="text-[10px] font-semibold text-ink-400 uppercase tracking-wide mb-2">{{ t('graph.nodeLabels') }}</div>
528
530
  <div class="flex gap-1">
529
531
  <button
530
532
  @click="labelMode = 'designation'; rebuildGraph()"
531
533
  class="text-[10px] px-2 py-1 rounded font-medium transition-colors"
532
534
  :class="labelMode === 'designation' ? 'bg-ink-800 text-white' : 'text-ink-500 hover:bg-ink-50'"
533
- >Designation</button>
535
+ >{{ t('graph.designation') }}</button>
534
536
  <button
535
537
  @click="labelMode = 'identifier'; rebuildGraph()"
536
538
  class="text-[10px] px-2 py-1 rounded font-medium transition-colors"
537
539
  :class="labelMode === 'identifier' ? 'bg-ink-800 text-white' : 'text-ink-500 hover:bg-ink-50'"
538
- >Identifier</button>
540
+ >{{ t('graph.identifier') }}</button>
539
541
  </div>
540
542
  </div>
541
543
 
542
544
  <div v-if="nodeCount === 0" class="text-xs text-ink-300 mt-3 leading-relaxed">
543
- {{ props.edges.length > 0 ? 'Enable datasets to see their graph.' : 'Browse concepts with cross-references to populate the graph.' }}
545
+ {{ props.edges.length > 0 ? t('graph.enableDatasets') : t('graph.browseToPopulate') }}
544
546
  </div>
545
547
  </div>
546
548
  </div>
@@ -549,7 +551,7 @@ function selectedNodeColor(): string {
549
551
  <!-- Legend -->
550
552
  <div v-if="nodeCount > 0" class="absolute top-4 right-4 z-10 bg-surface-raised/90 backdrop-blur rounded-lg px-3 py-2.5 border border-ink-100/60 text-xs" style="box-shadow: 0 2px 6px rgba(26, 27, 46, 0.04);">
551
553
  <div v-if="registers.length > 1">
552
- <div class="font-semibold text-ink-400 text-[10px] uppercase tracking-wide mb-2">Datasets</div>
554
+ <div class="font-semibold text-ink-400 text-[10px] uppercase tracking-wide mb-2">{{ t('graph.datasets') }}</div>
553
555
  <div v-for="reg in registers" :key="reg.id" class="flex items-center gap-2 mb-1.5 last:mb-0">
554
556
  <span
555
557
  class="w-2.5 h-2.5 rounded-full inline-block flex-shrink-0"
@@ -560,11 +562,11 @@ function selectedNodeColor(): string {
560
562
  </div>
561
563
  <div class="flex items-center gap-2 mt-2 pt-2 border-t border-ink-100/40">
562
564
  <span class="w-2 h-2 rounded-full inline-block" :style="{ backgroundColor: STUB_COLOR }"></span>
563
- <span class="text-ink-300">Stub (not loaded)</span>
565
+ <span class="text-ink-300">{{ t('graph.stubLabel') }}</span>
564
566
  </div>
565
567
  <div class="flex items-center gap-2 mt-2 pt-2 border-t border-ink-100/40">
566
568
  <span class="w-4 h-2 rounded inline-block flex-shrink-0" style="background: #ede9fe; border: 1px solid #8b5cf6;"></span>
567
- <span class="text-ink-300">Domain (standard)</span>
569
+ <span class="text-ink-300">{{ t('graph.domainLabel') }}</span>
568
570
  </div>
569
571
  </div>
570
572
 
@@ -590,7 +592,7 @@ function selectedNodeColor(): string {
590
592
  ></span>
591
593
  <span class="text-[10px] text-ink-400 uppercase tracking-wide">
592
594
  {{ registerTitle(selectedNode.register) }} &middot;
593
- {{ selectedNode.loaded ? 'loaded' : 'stub' }}
595
+ {{ selectedNode.loaded ? t('graph.loadedStatus') : t('graph.stubStatus') }}
594
596
  </span>
595
597
  </div>
596
598
  </div>
@@ -603,7 +605,7 @@ function selectedNodeColor(): string {
603
605
  :to="{ name: 'concept', params: { registerId: selectedNode.register, conceptId: selectedNode.conceptId } }"
604
606
  class="btn-primary text-xs mt-4 inline-block"
605
607
  >
606
- View concept
608
+ {{ t('graph.viewConcept') }}
607
609
  </router-link>
608
610
  </div>
609
611
  </div>
@@ -6,11 +6,13 @@ import { ref, watch, onMounted, computed, nextTick } from 'vue';
6
6
  import type { SearchHit } from '../adapters/types';
7
7
  import { langLabel, langName } from '../utils/lang';
8
8
  import { useDsStyle } from '../utils/dataset-style';
9
+ import { useI18n } from '../i18n';
9
10
 
10
11
  const router = useRouter();
11
12
  const ui = useUiStore();
12
13
  const store = useVocabularyStore();
13
14
  const { getStyle } = useDsStyle();
15
+ const { t } = useI18n();
14
16
  const query = ref('');
15
17
  const results = ref<SearchHit[]>([]);
16
18
  const searched = ref(false);
@@ -154,7 +156,6 @@ onMounted(() => {
154
156
  @input="onInput"
155
157
  @keydown="onKeydown"
156
158
  type="text"
157
- aria-label="Search terms across all datasets"
158
159
  placeholder="Search terms across all datasets..."
159
160
  class="w-full pl-9 pr-8 py-2.5 text-sm bg-surface border border-ink-100 rounded-lg focus:ring-2 focus:ring-ink-200 focus:border-ink-400 outline-none placeholder:text-ink-300 transition-all"
160
161
  autofocus
@@ -175,7 +176,7 @@ onMounted(() => {
175
176
  <svg class="w-4 h-4" 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>
176
177
  </button>
177
178
  </div>
178
- <button type="submit" class="btn-primary" :disabled="loading">Search</button>
179
+ <button type="submit" class="btn-primary" :disabled="loading">{{ t('search.button') }}</button>
179
180
  </div>
180
181
  </form>
181
182
 
@@ -184,23 +185,23 @@ onMounted(() => {
184
185
  <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
185
186
  <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>
186
187
  </svg>
187
- <p class="text-sm text-ink-400">Searching across datasets...</p>
188
+ <p class="text-sm text-ink-400">{{ t('search.searching') }}</p>
188
189
  </div>
189
190
 
190
191
  <div v-else-if="searchError" class="text-center py-16">
191
192
  <div class="card p-8 border-red-200 bg-red-50/50 max-w-md mx-auto">
192
- <p class="text-red-700 font-medium mb-1">Search failed</p>
193
+ <p class="text-red-700 font-medium mb-1">{{ t('search.failed') }}</p>
193
194
  <p class="text-sm text-red-600/80 mb-4">{{ searchError }}</p>
194
- <button @click="doSearch" class="btn-primary">Retry</button>
195
+ <button @click="doSearch" class="btn-primary">{{ t('search.retry') }}</button>
195
196
  </div>
196
197
  </div>
197
198
 
198
199
  <div v-else-if="searched">
199
- <p class="text-sm text-ink-400 mb-4">{{ results.length }} result{{ results.length === 1 ? '' : 's' }} for &ldquo;{{ ui.searchQuery }}&rdquo;</p>
200
+ <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>
200
201
  <div v-if="results.length === 0" class="text-center py-16">
201
202
  <div class="text-ink-200 text-5xl mb-4 font-serif">&empty;</div>
202
- <p class="text-ink-500 font-medium">No concepts found matching your search</p>
203
- <p class="text-sm text-ink-300 mt-1">Try a different term or check the spelling.</p>
203
+ <p class="text-ink-500 font-medium">{{ t('search.noResults') }}</p>
204
+ <p class="text-sm text-ink-300 mt-1">{{ t('search.tryDifferent') }}</p>
204
205
  </div>
205
206
 
206
207
  <!-- Grouped results -->
@@ -210,7 +211,7 @@ onMounted(() => {
210
211
  <div class="flex items-center gap-2 mb-2">
211
212
  <span class="w-2 h-2 rounded-full flex-shrink-0" :style="{ backgroundColor: group.style.color }"></span>
212
213
  <span class="text-xs font-semibold text-ink-500 uppercase tracking-wide">{{ group.title }}</span>
213
- <span class="text-xs text-ink-300">{{ group.hits.length }} result{{ group.hits.length === 1 ? '' : 's' }}</span>
214
+ <span class="text-xs text-ink-300">{{ group.hits.length }} {{ group.hits.length === 1 ? t('search.result') : t('search.results') }}</span>
214
215
  </div>
215
216
  <!-- Hits -->
216
217
  <div class="space-y-1.5">
@@ -227,7 +228,7 @@ onMounted(() => {
227
228
  <span v-if="hit.snippet" class="block text-xs text-ink-300 mt-0.5 truncate">{{ hit.snippet }}</span>
228
229
  </div>
229
230
  <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>
231
+ <span v-if="hit.matchField === 'id'" class="badge badge-gray text-[10px]">{{ t('search.idMatch') }}</span>
231
232
  <span class="text-xs font-semibold text-ink-500 bg-ink-50 px-1.5 py-0.5 rounded">{{ langName(hit.language) }}</span>
232
233
  </div>
233
234
  </button>
@@ -236,7 +237,7 @@ onMounted(() => {
236
237
  </div>
237
238
 
238
239
  <div v-if="results.length > 100" class="text-center text-sm text-ink-300 mt-6 pt-4 border-t border-ink-100/60">
239
- Showing first 100 of {{ results.length }} results. Refine your search for more specific matches.
240
+ {{ t('search.showingFirst', { max: '100', total: String(results.length) }) }}
240
241
  </div>
241
242
  </div>
242
243
  </div>
@@ -91,6 +91,7 @@ export interface DatasetConfig {
91
91
  tags?: string[];
92
92
  languageOrder?: string[];
93
93
  downloads?: string[];
94
+ translations?: Record<string, { title?: string; description?: string }>;
94
95
  }
95
96
 
96
97
  // === Contributors ===
@@ -141,6 +142,7 @@ export interface SiteConfig {
141
142
  title: string;
142
143
  subtitle?: string;
143
144
  description?: string;
145
+ translations?: Record<string, { title?: string; subtitle?: string; description?: string }>;
144
146
  datasets: DatasetConfig[];
145
147
  routing: RoutingEntry[];
146
148
  branding: SiteBranding;
@@ -1,5 +1,6 @@
1
1
  import { ref, computed } from 'vue';
2
2
  import type { PageConfig } from './types';
3
+ import { locale } from '../i18n';
3
4
 
4
5
  export interface RuntimeSiteConfig {
5
6
  id: string;
@@ -8,6 +9,8 @@ export interface RuntimeSiteConfig {
8
9
  title: string;
9
10
  subtitle?: string;
10
11
  description?: string;
12
+ translations?: Record<string, { title?: string; subtitle?: string; description?: string }>;
13
+ datasetTranslations?: Record<string, Record<string, { title?: string; description?: string }>>;
11
14
  datasets: string[];
12
15
  defaultDataset?: string;
13
16
  uiLanguages?: { code: string; label: string }[];
@@ -151,6 +154,26 @@ export function useSiteConfig() {
151
154
  const config = computed(() => siteConfig.value);
152
155
  const visibleDatasets = computed(() => siteConfig.value?.datasets ?? []);
153
156
 
157
+ const localizedTitle = computed(() => {
158
+ const tr = siteConfig.value?.translations?.[locale.value];
159
+ return tr?.title ?? siteConfig.value?.title ?? 'Glossarist';
160
+ });
161
+
162
+ const localizedSubtitle = computed(() => {
163
+ const tr = siteConfig.value?.translations?.[locale.value];
164
+ return tr?.subtitle ?? siteConfig.value?.subtitle;
165
+ });
166
+
167
+ const localizedDescription = computed(() => {
168
+ const tr = siteConfig.value?.translations?.[locale.value];
169
+ return tr?.description ?? siteConfig.value?.description;
170
+ });
171
+
172
+ function localizedDatasetField(datasetId: string, field: 'title' | 'description', fallback?: string): string {
173
+ const tr = siteConfig.value?.datasetTranslations?.[datasetId]?.[locale.value];
174
+ return tr?.[field] ?? fallback ?? '';
175
+ }
176
+
154
177
  const globalPages = computed<PageConfig[]>(() =>
155
178
  synthesizeGlobalPages(siteConfig.value?.features, siteConfig.value?.pages),
156
179
  );
@@ -159,5 +182,5 @@ export function useSiteConfig() {
159
182
  synthesizeDatasetPages(siteConfig.value?.features, siteConfig.value?.pages),
160
183
  );
161
184
 
162
- return { config, visibleDatasets, loadConfig, globalPages, datasetPages };
185
+ return { config, visibleDatasets, localizedTitle, localizedSubtitle, localizedDescription, localizedDatasetField, loadConfig, globalPages, datasetPages };
163
186
  }
package/src/i18n/index.ts CHANGED
@@ -20,6 +20,8 @@ const stored = typeof localStorage !== 'undefined'
20
20
  : DEFAULT_LANG;
21
21
  const locale = ref(stored);
22
22
 
23
+ export { locale };
24
+
23
25
  export function useI18n() {
24
26
  function t(key: string, params?: Record<string, string>): string {
25
27
  let msg = messages[locale.value]?.[key] || messages[DEFAULT_LANG]?.[key] || key;
@@ -7,7 +7,7 @@ nav.search: Search
7
7
  nav.graph: Graph View
8
8
  nav.ontology: Ontology
9
9
  nav.concepts: Concepts
10
- nav.statistics: Statistics
10
+ nav.stats: Statistics
11
11
  nav.about: About
12
12
  nav.navigation: Navigation
13
13
  nav.datasets: Datasets
@@ -27,6 +27,19 @@ home.browseConcepts: Browse concepts
27
27
  # Search
28
28
  search.placeholder: Search...
29
29
  search.conceptSearch: Search concepts
30
+ search.fullPlaceholder: Search terms across all datasets...
31
+ search.button: Search
32
+ search.searching: Searching across datasets...
33
+ search.failed: Search failed
34
+ search.retry: Retry
35
+ search.result: result
36
+ search.results: results
37
+ search.oneResultFor: '1 result for "{query}"'
38
+ search.manyResultsFor: '{count} results for "{query}"'
39
+ search.noResults: No concepts found matching your search
40
+ search.tryDifferent: Try a different term or check the spelling.
41
+ search.showingFirst: Showing first {max} of {total} results. Refine your search for more specific matches.
42
+ search.idMatch: ID match
30
43
 
31
44
  # Page view
32
45
  page.notFound: Page Not Found
@@ -64,3 +77,52 @@ header.datasets: datasets
64
77
 
65
78
  # Language
66
79
  lang.termOnlyIn: Term only in
80
+
81
+ # Dataset view
82
+ dataset.concepts: concepts
83
+ dataset.languages: languages
84
+ dataset.filterPlaceholder: "Filter concepts... (press /)"
85
+ dataset.of: of
86
+ dataset.in: in
87
+ dataset.all: All
88
+ dataset.prev: Prev
89
+ dataset.next: Next
90
+ dataset.noMatch: No concepts match your filter
91
+ dataset.clearFilter: Clear filter
92
+ dataset.noConcepts: This dataset has no concepts
93
+ dataset.failedToLoad: Failed to load dataset
94
+ dataset.retry: Retry
95
+ dataset.backToHome: Back to home
96
+ dataset.download: Download
97
+
98
+ # Statistics view
99
+ stats.title: Statistics
100
+ stats.summary: "{count} concepts across {langCount} languages."
101
+ stats.language: Language
102
+ stats.terms: Terms
103
+ stats.definitions: Definitions
104
+ stats.failedToLoad: Failed to load statistics
105
+ stats.retry: Retry
106
+
107
+ # Graph
108
+ graph.nodes: nodes
109
+ graph.edges: edges
110
+ graph.all: All
111
+ graph.none: None
112
+ graph.resetZoom: Reset zoom
113
+ graph.reLayout: Re-layout
114
+ graph.nodeLabels: Node labels
115
+ graph.designation: Designation
116
+ graph.identifier: Identifier
117
+ graph.viewConcept: View concept
118
+ graph.stubLabel: Stub (not loaded)
119
+ graph.domainLabel: Domain (standard)
120
+ graph.datasets: Datasets
121
+ graph.enableDatasets: Enable datasets to see their graph.
122
+ graph.browseToPopulate: Browse concepts with cross-references to populate the graph.
123
+ graph.renderingWarning: "Rendering first {max} of {total} nodes."
124
+ graph.loadedStatus: loaded
125
+ graph.stubStatus: stub
126
+ graph.collapseControls: Collapse controls
127
+ graph.expandControls: Expand controls
128
+ graph.loading: Loading graph data...
@@ -7,7 +7,7 @@ nav.search: Recherche
7
7
  nav.graph: Vue en graphe
8
8
  nav.ontology: Ontologie
9
9
  nav.concepts: Concepts
10
- nav.statistics: Statistiques
10
+ nav.stats: Statistiques
11
11
  nav.about: À propos
12
12
  nav.navigation: Navigation
13
13
  nav.datasets: Jeux de données
@@ -27,11 +27,24 @@ home.browseConcepts: Parcourir les concepts
27
27
  # Search
28
28
  search.placeholder: Rechercher…
29
29
  search.conceptSearch: Rechercher des concepts
30
+ search.fullPlaceholder: Rechercher des termes dans tous les jeux de données…
31
+ search.button: Rechercher
32
+ search.searching: Recherche dans les jeux de données...
33
+ search.failed: Échec de la recherche
34
+ search.retry: Réessayer
35
+ search.result: résultat
36
+ search.results: résultats
37
+ search.oneResultFor: '1 résultat pour « {query} »'
38
+ search.manyResultsFor: '{count} résultats pour « {query} »'
39
+ search.noResults: Aucun concept trouvé correspondant à votre recherche
40
+ search.tryDifferent: Essayez un autre terme ou vérifiez l'orthographe.
41
+ search.showingFirst: Affichage des {max} premiers résultats sur {total}. Affinez votre recherche.
42
+ search.idMatch: Correspondance d'identifiant
30
43
 
31
44
  # Page view
32
45
  page.notFound: Page non trouvée
33
46
  page.notFoundMsg: 'La page « {name} » n''existe pas.'
34
- page.goHome: Aller à l'accueil
47
+ page.goHome: Aller à l''accueil
35
48
 
36
49
  # Concept detail
37
50
  concept.definition: Définition
@@ -64,3 +77,52 @@ header.datasets: jeux de données
64
77
 
65
78
  # Language
66
79
  lang.termOnlyIn: Terme uniquement en
80
+
81
+ # Dataset view
82
+ dataset.concepts: concepts
83
+ dataset.languages: langues
84
+ dataset.filterPlaceholder: "Filtrer les concepts… (appuyez sur /)"
85
+ dataset.of: de
86
+ dataset.in: en
87
+ dataset.all: Tous
88
+ dataset.prev: Préc.
89
+ dataset.next: Suiv.
90
+ dataset.noMatch: Aucun concept ne correspond au filtre
91
+ dataset.clearFilter: Effacer le filtre
92
+ dataset.noConcepts: Ce jeu de données n'a aucun concept
93
+ dataset.failedToLoad: Échec du chargement du jeu de données
94
+ dataset.retry: Réessayer
95
+ dataset.backToHome: Retour à l'accueil
96
+ dataset.download: Télécharger
97
+
98
+ # Statistics view
99
+ stats.title: Statistiques
100
+ stats.summary: "{count} concepts dans {langCount} langues."
101
+ stats.language: Langue
102
+ stats.terms: Termes
103
+ stats.definitions: Définitions
104
+ stats.failedToLoad: Échec du chargement des statistiques
105
+ stats.retry: Réessayer
106
+
107
+ # Graph
108
+ graph.nodes: nœuds
109
+ graph.edges: arêtes
110
+ graph.all: Tous
111
+ graph.none: Aucun
112
+ graph.resetZoom: Réinitialiser le zoom
113
+ graph.reLayout: Réorganiser
114
+ graph.nodeLabels: Étiquettes des nœuds
115
+ graph.designation: Désignation
116
+ graph.identifier: Identifiant
117
+ graph.viewConcept: Voir le concept
118
+ graph.stubLabel: Non chargé
119
+ graph.domainLabel: Domaine (standard)
120
+ graph.datasets: Jeux de données
121
+ graph.enableDatasets: Activez les jeux de données pour voir leur graphe.
122
+ graph.browseToPopulate: Parcourez les concepts avec des renvois croisés pour remplir le graphe.
123
+ graph.renderingWarning: "Affichage des {max} premiers nœuds sur {total}."
124
+ graph.loadedStatus: chargé
125
+ graph.stubStatus: non chargé
126
+ graph.collapseControls: Réduire les contrôles
127
+ graph.expandControls: Développer les contrôles
128
+ graph.loading: Chargement des données du graphe...
@@ -4,10 +4,12 @@ import { useVocabularyStore } from '../stores/vocabulary';
4
4
  import { useDsStyle } from '../utils/dataset-style';
5
5
  import { useDatasetLoader } from '../composables/use-dataset-loader';
6
6
  import { langName, langLabel } from '../utils/lang';
7
+ import { useI18n } from '../i18n';
7
8
 
8
9
  const props = defineProps<{ registerId?: string }>();
9
10
 
10
11
  const store = useVocabularyStore();
12
+ const { t } = useI18n();
11
13
  const { getColor } = useDsStyle();
12
14
  const { loading, localError, ensureLoaded, resolvedId } = useDatasetLoader(() => props.registerId);
13
15
 
@@ -18,7 +20,7 @@ const manifest = computed(() => store.manifests.get(resolvedId.value));
18
20
  <div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
19
21
  <!-- Breadcrumb -->
20
22
  <nav aria-label="Breadcrumb" class="flex items-center gap-1.5 text-sm text-ink-400 mb-6">
21
- <router-link :to="{ name: 'home' }" class="hover:text-ink-700 transition-colors">Home</router-link>
23
+ <router-link :to="{ name: 'home' }" class="hover:text-ink-700 transition-colors">{{ t('nav.home') }}</router-link>
22
24
  <span class="text-ink-200">/</span>
23
25
  <router-link :to="{ name: 'dataset', params: { registerId: resolvedId } }" class="hover:text-ink-700 transition-colors">{{ manifest?.title || resolvedId }}</router-link>
24
26
  <span class="text-ink-200">/</span>
@@ -1,5 +1,6 @@
1
1
  <script setup lang="ts">
2
2
  import { useSiteConfig } from '../config/use-site-config';
3
+ import { useI18n } from '../i18n';
3
4
 
4
5
  interface Contributor {
5
6
  name: string;
@@ -9,6 +10,9 @@ interface Contributor {
9
10
  email?: string;
10
11
  }
11
12
 
13
+ const { config: siteConfig } = useSiteConfig();
14
+ const { t } = useI18n();
15
+
12
16
  const { config } = useSiteConfig();
13
17
  const contributors = config.value?.contributors as Contributor[] | undefined;
14
18
  </script>
@@ -16,7 +20,7 @@ const contributors = config.value?.contributors as Contributor[] | undefined;
16
20
  <template>
17
21
  <div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
18
22
  <nav aria-label="Breadcrumb" class="flex items-center gap-1.5 text-sm text-ink-400 mb-6">
19
- <router-link :to="{ name: 'home' }" class="hover:text-ink-700 transition-colors">Home</router-link>
23
+ <router-link :to="{ name: 'home' }" class="hover:text-ink-700 transition-colors">{{ t('nav.home') }}</router-link>
20
24
  <span class="text-ink-200">/</span>
21
25
  <span class="text-ink-700">Contributors</span>
22
26
  </nav>
@@ -6,12 +6,14 @@ import { useDatasetLoader } from '../composables/use-dataset-loader';
6
6
  import { FORMAT_LABELS } from '../config/types';
7
7
  import { langName, langLabel, sortLanguages } from '../utils/lang';
8
8
  import ConceptCard from '../components/ConceptCard.vue';
9
+ import { useI18n } from '../i18n';
9
10
 
10
11
  const props = defineProps<{ registerId: string }>();
11
12
 
12
13
  const store = useVocabularyStore();
13
14
  const { getStyle } = useDsStyle();
14
15
  const { loading, localError, ensureLoaded } = useDatasetLoader(() => props.registerId);
16
+ const { t } = useI18n();
15
17
 
16
18
  const manifest = computed(() => store.manifests.get(props.registerId));
17
19
  const adapter = computed(() => store.datasets.get(props.registerId));
@@ -180,7 +182,7 @@ function goToPage(p: number) {
180
182
  <div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
181
183
  <!-- Breadcrumb -->
182
184
  <nav aria-label="Breadcrumb" class="flex items-center gap-1.5 text-sm text-ink-400 mb-6">
183
- <router-link :to="{ name: 'home' }" class="hover:text-ink-700 transition-colors">Home</router-link>
185
+ <router-link :to="{ name: 'home' }" class="hover:text-ink-700 transition-colors">{{ t('nav.home') }}</router-link>
184
186
  <span class="text-ink-200">/</span>
185
187
  <span class="text-ink-700">{{ manifest?.title || registerId }}</span>
186
188
  </nav>
@@ -190,21 +192,21 @@ function goToPage(p: number) {
190
192
  <h1 class="font-serif text-3xl text-ink-800 mb-2">{{ manifest.title }}</h1>
191
193
  <p class="text-ink-400 leading-relaxed max-w-2xl">{{ manifest.description }}</p>
192
194
  <div class="flex flex-wrap gap-2 mt-4">
193
- <span class="badge" :style="{ backgroundColor: getStyle(registerId).light, color: getStyle(registerId).dark }">{{ manifest.conceptCount.toLocaleString() }} concepts</span>
194
- <span class="badge badge-gray">{{ manifest.languages.length }} languages</span>
195
+ <span class="badge" :style="{ backgroundColor: getStyle(registerId).light, color: getStyle(registerId).dark }">{{ manifest.conceptCount.toLocaleString() }} {{ t('dataset.concepts') }}</span>
196
+ <span class="badge badge-gray">{{ manifest.languages.length }} {{ t('dataset.languages') }}</span>
195
197
  <span class="badge badge-green">{{ manifest.owner }}</span>
196
198
  <router-link :to="{ name: 'stats', params: { registerId } }" class="badge badge-blue hover:opacity-80 transition-opacity">
197
- Statistics
199
+ {{ t('nav.stats') }}
198
200
  </router-link>
199
201
  <router-link :to="{ name: 'about', params: { registerId } }" class="badge badge-purple hover:opacity-80 transition-opacity">
200
- About
202
+ {{ t('nav.about') }}
201
203
  </router-link>
202
204
  </div>
203
205
  </div>
204
206
 
205
207
  <!-- Downloads -->
206
208
  <div v-if="bulkDownloads.length" class="card p-4 mb-6">
207
- <h3 class="text-xs font-semibold text-ink-400 uppercase tracking-wide mb-3">Download</h3>
209
+ <h3 class="text-xs font-semibold text-ink-400 uppercase tracking-wide mb-3">{{ t('dataset.download') }}</h3>
208
210
  <div class="flex flex-wrap gap-2">
209
211
  <a
210
212
  v-for="dl in bulkDownloads"
@@ -237,11 +239,11 @@ function goToPage(p: number) {
237
239
  <!-- Error state -->
238
240
  <div v-else-if="localError" class="max-w-xl mx-auto text-center py-20">
239
241
  <div class="card p-8 border-red-200 bg-red-50/50">
240
- <p class="text-red-700 font-medium mb-1">Failed to load dataset</p>
242
+ <p class="text-red-700 font-medium mb-1">{{ t('dataset.failedToLoad') }}</p>
241
243
  <p class="text-sm text-red-600/80 mb-4">{{ localError }}</p>
242
244
  <div class="flex gap-2 justify-center">
243
- <button @click="ensureLoaded" class="btn-primary">Retry</button>
244
- <router-link :to="{ name: 'home' }" class="btn-secondary">Back to home</router-link>
245
+ <button @click="ensureLoaded" class="btn-primary">{{ t('dataset.retry') }}</button>
246
+ <router-link :to="{ name: 'home' }" class="btn-secondary">{{ t('dataset.backToHome') }}</router-link>
245
247
  </div>
246
248
  </div>
247
249
  </div>
@@ -255,7 +257,7 @@ function goToPage(p: number) {
255
257
  v-model="filter"
256
258
  type="text"
257
259
  aria-label="Filter concepts"
258
- placeholder="Filter concepts... (press /)"
260
+ placeholder="Filter concepts..."
259
261
  class="pl-9 pr-3 py-2 text-sm bg-surface border border-ink-100 rounded-lg focus:ring-2 focus:ring-ink-200 focus:border-ink-400 outline-none placeholder:text-ink-300 transition-all w-full sm:w-64"
260
262
  />
261
263
  <svg class="absolute left-3 top-2.5 w-4 h-4 text-ink-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -264,16 +266,16 @@ function goToPage(p: number) {
264
266
  </div>
265
267
  <span class="text-sm text-ink-400">
266
268
  <template v-if="selectedLang">
267
- {{ filtered.length.toLocaleString() }} of {{ totalConceptCount.toLocaleString() }} concepts in {{ langName(selectedLang) }}
269
+ {{ filtered.length.toLocaleString() }} {{ t('dataset.of') }} {{ totalConceptCount.toLocaleString() }} {{ t('dataset.concepts') }} {{ t('dataset.in') }} {{ langName(selectedLang) }}
268
270
  </template>
269
271
  <template v-else-if="filter.trim()">
270
- {{ filtered.length.toLocaleString() }} of {{ totalConceptCount.toLocaleString() }} concepts
272
+ {{ filtered.length.toLocaleString() }} {{ t('dataset.of') }} {{ totalConceptCount.toLocaleString() }} {{ t('dataset.concepts') }}
271
273
  </template>
272
274
  <template v-else-if="totalPages > 1">
273
- {{ ((page - 1) * perPage + 1).toLocaleString() }}–{{ Math.min(page * perPage, totalConceptCount).toLocaleString() }} of {{ totalConceptCount.toLocaleString() }} concepts
275
+ {{ ((page - 1) * perPage + 1).toLocaleString() }}–{{ Math.min(page * perPage, totalConceptCount).toLocaleString() }} {{ t('dataset.of') }} {{ totalConceptCount.toLocaleString() }} {{ t('dataset.concepts') }}
274
276
  </template>
275
277
  <template v-else>
276
- {{ totalConceptCount.toLocaleString() }} concepts
278
+ {{ totalConceptCount.toLocaleString() }} {{ t('dataset.concepts') }}
277
279
  </template>
278
280
  </span>
279
281
  </div>
@@ -289,7 +291,7 @@ function goToPage(p: number) {
289
291
  ]"
290
292
  class="px-3 py-1.5 rounded-lg text-xs font-medium transition-colors"
291
293
  >
292
- All {{ totalConceptCount.toLocaleString() }}
294
+ {{ t('dataset.all') }} {{ totalConceptCount.toLocaleString() }}
293
295
  </button>
294
296
  <button
295
297
  v-for="lang in languageOptions"
@@ -333,11 +335,11 @@ function goToPage(p: number) {
333
335
  <div v-else class="text-center py-20">
334
336
  <div class="text-ink-200 text-5xl mb-4 font-serif">&empty;</div>
335
337
  <template v-if="filter.trim()">
336
- <p class="text-ink-500 font-medium mb-1">No concepts match your filter</p>
337
- <button @click="filter = ''" class="text-sm concept-link mt-1">Clear filter</button>
338
+ <p class="text-ink-500 font-medium mb-1">{{ t('dataset.noMatch') }}</p>
339
+ <button @click="filter = ''" class="text-sm concept-link mt-1">{{ t('dataset.clearFilter') }}</button>
338
340
  </template>
339
341
  <template v-else>
340
- <p class="text-ink-500 font-medium mb-1">This dataset has no concepts</p>
342
+ <p class="text-ink-500 font-medium mb-1">{{ t('dataset.noConcepts') }}</p>
341
343
  </template>
342
344
  </div>
343
345
 
@@ -347,7 +349,7 @@ function goToPage(p: number) {
347
349
  :disabled="page <= 1"
348
350
  @click="goToPage(page - 1)"
349
351
  class="btn-secondary disabled:opacity-30 text-xs"
350
- >&larr; Prev</button>
352
+ >&larr; {{ t('dataset.prev') }}</button>
351
353
  <template v-for="p in visiblePages" :key="p">
352
354
  <span v-if="p < 0" class="text-ink-300 px-0.5">&hellip;</span>
353
355
  <button
@@ -361,7 +363,7 @@ function goToPage(p: number) {
361
363
  :disabled="page >= totalPages"
362
364
  @click="goToPage(page + 1)"
363
365
  class="btn-secondary disabled:opacity-30 text-xs"
364
- >Next &rarr;</button>
366
+ >{{ t('dataset.next') }} &rarr;</button>
365
367
  </div>
366
368
  </template>
367
369
  </div>
@@ -2,8 +2,10 @@
2
2
  import { computed, onMounted, ref } from 'vue';
3
3
  import { useVocabularyStore } from '../stores/vocabulary';
4
4
  import GraphPanel from '../components/GraphPanel.vue';
5
+ import { useI18n } from '../i18n';
5
6
 
6
7
  const store = useVocabularyStore();
8
+ const { t } = useI18n();
7
9
  const graphLoading = ref(true);
8
10
 
9
11
  // Depend on graphVersion so computed re-evaluates when edges are added
@@ -44,19 +46,19 @@ onMounted(async () => {
44
46
  <div class="flex flex-col" style="height: calc(100vh - 56px)">
45
47
  <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
48
  <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>
49
+ <router-link :to="{ name: 'home' }" class="hover:text-ink-700 transition-colors">{{ t('nav.home') }}</router-link>
48
50
  <span class="text-ink-200">/</span>
49
- <span class="text-ink-700">Graph View</span>
51
+ <span class="text-ink-700">{{ t('nav.graph') }}</span>
50
52
  </nav>
51
53
  <span class="text-xs text-ink-300 ml-1">
52
- {{ graphEdges.length.toLocaleString() }} edges
54
+ {{ graphEdges.length.toLocaleString() }} {{ t('graph.edges') }}
53
55
  </span>
54
56
  </div>
55
57
  <div class="flex-1 min-h-0">
56
58
  <div v-if="graphLoading" class="flex items-center justify-center h-full">
57
59
  <div class="text-center">
58
60
  <div class="w-8 h-8 border-2 border-ink-200 border-t-ink-600 rounded-full animate-spin mx-auto mb-3"></div>
59
- <p class="text-sm text-ink-400">Loading graph data&hellip;</p>
61
+ <p class="text-sm text-ink-400">{{ t('graph.loading') }}</p>
60
62
  </div>
61
63
  </div>
62
64
  <GraphPanel v-else :nodes="graphNodes" :edges="graphEdges" :registers="registers" />
@@ -9,7 +9,7 @@ import { useI18n } from '../i18n';
9
9
  const store = useVocabularyStore();
10
10
  const router = useRouter();
11
11
  const { getStyle } = useDsStyle();
12
- const { config: siteConfig } = useSiteConfig();
12
+ const { config: siteConfig, localizedTitle, localizedSubtitle, localizedDescription } = useSiteConfig();
13
13
  const { t } = useI18n();
14
14
  const exploring = ref(false);
15
15
 
@@ -65,15 +65,15 @@ function goToGraph() { router.push({ name: 'graph' }); }
65
65
  <span class="text-[11px] font-semibold uppercase tracking-[0.2em] text-ink-300">{{ siteConfig?.branding?.ownerName || 'Glossarist' }}</span>
66
66
  <template v-if="siteConfig?.subtitle">
67
67
  <span class="w-4 sm:w-6 h-px bg-ink-200"></span>
68
- <span class="text-[11px] font-semibold uppercase tracking-[0.2em] text-ink-300 hidden sm:inline">{{ siteConfig.subtitle }}</span>
68
+ <span class="text-[11px] font-semibold uppercase tracking-[0.2em] text-ink-300 hidden sm:inline">{{ localizedSubtitle }}</span>
69
69
  </template>
70
70
  </div>
71
71
  <h1 class="font-serif text-[2rem] sm:text-[2.75rem] text-ink-800 leading-[1.1] mb-4 tracking-tight">
72
- {{ siteConfig?.title || 'Glossarist' }}
73
- <template v-if="siteConfig?.subtitle"><br class="hidden sm:block" /> {{ siteConfig.subtitle }}</template>
72
+ {{ localizedTitle }}
73
+ <template v-if="localizedSubtitle"><br class="hidden sm:block" /> {{ localizedSubtitle }}</template>
74
74
  </h1>
75
75
  <p class="text-base text-ink-400 max-w-lg leading-relaxed">
76
- {{ siteConfig?.description || 'Explore standardized terminology datasets from ISO and IEC technical committees. Browse concepts, definitions, and cross-references across multilingual vocabularies.' }}
76
+ {{ localizedDescription || 'Explore standardized terminology datasets from ISO and IEC technical committees. Browse concepts, definitions, and cross-references across multilingual vocabularies.' }}
77
77
  </p>
78
78
  <div class="flex flex-wrap gap-3 mt-7">
79
79
  <button @click="goToSearch" class="btn-primary flex items-center gap-2">
@@ -2,6 +2,7 @@
2
2
  import { ref, onMounted } from 'vue';
3
3
  import { useSiteConfig } from '../config/use-site-config';
4
4
  import { renderAsciiDocLite } from '../utils/asciidoc-lite';
5
+ import { useI18n } from '../i18n';
5
6
 
6
7
  interface NewsPost {
7
8
  slug: string;
@@ -13,6 +14,7 @@ interface NewsPost {
13
14
  }
14
15
 
15
16
  const { config } = useSiteConfig();
17
+ const { t } = useI18n();
16
18
  const posts = ref<NewsPost[]>([]);
17
19
  const loading = ref(true);
18
20
  const error = ref<string | null>(null);
@@ -80,7 +82,7 @@ function formatDate(dateStr: string) {
80
82
  <template>
81
83
  <div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
82
84
  <nav aria-label="Breadcrumb" class="flex items-center gap-1.5 text-sm text-ink-400 mb-6">
83
- <router-link :to="{ name: 'home' }" class="hover:text-ink-700 transition-colors">Home</router-link>
85
+ <router-link :to="{ name: 'home' }" class="hover:text-ink-700 transition-colors">{{ t('nav.home') }}</router-link>
84
86
  <span class="text-ink-200">/</span>
85
87
  <span class="text-ink-700">News</span>
86
88
  </nav>
@@ -4,12 +4,14 @@ import { useVocabularyStore } from '../stores/vocabulary';
4
4
  import { useDsStyle } from '../utils/dataset-style';
5
5
  import { useDatasetLoader } from '../composables/use-dataset-loader';
6
6
  import { langName, langLabel } from '../utils/lang';
7
+ import { useI18n } from '../i18n';
7
8
 
8
9
  const props = defineProps<{ registerId?: string }>();
9
10
 
10
11
  const store = useVocabularyStore();
11
12
  const { getColor } = useDsStyle();
12
13
  const { loading, localError, ensureLoaded, resolvedId } = useDatasetLoader(() => props.registerId);
14
+ const { t } = useI18n();
13
15
 
14
16
  const manifest = computed(() => store.manifests.get(resolvedId.value));
15
17
 
@@ -71,11 +73,11 @@ function coverageColor(ratio: number): string {
71
73
  <div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
72
74
  <!-- Breadcrumb -->
73
75
  <nav aria-label="Breadcrumb" class="flex items-center gap-1.5 text-sm text-ink-400 mb-6">
74
- <router-link :to="{ name: 'home' }" class="hover:text-ink-700 transition-colors">Home</router-link>
76
+ <router-link :to="{ name: 'home' }" class="hover:text-ink-700 transition-colors">{{ t('nav.home') }}</router-link>
75
77
  <span class="text-ink-200">/</span>
76
78
  <router-link :to="{ name: 'dataset', params: { registerId: resolvedId } }" class="hover:text-ink-700 transition-colors">{{ manifest?.title || resolvedId }}</router-link>
77
79
  <span class="text-ink-200">/</span>
78
- <span class="text-ink-700">Statistics</span>
80
+ <span class="text-ink-700">{{ t('stats.title') }}</span>
79
81
  </nav>
80
82
 
81
83
  <template v-if="loading">
@@ -89,15 +91,15 @@ function coverageColor(ratio: number): string {
89
91
  </template>
90
92
  <template v-else-if="localError">
91
93
  <div class="card p-8 border-red-200 bg-red-50/50 text-center">
92
- <p class="text-red-700 font-medium mb-1">Failed to load statistics</p>
94
+ <p class="text-red-700 font-medium mb-1">{{ t('stats.failedToLoad') }}</p>
93
95
  <p class="text-sm text-red-600/80 mb-4">{{ localError }}</p>
94
- <button @click="ensureLoaded" class="btn-primary">Retry</button>
96
+ <button @click="ensureLoaded" class="btn-primary">{{ t('stats.retry') }}</button>
95
97
  </div>
96
98
  </template>
97
99
  <template v-else-if="manifest">
98
- <h1 class="font-serif text-3xl text-ink-800 mb-2">Statistics</h1>
100
+ <h1 class="font-serif text-3xl text-ink-800 mb-2">{{ t('stats.title') }}</h1>
99
101
  <p class="text-ink-400 mb-8">
100
- {{ stats.total.toLocaleString() }} concepts across {{ manifest.languages.length }} languages.
102
+ {{ t('stats.summary', { count: stats.total.toLocaleString(), langCount: String(manifest.languages.length) }) }}
101
103
  </p>
102
104
 
103
105
  <!-- Language stats table -->
@@ -105,9 +107,9 @@ function coverageColor(ratio: number): string {
105
107
  <table class="w-full text-sm">
106
108
  <thead>
107
109
  <tr class="border-b border-ink-100/60 bg-ink-50">
108
- <th class="text-left px-5 py-3 text-ink-600 font-medium text-xs uppercase tracking-wide">Language</th>
109
- <th class="text-right px-5 py-3 text-ink-600 font-medium text-xs uppercase tracking-wide">Terms</th>
110
- <th class="text-right px-5 py-3 text-ink-600 font-medium text-xs uppercase tracking-wide">Definitions</th>
110
+ <th class="text-left px-5 py-3 text-ink-600 font-medium text-xs uppercase tracking-wide">{{ t('stats.language') }}</th>
111
+ <th class="text-right px-5 py-3 text-ink-600 font-medium text-xs uppercase tracking-wide">{{ t('stats.terms') }}</th>
112
+ <th class="text-right px-5 py-3 text-ink-600 font-medium text-xs uppercase tracking-wide">{{ t('stats.definitions') }}</th>
111
113
  <th class="px-5 py-3 text-ink-600 font-medium w-40"></th>
112
114
  </tr>
113
115
  </thead>