Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 99 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,105 @@ bun run dev

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.

# Adding a New Language

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:

## 1. Create the locale file

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.

```ts
// src/i18n/locales/es.ts
const es = {
nav: { home: "Inicio", blog: "Blog", portfolio: "Portafolio" },
hero: { prefix: "Construyo ", highlight: "apps increíbles", ... },
// ... all other keys translated
} as const;

export default es;
```

## 2. Register it in the context

In `src/i18n/context.tsx`, import the new locale and add it to the `locales` map:

```ts
import en from "~/i18n/locales/en";
import es from "~/i18n/locales/es"; // add

const locales: Record<string, Translation> = { en, es }; // add es
```

## 3. Add it to Astro and create the page

In `astro.config.mjs`, add the locale code to the `locales` array:

```js
i18n: {
locales: ["en", "es"], // add "es"
defaultLocale: "en",
routing: { prefixDefaultLocale: false },
},
```

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.

> **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`.

## Non-standard / fun locales

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:

```js
// astro.config.mjs
i18n: {
locales: [
"en",
{ path: "backward", codes: ["en-x-backward"] },
],
defaultLocale: "en",
routing: { prefixDefaultLocale: false },
},
```

- **`path`** — the URL segment (`/backward/`)
- **`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.

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`:

```astro
<!-- src/pages/backward/index.astro -->
<DefaultPageLayout description={description}>
<HomeSections locale="backward" ... client:visible />
</DefaultPageLayout>
```

Then register `"backward"` in `src/i18n/context.tsx` alongside its locale file:

```ts
import backward from "~/i18n/locales/backward";
const locales: Record<string, Translation> = { en, backward };
```

## TODO: Scale with `getStaticPaths`

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.

The better long-term solution is to use a dynamic `[locale]` route with `getStaticPaths` so a single file generates all locale variants:

```astro
---
// src/pages/[locale]/index.astro
export function getStaticPaths() {
return [{ params: { locale: "es" } }, { params: { locale: "fr" } }];
}
const { locale } = Astro.params;
---
```

This should be done when adding a second language for real, so the routing stays maintainable.

# Outputs

- `dist` - this is the output of the build
Expand Down
7 changes: 7 additions & 0 deletions astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,11 @@ export default defineConfig({
remarkPlugins: [remarkGfm, remarkSmartypants, remarkMermaid],
},
integrations: [mdx(), unoCss(), preact()],
i18n: {
locales: ["en"],
defaultLocale: "en",
routing: {
prefixDefaultLocale: false,
},
},
});
29 changes: 14 additions & 15 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,20 +1,6 @@
{
"name": "hrgui",
"version": "2026.03.22",
"description": "Portfolio website",
"private": true,
"scripts": {
"dev": "astro dev --port 3000",
"start": "astro dev",
"build": "astro build",
"preview": "astro preview",
"format": "prettier --write \"src/**/*\"",
"test": "vitest",
"test-ui": "vitest --ui",
"lint:es": "eslint src",
"lint": "yarn lint:es && tsc",
"prepare": "husky"
},
"devDependencies": {
"@astrojs/mdx": "^5.0.2",
"@astrojs/preact": "5.0.2",
Expand Down Expand Up @@ -66,9 +52,22 @@
"vite-tsconfig-paths": "^6.1.1",
"vitest": "^4.1.0"
},
"description": "Portfolio website",
"overrides": {
"preact": "^10.25.1",
"@preact/preset-vite": "^2.9.3"
},
"dependencies": {}
"private": true,
"scripts": {
"dev": "astro dev --port 3000",
"start": "astro dev",
"build": "astro build",
"preview": "astro preview",
"format": "prettier --write \"src/**/*\"",
"test": "vitest",
"test-ui": "vitest --ui",
"lint:es": "eslint src",
"lint": "yarn lint:es && tsc",
"prepare": "husky"
}
}
16 changes: 10 additions & 6 deletions src/components/app/AppLayout.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,25 @@
import type { ComponentChildren } from "preact";

import { I18nProvider } from "~/i18n/context";
import Footer from "./Footer";
import Header from "./Header";

interface Props {
children?: ComponentChildren;
currentUrl?: string;
currentPathName?: string;
locale?: string;
}

const AppLayout = ({ children, currentPathName }: Props) => {
const AppLayout = ({ children, currentPathName, locale = "en" }: Props) => {
return (
<div className="bg-background font-body text-on-background">
<Header currentPathName={currentPathName} />
<div className="min-h-screen">{children}</div>
<Footer />
</div>
<I18nProvider locale={locale}>
<div className="bg-background font-body text-on-background">
<Header currentPathName={currentPathName} />
<div className="min-h-screen">{children}</div>
<Footer />
</div>
</I18nProvider>
);
};

Expand Down
10 changes: 6 additions & 4 deletions src/components/app/AppSocialMedia.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import classNames from "classnames";
import { useTranslation } from "~/i18n/context";

import Github from "~/components/icons/Github";
import LinkedIn from "~/components/icons/LinkedIn";
Expand All @@ -9,11 +10,12 @@ type Props = {
};

const AppSocialMedia = ({ className }: Props) => {
const { t } = useTranslation();
return (
<div className={classNames("flex gap-2", className)}>
<a
title="View My GitHub Profile"
aria-label="View My GitHub Profile"
title={t("social.github")}
aria-label={t("social.github")}
href={GITHUB_URL}
target="_blank"
rel="noopener noreferrer"
Expand All @@ -22,8 +24,8 @@ const AppSocialMedia = ({ className }: Props) => {
<Github aria-hidden="true" />
</a>
<a
title="View My LinkedIn Profile"
aria-label="View My LinkedIn Profile"
title={t("social.linkedin")}
aria-label={t("social.linkedin")}
href={LINKEDIN_URL}
target="_blank"
rel="noopener noreferrer"
Expand Down
19 changes: 10 additions & 9 deletions src/components/app/Footer.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { useTranslation } from "~/i18n/context";

import Link from "~/components/layout/Link";
import LinkButton from "~/components/layout/LinkButton";

Expand All @@ -11,6 +13,8 @@ const footerBackToTopClassName =
"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";

const Footer = () => {
const { t } = useTranslation();

function handleBackToTop() {
window.scrollTo({ top: 0, behavior: "smooth" });
}
Expand All @@ -25,22 +29,19 @@ const Footer = () => {
<div>
<Logo />
<p className="prose prose-sm dark:text-gray-200 mt-4 mb-4">
Harman Goei (hrgui) is a developer that loves to make cool and
awesome web applications. His strength is in HTML, CSS,
JavaScript, but he is willing to code anywhere in the stack to
make the web be awesome.
{t("footer.bio")}
</p>
</div>
<div className="mb-4 mt-4">
<nav className="flex flex-col gap-1">
<Link href="/" className={footerLinkClassName}>
Home
{t("nav.home")}
</Link>
<Link href="/posts" className={footerLinkClassName}>
Blog
{t("nav.blog")}
</Link>
<Link href="/portfolio" className={footerLinkClassName}>
Portfolio
{t("nav.portfolio")}
</Link>
</nav>
</div>
Expand All @@ -49,15 +50,15 @@ const Footer = () => {
<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">
<div className="container mx-auto flex justify-between">
<div className="font-mono text-sm uppercase tracking-[0.12em] text-on-surface-muted">
&copy; {new Date().getFullYear()} Harman Goei
{t("footer.copyright", { year: new Date().getFullYear() })}
</div>
<AppSocialMedia />
<div>
<LinkButton
onClick={handleBackToTop}
className={footerBackToTopClassName}
>
back to top?
{t("footer.backToTop")}
</LinkButton>
</div>
</div>
Expand Down
8 changes: 5 additions & 3 deletions src/components/app/Header.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import classNames from "classnames";
import { useState } from "preact/hooks";
import { useTranslation } from "~/i18n/context";

import useScrollTrigger from "~/hooks/useScrollTrigger";

Expand All @@ -16,6 +17,7 @@ type Props = {
};

const Header = ({ currentPathName }: Props) => {
const { t } = useTranslation();
const [isOpen, setisOpen] = useState(false);
const handleSetIsOpen = () => setisOpen(!isOpen);
const trigger = useScrollTrigger({
Expand All @@ -26,17 +28,17 @@ const Header = ({ currentPathName }: Props) => {
// NOTE: this needs a key because of use in the Drawer
const links = [
<NavLink currentPathName={currentPathName} key="home" href="/" exact>
Home
{t("nav.home")}
</NavLink>,
<NavLink currentPathName={currentPathName} key="blog" href="/posts">
Blog
{t("nav.blog")}
</NavLink>,
<NavLink
currentPathName={currentPathName}
key="portfolio"
href="/portfolio"
>
Portfolio
{t("nav.portfolio")}
</NavLink>,
];

Expand Down
14 changes: 9 additions & 5 deletions src/components/app/blog/BlogSubHeader.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { useTranslation } from "~/i18n/context";

import { toDisplayDate } from "./utils";

type Props = {
Expand All @@ -8,17 +10,20 @@ type Props = {
};

const BlogSubHeader = ({ hidden, date, title, excerpt }: Props) => {
const { t } = useTranslation();
return (
<section className="circuit-board-bg relative overflow-hidden px-6 pb-8 pt-28">
<div className="relative z-10 container mx-auto max-w-[1536px]">
{date && (
<div className="mb-8 flex items-center gap-4">
<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">
<span className={"uppercase"}>{toDisplayDate(date)} //</span>
<span className="text-primary">ENTRY_RECORD</span>
<span className="text-primary">
{t("blog.subHeader.entryRecord")}
</span>
{hidden && process.env.NODE_ENV === "development" && (
<span className="rounded bg-tertiary/18 px-2 py-1 text-xs font-semibold uppercase tracking-[0.16em] text-tertiary">
Hidden Draft
{t("blog.subHeader.hiddenDraft")}
</span>
)}
</div>
Expand All @@ -29,14 +34,13 @@ const BlogSubHeader = ({ hidden, date, title, excerpt }: Props) => {
<div className="mb-8">
{!date && hidden && process.env.NODE_ENV === "development" && (
<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">
Hidden Draft
{t("blog.subHeader.hiddenDraft")}
</div>
)}

{hidden && process.env.NODE_ENV === "development" && (
<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">
You are looking at a hidden post. Remove `hidden: true` or set it
to `false` to publish this post.
{t("blog.subHeader.hiddenWarning")}
</div>
)}

Expand Down
Loading
Loading