@glossarist/concept-browser 0.5.1 → 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/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.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": {
@@ -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) {
@@ -1073,14 +1113,23 @@ for (const key of ['logo', 'footerLogo']) {
1073
1113
  }
1074
1114
  }
1075
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
+
1076
1122
  writeJson(path.join(PUBLIC, 'site-config.json'), {
1077
1123
  id: config.id,
1078
1124
  domain: config.domain,
1079
1125
  title: config.title,
1080
1126
  subtitle: config.subtitle,
1081
1127
  description: config.description,
1128
+ translations: config.translations || undefined,
1082
1129
  datasets: config.datasets.map(d => d.id),
1130
+ datasetTranslations: Object.keys(datasetTranslations).length ? datasetTranslations : undefined,
1083
1131
  defaultDataset: config.datasets.length === 1 ? config.datasets[0].id : undefined,
1132
+ uiLanguages: config.uiLanguages || undefined,
1084
1133
  branding: siteBranding,
1085
1134
  analytics: config.analytics,
1086
1135
  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
- const { config: siteConfig } = useSiteConfig();
12
+ const { config: siteConfig, localizedTitle } = 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>
@@ -55,7 +77,7 @@ function goHome() {
55
77
  <line x1="9" y1="11" x2="15" y2="11"/>
56
78
  </svg>
57
79
  </div>
58
- <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>
59
81
  </button>
60
82
 
61
83
  <!-- Search -->
@@ -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,13 +7,15 @@ 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();
13
14
  const router = useRouter();
14
15
  const route = useRoute();
15
16
  const { getColor } = useDsStyle();
16
- const { globalPages, datasetPages, config: siteConfig } = useSiteConfig();
17
+ const { globalPages, datasetPages, config: siteConfig, localizedTitle, localizedDatasetField } = useSiteConfig();
18
+ const { t } = useI18n();
17
19
 
18
20
  const currentDataset = computed(() => route.params.registerId as string ?? '');
19
21
 
@@ -125,6 +127,13 @@ function selectProperty(id: string) {
125
127
  }
126
128
 
127
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
+ }
128
137
  </script>
129
138
 
130
139
  <template>
@@ -139,7 +148,7 @@ const isSearching = computed(() => !!searchQuery.value.trim());
139
148
  >
140
149
  <div class="p-4">
141
150
  <!-- Navigation -->
142
- <div class="section-label">Navigation</div>
151
+ <div class="section-label">{{ t('nav.navigation') }}</div>
143
152
  <nav class="space-y-0.5 mb-6">
144
153
  <template v-for="page in globalPages" :key="page.route || 'home'">
145
154
  <router-link
@@ -149,7 +158,7 @@ const isSearching = computed(() => !!searchQuery.value.trim());
149
158
  @click="closeMobile"
150
159
  >
151
160
  <NavIcon :name="page.icon" />
152
- {{ page.title }}
161
+ {{ navTitle(page) }}
153
162
  </router-link>
154
163
 
155
164
  <!-- Ontology entity sections nested under Ontology nav item -->
@@ -422,7 +431,7 @@ const isSearching = computed(() => !!searchQuery.value.trim());
422
431
 
423
432
  <!-- Dataset-level navigation (shown when viewing a dataset) -->
424
433
  <div v-if="showDatasetNav" class="mb-6">
425
- <div class="section-label">{{ currentManifest?.title || siteConfig?.title || 'Dataset' }}</div>
434
+ <div class="section-label">{{ localizedDatasetField(currentDataset, 'title', currentManifest?.title || siteConfig?.title || 'Dataset') }}</div>
426
435
  <nav class="space-y-0.5">
427
436
  <router-link
428
437
  v-for="page in datasetPages"
@@ -433,13 +442,13 @@ const isSearching = computed(() => !!searchQuery.value.trim());
433
442
  @click="closeMobile"
434
443
  >
435
444
  <NavIcon :name="page.icon" />
436
- {{ page.title }}
445
+ {{ navTitle(page) }}
437
446
  </router-link>
438
447
  </nav>
439
448
  </div>
440
449
 
441
450
  <!-- Datasets -->
442
- <div class="section-label">Datasets</div>
451
+ <div class="section-label">{{ t('nav.datasets') }}</div>
443
452
  <nav class="space-y-1">
444
453
  <button
445
454
  v-for="ds in datasetEntries"
@@ -453,9 +462,9 @@ const isSearching = computed(() => !!searchQuery.value.trim());
453
462
  ]"
454
463
  :style="currentDataset === ds.id ? { borderLeftColor: getColor(ds.id), borderLeftWidth: '2px' } : {}"
455
464
  >
456
- <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>
457
466
  <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
467
+ {{ ds.conceptCount.toLocaleString() }} {{ t('home.concepts').toLowerCase() }}
459
468
  </div>
460
469
  </button>
461
470
  </nav>
@@ -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
  });
@@ -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;
@@ -363,7 +366,7 @@ const nonVerbalReps = computed(() => {
363
366
  <!-- Breadcrumb + nav row -->
364
367
  <div class="flex items-start gap-2 mb-3">
365
368
  <nav aria-label="Breadcrumb" class="flex items-center gap-1.5 text-sm text-ink-400 min-w-0 flex-1 flex-wrap">
366
- <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>
367
370
  <span class="text-ink-200">/</span>
368
371
  <router-link :to="{ name: 'dataset', params: { registerId: manifest.id }}" class="hover:text-ink-700 transition-colors truncate max-w-[180px]">
369
372
  {{ manifest.title }}
@@ -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>
@@ -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>