@glossarist/concept-browser 0.5.1 → 0.6.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/cli/index.mjs CHANGED
@@ -148,6 +148,23 @@ Environment:
148
148
  process.env.FAVICON_HTML = faviconHtml;
149
149
  }
150
150
 
151
+ // Pass branding info for HTML transformation
152
+ process.env.SITE_TITLE = config?.title || 'Glossarist';
153
+ if (branding.fonts?.header || branding.fonts?.body) {
154
+ const fontFamilies = [];
155
+ if (branding.fonts.header?.source === 'google') {
156
+ const w = (branding.fonts.header.weights || [400, 700]).join(';');
157
+ fontFamilies.push(`family=${branding.fonts.header.family.replace(/ /g, '+')}:wght@${w}`);
158
+ }
159
+ if (branding.fonts.body?.source === 'google') {
160
+ const w = (branding.fonts.body.weights || [400, 700]).join(';');
161
+ fontFamilies.push(`family=${branding.fonts.body.family.replace(/ /g, '+')}:wght@${w}`);
162
+ }
163
+ if (fontFamilies.length) {
164
+ process.env.SITE_FONTS_URL = `https://fonts.googleapis.com/css2?${fontFamilies.join('&')}&display=swap`;
165
+ }
166
+ }
167
+
151
168
  // Run vite build using the package's vite.config.ts
152
169
  console.log(`\n=== BUILD SPA ===\n`);
153
170
  const viteConfig = resolve(pkgRoot, 'vite.config.ts');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glossarist/concept-browser",
3
- "version": "0.5.1",
3
+ "version": "0.6.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": {
@@ -591,6 +591,10 @@ function processDataset(dir, register, opts) {
591
591
  console.log(`Processing ${register}: ${files.length} files`);
592
592
 
593
593
  const conceptsDir = path.join(DATA, register, 'concepts');
594
+ // Clean previous output to avoid stale files accumulating across runs
595
+ if (fs.existsSync(conceptsDir)) {
596
+ for (const f of fs.readdirSync(conceptsDir)) fs.unlinkSync(path.join(conceptsDir, f));
597
+ }
594
598
  const concepts = [];
595
599
  const langTermCounts = {};
596
600
  const langDefCounts = {};
@@ -994,6 +998,19 @@ function renderMarkdown(input) {
994
998
  while (i < lines.length && /^>\s?/.test(lines[i])) { ql.push(lines[i].replace(/^>\s?/, '')); i++; }
995
999
  blocks.push(`<blockquote>${renderInline(ql.join(' '))}</blockquote>`); continue;
996
1000
  }
1001
+ if (/^\|(.+)\|$/.test(line) && i + 1 < lines.length && /^\|[-:| ]+\|$/.test(lines[i + 1])) {
1002
+ const headerCells = line.split('|').map(c => c.trim()).filter(Boolean);
1003
+ i += 2;
1004
+ const rows = [];
1005
+ while (i < lines.length && /^\|(.+)\|$/.test(lines[i])) {
1006
+ rows.push(lines[i].split('|').map(c => c.trim()).filter(Boolean));
1007
+ i++;
1008
+ }
1009
+ const thCells = headerCells.map(c => `<th>${renderInline(c)}</th>`).join('');
1010
+ const trRows = rows.map(r => `<tr>${r.map(c => `<td>${renderInline(c)}</td>`).join('')}</tr>`).join('');
1011
+ blocks.push(`<table><thead><tr>${thCells}</tr></thead><tbody>${trRows}</tbody></table>`);
1012
+ continue;
1013
+ }
997
1014
  if (!line.trim()) { i++; continue; }
998
1015
  const pl = [];
999
1016
  while (i < lines.length && lines[i].trim() && !/^#{1,4}\s/.test(lines[i]) && !/^\s*[-*]\s+/.test(lines[i]) && !/^\s*\d+\.\s+/.test(lines[i]) && !/^>\s?/.test(lines[i]) && !lines[i].trimStart().startsWith('```')) { pl.push(lines[i]); i++; }
@@ -1026,6 +1043,29 @@ function processContentPage(config, page) {
1026
1043
  fs.mkdirSync(pagesDir, { recursive: true });
1027
1044
  writeJson(path.join(pagesDir, `${page.route}.json`), { title: page.title, html });
1028
1045
  console.log(` Generated content page: ${page.route} (${ext})`);
1046
+
1047
+ // Generate localized versions
1048
+ if (page.translations) {
1049
+ for (const [lang, tr] of Object.entries(page.translations)) {
1050
+ const { source, title: trTitle } = tr;
1051
+ if (!source) continue;
1052
+ const trSrcPath = path.resolve(ROOT, source);
1053
+ if (!fs.existsSync(trSrcPath)) {
1054
+ console.warn(` Skipping '${page.route}' translation '${lang}': source not found (${trSrcPath})`);
1055
+ continue;
1056
+ }
1057
+ const trRaw = fs.readFileSync(trSrcPath, 'utf8');
1058
+ const trExt = path.extname(trSrcPath).toLowerCase();
1059
+ let trHtml;
1060
+ if (trExt === '.html' || trExt === '.htm') {
1061
+ trHtml = trRaw;
1062
+ } else {
1063
+ trHtml = renderMarkdown(stripFrontmatter(trRaw));
1064
+ }
1065
+ writeJson(path.join(pagesDir, `${page.route}.${lang}.json`), { title: trTitle || page.title, html: trHtml });
1066
+ console.log(` Generated localized page: ${page.route}.${lang} (${trExt})`);
1067
+ }
1068
+ }
1029
1069
  }
1030
1070
 
1031
1071
  function stripFrontmatter(text) {
@@ -1081,6 +1121,7 @@ writeJson(path.join(PUBLIC, 'site-config.json'), {
1081
1121
  description: config.description,
1082
1122
  datasets: config.datasets.map(d => d.id),
1083
1123
  defaultDataset: config.datasets.length === 1 ? config.datasets[0].id : undefined,
1124
+ uiLanguages: config.uiLanguages || undefined,
1084
1125
  branding: siteBranding,
1085
1126
  analytics: config.analytics,
1086
1127
  features: config.features,
package/src/App.vue CHANGED
@@ -2,12 +2,14 @@
2
2
  import { onMounted, onUnmounted, ref } from 'vue';
3
3
  import { useVocabularyStore } from './stores/vocabulary';
4
4
  import { useSiteConfig } from './config/use-site-config';
5
+ import { useI18n } from './i18n';
5
6
  import AppHeader from './components/AppHeader.vue';
6
7
  import AppSidebar from './components/AppSidebar.vue';
7
8
  import AppFooter from './components/AppFooter.vue';
8
9
 
9
10
  const store = useVocabularyStore();
10
11
  const { loadConfig, config } = useSiteConfig();
12
+ const { initLocale } = useI18n();
11
13
  const appReady = ref(false);
12
14
  const showScrollTop = ref(false);
13
15
  let mainEl: HTMLElement | null = null;
@@ -25,6 +27,7 @@ onMounted(async () => {
25
27
  if (cfg?.title) {
26
28
  document.title = cfg.title;
27
29
  }
30
+ initLocale(cfg?.defaults?.language);
28
31
  appReady.value = true;
29
32
  // Watch scroll on main content area
30
33
  mainEl = document.querySelector('main');
@@ -85,4 +85,30 @@ describe('renderMarkdown', () => {
85
85
  expect(result).toContain('<p>line one line two</p>');
86
86
  expect(result).toContain('<p>new paragraph</p>');
87
87
  });
88
+
89
+ it('renders markdown tables', () => {
90
+ const input = '| Name | Value |\n|------|-------|\n| a | 1 |\n| b | 2 |';
91
+ const result = renderMarkdown(input);
92
+ expect(result).toContain('<table>');
93
+ expect(result).toContain('<thead>');
94
+ expect(result).toContain('<th>Name</th>');
95
+ expect(result).toContain('<th>Value</th>');
96
+ expect(result).toContain('<tbody>');
97
+ expect(result).toContain('<td>a</td>');
98
+ expect(result).toContain('<td>b</td>');
99
+ expect(result).toContain('</table>');
100
+ });
101
+
102
+ it('renders table cells with inline formatting', () => {
103
+ const input = '| Col |\n|-----|\n| **bold** |';
104
+ const result = renderMarkdown(input);
105
+ expect(result).toContain('<td><strong>bold</strong></td>');
106
+ });
107
+
108
+ it('does not treat non-table pipe lines as tables', () => {
109
+ const input = 'some text | with pipes';
110
+ const result = renderMarkdown(input);
111
+ expect(result).toContain('<p>');
112
+ expect(result).not.toContain('<table>');
113
+ });
88
114
  });
@@ -1,12 +1,14 @@
1
1
  <script setup lang="ts">
2
2
  import { computed } from 'vue';
3
3
  import { useSiteConfig } from '../config/use-site-config';
4
+ import { useI18n } from '../i18n';
4
5
 
5
6
  const { config } = useSiteConfig();
7
+ const { t } = useI18n();
6
8
 
7
9
  const poweredBy = computed(() => {
8
10
  const pb = config.value?.features?.poweredBy as { message?: string; url?: string } | undefined;
9
- return { message: pb?.message || 'Built with the Glossarist Concept Browser', url: pb?.url || 'https://github.com/glossarist/concept-browser' };
11
+ return { message: pb?.message || t('footer.builtWith'), url: pb?.url || 'https://github.com/glossarist/concept-browser' };
10
12
  });
11
13
 
12
14
  const socialLinks = computed(() => {
@@ -3,13 +3,23 @@ import { useRouter } from 'vue-router';
3
3
  import { useUiStore } from '../stores/ui';
4
4
  import { useVocabularyStore } from '../stores/vocabulary';
5
5
  import { useSiteConfig } from '../config/use-site-config';
6
- import { ref } from 'vue';
6
+ import { useI18n } from '../i18n';
7
+ import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
7
8
 
8
9
  const router = useRouter();
9
10
  const ui = useUiStore();
10
11
  const store = useVocabularyStore();
11
12
  const { config: siteConfig } = useSiteConfig();
13
+ const { locale, t, setLocale } = useI18n();
12
14
  const searchInput = ref('');
15
+ const langOpen = ref(false);
16
+
17
+ const uiLanguages = computed(() => siteConfig.value?.uiLanguages || []);
18
+ const showLangSelector = computed(() => uiLanguages.value.length > 1);
19
+ const currentLangLabel = computed(() => {
20
+ const cur = uiLanguages.value.find(l => l.code === locale.value);
21
+ return cur?.label || locale.value.toUpperCase();
22
+ });
13
23
 
14
24
  function doSearch() {
15
25
  const q = searchInput.value.trim();
@@ -22,6 +32,18 @@ function doSearch() {
22
32
  function goHome() {
23
33
  router.push({ name: 'home' });
24
34
  }
35
+
36
+ function selectLang(code: string) {
37
+ setLocale(code);
38
+ langOpen.value = false;
39
+ }
40
+
41
+ function closeLangOnOutside(e: MouseEvent) {
42
+ if (langOpen.value) langOpen.value = false;
43
+ }
44
+
45
+ onMounted(() => document.addEventListener('click', closeLangOnOutside));
46
+ onBeforeUnmount(() => document.removeEventListener('click', closeLangOnOutside));
25
47
  </script>
26
48
 
27
49
  <template>
@@ -64,8 +86,8 @@ function goHome() {
64
86
  <input
65
87
  v-model="searchInput"
66
88
  type="text"
67
- aria-label="Search concepts"
68
- placeholder="Search..."
89
+ :aria-label="t('search.conceptSearch')"
90
+ :placeholder="t('search.placeholder')"
69
91
  class="w-full 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"
70
92
  />
71
93
  <svg class="absolute left-3 top-2.5 w-4 h-4 text-ink-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -76,7 +98,27 @@ function goHome() {
76
98
 
77
99
  <!-- Stats -->
78
100
  <div class="text-xs text-ink-400 flex-shrink-0 hidden md:block">
79
- {{ store.datasetList.length }} datasets
101
+ {{ store.datasetList.length }} {{ t('header.datasets') }}
102
+ </div>
103
+
104
+ <!-- Language selector -->
105
+ <div v-if="showLangSelector" class="relative flex-shrink-0" @click.stop>
106
+ <button
107
+ @click="langOpen = !langOpen"
108
+ class="px-2.5 py-1 rounded-lg text-xs font-semibold text-ink-500 hover:text-ink-700 hover:bg-ink-50 transition-colors border border-ink-100 flex items-center gap-1"
109
+ >
110
+ {{ currentLangLabel }}
111
+ <svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg>
112
+ </button>
113
+ <div v-if="langOpen" class="absolute right-0 top-full mt-1 bg-surface-raised border border-ink-100 rounded-lg shadow-lg py-1 min-w-[120px] z-50">
114
+ <button
115
+ v-for="lang in uiLanguages"
116
+ :key="lang.code"
117
+ @click="selectLang(lang.code)"
118
+ class="w-full text-left px-3 py-1.5 text-sm transition-colors"
119
+ :class="locale === lang.code ? 'bg-ink-50 text-ink-800 font-medium' : 'text-ink-600 hover:bg-ink-50'"
120
+ >{{ lang.label }}</button>
121
+ </div>
80
122
  </div>
81
123
 
82
124
  <!-- Theme toggle -->
@@ -7,6 +7,7 @@ import { useDsStyle } from '../utils/dataset-style';
7
7
  import { useSiteConfig } from '../config/use-site-config';
8
8
  import { useOntologyNav, compactToSlug } from '../composables/use-ontology-nav';
9
9
  import NavIcon from './NavIcon.vue';
10
+ import { useI18n } from '../i18n';
10
11
 
11
12
  const store = useVocabularyStore();
12
13
  const ui = useUiStore();
@@ -14,6 +15,7 @@ const router = useRouter();
14
15
  const route = useRoute();
15
16
  const { getColor } = useDsStyle();
16
17
  const { globalPages, datasetPages, config: siteConfig } = useSiteConfig();
18
+ const { t } = useI18n();
17
19
 
18
20
  const currentDataset = computed(() => route.params.registerId as string ?? '');
19
21
 
@@ -139,7 +141,7 @@ const isSearching = computed(() => !!searchQuery.value.trim());
139
141
  >
140
142
  <div class="p-4">
141
143
  <!-- Navigation -->
142
- <div class="section-label">Navigation</div>
144
+ <div class="section-label">{{ t('nav.navigation') }}</div>
143
145
  <nav class="space-y-0.5 mb-6">
144
146
  <template v-for="page in globalPages" :key="page.route || 'home'">
145
147
  <router-link
@@ -439,7 +441,7 @@ const isSearching = computed(() => !!searchQuery.value.trim());
439
441
  </div>
440
442
 
441
443
  <!-- Datasets -->
442
- <div class="section-label">Datasets</div>
444
+ <div class="section-label">{{ t('nav.datasets') }}</div>
443
445
  <nav class="space-y-1">
444
446
  <button
445
447
  v-for="ds in datasetEntries"
@@ -455,7 +457,7 @@ const isSearching = computed(() => !!searchQuery.value.trim());
455
457
  >
456
458
  <div class="font-medium truncate leading-snug">{{ ds.title }}</div>
457
459
  <div v-if="ds.loaded" class="text-xs mt-0.5" :class="currentDataset === ds.id ? 'text-ink-400' : 'text-ink-300'">
458
- {{ ds.conceptCount.toLocaleString() }} concepts
460
+ {{ ds.conceptCount.toLocaleString() }} {{ t('home.concepts').toLowerCase() }}
459
461
  </div>
460
462
  </button>
461
463
  </nav>
@@ -21,6 +21,9 @@ import ConceptRdfView from './ConceptRdfView.vue';
21
21
  import FormatDownloads from './FormatDownloads.vue';
22
22
  import NonVerbalRepDisplay from './NonVerbalRepDisplay.vue';
23
23
  import CitationDisplay from './CitationDisplay.vue';
24
+ import { useI18n } from '../i18n';
25
+
26
+ const { t } = useI18n();
24
27
 
25
28
  const props = defineProps<{
26
29
  concept: Concept;
@@ -563,7 +566,7 @@ const nonVerbalReps = computed(() => {
563
566
  <!-- Notes -->
564
567
  <div v-if="lc.notes.length" class="space-y-2">
565
568
  <div v-for="(note, i) in lc.notes" :key="i" class="text-ink-600 text-sm leading-relaxed">
566
- <span class="font-medium text-ink-400 text-xs uppercase tracking-wide">Note {{ i + 1 }}</span>
569
+ <span class="font-medium text-ink-400 text-xs uppercase tracking-wide">{{ t('concept.note') }} {{ i + 1 }}</span>
567
570
  <div class="mt-1" v-html="renderMath(note, renderOpts)"></div>
568
571
  </div>
569
572
  </div>
@@ -571,7 +574,7 @@ const nonVerbalReps = computed(() => {
571
574
  <!-- Examples -->
572
575
  <div v-if="lc.examples.length" class="space-y-2">
573
576
  <div v-for="(ex, i) in lc.examples" :key="i" class="text-ink-600 text-sm leading-relaxed">
574
- <span class="font-medium text-ink-400 text-xs uppercase tracking-wide">Example {{ i + 1 }}</span>
577
+ <span class="font-medium text-ink-400 text-xs uppercase tracking-wide">{{ t('concept.example') }} {{ i + 1 }}</span>
575
578
  <div class="mt-1" v-html="renderMath(ex, renderOpts)"></div>
576
579
  </div>
577
580
  </div>
@@ -635,9 +638,9 @@ const nonVerbalReps = computed(() => {
635
638
  <div class="w-full lg:w-64 flex-shrink-0 space-y-4 mt-6 lg:mt-0">
636
639
  <!-- Relations -->
637
640
  <div v-if="outgoingEdges.length || incomingEdges.length" class="card p-5">
638
- <div class="section-label">Relations</div>
641
+ <div class="section-label">{{ t('concept.relations') }}</div>
639
642
  <div v-if="outgoingEdges.length" class="mt-3">
640
- <div class="text-xs text-ink-300 mb-2">Outgoing ({{ outgoingEdges.length }})</div>
643
+ <div class="text-xs text-ink-300 mb-2">{{ t('concept.outgoing') }} ({{ outgoingEdges.length }})</div>
641
644
  <div class="space-y-1 max-h-64 overflow-y-auto">
642
645
  <button
643
646
  v-for="edge in outgoingEdges"
@@ -654,7 +657,7 @@ const nonVerbalReps = computed(() => {
654
657
  </div>
655
658
  </div>
656
659
  <div v-if="incomingEdges.length" class="mt-3 pt-3 border-t border-ink-100/60">
657
- <div class="text-xs text-ink-300 mb-2">Incoming ({{ incomingEdges.length }})</div>
660
+ <div class="text-xs text-ink-300 mb-2">{{ t('concept.incoming') }} ({{ incomingEdges.length }})</div>
658
661
  <div class="space-y-1 max-h-48 overflow-y-auto">
659
662
  <button
660
663
  v-for="edge in incomingEdges"
@@ -673,7 +676,7 @@ const nonVerbalReps = computed(() => {
673
676
 
674
677
  <!-- Domains -->
675
678
  <div v-if="conceptDomains.length" class="card p-5">
676
- <div class="section-label">Domains</div>
679
+ <div class="section-label">{{ t('concept.domains') }}</div>
677
680
  <div class="space-y-1 mt-3">
678
681
  <div v-for="domain in conceptDomains" :key="domain.slug" class="flex items-center gap-1.5 text-sm">
679
682
  <span class="w-2 h-1.5 rounded inline-block flex-shrink-0" style="background: #8b5cf6;"></span>
@@ -688,7 +691,7 @@ const nonVerbalReps = computed(() => {
688
691
 
689
692
  <!-- Tags -->
690
693
  <div v-if="conceptTags.length" class="card p-5">
691
- <div class="section-label">Tags</div>
694
+ <div class="section-label">{{ t('concept.tags') }}</div>
692
695
  <div class="flex flex-wrap gap-1.5 mt-3">
693
696
  <span v-for="tag in conceptTags" :key="tag" class="badge badge-gray text-[10px]">{{ tag }}</span>
694
697
  </div>
@@ -696,7 +699,7 @@ const nonVerbalReps = computed(() => {
696
699
 
697
700
  <!-- Managed concept dates -->
698
701
  <div v-if="conceptDates.length" class="card p-5">
699
- <div class="section-label">Lifecycle dates</div>
702
+ <div class="section-label">{{ t('concept.lifecycleDates') }}</div>
700
703
  <dl class="mt-3 space-y-1.5 text-xs">
701
704
  <div v-for="(d, i) in conceptDates" :key="i" class="flex gap-2">
702
705
  <dt class="text-ink-300 min-w-[70px]">{{ d.type }}</dt>
@@ -707,7 +710,7 @@ const nonVerbalReps = computed(() => {
707
710
 
708
711
  <!-- Managed concept sources -->
709
712
  <div v-if="conceptSources.length" class="card p-5">
710
- <div class="section-label">Concept sources</div>
713
+ <div class="section-label">{{ t('concept.conceptSources') }}</div>
711
714
  <div class="space-y-2 mt-3">
712
715
  <div v-for="(src, i) in conceptSources" :key="i" class="text-xs">
713
716
  <div class="flex items-center gap-1.5 flex-wrap mb-0.5">
@@ -757,7 +760,7 @@ const nonVerbalReps = computed(() => {
757
760
 
758
761
  <!-- Metadata -->
759
762
  <div class="card p-5">
760
- <div class="section-label">Metadata</div>
763
+ <div class="section-label">{{ t('concept.metadata') }}</div>
761
764
  <dl class="space-y-2 text-xs mt-3">
762
765
  <div v-if="managedStatus">
763
766
  <dt class="text-ink-300">Status</dt>
@@ -11,6 +11,9 @@ import { useRouter } from 'vue-router';
11
11
  import { useVocabularyStore } from '../stores/vocabulary';
12
12
  import { getFactory } from '../adapters/factory';
13
13
  import CitationDisplay from './CitationDisplay.vue';
14
+ import { useI18n } from '../i18n';
15
+
16
+ const { t } = useI18n();
14
17
 
15
18
  const props = defineProps<{
16
19
  concept: Concept;
@@ -109,7 +112,7 @@ function handleContentClick(e: MouseEvent) {
109
112
 
110
113
  <!-- Designations -->
111
114
  <div v-if="designations.length > 0" class="card p-5">
112
- <div class="section-label">Designations</div>
115
+ <div class="section-label">{{ t('concept.designations') }}</div>
113
116
  <div class="space-y-2 mt-3">
114
117
  <div v-for="(d, i) in designations" :key="i" class="flex items-center gap-2 flex-wrap">
115
118
  <span class="font-medium text-ink-800 text-lg" v-html="renderMath(d.designation)"></span>
@@ -138,16 +141,16 @@ function handleContentClick(e: MouseEvent) {
138
141
 
139
142
  <!-- Definition -->
140
143
  <div v-if="definition" class="card p-5">
141
- <div class="section-label">Definition</div>
144
+ <div class="section-label">{{ t('concept.definition') }}</div>
142
145
  <div class="text-ink-800 leading-relaxed mt-3" v-html="renderMath(definition, renderOpts)"></div>
143
146
  </div>
144
147
 
145
148
  <!-- Notes -->
146
149
  <div v-if="notes.length" class="card p-5">
147
- <div class="section-label">Notes</div>
150
+ <div class="section-label">{{ t('concept.notes') }}</div>
148
151
  <div class="space-y-3 mt-3">
149
152
  <div v-for="(note, i) in notes" :key="i" class="text-ink-600 text-sm leading-relaxed">
150
- <span class="font-medium text-ink-400 text-xs uppercase tracking-wide">Note {{ i + 1 }}</span>
153
+ <span class="font-medium text-ink-400 text-xs uppercase tracking-wide">{{ t('concept.note') }} {{ i + 1 }}</span>
151
154
  <div class="mt-1" v-html="renderMath(note, renderOpts)"></div>
152
155
  </div>
153
156
  </div>
@@ -155,10 +158,10 @@ function handleContentClick(e: MouseEvent) {
155
158
 
156
159
  <!-- Examples -->
157
160
  <div v-if="examples.length" class="card p-5">
158
- <div class="section-label">Examples</div>
161
+ <div class="section-label">{{ t('concept.examples') }}</div>
159
162
  <div class="space-y-3 mt-3">
160
163
  <div v-for="(ex, i) in examples" :key="i" class="text-ink-600 text-sm leading-relaxed">
161
- <span class="font-medium text-ink-400 text-xs uppercase tracking-wide">Example {{ i + 1 }}</span>
164
+ <span class="font-medium text-ink-400 text-xs uppercase tracking-wide">{{ t('concept.example') }} {{ i + 1 }}</span>
162
165
  <div class="mt-1" v-html="renderMath(ex, renderOpts)"></div>
163
166
  </div>
164
167
  </div>
@@ -187,7 +190,7 @@ function handleContentClick(e: MouseEvent) {
187
190
  <span class="text-xs font-semibold text-ink-500 bg-ink-50 px-1.5 py-0.5 rounded">{{ langLabel(activeLang) }}</span>
188
191
  </div>
189
192
  <div>
190
- <p class="text-sm text-ink-600 font-medium">Term only in {{ langName(activeLang) }}</p>
193
+ <p class="text-sm text-ink-600 font-medium">{{ t('lang.termOnlyIn') }} {{ langName(activeLang) }}</p>
191
194
  <p class="text-xs text-ink-400 mt-1 leading-relaxed">
192
195
  This concept has a registered designation in {{ langName(activeLang) }} but no definition or notes.
193
196
  </p>
@@ -198,7 +201,7 @@ function handleContentClick(e: MouseEvent) {
198
201
 
199
202
  <!-- No data for this language -->
200
203
  <div v-else class="card p-5 text-center">
201
- <p class="text-sm text-ink-400">No data available for {{ langName(activeLang) }}.</p>
204
+ <p class="text-sm text-ink-400">{{ t('concept.noData') }}</p>
202
205
  </div>
203
206
  </div>
204
207
  </template>
@@ -10,6 +10,7 @@ export interface RuntimeSiteConfig {
10
10
  description?: string;
11
11
  datasets: string[];
12
12
  defaultDataset?: string;
13
+ uiLanguages?: { code: string; label: string }[];
13
14
  branding?: {
14
15
  primaryColor?: string;
15
16
  darkColor?: string;
@@ -0,0 +1,49 @@
1
+ import { ref } from 'vue';
2
+
3
+ // Auto-discover all locale YAML files — adding a new .yml file is all that's needed
4
+ const localeModules = import.meta.glob<{ default: Record<string, string> }>('./locales/*.yml', { eager: true });
5
+
6
+ const messages: Record<string, Record<string, string>> = {};
7
+ const availableLocales: string[] = [];
8
+
9
+ for (const path of Object.keys(localeModules)) {
10
+ const code = path.match(/\/([^/]+)\.yml$/)?.[1];
11
+ if (code) {
12
+ messages[code] = localeModules[path].default || {};
13
+ availableLocales.push(code);
14
+ }
15
+ }
16
+
17
+ const DEFAULT_LANG = 'eng';
18
+ const stored = typeof localStorage !== 'undefined'
19
+ ? (localStorage.getItem('ui-lang') || DEFAULT_LANG)
20
+ : DEFAULT_LANG;
21
+ const locale = ref(stored);
22
+
23
+ export function useI18n() {
24
+ function t(key: string, params?: Record<string, string>): string {
25
+ let msg = messages[locale.value]?.[key] || messages[DEFAULT_LANG]?.[key] || key;
26
+ if (params) {
27
+ for (const [k, v] of Object.entries(params)) {
28
+ msg = msg.replace(`{${k}}`, v);
29
+ }
30
+ }
31
+ return msg;
32
+ }
33
+
34
+ function setLocale(lang: string) {
35
+ locale.value = lang;
36
+ localStorage.setItem('ui-lang', lang);
37
+ }
38
+
39
+ function initLocale(configuredDefault?: string) {
40
+ const storedLang = localStorage.getItem('ui-lang');
41
+ if (storedLang && availableLocales.includes(storedLang)) {
42
+ locale.value = storedLang;
43
+ } else if (configuredDefault && availableLocales.includes(configuredDefault)) {
44
+ locale.value = configuredDefault;
45
+ }
46
+ }
47
+
48
+ return { locale, t, setLocale, initLocale, availableLocales };
49
+ }
@@ -0,0 +1,66 @@
1
+ # English UI translations
2
+ # Adding a new language: copy this file, rename to {lang-code}.yml, translate values.
3
+
4
+ # Navigation
5
+ nav.home: Home
6
+ nav.search: Search
7
+ nav.graph: Graph View
8
+ nav.ontology: Ontology
9
+ nav.concepts: Concepts
10
+ nav.statistics: Statistics
11
+ nav.about: About
12
+ nav.navigation: Navigation
13
+ nav.datasets: Datasets
14
+
15
+ # Home page
16
+ home.search: Search
17
+ home.graphView: Graph View
18
+ home.surpriseMe: Surprise Me
19
+ home.exploring: Exploring…
20
+ home.datasets: Datasets
21
+ home.concepts: Concepts
22
+ home.languages: Languages
23
+ home.availableDatasets: Available Datasets
24
+ home.clickToBrowse: Click to browse
25
+ home.browseConcepts: Browse concepts
26
+
27
+ # Search
28
+ search.placeholder: Search...
29
+ search.conceptSearch: Search concepts
30
+
31
+ # Page view
32
+ page.notFound: Page Not Found
33
+ page.notFoundMsg: 'The page "{name}" does not exist.'
34
+ page.goHome: Go Home
35
+
36
+ # Concept detail
37
+ concept.definition: Definition
38
+ concept.notes: Notes
39
+ concept.note: Note
40
+ concept.examples: Examples
41
+ concept.example: Example
42
+ concept.sources: Sources
43
+ concept.designations: Designations
44
+ concept.relations: Relations
45
+ concept.classification: Classification
46
+ concept.domains: Domains
47
+ concept.tags: Tags
48
+ concept.metadata: Metadata
49
+ concept.noData: No data available for this language.
50
+ concept.lifecycleDates: Lifecycle dates
51
+ concept.conceptSources: Concept sources
52
+ concept.incoming: Incoming
53
+ concept.outgoing: Outgoing
54
+
55
+ # Sidebar
56
+ sidebar.overview: Overview
57
+ sidebar.dataset: Dataset
58
+
59
+ # Footer
60
+ footer.builtWith: Built with the Glossarist Concept Browser
61
+
62
+ # Header
63
+ header.datasets: datasets
64
+
65
+ # Language
66
+ lang.termOnlyIn: Term only in
@@ -0,0 +1,66 @@
1
+ # Traductions françaises de l'interface
2
+ # French UI translations
3
+
4
+ # Navigation
5
+ nav.home: Accueil
6
+ nav.search: Recherche
7
+ nav.graph: Vue en graphe
8
+ nav.ontology: Ontologie
9
+ nav.concepts: Concepts
10
+ nav.statistics: Statistiques
11
+ nav.about: À propos
12
+ nav.navigation: Navigation
13
+ nav.datasets: Jeux de données
14
+
15
+ # Home page
16
+ home.search: Rechercher
17
+ home.graphView: Vue en graphe
18
+ home.surpriseMe: Surprenez-moi
19
+ home.exploring: Exploration…
20
+ home.datasets: Jeux de données
21
+ home.concepts: Concepts
22
+ home.languages: Langues
23
+ home.availableDatasets: Jeux de données disponibles
24
+ home.clickToBrowse: Cliquez pour parcourir
25
+ home.browseConcepts: Parcourir les concepts
26
+
27
+ # Search
28
+ search.placeholder: Rechercher…
29
+ search.conceptSearch: Rechercher des concepts
30
+
31
+ # Page view
32
+ page.notFound: Page non trouvée
33
+ page.notFoundMsg: 'La page « {name} » n''existe pas.'
34
+ page.goHome: Aller à l'accueil
35
+
36
+ # Concept detail
37
+ concept.definition: Définition
38
+ concept.notes: Notes
39
+ concept.note: Note
40
+ concept.examples: Exemples
41
+ concept.example: Exemple
42
+ concept.sources: Sources
43
+ concept.designations: Désignations
44
+ concept.relations: Relations
45
+ concept.classification: Classification
46
+ concept.domains: Domaines
47
+ concept.tags: Étiquettes
48
+ concept.metadata: Métadonnées
49
+ concept.noData: Aucune donnée disponible pour cette langue.
50
+ concept.lifecycleDates: Dates du cycle de vie
51
+ concept.conceptSources: Sources du concept
52
+ concept.incoming: Entrantes
53
+ concept.outgoing: Sortantes
54
+
55
+ # Sidebar
56
+ sidebar.overview: Vue d'ensemble
57
+ sidebar.dataset: Jeu de données
58
+
59
+ # Footer
60
+ footer.builtWith: Construit avec le Glossarist Concept Browser
61
+
62
+ # Header
63
+ header.datasets: jeux de données
64
+
65
+ # Language
66
+ lang.termOnlyIn: Terme uniquement en
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Module augmentation for glossarist Concept.tags.
3
+ * The JS runtime already supports tags (string[]) but the installed
4
+ * type declarations have not been updated yet. This shim bridges the gap.
5
+ */
6
+ declare module 'glossarist/models' {
7
+ interface Concept {
8
+ tags: string[];
9
+ }
10
+ }
package/src/style.css CHANGED
@@ -244,6 +244,18 @@
244
244
  .prose-page hr {
245
245
  @apply border-ink-100 my-6;
246
246
  }
247
+ .prose-page table,
248
+ .prose-news table {
249
+ @apply w-full mb-4 text-sm;
250
+ }
251
+ .prose-page th,
252
+ .prose-news th {
253
+ @apply text-left font-semibold text-ink-800 px-3 py-2 border-b-2 border-ink-200;
254
+ }
255
+ .prose-page td,
256
+ .prose-news td {
257
+ @apply px-3 py-2 border-b border-ink-100;
258
+ }
247
259
  }
248
260
 
249
261
  @layer utilities {
@@ -83,6 +83,21 @@ export function renderMarkdown(input: string): string {
83
83
  continue;
84
84
  }
85
85
 
86
+ // Table
87
+ if (/^\|(.+)\|$/.test(line) && i + 1 < lines.length && /^\|[-:| ]+\|$/.test(lines[i + 1])) {
88
+ const headerCells = line.split('|').map(c => c.trim()).filter(Boolean);
89
+ i += 2; // skip header and separator
90
+ const rows: string[][] = [];
91
+ while (i < lines.length && /^\|(.+)\|$/.test(lines[i])) {
92
+ rows.push(lines[i].split('|').map(c => c.trim()).filter(Boolean));
93
+ i++;
94
+ }
95
+ const thCells = headerCells.map(c => `<th>${renderInline(c)}</th>`).join('');
96
+ const trRows = rows.map(r => `<tr>${r.map(c => `<td>${renderInline(c)}</td>`).join('')}</tr>`).join('');
97
+ blocks.push(`<table><thead><tr>${thCells}</tr></thead><tbody>${trRows}</tbody></table>`);
98
+ continue;
99
+ }
100
+
86
101
  // Blank line
87
102
  if (!line.trim()) {
88
103
  i++;
@@ -4,11 +4,13 @@ import { useVocabularyStore } from '../stores/vocabulary';
4
4
  import { useRouter } from 'vue-router';
5
5
  import { useDsStyle } from '../utils/dataset-style';
6
6
  import { useSiteConfig } from '../config/use-site-config';
7
+ import { useI18n } from '../i18n';
7
8
 
8
9
  const store = useVocabularyStore();
9
10
  const router = useRouter();
10
11
  const { getStyle } = useDsStyle();
11
12
  const { config: siteConfig } = useSiteConfig();
13
+ const { t } = useI18n();
12
14
  const exploring = ref(false);
13
15
 
14
16
  async function exploreRandom() {
@@ -61,12 +63,14 @@ function goToGraph() { router.push({ name: 'graph' }); }
61
63
  <div class="mb-10 sm:mb-14">
62
64
  <div class="flex items-center gap-2 mb-4">
63
65
  <span class="text-[11px] font-semibold uppercase tracking-[0.2em] text-ink-300">{{ siteConfig?.branding?.ownerName || 'Glossarist' }}</span>
64
- <span class="w-4 sm:w-6 h-px bg-ink-200"></span>
65
- <span class="text-[11px] font-semibold uppercase tracking-[0.2em] text-ink-300 hidden sm:inline">{{ siteConfig?.subtitle || 'Terminology Register' }}</span>
66
+ <template v-if="siteConfig?.subtitle">
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>
69
+ </template>
66
70
  </div>
67
71
  <h1 class="font-serif text-[2rem] sm:text-[2.75rem] text-ink-800 leading-[1.1] mb-4 tracking-tight">
68
- {{ siteConfig?.title || 'Glossarist' }}<br class="hidden sm:block" /> <span v-if="siteConfig?.subtitle">{{ siteConfig.subtitle }}</span>
69
- <template v-if="!siteConfig?.subtitle">Terminology<br class="hidden sm:block" /> Register</template>
72
+ {{ siteConfig?.title || 'Glossarist' }}
73
+ <template v-if="siteConfig?.subtitle"><br class="hidden sm:block" /> {{ siteConfig.subtitle }}</template>
70
74
  </h1>
71
75
  <p class="text-base text-ink-400 max-w-lg leading-relaxed">
72
76
  {{ siteConfig?.description || 'Explore standardized terminology datasets from ISO and IEC technical committees. Browse concepts, definitions, and cross-references across multilingual vocabularies.' }}
@@ -74,16 +78,16 @@ function goToGraph() { router.push({ name: 'graph' }); }
74
78
  <div class="flex flex-wrap gap-3 mt-7">
75
79
  <button @click="goToSearch" class="btn-primary flex items-center gap-2">
76
80
  <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="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>
77
- Search
81
+ {{ t('home.search') }}
78
82
  </button>
79
83
  <button @click="goToGraph" class="btn-secondary flex items-center gap-2">
80
84
  <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"/></svg>
81
- Graph View
85
+ {{ t('home.graphView') }}
82
86
  </button>
83
87
  <button @click="exploreRandom" :disabled="exploring" class="btn-secondary flex items-center gap-2">
84
88
  <svg v-if="!exploring" 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="M13 10V3L4 14h7v7l9-11h-7z"/></svg>
85
89
  <svg v-else class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/></svg>
86
- {{ exploring ? 'Exploring…' : 'Surprise Me' }}
90
+ {{ exploring ? t('home.exploring') : t('home.surpriseMe') }}
87
91
  </button>
88
92
  </div>
89
93
  </div>
@@ -92,15 +96,15 @@ function goToGraph() { router.push({ name: 'graph' }); }
92
96
  <div class="grid grid-cols-1 sm:grid-cols-3 gap-px bg-ink-100/60 rounded-xl overflow-hidden mb-10 sm:mb-14">
93
97
  <div class="bg-surface-raised px-4 sm:px-6 py-5 animate-entrance" style="animation-delay: 80ms">
94
98
  <div class="text-3xl font-serif text-ink-800 tabular-nums">{{ filteredDatasets.length }}</div>
95
- <div class="text-sm text-ink-400 mt-1">Datasets</div>
99
+ <div class="text-sm text-ink-400 mt-1">{{ t('home.datasets') }}</div>
96
100
  </div>
97
101
  <div class="bg-surface-raised px-6 py-5 animate-entrance" style="animation-delay: 140ms">
98
102
  <div class="text-3xl font-serif text-ink-800 tabular-nums">{{ totalConcepts.toLocaleString() }}</div>
99
- <div class="text-sm text-ink-400 mt-1">Concepts</div>
103
+ <div class="text-sm text-ink-400 mt-1">{{ t('home.concepts') }}</div>
100
104
  </div>
101
105
  <div class="bg-surface-raised px-6 py-5 animate-entrance" style="animation-delay: 200ms">
102
106
  <div class="text-3xl font-serif text-ink-800 tabular-nums">{{ totalLanguages }}</div>
103
- <div class="text-sm text-ink-400 mt-1">Languages</div>
107
+ <div class="text-sm text-ink-400 mt-1">{{ t('home.languages') }}</div>
104
108
  </div>
105
109
  </div>
106
110
 
@@ -122,14 +126,14 @@ function goToGraph() { router.push({ name: 'graph' }); }
122
126
  </p>
123
127
  <div class="flex items-center gap-3 pl-6 mb-4">
124
128
  <span :style="{ color: getStyle(filteredDatasets[0].id).color }" class="text-sm font-semibold tabular-nums">{{ filteredDatasets[0].manifest.conceptCount.toLocaleString() }}</span>
125
- <span class="text-xs text-ink-300">concepts</span>
129
+ <span class="text-xs text-ink-300">{{ t('home.concepts').toLowerCase() }}</span>
126
130
  <span class="text-ink-200 text-xs">&middot;</span>
127
131
  <span class="text-sm text-ink-500 tabular-nums">{{ filteredDatasets[0].manifest.languages.length }}</span>
128
- <span class="text-xs text-ink-300">languages</span>
132
+ <span class="text-xs text-ink-300">{{ t('home.languages').toLowerCase() }}</span>
129
133
  </div>
130
134
  <div class="pl-6">
131
135
  <span class="btn-primary inline-flex items-center gap-2">
132
- Browse concepts
136
+ {{ t('home.browseConcepts') }}
133
137
  <svg class="w-4 h-4 group-hover:translate-x-0.5 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>
134
138
  </span>
135
139
  </div>
@@ -137,8 +141,8 @@ function goToGraph() { router.push({ name: 'graph' }); }
137
141
  </template>
138
142
  <template v-else-if="filteredDatasets.length > 1">
139
143
  <div class="flex items-center justify-between mb-5">
140
- <div class="section-label mb-0">Available Datasets</div>
141
- <span class="text-xs text-ink-300">Click to browse</span>
144
+ <div class="section-label mb-0">{{ t('home.availableDatasets') }}</div>
145
+ <span class="text-xs text-ink-300">{{ t('home.clickToBrowse') }}</span>
142
146
  </div>
143
147
  <div :class="[
144
148
  filteredDatasets.length === 2 ? 'max-w-3xl' : '',
@@ -166,10 +170,10 @@ function goToGraph() { router.push({ name: 'graph' }); }
166
170
 
167
171
  <div class="flex items-center gap-3 pl-[22px] mb-3">
168
172
  <span :style="{ color: getStyle(ds.id).color }" class="text-sm font-semibold tabular-nums">{{ ds.manifest.conceptCount.toLocaleString() }}</span>
169
- <span class="text-xs text-ink-300">concepts</span>
173
+ <span class="text-xs text-ink-300">{{ t('home.concepts').toLowerCase() }}</span>
170
174
  <span class="text-ink-200 text-xs">&middot;</span>
171
175
  <span class="text-sm text-ink-500 tabular-nums">{{ ds.manifest.languages.length }}</span>
172
- <span class="text-xs text-ink-300">languages</span>
176
+ <span class="text-xs text-ink-300">{{ t('home.languages').toLowerCase() }}</span>
173
177
  </div>
174
178
 
175
179
  <div class="flex flex-wrap gap-1.5 pl-[22px] mb-3">
@@ -1,6 +1,7 @@
1
1
  <script setup lang="ts">
2
- import { ref, onMounted, computed } from 'vue';
2
+ import { ref, onMounted, computed, watch } from 'vue';
3
3
  import { useRoute } from 'vue-router';
4
+ import { useI18n } from '../i18n';
4
5
 
5
6
  interface PageData {
6
7
  title: string;
@@ -8,6 +9,7 @@ interface PageData {
8
9
  }
9
10
 
10
11
  const route = useRoute();
12
+ const { locale, t } = useI18n();
11
13
  const registerId = computed(() => route.params.registerId as string | undefined);
12
14
  const pageName = computed(() => {
13
15
  if (route.params.page) return route.params.page as string;
@@ -19,14 +21,24 @@ const data = ref<PageData | null>(null);
19
21
  const loading = ref(true);
20
22
  const notFound = ref(false);
21
23
 
22
- onMounted(async () => {
24
+ async function fetchPage() {
25
+ loading.value = true;
26
+ notFound.value = false;
27
+ data.value = null;
28
+
23
29
  const page = pageName.value;
24
30
  const dsId = registerId.value;
25
-
26
31
  const base = import.meta.env.BASE_URL;
27
- const urls = dsId
28
- ? [`${base}pages/${dsId}-${page}.json`, `${base}pages/${page}.json`]
29
- : [`${base}pages/${page}.json`];
32
+
33
+ // Build candidate URLs: try localized first, then default
34
+ const langSuffix = locale.value !== 'eng' ? `.${locale.value}` : '';
35
+ const urls: string[] = [];
36
+ if (dsId) {
37
+ if (langSuffix) urls.push(`${base}pages/${dsId}-${page}${langSuffix}.json`);
38
+ urls.push(`${base}pages/${dsId}-${page}.json`);
39
+ }
40
+ if (langSuffix) urls.push(`${base}pages/${page}${langSuffix}.json`);
41
+ urls.push(`${base}pages/${page}.json`);
30
42
 
31
43
  for (const url of urls) {
32
44
  try {
@@ -40,13 +52,16 @@ onMounted(async () => {
40
52
  }
41
53
  notFound.value = true;
42
54
  loading.value = false;
43
- });
55
+ }
56
+
57
+ onMounted(fetchPage);
58
+ watch([pageName, locale], fetchPage);
44
59
  </script>
45
60
 
46
61
  <template>
47
62
  <div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
48
63
  <nav aria-label="Breadcrumb" class="flex items-center gap-1.5 text-sm text-ink-400 mb-6">
49
- <router-link :to="{ name: 'home' }" class="hover:text-ink-700 transition-colors">Home</router-link>
64
+ <router-link :to="{ name: 'home' }" class="hover:text-ink-700 transition-colors">{{ t('nav.home') }}</router-link>
50
65
  <template v-if="registerId">
51
66
  <span class="text-ink-200">/</span>
52
67
  <router-link :to="{ name: 'dataset', params: { registerId } }" class="hover:text-ink-700 transition-colors">{{ registerId }}</router-link>
@@ -66,9 +81,9 @@ onMounted(async () => {
66
81
 
67
82
  <template v-else-if="notFound">
68
83
  <div class="card p-8 text-center">
69
- <h1 class="font-serif text-2xl text-ink-800 mb-2">Page Not Found</h1>
70
- <p class="text-ink-500 mb-4">The page "{{ pageName }}" does not exist.</p>
71
- <router-link :to="{ name: 'home' }" class="btn-primary">Go Home</router-link>
84
+ <h1 class="font-serif text-2xl text-ink-800 mb-2">{{ t('page.notFound') }}</h1>
85
+ <p class="text-ink-500 mb-4">{{ t('page.notFoundMsg', { name: pageName }) }}</p>
86
+ <router-link :to="{ name: 'home' }" class="btn-primary">{{ t('page.goHome') }}</router-link>
72
87
  </div>
73
88
  </template>
74
89
 
@@ -3,10 +3,12 @@ import { ref, onMounted, watch } from 'vue';
3
3
  import { useRoute, useRouter } from 'vue-router';
4
4
  import SearchBar from '../components/SearchBar.vue';
5
5
  import { useUiStore } from '../stores/ui';
6
+ import { useI18n } from '../i18n';
6
7
 
7
8
  const route = useRoute();
8
9
  const router = useRouter();
9
10
  const ui = useUiStore();
11
+ const { t } = useI18n();
10
12
 
11
13
  onMounted(() => {
12
14
  if (route.query.q && typeof route.query.q === 'string') {
@@ -24,9 +26,9 @@ watch(() => route.query.q, (q) => {
24
26
  <template>
25
27
  <div class="px-4 sm:px-6 lg:px-8 py-8">
26
28
  <nav aria-label="Breadcrumb" class="flex items-center gap-1.5 text-sm text-ink-400 mb-6">
27
- <router-link :to="{ name: 'home' }" class="hover:text-ink-700 transition-colors">Home</router-link>
29
+ <router-link :to="{ name: 'home' }" class="hover:text-ink-700 transition-colors">{{ t('nav.home') }}</router-link>
28
30
  <span class="text-ink-200">/</span>
29
- <span class="text-ink-700">Search</span>
31
+ <span class="text-ink-700">{{ t('nav.search') }}</span>
30
32
  </nav>
31
33
  <SearchBar />
32
34
  </div>
package/vite.config.ts CHANGED
@@ -2,12 +2,24 @@ import { defineConfig } from 'vite'
2
2
  import vue from '@vitejs/plugin-vue'
3
3
  import { resolve, dirname } from 'path'
4
4
  import { fileURLToPath } from 'url'
5
+ import yaml from 'js-yaml'
5
6
 
6
7
  const __dirname = dirname(fileURLToPath(import.meta.url))
7
8
  const cwd = process.cwd()
8
9
 
9
10
  const isTest = process.env.VITEST !== undefined
10
11
 
12
+ function yamlPlugin() {
13
+ return {
14
+ name: 'yaml-transform',
15
+ transform(code: string, id: string) {
16
+ if (!id.endsWith('.yml') && !id.endsWith('.yaml')) return
17
+ const data = yaml.load(code)
18
+ return { code: `export default ${JSON.stringify(data)}`, map: null }
19
+ },
20
+ }
21
+ }
22
+
11
23
  function faviconPlugin() {
12
24
  return {
13
25
  name: 'favicon-inject',
@@ -29,6 +41,27 @@ function faviconPlugin() {
29
41
  }
30
42
  }
31
43
 
44
+ function brandingPlugin() {
45
+ return {
46
+ name: 'branding-inject',
47
+ transformIndexHtml(html: string) {
48
+ let result = html
49
+ const title = process.env.SITE_TITLE
50
+ if (title) {
51
+ result = result.replace(/<title>[^<]*<\/title>/, `<title>${title}</title>`)
52
+ }
53
+ const fontsUrl = process.env.SITE_FONTS_URL
54
+ if (fontsUrl) {
55
+ result = result.replace(
56
+ /<link[^>]*href="https:\/\/fonts\.googleapis\.com\/css2\?[^"]*"[^>]*>/,
57
+ `<link href="${fontsUrl}" rel="stylesheet">`
58
+ )
59
+ }
60
+ return result
61
+ },
62
+ }
63
+ }
64
+
32
65
  export default defineConfig({
33
66
  base: process.env.BASE_PATH || '/',
34
67
  root: __dirname,
@@ -37,7 +70,7 @@ export default defineConfig({
37
70
  outDir: resolve(cwd, 'dist'),
38
71
  emptyOutDir: true,
39
72
  },
40
- plugins: [faviconPlugin(), vue()],
73
+ plugins: [yamlPlugin(), faviconPlugin(), brandingPlugin(), vue()],
41
74
  resolve: {
42
75
  alias: {
43
76
  '@': resolve(__dirname, 'src'),