diff --git a/README.md b/README.md index 5e3271df..16b06154 100644 --- a/README.md +++ b/README.md @@ -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` + `
`. 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 = { 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 ``, 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 ``. 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 + + + + +``` + +Then register `"backward"` in `src/i18n/context.tsx` alongside its locale file: + +```ts +import backward from "~/i18n/locales/backward"; +const locales: Record = { 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 diff --git a/astro.config.mjs b/astro.config.mjs index 4dee7017..c33c7bc4 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -16,4 +16,11 @@ export default defineConfig({ remarkPlugins: [remarkGfm, remarkSmartypants, remarkMermaid], }, integrations: [mdx(), unoCss(), preact()], + i18n: { + locales: ["en"], + defaultLocale: "en", + routing: { + prefixDefaultLocale: false, + }, + }, }); diff --git a/package.json b/package.json index 2d1bc68f..0d715845 100644 --- a/package.json +++ b/package.json @@ -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", @@ -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" + } } diff --git a/src/components/app/AppLayout.tsx b/src/components/app/AppLayout.tsx index 3d6b3706..9c00f1de 100644 --- a/src/components/app/AppLayout.tsx +++ b/src/components/app/AppLayout.tsx @@ -1,5 +1,6 @@ import type { ComponentChildren } from "preact"; +import { I18nProvider } from "~/i18n/context"; import Footer from "./Footer"; import Header from "./Header"; @@ -7,15 +8,18 @@ interface Props { children?: ComponentChildren; currentUrl?: string; currentPathName?: string; + locale?: string; } -const AppLayout = ({ children, currentPathName }: Props) => { +const AppLayout = ({ children, currentPathName, locale = "en" }: Props) => { return ( -
-
-
{children}
-
-
+ +
+
+
{children}
+
+
+
); }; diff --git a/src/components/app/AppSocialMedia.tsx b/src/components/app/AppSocialMedia.tsx index 1515906f..1e1922ad 100644 --- a/src/components/app/AppSocialMedia.tsx +++ b/src/components/app/AppSocialMedia.tsx @@ -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"; @@ -9,11 +10,12 @@ type Props = { }; const AppSocialMedia = ({ className }: Props) => { + const { t } = useTranslation(); return (
{ { + const { t } = useTranslation(); + function handleBackToTop() { window.scrollTo({ top: 0, behavior: "smooth" }); } @@ -25,22 +29,19 @@ const Footer = () => {

- 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")}

@@ -49,7 +50,7 @@ const Footer = () => {
- © {new Date().getFullYear()} Harman Goei + {t("footer.copyright", { year: new Date().getFullYear() })}
@@ -57,7 +58,7 @@ const Footer = () => { onClick={handleBackToTop} className={footerBackToTopClassName} > - back to top? + {t("footer.backToTop")}
diff --git a/src/components/app/Header.tsx b/src/components/app/Header.tsx index 71bee1de..6947c134 100644 --- a/src/components/app/Header.tsx +++ b/src/components/app/Header.tsx @@ -1,5 +1,6 @@ import classNames from "classnames"; import { useState } from "preact/hooks"; +import { useTranslation } from "~/i18n/context"; import useScrollTrigger from "~/hooks/useScrollTrigger"; @@ -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({ @@ -26,17 +28,17 @@ const Header = ({ currentPathName }: Props) => { // NOTE: this needs a key because of use in the Drawer const links = [ - Home + {t("nav.home")} , - Blog + {t("nav.blog")} , - Portfolio + {t("nav.portfolio")} , ]; diff --git a/src/components/app/blog/BlogSubHeader.tsx b/src/components/app/blog/BlogSubHeader.tsx index 8d0e9994..f0c43901 100644 --- a/src/components/app/blog/BlogSubHeader.tsx +++ b/src/components/app/blog/BlogSubHeader.tsx @@ -1,3 +1,5 @@ +import { useTranslation } from "~/i18n/context"; + import { toDisplayDate } from "./utils"; type Props = { @@ -8,6 +10,7 @@ type Props = { }; const BlogSubHeader = ({ hidden, date, title, excerpt }: Props) => { + const { t } = useTranslation(); return (
@@ -15,10 +18,12 @@ const BlogSubHeader = ({ hidden, date, title, excerpt }: Props) => {
{toDisplayDate(date)} // - ENTRY_RECORD + + {t("blog.subHeader.entryRecord")} + {hidden && process.env.NODE_ENV === "development" && ( - Hidden Draft + {t("blog.subHeader.hiddenDraft")} )}
@@ -29,14 +34,13 @@ const BlogSubHeader = ({ hidden, date, title, excerpt }: Props) => {
{!date && hidden && process.env.NODE_ENV === "development" && (
- Hidden Draft + {t("blog.subHeader.hiddenDraft")}
)} {hidden && process.env.NODE_ENV === "development" && (
- You are looking at a hidden post. Remove `hidden: true` or set it - to `false` to publish this post. + {t("blog.subHeader.hiddenWarning")}
)} diff --git a/src/components/app/blog/Posts.tsx b/src/components/app/blog/Posts.tsx index 6ecc83fd..314b4b87 100644 --- a/src/components/app/blog/Posts.tsx +++ b/src/components/app/blog/Posts.tsx @@ -1,4 +1,5 @@ import classNames from "classnames"; +import { useTranslation } from "~/i18n/context"; import { type Frontmatter } from "~/types/frontmatter"; @@ -15,6 +16,7 @@ const toNodeId = (index: number) => `SYSLOG_${String(index + 1).padStart(2, "0")}`; const Posts = ({ posts }: Props) => { + const { t } = useTranslation(); const visiblePosts = posts?.filter( (post) => !(post.hidden && process.env.NODE_ENV !== "development") @@ -38,7 +40,7 @@ const Posts = ({ posts }: Props) => {
{isNew(featuredPost.date) && ( - New + {t("blog.posts.newBadge")} )}

@@ -65,7 +67,7 @@ const Posts = ({ posts }: Props) => {

- Execute_Read -> + {t("blog.posts.executeRead")}
@@ -92,7 +94,7 @@ const Posts = ({ posts }: Props) => { {remainingPosts[0].excerpt}

- Read_More -> + {t("blog.posts.readMore")}

)} @@ -134,7 +136,7 @@ const Posts = ({ posts }: Props) => { {post.excerpt}

- Read_More -> + {t("blog.posts.readMore")}

); @@ -144,7 +146,7 @@ const Posts = ({ posts }: Props) => { {visiblePosts.length === 0 && (
- No posts available. + {t("blog.posts.noPosts")}
)}
diff --git a/src/components/app/home/HomeSections.tsx b/src/components/app/home/HomeSections.tsx new file mode 100644 index 00000000..59baecda --- /dev/null +++ b/src/components/app/home/HomeSections.tsx @@ -0,0 +1,33 @@ +import { I18nProvider } from "~/i18n/context"; +import { type PortfolioFrontmatter } from "~/types/frontmatter"; + +import { Hero } from "./sections/Hero"; +import { ThreeSellPoints } from "./sections/ThreeSellPoints"; +import { TechnicalSkills } from "./sections/TechnicalSkills"; +import { Education } from "./sections/Education"; +import { PortfolioShowcase } from "~/components/app/portfolio/PortfolioShowcase"; + +interface Props { + locale?: string; + items: PortfolioFrontmatter[]; + containerClassName?: string; +} + +export function HomeSections({ + locale = "en", + items, + containerClassName, +}: Props) { + return ( + + + + + + + + ); +} diff --git a/src/components/app/home/sections/Education.test.tsx b/src/components/app/home/sections/Education.test.tsx index 92c22546..d25b3cb4 100644 --- a/src/components/app/home/sections/Education.test.tsx +++ b/src/components/app/home/sections/Education.test.tsx @@ -18,12 +18,12 @@ test("should be able to render passed in education", () => { key: "pcc", imgSrc: "/images/pcc.png", url: "https://pasadena.edu/", - title: "Pasadena City College", - description: "", timeframe: { start: 2008, end: 2010 }, }, ]} /> ); - expect(screen.getByText(/Pasadena City College/)).toBeInTheDocument(); + const links = screen.getAllByRole("link"); + expect(links[0]).toHaveAttribute("href", "https://pasadena.edu/"); + expect(screen.getByText(/2008/)).toBeInTheDocument(); }); diff --git a/src/components/app/home/sections/Education.tsx b/src/components/app/home/sections/Education.tsx index 7234777c..92f8d868 100644 --- a/src/components/app/home/sections/Education.tsx +++ b/src/components/app/home/sections/Education.tsx @@ -1,3 +1,5 @@ +import { useTranslation } from "~/i18n/context"; + import { education as defaultEducation } from "~/constants"; export function Education({ @@ -5,6 +7,7 @@ export function Education({ }: { education?: typeof defaultEducation; }) { + const { t } = useTranslation(); return (
-

module_02 // education

+

+ {t("home.education.moduleLabel")} +

- Education + {t("home.education.heading")}

{education?.map( - ({ - key, - imgSrc, - url, - title, - description, - timeframe: { start, end }, - }) => { + ({ key, imgSrc, url, timeframe: { start, end } }) => { + const title = t(`home.education.${key}.title`); return (
{`Open @@ -50,7 +49,7 @@ export function Education({ {title}

- {description} + {t(`home.education.${key}.description`)}

{start} - {end} diff --git a/src/components/app/home/sections/Hero.tsx b/src/components/app/home/sections/Hero.tsx index 31a8234b..3f5396c5 100644 --- a/src/components/app/home/sections/Hero.tsx +++ b/src/components/app/home/sections/Hero.tsx @@ -1,23 +1,26 @@ import { useEffect, useRef, useState } from "preact/hooks"; +import { useTranslation } from "~/i18n/context"; import AppSocialMedia from "~/components/app/AppSocialMedia"; const HERO_COMMAND_PROMPT = "$"; -const HERO_COMMAND_BODY = " about"; -const HERO_COMMAND_TEXT = `${HERO_COMMAND_PROMPT}${HERO_COMMAND_BODY}`; -const HERO_PREFIX = "I build "; -const HERO_HIGHLIGHT = "cool and awesome"; -const HERO_SUFFIX = "web and mobile apps."; -const HERO_TEXT = `${HERO_PREFIX}${HERO_HIGHLIGHT}${HERO_SUFFIX}`; const TYPING_INTERVAL_MS = 42; const HERO_COMMAND_DELAY_MS = 500; const HERO_COMMAND_DELAY_TICKS = Math.ceil( HERO_COMMAND_DELAY_MS / TYPING_INTERVAL_MS ); -const HERO_TOTAL_TYPED_CHARS = - HERO_COMMAND_TEXT.length + HERO_COMMAND_DELAY_TICKS + HERO_TEXT.length; export function Hero() { + const { t } = useTranslation(); + const HERO_COMMAND_BODY = t("hero.commandBody"); + const HERO_COMMAND_TEXT = `${HERO_COMMAND_PROMPT}${HERO_COMMAND_BODY}`; + const HERO_PREFIX = t("hero.prefix"); + const HERO_HIGHLIGHT = t("hero.highlight"); + const HERO_SUFFIX = t("hero.suffix"); + const HERO_TEXT = `${HERO_PREFIX}${HERO_HIGHLIGHT}${HERO_SUFFIX}`; + const HERO_TOTAL_TYPED_CHARS = + HERO_COMMAND_TEXT.length + HERO_COMMAND_DELAY_TICKS + HERO_TEXT.length; + const intervalRef = useRef(null); const [typedChars, setTypedChars] = useState(0); @@ -193,7 +196,7 @@ export function Hero() { {isTypingComplete && (

- // quick_links + {t("hero.quickLinks")}

)} {isTypingComplete && ( diff --git a/src/components/app/home/sections/TechnicalSkills.tsx b/src/components/app/home/sections/TechnicalSkills.tsx index 0dcdfd03..596b78a4 100644 --- a/src/components/app/home/sections/TechnicalSkills.tsx +++ b/src/components/app/home/sections/TechnicalSkills.tsx @@ -1,4 +1,5 @@ import type { JSX } from "preact"; +import { useTranslation } from "~/i18n/context"; import { technicalSkills as defaultTechnicalSkills } from "~/constants"; @@ -14,22 +15,22 @@ const sectionTitleMap: Record = { const summaryCards = [ { key: "javascript", - title: "JavaScript", - subtitle: "LOGIC_ENGINE_V8", + titleKey: "home.technicalSkills.javascript.title", + subtitleKey: "home.technicalSkills.javascript.subtitle", className: "text-primary", accentBorderClassName: "border-primary", }, { key: "html-css", - title: "HTML/CSS", - subtitle: "STRUCTURAL_UI_CORE", + titleKey: "home.technicalSkills.htmlCss.title", + subtitleKey: "home.technicalSkills.htmlCss.subtitle", className: "text-secondary", accentBorderClassName: "border-secondary", }, { key: "other", - title: "JACK OF ALL TRADES", - subtitle: "OTHER_TECH_SKILLS_I_HAVE", + titleKey: "home.technicalSkills.other.title", + subtitleKey: "home.technicalSkills.other.subtitle", className: "text-tertiary", accentBorderClassName: "border-tertiary", }, @@ -94,6 +95,7 @@ export function TechnicalSkills({ }: { technicalSkills?: typeof defaultTechnicalSkills; }) { + const { t } = useTranslation(); const summaryCardMap = Object.fromEntries( summaryCards.map((card) => [card.key, card]) ); @@ -150,7 +152,9 @@ export function TechnicalSkills({
-

module_01 // tech_stack

+

+ {t("home.technicalSkills.moduleLabel")} +

- Core Proficiencies + {t("home.technicalSkills.heading")}

@@ -192,11 +196,15 @@ export function TechnicalSkills({ const title = getSectionTitle(section, index); const card = (section.key && summaryCardMap[section.key]) || summaryCards[index] || { - title, - subtitle: "TECH_MODULE", + titleKey: undefined, + subtitleKey: undefined, className: "text-on-surface", accentBorderClassName: "border-outline", }; + const cardTitle = card.titleKey ? t(card.titleKey) : title; + const cardSubtitle = card.subtitleKey + ? t(card.subtitleKey) + : "TECH_MODULE"; return (

- {card.title} + {cardTitle}

- {card.subtitle} + {cardSubtitle}

diff --git a/src/components/app/home/sections/ThreeSellPoints.tsx b/src/components/app/home/sections/ThreeSellPoints.tsx index 9773e95c..2dea4b41 100644 --- a/src/components/app/home/sections/ThreeSellPoints.tsx +++ b/src/components/app/home/sections/ThreeSellPoints.tsx @@ -1,18 +1,20 @@ import type { JSX } from "preact"; - -import { threeSellPoints as defaultThreeSellPoints } from "~/constants"; +import { useTranslation } from "~/i18n/context"; type ItemProps = JSX.HTMLAttributes & { title: string; }; function Item({ title, children, ...props }: ItemProps) { + const { t } = useTranslation(); return (
-

core

+

+ {t("home.sellPoints.moduleLabel")} +

{title}

@@ -23,24 +25,23 @@ function Item({ title, children, ...props }: ItemProps) { ); } -export function ThreeSellPoints({ - threeSellPoints = defaultThreeSellPoints, -}: { - threeSellPoints?: typeof defaultThreeSellPoints; -}) { +export function ThreeSellPoints() { + const { t } = useTranslation(); return (
- {threeSellPoints.map(({ title, description }) => { - return ( - - {description} - - ); - })} + + {t("home.sellPoints.passion.description")} + + + {t("home.sellPoints.versatile.description")} + + + {t("home.sellPoints.offWork.description")} +
); diff --git a/src/components/app/portfolio/PortfolioShowcase.tsx b/src/components/app/portfolio/PortfolioShowcase.tsx index e14bf93d..4b93dce3 100644 --- a/src/components/app/portfolio/PortfolioShowcase.tsx +++ b/src/components/app/portfolio/PortfolioShowcase.tsx @@ -1,3 +1,5 @@ +import { useTranslation } from "~/i18n/context"; + import { type PortfolioFrontmatter } from "~/types/frontmatter"; import { getTechBadgeStyle } from "~/components/portfolio/technologyColors"; import Github from "~/components/icons/Github"; @@ -43,6 +45,7 @@ export function PortfolioShowcase({ hasTitle = true, containerClassName = "container mx-auto max-w-[1536px]", }: Props) { + const { t } = useTranslation(); const featuredItem = items?.find((item) => item.featured); const regularItems = items?.filter((item) => !item.featured); const sectionClassName = hasTitle @@ -54,15 +57,13 @@ export function PortfolioShowcase({ {hasTitle && (

- module_03 // portfolio + {t("portfolio.showcase.moduleLabel")}

- Portfolio + {t("portfolio.showcase.heading")}

- Built in the digital underground: apps, UI experiments, and - creative code modules I designed and shipped, from React tools to - interactive front-end prototypes. + {t("portfolio.showcase.description")}

@@ -73,7 +74,9 @@ export function PortfolioShowcase({ {/* Image Container */}
@@ -128,7 +131,9 @@ export function PortfolioShowcase({ {/* Footer with Links */}
- View + + {t("portfolio.showcase.viewAction")} + {featuredItem.demoUrl && ( @@ -169,7 +176,9 @@ export function PortfolioShowcase({ {/* Image Container */}
@@ -224,7 +233,7 @@ export function PortfolioShowcase({ - View + {t("portfolio.showcase.viewAction")} {item.demoUrl && ( @@ -264,7 +273,9 @@ export function PortfolioShowcase({ {/* Image Container */}
@@ -319,7 +330,9 @@ export function PortfolioShowcase({ {/* Footer with Links */}
- View + + {t("portfolio.showcase.viewAction")} + {featuredItem.demoUrl && ( @@ -352,7 +365,9 @@ export function PortfolioShowcase({ {/* Image Container */}
@@ -407,7 +422,7 @@ export function PortfolioShowcase({ - View + {t("portfolio.showcase.viewAction")} {item.demoUrl && ( diff --git a/src/components/portfolio/PortfolioEntry.tsx b/src/components/portfolio/PortfolioEntry.tsx index 714aff57..b63b3c48 100644 --- a/src/components/portfolio/PortfolioEntry.tsx +++ b/src/components/portfolio/PortfolioEntry.tsx @@ -1,4 +1,5 @@ import type { ComponentChildren } from "preact"; +import { useTranslation } from "~/i18n/context"; import TechnologiesUsed from "~/components/portfolio/TechnologiesUsed"; @@ -42,13 +43,14 @@ const PortfolioEntry = ({ technologiesUsed, children, }: Props) => { + const { t } = useTranslation(); return ( <>

- project_entry // live_record + {t("portfolio.entry.projectLabel")}

{title} @@ -58,7 +60,7 @@ const PortfolioEntry = ({ {(demoUrl || githubUrl || (urls && urls.length > 0)) && (

- external_links + {t("portfolio.entry.externalLinksLabel")}

{demoUrl && ( @@ -68,7 +70,7 @@ const PortfolioEntry = ({ target="__blank" rel="noreferrer" > - Open Demo + {t("portfolio.entry.openDemo")} )} {githubUrl && ( @@ -78,7 +80,7 @@ const PortfolioEntry = ({ target="_blank" rel="noreferrer" > - View GitHub Code + {t("portfolio.entry.viewGithubCode")} )} {urls && @@ -90,7 +92,7 @@ const PortfolioEntry = ({ rel="noreferrer" className={`${externalLinkClassName} overflow-hidden overflow-ellipsis`} > - Visit {url} + {t("portfolio.entry.visitUrl", { url })} ))}
@@ -118,7 +120,7 @@ const PortfolioEntry = ({ className={`${accentGlowBaseClassName} ${accentGlowGeometry.impact} bg-gradient-to-r from-transparent via-primary/30 to-transparent dark:via-primary/22`} />

- impact_log // execution + {t("portfolio.entry.impactLabel")}

@@ -144,7 +146,7 @@ const PortfolioEntry = ({ className={`${accentGlowBaseClassName} ${accentGlowGeometry.notes} bg-gradient-to-r from-transparent via-secondary/30 to-transparent dark:via-secondary/22`} />

- project_notes // context + {t("portfolio.entry.notesLabel")}

{children}
diff --git a/src/components/portfolio/PortfolioMedia.test.tsx b/src/components/portfolio/PortfolioMedia.test.tsx index d68ef8e8..f4630e00 100644 --- a/src/components/portfolio/PortfolioMedia.test.tsx +++ b/src/components/portfolio/PortfolioMedia.test.tsx @@ -46,12 +46,6 @@ it("renders image links inside the slider", () => { expect(links[1]).toHaveAttribute("href", images[1].src); }); -it("shows the filename caption for the current image when a slider is present", () => { - render(); - // Default index is 0, so filename of images[0].src should appear - expect(screen.getByText("a.jpg")).toBeInTheDocument(); -}); - it("renders iframe when iframe prop is provided", () => { render( { + const { t } = useTranslation(); const [activeIndex, setActiveIndex] = useState(null); const technologies = (props.data || []) @@ -102,7 +104,7 @@ const TechnologiesUsed = ({ className, ...props }: Props) => { return (

- Technologies Used + {t("portfolio.technologiesUsed.heading")}

diff --git a/src/components/portfolio/WhatIDid.tsx b/src/components/portfolio/WhatIDid.tsx index d4b28bc6..ba9b50a3 100644 --- a/src/components/portfolio/WhatIDid.tsx +++ b/src/components/portfolio/WhatIDid.tsx @@ -1,12 +1,15 @@ +import { useTranslation } from "~/i18n/context"; + import { type PortfolioFrontmatter } from "~/types/frontmatter"; type Props = Pick; const WhatIDid = ({ whatIDid }: Props) => { + const { t } = useTranslation(); return (

- What I Did + {t("portfolio.whatIDid.heading")}

    {whatIDid.map((bullet, i) => ( diff --git a/src/constants.tsx b/src/constants.tsx index ef3b467b..bd878c13 100644 --- a/src/constants.tsx +++ b/src/constants.tsx @@ -4,39 +4,6 @@ export const LINKEDIN_URL = "https://www.linkedin.com/in/hrgui"; export const siteTitle = "hrgui"; -type SellPoint = { - title: string; - description: string; -}; - -export const threeSellPoints: SellPoint[] = [ - { - title: "Making the web awesome is my passion.", - description: `I am a Software Engineer who loves building interactive web - applications. I believe great apps should work smoothly and look clean, - so people can focus on getting things done. Seeing users succeed with - something I helped build is the most rewarding part of my work. I am - always refining the experience to make each product better than before.`, - }, - { - title: "I will work with anything... to make the web work.", - description: `Throughout my career, I have taken on unfamiliar and - challenging problems. I have contributed to backend systems, - automation platforms, and mobile applications across very different - codebases. Some were clean, some were heavily patched, but I focus on - improving the developer experience so teams can build and ship with - confidence.`, - }, - { - title: "While my code is compiling...", - description: `Outside of coding, I play guitar and enjoy Japanese - animation (anime). Both constantly fuel my creativity and inspire how I think - about building products. I often learn anime theme songs on guitar, - and that mix of music and storytelling keeps my ideas fresh. When I can - bring those creative influences into software, I do my best work.`, - }, -]; - type TechnicalSkillSection = { key?: string; title: string | JSX.Element; @@ -121,8 +88,6 @@ type Education = { key: string; url: string; imgSrc: string; - title: string; - description: string; timeframe: { start: number | string; end: number | string }; }; @@ -131,9 +96,6 @@ export const education: Education[] = [ key: "usc", url: "https://www.usc.edu", imgSrc: "/images/usc.webp", - title: "University of Southern California", - description: `Bachelor of Science (B.S.), Computer Science and Engineering, - Magna Cum Laude`, timeframe: { start: 2010, end: 2013 }, }, ]; diff --git a/src/i18n/context.tsx b/src/i18n/context.tsx new file mode 100644 index 00000000..e78d6913 --- /dev/null +++ b/src/i18n/context.tsx @@ -0,0 +1,51 @@ +import { createContext, type ComponentChildren } from "preact"; +import { useContext } from "preact/hooks"; + +import type { Translation } from "~/i18n/locales/en"; +import en from "~/i18n/locales/en"; + +type TFunction = (key: string, opts?: Record) => string; + +const locales: Record = { en }; + +function resolvePath(obj: unknown, path: string): string { + const parts = path.split("."); + let current: unknown = obj; + for (const part of parts) { + if (current == null || typeof current !== "object") return path; + current = (current as Record)[part]; + } + return typeof current === "string" ? current : path; +} + +function createT(locale: string): TFunction { + const translations = locales[locale] ?? locales.en; + return (key, opts) => { + let str = resolvePath(translations, key); + if (opts) { + Object.entries(opts).forEach(([k, v]) => { + str = str.replace(`{{${k}}}`, String(v)); + }); + } + return str; + }; +} + +const I18nContext = createContext(createT("en")); + +interface ProviderProps { + locale?: string; + children: ComponentChildren; +} + +export function I18nProvider({ locale = "en", children }: ProviderProps) { + return ( + + {children} + + ); +} + +export function useTranslation() { + return { t: useContext(I18nContext) }; +} diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts new file mode 100644 index 00000000..956ff9bd --- /dev/null +++ b/src/i18n/locales/en.ts @@ -0,0 +1,117 @@ +const en = { + nav: { + home: "Home", + blog: "Blog", + portfolio: "Portfolio", + }, + footer: { + bio: "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.", + backToTop: "back to top?", + copyright: "© {{year}} Harman Goei", + }, + social: { + github: "View My GitHub Profile", + linkedin: "View My LinkedIn Profile", + }, + hero: { + commandBody: " about", + prefix: "I build ", + highlight: "cool and awesome", + suffix: "web and mobile apps.", + quickLinks: "// quick_links", + }, + home: { + sellPoints: { + moduleLabel: "core", + passion: { + title: "Making the web awesome is my passion.", + description: + "I am a Software Engineer who loves building interactive web applications. I believe great apps should work smoothly and look clean, so people can focus on getting things done. Seeing users succeed with something I helped build is the most rewarding part of my work. I am always refining the experience to make each product better than before.", + }, + versatile: { + title: "I will work with anything... to make the web work.", + description: + "Throughout my career, I have taken on unfamiliar and challenging problems. I have contributed to backend systems, automation platforms, and mobile applications across very different codebases. Some were clean, some were heavily patched, but I focus on improving the developer experience so teams can build and ship with confidence.", + }, + offWork: { + title: "While my code is compiling...", + description: + "Outside of coding, I play guitar and enjoy Japanese animation (anime). Both constantly fuel my creativity and inspire how I think about building products. I often learn anime theme songs on guitar, and that mix of music and storytelling keeps my ideas fresh. When I can bring those creative influences into software, I do my best work.", + }, + }, + technicalSkills: { + moduleLabel: "module_01 // tech_stack", + heading: "Core Proficiencies", + javascript: { + title: "JavaScript", + subtitle: "LOGIC_ENGINE_V8", + }, + htmlCss: { + title: "HTML/CSS", + subtitle: "STRUCTURAL_UI_CORE", + }, + other: { + title: "JACK OF ALL TRADES", + subtitle: "OTHER_TECH_SKILLS_I_HAVE", + }, + }, + education: { + moduleLabel: "module_02 // education", + heading: "Education", + imgAlt: "Open {{title}} in a new tab", + usc: { + title: "University of Southern California", + description: + "Bachelor of Science (B.S.), Computer Science and Engineering, Magna Cum Laude", + }, + }, + }, + portfolio: { + showcase: { + moduleLabel: "module_03 // portfolio", + heading: "Portfolio", + description: + "Built in the digital underground: apps, UI experiments, and creative code modules I designed and shipped, from React tools to interactive front-end prototypes.", + viewAction: "View", + viewOnGithub: "View on GitHub", + viewOnGithubShort: "View on Github", + viewItem: "View {{title}}", + }, + entry: { + projectLabel: "project_entry // live_record", + externalLinksLabel: "external_links", + openDemo: "Open Demo", + viewGithubCode: "View GitHub Code", + visitUrl: "Visit {{url}}", + impactLabel: "impact_log // execution", + notesLabel: "project_notes // context", + }, + technologiesUsed: { + heading: "Technologies Used", + empty: "No technology data available.", + }, + whatIDid: { + heading: "What I Did", + }, + }, + blog: { + posts: { + newBadge: "New", + executeRead: "Execute_Read ->", + readMore: "Read_More ->", + noPosts: "No posts available.", + }, + subHeader: { + entryRecord: "ENTRY_RECORD", + hiddenDraft: "Hidden Draft", + hiddenWarning: + "You are looking at a hidden post. Remove `hidden: true` or set it to `false` to publish this post.", + }, + }, +} as const; + +type DeepString = { + readonly [K in keyof T]: T[K] extends string ? string : DeepString; +}; +export type Translation = DeepString; +export default en; diff --git a/src/layouts/blog.astro b/src/layouts/blog.astro index cfd7319b..dcd98693 100644 --- a/src/layouts/blog.astro +++ b/src/layouts/blog.astro @@ -17,9 +17,9 @@ const { title, excerpt, date, hidden } = Astro.props; --- - + - + - + - + diff --git a/src/layouts/portfolio.astro b/src/layouts/portfolio.astro index e3cb4d2e..16d90ad3 100644 --- a/src/layouts/portfolio.astro +++ b/src/layouts/portfolio.astro @@ -16,9 +16,9 @@ export type Props = { --- - + - + diff --git a/src/pages/index.astro b/src/pages/index.astro index d7a00309..1d94c5ef 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -2,11 +2,7 @@ import { getCollection } from "astro:content"; import DefaultPageLayout from "~/layouts/default.astro"; import { type PortfolioFrontmatter } from "~/types/frontmatter"; -import { Education } from "~/components/app/home/sections/Education"; -import { Hero } from "~/components/app/home/sections/Hero"; -import { TechnicalSkills } from "~/components/app/home/sections/TechnicalSkills"; -import { ThreeSellPoints } from "~/components/app/home/sections/ThreeSellPoints"; -import { PortfolioShowcase } from "~/components/app/portfolio/PortfolioShowcase"; +import { HomeSections } from "~/components/app/home/HomeSections"; const description = `Hi! I'm Harman. I am a developer who loves to make cool and awesome web applications that rock the world.`; @@ -14,14 +10,12 @@ const portfolioItems = await getCollection("portfolio"); --- - - - - - ({ ...item.data, slug: item.id } as PortfolioFrontmatter) )} + client:visible /> diff --git a/src/test/setup.tsx b/src/test/setup.tsx index a6e8eb82..291c37b6 100644 --- a/src/test/setup.tsx +++ b/src/test/setup.tsx @@ -1,7 +1,45 @@ import "@testing-library/jest-dom"; +import { vi } from "vitest"; globalThis.IS_REACT_ACT_ENVIRONMENT = true; +import en from "~/i18n/locales/en"; + +function resolveTranslationKey( + + obj: Record, + key: string +): string { + const parts = key.split("."); + + let current: any = obj; + for (const part of parts) { + if (typeof current !== "object" || current === null) return key; + current = current[part]; + } + return typeof current === "string" ? current : key; +} + +vi.mock("~/i18n/context", () => ({ + useTranslation: () => ({ + t: (key: string, opts?: Record) => { + let str = resolveTranslationKey( + en as unknown as Record, + key + ); + if (opts) { + str = Object.entries(opts).reduce( + (s, [k, v]) => s.replace(`{{${k}}}`, String(v)), + str + ); + } + return str; + }, + }), + + I18nProvider: ({ children }: { children: any }) => children, +})); + Element.prototype.scrollTo = () => {}; Object.defineProperty(window, "matchMedia", {