Skip to content

Commit 95c421b

Browse files
authored
feat: hoist all text to i18n (#292)
* feat(i18n): add internationalization support with English translations - Implemented i18n context and provider for translation management. - Added English translations for various components and sections. - Updated components to utilize the translation function for dynamic text. - Modified layout files to support locale-based rendering. - Removed hardcoded strings in favor of translation keys for better localization. * feat: add more text to i18n and explain how to make a new language * feat(i18n): add support for non-standard locales and update documentation
1 parent 34656bf commit 95c421b

28 files changed

Lines changed: 528 additions & 186 deletions

README.md

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,105 @@ bun run dev
2828

2929
Note: I added [remark-mermaid](https://github.com/temando/remark-mermaid) into utils and slightly modified it so that it is always simple mode and always returns a `pre` + `<div class="mermaid"></div>`. This allows mermaid code blocks to be included.
3030

31+
# Adding a New Language
32+
33+
UI strings live in `src/i18n/locales/` and are served via a pure Preact context — no external i18n library required. Adding a new locale takes 3 steps:
34+
35+
## 1. Create the locale file
36+
37+
Copy `src/i18n/locales/en.ts` to a new file, e.g. `src/i18n/locales/es.ts`. Translate all string values. The key structure must stay identical.
38+
39+
```ts
40+
// src/i18n/locales/es.ts
41+
const es = {
42+
nav: { home: "Inicio", blog: "Blog", portfolio: "Portafolio" },
43+
hero: { prefix: "Construyo ", highlight: "apps increíbles", ... },
44+
// ... all other keys translated
45+
} as const;
46+
47+
export default es;
48+
```
49+
50+
## 2. Register it in the context
51+
52+
In `src/i18n/context.tsx`, import the new locale and add it to the `locales` map:
53+
54+
```ts
55+
import en from "~/i18n/locales/en";
56+
import es from "~/i18n/locales/es"; // add
57+
58+
const locales: Record<string, Translation> = { en, es }; // add es
59+
```
60+
61+
## 3. Add it to Astro and create the page
62+
63+
In `astro.config.mjs`, add the locale code to the `locales` array:
64+
65+
```js
66+
i18n: {
67+
locales: ["en", "es"], // add "es"
68+
defaultLocale: "en",
69+
routing: { prefixDefaultLocale: false },
70+
},
71+
```
72+
73+
Then create `src/pages/es/index.astro` (mirroring `src/pages/index.astro`). Astro will set `Astro.currentLocale` to `"es"` for that page, which flows through the layout's `locale` prop into `I18nProvider` — all components then resolve strings from the new locale automatically.
74+
75+
> **Note on islands**: All home-page sections are grouped inside `<HomeSections locale={...} client:visible />`, which wraps them in a single `I18nProvider`. Each Astro `client:*` island is an isolated Preact tree, so any new island components that use `useTranslation()` must also receive `locale` and render inside an `I18nProvider`.
76+
77+
## Non-standard / fun locales
78+
79+
For locales that aren't real BCP 47 language codes (e.g. a "backward" locale that reverses all strings for testing), use Astro's locale **object syntax** to decouple the URL path from the `lang` attribute:
80+
81+
```js
82+
// astro.config.mjs
83+
i18n: {
84+
locales: [
85+
"en",
86+
{ path: "backward", codes: ["en-x-backward"] },
87+
],
88+
defaultLocale: "en",
89+
routing: { prefixDefaultLocale: false },
90+
},
91+
```
92+
93+
- **`path`** — the URL segment (`/backward/`)
94+
- **`codes`** — what goes in `<html lang="...">`. Using `en-x-backward` is a valid BCP 47 private-use extension: screen readers treat it as English while still accurately describing the variant.
95+
96+
Because `Astro.currentLocale` resolves to the first entry in `codes` (`"en-x-backward"`), you must pass `locale` explicitly in the page rather than relying on `Astro.currentLocale`:
97+
98+
```astro
99+
<!-- src/pages/backward/index.astro -->
100+
<DefaultPageLayout description={description}>
101+
<HomeSections locale="backward" ... client:visible />
102+
</DefaultPageLayout>
103+
```
104+
105+
Then register `"backward"` in `src/i18n/context.tsx` alongside its locale file:
106+
107+
```ts
108+
import backward from "~/i18n/locales/backward";
109+
const locales: Record<string, Translation> = { en, backward };
110+
```
111+
112+
## TODO: Scale with `getStaticPaths`
113+
114+
The current approach (one folder per locale, e.g. `src/pages/es/index.astro`) works but doesn't scale — each new locale requires duplicating every page file.
115+
116+
The better long-term solution is to use a dynamic `[locale]` route with `getStaticPaths` so a single file generates all locale variants:
117+
118+
```astro
119+
---
120+
// src/pages/[locale]/index.astro
121+
export function getStaticPaths() {
122+
return [{ params: { locale: "es" } }, { params: { locale: "fr" } }];
123+
}
124+
const { locale } = Astro.params;
125+
---
126+
```
127+
128+
This should be done when adding a second language for real, so the routing stays maintainable.
129+
31130
# Outputs
32131

33132
- `dist` - this is the output of the build

astro.config.mjs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,4 +16,11 @@ export default defineConfig({
1616
remarkPlugins: [remarkGfm, remarkSmartypants, remarkMermaid],
1717
},
1818
integrations: [mdx(), unoCss(), preact()],
19+
i18n: {
20+
locales: ["en"],
21+
defaultLocale: "en",
22+
routing: {
23+
prefixDefaultLocale: false,
24+
},
25+
},
1926
});

package.json

Lines changed: 14 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,6 @@
11
{
22
"name": "hrgui",
33
"version": "2026.03.22",
4-
"description": "Portfolio website",
5-
"private": true,
6-
"scripts": {
7-
"dev": "astro dev --port 3000",
8-
"start": "astro dev",
9-
"build": "astro build",
10-
"preview": "astro preview",
11-
"format": "prettier --write \"src/**/*\"",
12-
"test": "vitest",
13-
"test-ui": "vitest --ui",
14-
"lint:es": "eslint src",
15-
"lint": "yarn lint:es && tsc",
16-
"prepare": "husky"
17-
},
184
"devDependencies": {
195
"@astrojs/mdx": "^5.0.2",
206
"@astrojs/preact": "5.0.2",
@@ -66,9 +52,22 @@
6652
"vite-tsconfig-paths": "^6.1.1",
6753
"vitest": "^4.1.0"
6854
},
55+
"description": "Portfolio website",
6956
"overrides": {
7057
"preact": "^10.25.1",
7158
"@preact/preset-vite": "^2.9.3"
7259
},
73-
"dependencies": {}
60+
"private": true,
61+
"scripts": {
62+
"dev": "astro dev --port 3000",
63+
"start": "astro dev",
64+
"build": "astro build",
65+
"preview": "astro preview",
66+
"format": "prettier --write \"src/**/*\"",
67+
"test": "vitest",
68+
"test-ui": "vitest --ui",
69+
"lint:es": "eslint src",
70+
"lint": "yarn lint:es && tsc",
71+
"prepare": "husky"
72+
}
7473
}

src/components/app/AppLayout.tsx

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,25 @@
11
import type { ComponentChildren } from "preact";
22

3+
import { I18nProvider } from "~/i18n/context";
34
import Footer from "./Footer";
45
import Header from "./Header";
56

67
interface Props {
78
children?: ComponentChildren;
89
currentUrl?: string;
910
currentPathName?: string;
11+
locale?: string;
1012
}
1113

12-
const AppLayout = ({ children, currentPathName }: Props) => {
14+
const AppLayout = ({ children, currentPathName, locale = "en" }: Props) => {
1315
return (
14-
<div className="bg-background font-body text-on-background">
15-
<Header currentPathName={currentPathName} />
16-
<div className="min-h-screen">{children}</div>
17-
<Footer />
18-
</div>
16+
<I18nProvider locale={locale}>
17+
<div className="bg-background font-body text-on-background">
18+
<Header currentPathName={currentPathName} />
19+
<div className="min-h-screen">{children}</div>
20+
<Footer />
21+
</div>
22+
</I18nProvider>
1923
);
2024
};
2125

src/components/app/AppSocialMedia.tsx

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import classNames from "classnames";
2+
import { useTranslation } from "~/i18n/context";
23

34
import Github from "~/components/icons/Github";
45
import LinkedIn from "~/components/icons/LinkedIn";
@@ -9,11 +10,12 @@ type Props = {
910
};
1011

1112
const AppSocialMedia = ({ className }: Props) => {
13+
const { t } = useTranslation();
1214
return (
1315
<div className={classNames("flex gap-2", className)}>
1416
<a
15-
title="View My GitHub Profile"
16-
aria-label="View My GitHub Profile"
17+
title={t("social.github")}
18+
aria-label={t("social.github")}
1719
href={GITHUB_URL}
1820
target="_blank"
1921
rel="noopener noreferrer"
@@ -22,8 +24,8 @@ const AppSocialMedia = ({ className }: Props) => {
2224
<Github aria-hidden="true" />
2325
</a>
2426
<a
25-
title="View My LinkedIn Profile"
26-
aria-label="View My LinkedIn Profile"
27+
title={t("social.linkedin")}
28+
aria-label={t("social.linkedin")}
2729
href={LINKEDIN_URL}
2830
target="_blank"
2931
rel="noopener noreferrer"

src/components/app/Footer.tsx

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { useTranslation } from "~/i18n/context";
2+
13
import Link from "~/components/layout/Link";
24
import LinkButton from "~/components/layout/LinkButton";
35

@@ -11,6 +13,8 @@ const footerBackToTopClassName =
1113
"font-mono text-sm uppercase tracking-[0.16em] text-primary transition-all duration-150 ease-out hover:text-primary-container hover:underline hover:decoration-2 hover:underline-offset-4 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/70 focus-visible:ring-offset-2 focus-visible:ring-offset-surface-container-high active:opacity-80";
1214

1315
const Footer = () => {
16+
const { t } = useTranslation();
17+
1418
function handleBackToTop() {
1519
window.scrollTo({ top: 0, behavior: "smooth" });
1620
}
@@ -25,22 +29,19 @@ const Footer = () => {
2529
<div>
2630
<Logo />
2731
<p className="prose prose-sm dark:text-gray-200 mt-4 mb-4">
28-
Harman Goei (hrgui) is a developer that loves to make cool and
29-
awesome web applications. His strength is in HTML, CSS,
30-
JavaScript, but he is willing to code anywhere in the stack to
31-
make the web be awesome.
32+
{t("footer.bio")}
3233
</p>
3334
</div>
3435
<div className="mb-4 mt-4">
3536
<nav className="flex flex-col gap-1">
3637
<Link href="/" className={footerLinkClassName}>
37-
Home
38+
{t("nav.home")}
3839
</Link>
3940
<Link href="/posts" className={footerLinkClassName}>
40-
Blog
41+
{t("nav.blog")}
4142
</Link>
4243
<Link href="/portfolio" className={footerLinkClassName}>
43-
Portfolio
44+
{t("nav.portfolio")}
4445
</Link>
4546
</nav>
4647
</div>
@@ -49,15 +50,15 @@ const Footer = () => {
4950
<div className="p-6 bg-gray-200 dark:bg-neutral-800 border-t-2 border-gray-300 dark:border-neutral-700 dark:text-gray-200">
5051
<div className="container mx-auto flex justify-between">
5152
<div className="font-mono text-sm uppercase tracking-[0.12em] text-on-surface-muted">
52-
&copy; {new Date().getFullYear()} Harman Goei
53+
{t("footer.copyright", { year: new Date().getFullYear() })}
5354
</div>
5455
<AppSocialMedia />
5556
<div>
5657
<LinkButton
5758
onClick={handleBackToTop}
5859
className={footerBackToTopClassName}
5960
>
60-
back to top?
61+
{t("footer.backToTop")}
6162
</LinkButton>
6263
</div>
6364
</div>

src/components/app/Header.tsx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import classNames from "classnames";
22
import { useState } from "preact/hooks";
3+
import { useTranslation } from "~/i18n/context";
34

45
import useScrollTrigger from "~/hooks/useScrollTrigger";
56

@@ -16,6 +17,7 @@ type Props = {
1617
};
1718

1819
const Header = ({ currentPathName }: Props) => {
20+
const { t } = useTranslation();
1921
const [isOpen, setisOpen] = useState(false);
2022
const handleSetIsOpen = () => setisOpen(!isOpen);
2123
const trigger = useScrollTrigger({
@@ -26,17 +28,17 @@ const Header = ({ currentPathName }: Props) => {
2628
// NOTE: this needs a key because of use in the Drawer
2729
const links = [
2830
<NavLink currentPathName={currentPathName} key="home" href="/" exact>
29-
Home
31+
{t("nav.home")}
3032
</NavLink>,
3133
<NavLink currentPathName={currentPathName} key="blog" href="/posts">
32-
Blog
34+
{t("nav.blog")}
3335
</NavLink>,
3436
<NavLink
3537
currentPathName={currentPathName}
3638
key="portfolio"
3739
href="/portfolio"
3840
>
39-
Portfolio
41+
{t("nav.portfolio")}
4042
</NavLink>,
4143
];
4244

src/components/app/blog/BlogSubHeader.tsx

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { useTranslation } from "~/i18n/context";
2+
13
import { toDisplayDate } from "./utils";
24

35
type Props = {
@@ -8,17 +10,20 @@ type Props = {
810
};
911

1012
const BlogSubHeader = ({ hidden, date, title, excerpt }: Props) => {
13+
const { t } = useTranslation();
1114
return (
1215
<section className="circuit-board-bg relative overflow-hidden px-6 pb-8 pt-28">
1316
<div className="relative z-10 container mx-auto max-w-[1536px]">
1417
{date && (
1518
<div className="mb-8 flex items-center gap-4">
1619
<div className="flex items-center gap-2 whitespace-nowrap font-mono text-sm text-primary bg-primary/10 px-2 py-1 rounded tracking-widest">
1720
<span className={"uppercase"}>{toDisplayDate(date)} //</span>
18-
<span className="text-primary">ENTRY_RECORD</span>
21+
<span className="text-primary">
22+
{t("blog.subHeader.entryRecord")}
23+
</span>
1924
{hidden && process.env.NODE_ENV === "development" && (
2025
<span className="rounded bg-tertiary/18 px-2 py-1 text-xs font-semibold uppercase tracking-[0.16em] text-tertiary">
21-
Hidden Draft
26+
{t("blog.subHeader.hiddenDraft")}
2227
</span>
2328
)}
2429
</div>
@@ -29,14 +34,13 @@ const BlogSubHeader = ({ hidden, date, title, excerpt }: Props) => {
2934
<div className="mb-8">
3035
{!date && hidden && process.env.NODE_ENV === "development" && (
3136
<div className="mb-5 inline-flex rounded bg-tertiary/18 px-2 py-1 font-mono text-xs font-semibold uppercase tracking-[0.16em] text-tertiary">
32-
Hidden Draft
37+
{t("blog.subHeader.hiddenDraft")}
3338
</div>
3439
)}
3540

3641
{hidden && process.env.NODE_ENV === "development" && (
3742
<div className="mb-6 max-w-3xl rounded-2xl border border-tertiary/40 bg-tertiary/10 px-4 py-3 text-sm text-on-surface">
38-
You are looking at a hidden post. Remove `hidden: true` or set it
39-
to `false` to publish this post.
43+
{t("blog.subHeader.hiddenWarning")}
4044
</div>
4145
)}
4246

0 commit comments

Comments
 (0)