diff --git a/astro.config.mjs b/astro.config.mjs index 08fe11c..626f03a 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -66,7 +66,10 @@ export default defineConfig({ integrations: [ react(), tailwind(), - sitemap(), + sitemap({ + // Emit hreflang alternates linking each page to its /id/ counterpart. + i18n: { defaultLocale: 'en', locales: { en: 'en', id: 'id' } }, + }), AstroPWA({ mode: 'production', base: '/', diff --git a/src/components/ToolGrid.tsx b/src/components/ToolGrid.tsx index c67a027..55cd38f 100644 --- a/src/components/ToolGrid.tsx +++ b/src/components/ToolGrid.tsx @@ -1,11 +1,13 @@ import { tools } from '@/registry/tools'; import { categories, categoryColors, categoryNotes } from '@/registry/categories'; +import { localizePath, DEFAULT_LOCALE, type Lang } from '@/i18n/config'; /** * Static tool grid grouped by category. Rendered without a client directive so - * Astro emits plain HTML (icons become inline SVG) — no JS shipped. + * Astro emits plain HTML (icons become inline SVG) — no JS shipped. `lang` + * localizes the tool links (e.g. /id/tools/x on the Indonesian home page). */ -export function ToolGrid() { +export function ToolGrid({ lang = DEFAULT_LOCALE }: { lang?: Lang }) { const usedCategories = categories.filter(category => tools.some(tool => tool.category === category) ); @@ -34,7 +36,7 @@ export function ToolGrid() { return ( diff --git a/src/components/shell/LangSwitcher.tsx b/src/components/shell/LangSwitcher.tsx new file mode 100644 index 0000000..f289703 --- /dev/null +++ b/src/components/shell/LangSwitcher.tsx @@ -0,0 +1,40 @@ +import { useEffect, useState } from 'react'; +import { LOCALES, LOCALE_LABEL, LOCALE_NAME, localizePath, stripLocale, type Lang } from '@/i18n/config'; + +// Public sections that exist in every locale. Other paths (about, settings…) fall +// back to the locale home when switching, so the switcher never lands on a 404. +const LOCALIZED_PREFIXES = ['/tools/', '/category/']; + +export function LangSwitcher() { + const [current, setCurrent] = useState('en'); + const [base, setBase] = useState('/'); + + useEffect(() => { + const path = location.pathname; + setCurrent(/^\/id(\/|$)/.test(path) ? 'id' : 'en'); + const b = stripLocale(path); + setBase(b === '/' || LOCALIZED_PREFIXES.some(p => b.startsWith(p)) ? b : '/'); + }, []); + + const remember = (l: Lang) => { + document.cookie = `gwt.lang=${l};path=/;max-age=31536000;samesite=lax`; + }; + + return ( +
+ {LOCALES.map(l => ( + remember(l)} + aria-current={current === l ? 'true' : undefined} + aria-label={LOCALE_NAME[l]} + title={LOCALE_NAME[l]} + className={`px-2 py-1.5 text-xs font-bold uppercase tracking-wide ${current === l ? 'bg-accent text-accent-foreground' : 'bg-muted text-muted-foreground hover:text-foreground'}`} + > + {LOCALE_LABEL[l]} + + ))} +
+ ); +} diff --git a/src/components/shell/ShellIsland.tsx b/src/components/shell/ShellIsland.tsx index 1ab26e4..a24ccd3 100644 --- a/src/components/shell/ShellIsland.tsx +++ b/src/components/shell/ShellIsland.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from 'react'; import { Search, Github, Bookmark, Info, ExternalLink, Settings } from 'lucide-react'; import { ThemeToggle } from './ThemeToggle'; +import { LangSwitcher } from './LangSwitcher'; import { CommandPalette } from './CommandPalette'; import { Modal } from '@/components/ui/Modal'; import { initTheme } from '@/stores/theme.store'; @@ -68,6 +69,7 @@ export function ShellIsland() { + diff --git a/src/i18n/config.ts b/src/i18n/config.ts new file mode 100644 index 0000000..4f3d298 --- /dev/null +++ b/src/i18n/config.ts @@ -0,0 +1,37 @@ +/** + * i18n core. English is the default locale (served at the root, e.g. /tools/x); + * Bahasa Indonesia is served under an /id/ prefix (/id/tools/x). Public pages live + * under src/pages/[...locale]/ — one template emits both trees (the `locale` param + * is `undefined` for English → no prefix, and 'id' for Indonesian). + */ +export const LOCALES = ['en', 'id'] as const; +export type Lang = (typeof LOCALES)[number]; +export const DEFAULT_LOCALE: Lang = 'en'; + +/** Human label for the language switcher. */ +export const LOCALE_LABEL: Record = { en: 'EN', id: 'ID' }; +export const LOCALE_NAME: Record = { en: 'English', id: 'Bahasa Indonesia' }; + +/** The `locale` route-param value for a language (undefined = default, no prefix). */ +export function localeParam(lang: Lang): string | undefined { + return lang === DEFAULT_LOCALE ? undefined : lang; +} + +/** getStaticPaths helper: [{ locale: undefined }, { locale: 'id' }]. */ +export function localeParams(): { params: { locale: string | undefined }; props: { lang: Lang } }[] { + return LOCALES.map(lang => ({ params: { locale: localeParam(lang) }, props: { lang } })); +} + +/** Prefix a root-relative path for a locale. localizePath('/tools/x','id') → '/id/tools/x'. */ +export function localizePath(path: string, lang: Lang): string { + const clean = '/' + path.replace(/^\/+/, ''); + return lang === DEFAULT_LOCALE ? clean : `/${lang}${clean === '/' ? '' : clean}`; +} + +/** Remove any locale prefix from a path. stripLocale('/id/tools/x') → '/tools/x'. */ +export function stripLocale(path: string): string { + const m = path.match(/^\/(id)(\/|$)/); + if (!m) return path; + const rest = path.slice(m[1].length + 1); + return rest === '' ? '/' : rest; +} diff --git a/src/i18n/ui.ts b/src/i18n/ui.ts new file mode 100644 index 0000000..741f3d7 --- /dev/null +++ b/src/i18n/ui.ts @@ -0,0 +1,50 @@ +import type { Lang } from './config'; + +/** + * Shell / common UI strings, per language. Page-level content authored elsewhere + * (tool SEO copy in registry/tool-seo, category copy in registry/categories). Use + * `t(lang, key, vars)` — {name}-style placeholders are interpolated. + */ +type Dict = Record; + +const en: Dict = { + 'home.title': 'GoodWebTools — Free Privacy-First Browser Tools', + 'home.metaDescription': '{n} free online tools that run entirely in your browser — PDF, image, file, developer, and media utilities. No uploads, no sign-up, works offline. Your files never leave your device.', + 'home.lead': 'Privacy-first client-side utilities. {n} tools — everything runs in your browser.', + 'home.searchHint': 'Press {kbd} to search tools', + 'nav.home': 'Home', + 'a11y.breadcrumb': 'Breadcrumb', + 'share.label': 'Share', + 'tool.howTo': 'How to use {name}', + 'tool.faq': 'Frequently asked questions', + 'tool.related': 'Related {category} tools', + 'category.heading': '{category} Tools', + 'category.freePrivate': '{category} Tools — Free & Private', + 'category.clickInfo': '{n} feature · click one to see its properties. ', + 'lang.switch': 'Language', +}; + +const id: Dict = { + 'home.title': 'GoodWebTools — Tool Browser Gratis yang Mengutamakan Privasi', + 'home.metaDescription': '{n} tool online gratis yang berjalan sepenuhnya di browser Anda — utilitas PDF, gambar, berkas, pengembang, dan media. Tanpa unggahan, tanpa pendaftaran, bekerja offline. Berkas Anda tidak pernah meninggalkan perangkat.', + 'home.lead': 'Perkakas sisi-klien yang mengutamakan privasi. {n} tool — semuanya berjalan di browser Anda.', + 'home.searchHint': 'Tekan {kbd} untuk mencari tool', + 'nav.home': 'Beranda', + 'a11y.breadcrumb': 'Remah roti', + 'share.label': 'Bagikan', + 'tool.howTo': 'Cara menggunakan {name}', + 'tool.faq': 'Pertanyaan yang sering diajukan', + 'tool.related': 'Tool {category} lainnya', + 'category.heading': 'Tool {category}', + 'category.freePrivate': 'Tool {category} — Gratis & Privat', + 'category.clickInfo': '{n} fitur · klik salah satu untuk melihat propertinya. ', + 'lang.switch': 'Bahasa', +}; + +const dicts: Record = { en, id }; + +export function t(lang: Lang, key: string, vars?: Record): string { + let s = dicts[lang]?.[key] ?? dicts.en[key] ?? key; + if (vars) for (const [k, v] of Object.entries(vars)) s = s.replaceAll(`{${k}}`, String(v)); + return s; +} diff --git a/src/layouts/Base.astro b/src/layouts/Base.astro index 2515307..41ec540 100644 --- a/src/layouts/Base.astro +++ b/src/layouts/Base.astro @@ -4,6 +4,7 @@ import { ShellIsland } from '@/components/shell/ShellIsland'; import { GlobalHotkeyInit } from '@/components/shell/GlobalHotkeyInit'; import { ConsentRibbon } from '@/components/shell/ConsentRibbon'; import { SITE_NAME, SITE_TAGLINE, SITE_URL, GA_ID, NOINDEX } from '@/config'; +import { LOCALES, DEFAULT_LOCALE, localizePath, stripLocale, type Lang } from '@/i18n/config'; import '../styles/global.css'; export interface Props { @@ -20,6 +21,10 @@ export interface Props { fullTitle?: string; /** JSON-LD structured data object(s) to embed. */ jsonLd?: Record | Record[]; + /** Current locale (sets + og:locale). */ + lang?: Lang; + /** Public page available in every locale → emit hreflang alternates. */ + localized?: boolean; } const { @@ -31,12 +36,21 @@ const { noindex = false, fullTitle, jsonLd, + lang = DEFAULT_LOCALE, + localized = false, } = Astro.props; const canonicalURL = canonical ?? new URL(Astro.url.pathname, SITE_URL).href; const pageTitle = fullTitle ?? `${title} | ${SITE_NAME}`; const ogImage = new URL(image, SITE_URL).href; +// hreflang alternates: the same page in each locale (x-default → English). +const basePath = stripLocale(Astro.url.pathname); +const alternates = localized + ? LOCALES.map(l => ({ hreflang: l, href: new URL(localizePath(basePath, l), SITE_URL).href })) + : []; +const ogLocale = lang === 'id' ? 'id_ID' : 'en_US'; + // Site-wide structured data + any page-specific graph. const siteJsonLd = { '@context': 'https://schema.org', @@ -49,7 +63,7 @@ const jsonLdBlocks = [siteJsonLd, ...(Array.isArray(jsonLd) ? jsonLd : jsonLd ? --- - + @@ -57,11 +71,14 @@ const jsonLdBlocks = [siteJsonLd, ...(Array.isArray(jsonLd) ? jsonLd : jsonLd ? {keywords && keywords.length > 0 && } + {alternates.map(a => )} + {alternates.length > 0 && } + diff --git a/src/pages/category/[category].astro b/src/pages/[...locale]/category/[category].astro similarity index 58% rename from src/pages/category/[category].astro rename to src/pages/[...locale]/category/[category].astro index ceabfdb..5b0328b 100644 --- a/src/pages/category/[category].astro +++ b/src/pages/[...locale]/category/[category].astro @@ -2,27 +2,32 @@ import { ChevronRight } from 'lucide-react'; import Base from '@/layouts/Base.astro'; import { tools } from '@/registry/tools'; -import { categories, categoryColors, categoryDescriptions, categorySlug } from '@/registry/categories'; +import { categories, categoryColors, catDescription, categorySlug } from '@/registry/categories'; import { SITE_URL, SITE_NAME } from '@/config'; import type { Category } from '@/types/tool'; +import { LOCALES, localeParam, localizePath, type Lang } from '@/i18n/config'; +import { t } from '@/i18n/ui'; export function getStaticPaths() { - return categories - .filter(category => tools.some(t => t.category === category && !t.desktopOnly)) - .map(category => ({ params: { category: categorySlug(category) }, props: { category } })); + return LOCALES.flatMap(lang => + categories + .filter(category => tools.some(t2 => t2.category === category && !t2.desktopOnly)) + .map(category => ({ params: { locale: localeParam(lang), category: categorySlug(category) }, props: { category, lang } })), + ); } -const { category } = Astro.props as { category: Category }; -const categoryTools = tools.filter(t => t.category === category && !t.desktopOnly); -const pageTitle = `${category} Tools`; -const description = categoryDescriptions[category]; -const pageUrl = new URL(`/category/${categorySlug(category)}`, SITE_URL).href; +const { category, lang } = Astro.props as { category: Category; lang: Lang }; +const categoryTools = tools.filter(t2 => t2.category === category && !t2.desktopOnly); +const pageTitle = t(lang, 'category.heading', { category }); +const description = catDescription(category, lang); +const homePath = localizePath('/', lang); +const pageUrl = new URL(localizePath(`/category/${categorySlug(category)}`, lang), SITE_URL).href; const breadcrumbJsonLd = { '@context': 'https://schema.org', '@type': 'BreadcrumbList', itemListElement: [ - { '@type': 'ListItem', position: 1, name: 'Home', item: SITE_URL + '/' }, + { '@type': 'ListItem', position: 1, name: t(lang, 'nav.home'), item: new URL(homePath, SITE_URL).href }, { '@type': 'ListItem', position: 2, name: pageTitle, item: pageUrl }, ], }; @@ -31,26 +36,28 @@ const itemListJsonLd = { '@type': 'ItemList', name: `${category} tools on ${SITE_NAME}`, numberOfItems: categoryTools.length, - itemListElement: categoryTools.map((t, i) => ({ + itemListElement: categoryTools.map((t2, i) => ({ '@type': 'ListItem', position: i + 1, - name: t.name, - url: new URL(t.route, SITE_URL).href, + name: t2.name, + url: new URL(localizePath(t2.route, lang), SITE_URL).href, })), }; ---
-