@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 +17 -0
- package/package.json +1 -1
- package/scripts/generate-data.mjs +41 -0
- package/src/App.vue +3 -0
- package/src/__tests__/markdown-lite.test.ts +26 -0
- package/src/components/AppFooter.vue +3 -1
- package/src/components/AppHeader.vue +46 -4
- package/src/components/AppSidebar.vue +5 -3
- package/src/components/ConceptDetail.vue +13 -10
- package/src/components/LanguageDetail.vue +11 -8
- package/src/config/use-site-config.ts +1 -0
- package/src/i18n/index.ts +49 -0
- package/src/i18n/locales/eng.yml +66 -0
- package/src/i18n/locales/fra.yml +66 -0
- package/src/shims/glossarist-tags.ts +10 -0
- package/src/style.css +12 -0
- package/src/utils/markdown-lite.ts +15 -0
- package/src/views/HomeView.vue +21 -17
- package/src/views/PageView.vue +26 -11
- package/src/views/SearchView.vue +4 -2
- package/vite.config.ts +34 -1
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.
|
|
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 || '
|
|
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 {
|
|
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="
|
|
68
|
-
placeholder="
|
|
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">
|
|
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">
|
|
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">
|
|
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">
|
|
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">
|
|
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">
|
|
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">
|
|
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">
|
|
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">
|
|
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">
|
|
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">
|
|
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">
|
|
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">
|
|
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">
|
|
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">
|
|
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">
|
|
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">
|
|
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">
|
|
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">
|
|
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">
|
|
204
|
+
<p class="text-sm text-ink-400">{{ t('concept.noData') }}</p>
|
|
202
205
|
</div>
|
|
203
206
|
</div>
|
|
204
207
|
</template>
|
|
@@ -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++;
|
package/src/views/HomeView.vue
CHANGED
|
@@ -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
|
-
<
|
|
65
|
-
|
|
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' }}
|
|
69
|
-
<template v-if="
|
|
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
|
-
|
|
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
|
-
|
|
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 ? '
|
|
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">
|
|
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">
|
|
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">
|
|
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">·</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
|
-
|
|
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">
|
|
141
|
-
<span class="text-xs text-ink-300">
|
|
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">·</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">
|
package/src/views/PageView.vue
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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">
|
|
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">
|
|
70
|
-
<p class="text-ink-500 mb-4">
|
|
71
|
-
<router-link :to="{ name: 'home' }" class="btn-primary">
|
|
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
|
|
package/src/views/SearchView.vue
CHANGED
|
@@ -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">
|
|
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">
|
|
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'),
|