Skip to content

Commit 7b1b494

Browse files
committed
Add internationalization support for multiple languages
1 parent 3a8f54f commit 7b1b494

24 files changed

Lines changed: 1293 additions & 85 deletions

AGENTS.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,12 +55,44 @@ Event data lives in `src/content/events/<event-id>/`:
5555
| `src/components/MapView.vue` | Leaflet map, marker clustering, keyboard shortcuts, tile layer switching |
5656
| `src/components/NodePanel.vue` | Slide-in event detail panel with minimap, calendar links, share button |
5757
| `src/components/NodeList.vue` | Alphabetical event list overlay with map style switcher + dark mode toggle |
58+
| `src/components/LanguageSwitcher.vue` | Language selector dropdown in the top bar |
5859
| `src/lib/nodes.ts` | `Node` interface + `loadNodes()` |
5960
| `src/lib/format.ts` | `formatDate()`, `formatDateRange()`, `calendarLinks()`, etc. |
6061
| `src/lib/popup.ts` | Leaflet popup HTML generation (`makePopupContent()`) |
6162
| `src/styles/global.css` | Design tokens (CSS custom properties), IBM Plex Sans, Leaflet overrides |
6263
| `src/content.config.ts` | Astro content collection Zod schema for events |
6364
| `src/config.ts` | Global static constants (contact email, etc.) |
65+
| `src/i18n/index.ts` | Creates the `vue-i18n` instance and exports `syncLocale()` |
66+
| `src/i18n/localeState.ts` | Reactive `currentLocale` ref, browser detection, localStorage persistence |
67+
| `src/i18n/vuePlugin.ts` | Astro `appEntrypoint` — installs `vue-i18n` on every Vue island |
68+
| `src/i18n/locales/en.json` | Source-of-truth translation file (all keys must exist here) |
69+
| `src/i18n/locales/*.json` | Per-language translations (es, de, fr, pt, zh-TW, zh-CN, ja, ko) |
70+
71+
## Internationalization (i18n)
72+
73+
The site uses `vue-i18n@11` with 9 supported locales: `en`, `es`, `de`, `fr`, `pt`, `zh-TW`, `zh-CN`, `ja`, `ko`.
74+
75+
### How it's wired up
76+
77+
- `vue-i18n` is installed globally via `astro.config.mjs``vue({ appEntrypoint: '/src/i18n/vuePlugin' })`.
78+
- Locale detection order: localStorage (`pcd-locale`) → `navigator.language``'en'`.
79+
- The active locale is a reactive singleton (`currentLocale` ref in `localeState.ts`) shared across all components.
80+
81+
### Adding or changing UI strings
82+
83+
1. **Always add the key to `en.json` first.** It is the source of truth and the fallback for all other locales.
84+
2. Add the same key to every other locale file in `src/i18n/locales/`. Missing keys fall back to English silently.
85+
3. In Vue components, use `const { t, locale } = useI18n()` and replace hardcoded text with `t('key')`.
86+
4. In non-component TS files (e.g. `popup.ts`), use `i18n.global.t('key')` imported from `src/i18n/index.ts`.
87+
5. Pass `locale` (or `locale.value` as a string) to `formatDateRange()`, `formatDate()`, etc. for locale-aware date formatting.
88+
89+
### What NOT to translate
90+
91+
Event data coming from content files — `event_name`, `details_text`, `city`, `country`, `organization_name`, organizer names, URLs — must never be wrapped in `t()`. Only static UI strings get translated.
92+
93+
### Non-English word choices
94+
95+
Non-English locales use "Events" (not "Nodes") in list/dialog labels, since "Nodes" is a technical term that doesn't translate naturally.
6496

6597
## Global Configuration (`src/config.ts`)
6698

pcd-website/astro.config.mjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ export default defineConfig({
99
output: 'static',
1010
site: isNetlify ? 'https://processing-community-day.netlify.app' : 'https://processing.github.io',
1111
base: isNetlify ? '/' : '/pcd-website-mvp-2',
12-
integrations: [vue()],
12+
integrations: [vue({ appEntrypoint: '/src/i18n/vuePlugin' })],
1313
vite: {
1414
build: {
1515
rollupOptions: {

pcd-website/package-lock.json

Lines changed: 90 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pcd-website/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@
1818
"leaflet": "^1.9.4",
1919
"leaflet.markercluster": "^1.5.3",
2020
"open-location-code": "^1.0.3",
21-
"vue": "^3.5.29"
21+
"vue": "^3.5.29",
22+
"vue-i18n": "^11.3.0"
2223
},
2324
"devDependencies": {
2425
"@types/leaflet": "^1.9.21",
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
<template>
2+
<div class="lang-switcher" ref="wrapRef">
3+
<button
4+
class="lang-btn"
5+
:aria-label="t('language_switcher.label')"
6+
:title="t('language_switcher.label')"
7+
:aria-expanded="open"
8+
aria-haspopup="listbox"
9+
@click.stop="open = !open"
10+
>
11+
<Icon icon="bi:globe" width="1em" height="1em" aria-hidden="true" />
12+
<span class="lang-current">{{ currentLocale.toUpperCase() }}</span>
13+
<Icon icon="bi:chevron-down" class="lang-chevron" :class="{ 'lang-chevron--open': open }" width="0.75em" height="0.75em" aria-hidden="true" />
14+
</button>
15+
<ul
16+
v-show="open"
17+
role="listbox"
18+
:aria-label="t('language_switcher.label')"
19+
class="lang-dropdown"
20+
>
21+
<li
22+
v-for="locale in SUPPORTED_LOCALES"
23+
:key="locale"
24+
role="option"
25+
:aria-selected="locale === currentLocale"
26+
class="lang-option"
27+
:class="{ 'lang-option--active': locale === currentLocale }"
28+
@click="select(locale)"
29+
>
30+
{{ LANGUAGE_NAMES[locale] }}
31+
</li>
32+
</ul>
33+
</div>
34+
</template>
35+
36+
<script setup lang="ts">
37+
import { ref, onMounted, onUnmounted } from 'vue';
38+
import { useI18n } from 'vue-i18n';
39+
import { Icon } from '@iconify/vue';
40+
import { SUPPORTED_LOCALES, type SupportedLocale } from '../i18n/index';
41+
import { currentLocale, setLocale } from '../i18n/localeState';
42+
43+
const { t } = useI18n();
44+
const open = ref(false);
45+
const wrapRef = ref<HTMLElement | null>(null);
46+
47+
const LANGUAGE_NAMES: Record<SupportedLocale, string> = {
48+
en: 'English',
49+
es: 'Español',
50+
de: 'Deutsch',
51+
fr: 'Français',
52+
pt: 'Português',
53+
'zh-TW': '中文(繁體)',
54+
'zh-CN': '中文(简体)',
55+
ja: '日本語',
56+
ko: '한국어',
57+
};
58+
59+
function select(locale: SupportedLocale) {
60+
setLocale(locale);
61+
open.value = false;
62+
}
63+
64+
function handleOutsideClick(e: MouseEvent) {
65+
if (open.value && !wrapRef.value?.contains(e.target as Node)) {
66+
open.value = false;
67+
}
68+
}
69+
70+
onMounted(() => document.addEventListener('click', handleOutsideClick));
71+
onUnmounted(() => document.removeEventListener('click', handleOutsideClick));
72+
</script>
73+
74+
<style scoped>
75+
.lang-switcher {
76+
position: fixed;
77+
top: 1rem;
78+
right: calc(1rem + 40px + 0.5rem);
79+
z-index: var(--z-controls);
80+
}
81+
82+
.lang-btn {
83+
display: flex;
84+
align-items: center;
85+
gap: 0.3em;
86+
height: 40px;
87+
padding: 0 0.625rem;
88+
background: var(--color-bg-popup);
89+
border: 1px solid var(--color-border);
90+
border-radius: 8px;
91+
cursor: pointer;
92+
color: var(--color-text);
93+
font-family: var(--font-family);
94+
font-size: 0.8125rem;
95+
font-weight: 600;
96+
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.15);
97+
transition: background-color 0.12s ease, color 0.12s ease, border-color 0.12s ease;
98+
white-space: nowrap;
99+
}
100+
101+
.lang-btn:hover {
102+
background: var(--color-primary);
103+
color: #fff;
104+
border-color: var(--color-primary);
105+
}
106+
107+
.lang-btn:focus-visible {
108+
outline: 2px solid var(--color-focus);
109+
outline-offset: 2px;
110+
}
111+
112+
.lang-current {
113+
font-size: 0.75rem;
114+
letter-spacing: 0.03em;
115+
}
116+
117+
.lang-chevron {
118+
transition: transform 0.15s ease;
119+
opacity: 0.7;
120+
}
121+
122+
.lang-chevron--open {
123+
transform: rotate(180deg);
124+
}
125+
126+
.lang-dropdown {
127+
position: absolute;
128+
right: 0;
129+
top: calc(100% + 4px);
130+
list-style: none;
131+
margin: 0;
132+
padding: 4px;
133+
background: var(--color-bg-panel);
134+
border: 1px solid var(--color-border);
135+
border-radius: 8px;
136+
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
137+
min-width: 160px;
138+
max-height: 320px;
139+
overflow-y: auto;
140+
}
141+
142+
.lang-option {
143+
padding: 8px 12px;
144+
font-size: 0.875rem;
145+
cursor: pointer;
146+
border-radius: 4px;
147+
color: var(--color-text);
148+
font-family: var(--font-family);
149+
list-style: none;
150+
}
151+
152+
.lang-option:hover {
153+
background: var(--color-border);
154+
}
155+
156+
.lang-option--active {
157+
color: var(--color-primary);
158+
font-weight: 600;
159+
}
160+
161+
@media (max-width: 480px) {
162+
.lang-current {
163+
display: none;
164+
}
165+
166+
.lang-chevron {
167+
display: none;
168+
}
169+
}
170+
</style>

0 commit comments

Comments
 (0)