Commit 92f6e13

HPCesia <me@hpcesia.com>
2026-09-16 10:03:49
i18n: init multi locale support
1 parent 6494604
src/components/widgets/PostsList.astro
@@ -1,14 +1,20 @@
 ---
 import type { CollectionEntry } from "astro:content";
 
-import { SITE_CONFIG } from "@/config";
-import { url } from "@/utils/url.ts";
+import { DEFAULT_LOCALE } from "@/config.ts";
+import { getPostLocaleSlug } from "@/utils/content.ts";
+import { getRelativeLocaleUrl } from "@/utils/url.ts";
 
 interface Props {
   posts: CollectionEntry<"posts">[];
 }
 
 const { posts } = Astro.props;
+
+const postUrl = (post) => {
+  const { locale, slug } = getPostLocaleSlug(post);
+  return getRelativeLocaleUrl(slug, locale);
+};
 ---
 <nav class="posts-list">
   <ol>
@@ -17,9 +23,11 @@ const { posts } = Astro.props;
         const dt = Temporal.ZonedDateTime.from(post.data.date).toPlainDateTime();
         return (
           <li>
-            <a href={url(`/posts/${post.id}`)}>{post.data.title}</a>
+            <a href={postUrl(post)}>
+              {post.data.title}
+            </a>
             <time datetime={dt.toString()}>
-              {dt.toPlainDate().toLocaleString(SITE_CONFIG.locale)}
+              {dt.toPlainDate().toLocaleString(Astro.currentLocale || DEFAULT_LOCALE)}
             </time>
           </li>
         );
src/components/Header.astro
@@ -2,16 +2,18 @@
 import { getIntlayer } from "intlayer";
 
 import { SITE_CONFIG } from "@/config.ts";
-import { url } from "@/utils/url.ts";
+import { getRelativeLocaleUrl, url } from "@/utils/url.ts";
 
-const globalI18n = getIntlayer("global", SITE_CONFIG.locale);
+const locale = Astro.currentLocale!;
+const globalI18n = getIntlayer("global", locale);
 ---
 <header class="header">
-  <a class="header-home" href={url("/")}>{SITE_CONFIG.title}</a>
+  <a class="header-home"
+    href={getRelativeLocaleUrl("/", locale)}>{SITE_CONFIG.title}</a>
   <nav>
     <ul>
-      <li><a href={url("/posts")}>{globalI18n.posts}</a></li>
-      <li><a href={url("/about")}>{globalI18n.about}</a></li>
+      <li><a href={getRelativeLocaleUrl("/posts", locale)}>{globalI18n.posts}</a></li>
+      <li><a href={getRelativeLocaleUrl("/about", locale)}>{globalI18n.about}</a></li>
       <li><a href={url("/feed.atom")}>{globalI18n.feed}</a></li>
     </ul>
   </nav>
src/contents/posts/typst-syntax.typ → src/contents/posts/typst-syntax.en.typ
File renamed without changes
src/contents/about.typ → src/contents/about.en.typ
File renamed without changes
src/contents/home.typ → src/contents/home.en.typ
File renamed without changes
src/layouts/BaseLayout.astro
@@ -1,7 +1,9 @@
 ---
 import { Font } from "astro:assets";
+import { getIntlayer, type Locale } from "intlayer";
 
-import { SITE_CONFIG } from "@/config";
+import { DEFAULT_LOCALE, SITE_CONFIG } from "@/config";
+import { getPathWithoutLocale, getRelativeLocaleUrl } from "@/utils/url.ts";
 
 import Footer from "@/components/Footer.astro";
 import Header from "@/components/Header.astro";
@@ -16,14 +18,20 @@ interface Props {
   ogType?: "website" | "article";
 }
 
+const locale = Astro.currentLocale;
+const globalI18n = getIntlayer("global", locale);
+
 const {
   title = SITE_CONFIG.title,
   author = SITE_CONFIG.author,
-  description = SITE_CONFIG.description,
+  description = globalI18n.description,
   ogType = "website",
 } = Astro.props;
 
-const locale = SITE_CONFIG.locale;
+const getAbsoluteLocaleUrl = (
+  path: string,
+  locale: Locale,
+) => new URL(getRelativeLocaleUrl(path, locale), Astro.site).toString();
 ---
 <!doctype html>
 <html lang={locale}>
@@ -47,8 +55,16 @@ const locale = SITE_CONFIG.locale;
     <meta name="description" content={description} />
     <meta name="author" content={author} />
 
-    <link rel="alternate" hreflang={locale} href={Astro.url}>
-    <link rel="alternate" hreflang="x-default" href={Astro.url}>
+    {
+      SITE_CONFIG.locales.map((target) => (
+        <link rel="alternate" hreflang={target}
+          href={getAbsoluteLocaleUrl(getPathWithoutLocale(Astro.url.pathname), target)}
+        >
+      ))
+    }
+    <link rel="alternate" hreflang="x-default"
+      href={getAbsoluteLocaleUrl(getPathWithoutLocale(Astro.url.pathname), DEFAULT_LOCALE)}
+    >
 
     <meta property="og:url" content={Astro.url} />
     <meta property="og:site_name" content={SITE_CONFIG.title} />
src/pages/[locale]/posts/[slug].astro
@@ -0,0 +1,47 @@
+---
+import type { GetStaticPaths, InferGetStaticPropsType } from "astro";
+import { getCollection, render } from "astro:content";
+
+import { SITE_CONFIG } from "@/config.ts";
+import { getPostsLocaleMap } from "@/utils/content.ts";
+import { getPathWithoutLocale, getRelativeLocaleUrl } from "@/utils/url.ts";
+
+import BaseLayout from "@/layouts/BaseLayout.astro";
+import "@/styles/article/index.scss";
+
+type Props = InferGetStaticPropsType<typeof getStaticPaths>;
+
+export const getStaticPaths = (async () => {
+  const posts = await getCollection("posts");
+  const postsLocaleMap = getPostsLocaleMap(posts);
+
+  return Object.entries(postsLocaleMap).flatMap(([slug, localeMap]) => {
+    return SITE_CONFIG.locales.map((locale) => {
+      const entry = localeMap[locale];
+      if (entry === undefined) {
+        return undefined;
+      } else {
+        return {
+          params: { locale, slug },
+          props: { entry },
+        };
+      }
+    }).filter((i) => i !== undefined);
+  });
+}) satisfies GetStaticPaths;
+
+const { entry } = Astro.props;
+
+const data = entry.data;
+const { Content } = await render(entry);
+---
+<BaseLayout
+  title={data.title}
+  author={data.author}
+  description={data.desc}
+  ogType="article"
+>
+  <article>
+    <Content />
+  </article>
+</BaseLayout>
src/pages/posts/index.astro → src/pages/[locale]/posts/index.astro
@@ -1,13 +1,24 @@
 ---
+import type { GetStaticPaths, InferGetStaticPropsType } from "astro";
 import { getCollection } from "astro:content";
 import { getIntlayer } from "intlayer";
 
 import { SITE_CONFIG } from "@/config.ts";
+import { getLocalePosts } from "@/utils/content.ts";
 
 import BaseLayout from "@/layouts/BaseLayout.astro";
 import PostsList from "@/components/widgets/PostsList.astro";
 
-const posts = await getCollection("posts");
+type Props = InferGetStaticPropsType<typeof getStaticPaths>;
+
+export const getStaticPaths = (() => {
+  return SITE_CONFIG.locales.map((locale) => ({ params: { locale } }));
+}) satisfies GetStaticPaths;
+
+const locale = Astro.currentLocale;
+const globalI18n = getIntlayer("global", locale);
+
+const posts = getLocalePosts(await getCollection("posts"), locale);
 const sortedPosts = posts.sort(
   (a, b) => Temporal.ZonedDateTime.compare(b.data.date, a.data.date),
 );
@@ -22,8 +33,6 @@ const postsByYear = sortedPosts.reduce(
   },
   {} as Record<number, typeof posts>,
 );
-
-const globalI18n = getIntlayer("global", SITE_CONFIG.locale);
 ---
 <BaseLayout title={globalI18n.posts}>
   <div style={{fontSize: "1.2rem"}}>
src/pages/[locale]/about.astro
@@ -0,0 +1,25 @@
+---
+import type { GetStaticPaths } from "astro";
+import { getIntlayer } from "intlayer";
+
+import { SITE_CONFIG } from "@/config.ts";
+import { loadLocaleContent } from "@/utils/content.ts";
+
+import BaseLayout from "@/layouts/BaseLayout.astro";
+import "@/styles/article/index.scss";
+
+export const getStaticPaths: GetStaticPaths = () => (SITE_CONFIG.locales.map(
+  (locale) => ({ params: { locale } }),
+));
+
+const locale = Astro.currentLocale;
+const globalI18n = getIntlayer("global", locale);
+
+const { Content: Article } = await loadLocaleContent("about", locale);
+---
+<BaseLayout title={globalI18n.about} ogType="article">
+  <article>
+    <h1>{globalI18n.about}</h1>
+    <Article />
+  </article>
+</BaseLayout>
src/pages/index.astro → src/pages/[locale]/index.astro
@@ -1,21 +1,31 @@
 ---
+import type { GetStaticPaths, InferGetStaticPropsType } from "astro";
 import { getCollection } from "astro:content";
 import { getIntlayer } from "intlayer";
 
 import { SITE_CONFIG } from "@/config.ts";
+import { loadLocaleContent } from "@/utils/content.ts";
+import { getLocalePosts } from "@/utils/content.ts";
 
 import BaseLayout from "@/layouts/BaseLayout.astro";
 import PostsList from "@/components/widgets/PostsList.astro";
 import "@/styles/article/index.scss";
 
-import Article from "@/contents/home.typ";
+type Props = InferGetStaticPropsType<typeof getStaticPaths>;
 
-const posts = await getCollection("posts");
+export const getStaticPaths = (() => {
+  return SITE_CONFIG.locales.map((locale) => ({ params: { locale } }));
+}) satisfies GetStaticPaths;
+
+const locale = Astro.currentLocale;
+const globalI18n = getIntlayer("global", locale);
+
+const { Content: Article } = await loadLocaleContent("home", locale);
+
+const posts = getLocalePosts(await getCollection("posts"), locale);
 const sortedPosts = posts.sort(
   (a, b) => Temporal.ZonedDateTime.compare(b.data.date, a.data.date),
 );
-
-const globalI18n = getIntlayer("global", SITE_CONFIG.locale);
 ---
 <BaseLayout title="Kerplunk">
   <article>
src/pages/posts/[slug].astro
@@ -1,33 +0,0 @@
----
-import { getCollection, render } from "astro:content";
-import type { GetStaticPaths, InferGetStaticPropsType } from "astro";
-
-import BaseLayout from "@/layouts/BaseLayout.astro";
-import "@/styles/article/index.scss";
-
-type Props = InferGetStaticPropsType<typeof getStaticPaths>;
-
-export const getStaticPaths = (async () => {
-  const entries = await getCollection("posts");
-  return entries.map((entry) => ({
-    params: {
-      slug: entry.id,
-    },
-    props: { entry },
-  }));
-}) satisfies GetStaticPaths;
-
-const { entry } = Astro.props;
-const data = entry.data;
-const { Content } = await render(entry);
----
-<BaseLayout
-  title={data.title}
-  author={data.author}
-  description={data.desc}
-  ogType="article"
->
-  <article>
-    <Content />
-  </article>
-</BaseLayout>
src/pages/404.astro
@@ -0,0 +1,22 @@
+---
+import BaseLayout from "@/layouts/BaseLayout.astro";
+---
+<BaseLayout>
+  <div>404 Not Found</div>
+  <script>
+  import { DEFAULT_LOCALE } from "@/config.ts";
+  import {
+    getPathWithoutLocale,
+    getRelativeLocaleUrl,
+    getRelativeUrl,
+  } from "@/utils/url.ts";
+
+  const { pathname, search, hash } = window.location;
+  const pathLocale = getRelativeUrl(pathname).split("/")[1] ?? "";
+
+  if (pathLocale !== DEFAULT_LOCALE) {
+    const target = getRelativeLocaleUrl(getPathWithoutLocale(pathname));
+    window.location.replace(target + search + hash);
+  }
+  </script>
+</BaseLayout>
src/pages/about.astro
@@ -1,18 +0,0 @@
----
-import { getIntlayer } from "intlayer";
-
-import { SITE_CONFIG } from "@/config.ts";
-
-import BaseLayout from "@/layouts/BaseLayout.astro";
-import "@/styles/article/index.scss";
-
-import Article from "@/contents/about.typ";
-
-const globalI18n = getIntlayer("global", SITE_CONFIG.locale);
----
-<BaseLayout title={globalI18n.about} ogType="article">
-  <article>
-    <h1>{globalI18n.about}</h1>
-    <Article />
-  </article>
-</BaseLayout>
src/types/config.d.ts
@@ -1,4 +1,4 @@
-import type { Locale } from "intlayer";
+import type { Locale, StrictModeLocaleMap } from "intlayer";
 
 export type SiteConfig = {
   /**
@@ -16,7 +16,7 @@ export type SiteConfig = {
   /**
    * Description of this website.
    */
-  description: string;
+  description: StrictModeLocaleMap<string>;
   /**
    * Time zone of typst posts' date.
    * It should be a valid IANA time zone name,
@@ -24,7 +24,8 @@ export type SiteConfig = {
    */
   timeZone: string;
   /**
-   * Locale of this website
+   * Locales supported by this website.
+   * The first one is the default locale.
    */
-  locale: Locale;
+  locales: [Locale, ...Locale[]];
 };
src/utils/content.ts
@@ -0,0 +1,64 @@
+import type { AstroComponentFactory } from "astro/runtime/server/index.js";
+import { type CollectionEntry } from "astro:content";
+
+import { DEFAULT_LOCALE } from "@/config.ts";
+
+const contents = import.meta.glob<{ default: AstroComponentFactory }>(
+  "/src/contents/**/*.typ",
+);
+
+/**
+ * Load content from `contents/<name>.<locale>.typ` or `contents/<name>/<locale>.typ`
+ */
+export async function loadLocaleContent(
+  name: string,
+  locale: string = DEFAULT_LOCALE,
+) {
+  const hasFlatFile = Boolean(contents[`/src/contents/${name}.${locale}.typ`]);
+  const hasNestFile = Boolean(contents[`/src/contents/${name}/${locale}.typ`]);
+
+  const resolvedLocale = (hasFlatFile || hasNestFile) ? locale : DEFAULT_LOCALE;
+
+  const loadedFile = contents[`/src/contents/${name}.${resolvedLocale}.typ`] ||
+    contents[`/src/contents/${name}/${resolvedLocale}.typ`];
+  if (!loadedFile) {
+    throw new Error(`Missing content: /src/contents/${name}.${locale}.typ`);
+  }
+
+  return { Content: (await loadedFile()).default, locale: resolvedLocale };
+}
+
+export function getPostsLocaleMap(posts: CollectionEntry<"posts">[]) {
+  const postsLocaleMap: Record<
+    string,
+    Record<string, CollectionEntry<"posts">>
+  > = posts.reduce(
+    (
+      acc: Record<string, Record<string, CollectionEntry<"posts">>>,
+      curr: CollectionEntry<"posts">,
+    ) => {
+      const slug = curr.id.split("/").at(-1)!;
+      acc[slug] = { ...acc[slug], [curr.id.split("/").at(0)!]: curr };
+      return acc;
+    },
+    {},
+  );
+  return postsLocaleMap;
+}
+
+export function getLocalePosts(
+  posts: CollectionEntry<"posts">[],
+  locale: string = DEFAULT_LOCALE,
+) {
+  const postsLocaleMap = getPostsLocaleMap(posts);
+  return Object.values(postsLocaleMap).map((
+    locales,
+  ) => (locales[locale] || locales[DEFAULT_LOCALE]));
+}
+
+export function getPostLocaleSlug(post: CollectionEntry<"posts">) {
+  return {
+    locale: post.id.split("/")[0],
+    slug: `/posts/${post.id.split("/").at(-1)}`,
+  };
+}
src/utils/url.ts
@@ -1,3 +1,5 @@
+import { DEFAULT_LOCALE, SITE_CONFIG } from "@/config.ts";
+
 export function joinUrl(...parts: string[]): string {
   const joined = parts.join("/");
   return joined.replace(/\/+/g, "/");
@@ -17,6 +19,22 @@ export function getRelativeUrl(path: string) {
   return joinUrl("/", path.replace(import.meta.env.BASE_URL, ""));
 }
 
+export function getRelativeLocaleUrl(path: string, locale = DEFAULT_LOCALE) {
+  return url(joinUrl(locale, path, "/"));
+}
+
+export function getPathWithoutLocale(path: string): string {
+  const relative = getRelativeUrl(path);
+  const first = relative.split("/")[1] ?? "";
+  const isLocale = SITE_CONFIG.locales.some(
+    (locale) => locale.toLowerCase() === first.toLowerCase(),
+  );
+  if (!isLocale) {
+    return relative;
+  }
+  return joinUrl("/", relative.slice(first.length + 1));
+}
+
 export function url(path: string) {
   return joinUrl("", import.meta.env.BASE_URL, path);
 }
src/config.ts
@@ -4,11 +4,16 @@ export const SITE_CONFIG: SiteConfig = {
   website: "https://example.com",
   title: "Kerplunk",
   author: "John Doe",
-  description: "A static blog template.",
+  description: {
+    en: "A static blog template.",
+    "zh-CN": "一个静态博客模板。",
+  },
   timeZone: "Etc/UTC",
-  locale: "en",
+  locales: ["en", "zh-CN"],
 };
 
+export const DEFAULT_LOCALE = SITE_CONFIG.locales[0];
+
 const protocol = SITE_CONFIG.website.split("://")[0];
 const host = SITE_CONFIG.website.split("://")[1].split("/")[0];
 const basePath = SITE_CONFIG.website.split(host).slice(1).join("/") ||
src/content.config.ts
@@ -1,13 +1,27 @@
 import { defineCollection } from "astro:content";
 import { glob } from "astro/loaders";
 import { z } from "astro/zod";
+import { type Locale } from "intlayer";
+import { slug as slugify } from "github-slugger";
 
 import { SITE_CONFIG } from "@/config.ts";
 
-const typCollection = defineCollection({
+const typCollection = (base: string) => (defineCollection({
   loader: glob({
     pattern: ["**/[^_]*.{typ,typst}", "!**/_*/**"],
-    base: "./src/contents/posts",
+    base: "./src/contents/" + base,
+    generateId: ({ entry, data }) => {
+      const slugFromData = data.slug as string | undefined;
+      const filename = entry.split("/").at(-1);
+      const isFlatEntry = (filename.split(".").length > 2) &&
+        (SITE_CONFIG.locales.indexOf(filename.split(".").at(-2) as Locale) !==
+          -1);
+      const locale = filename.split(".").at(-2);
+      const slugFromEntry = isFlatEntry
+        ? filename.split(".").slice(0, -2).join("-")
+        : entry.split("/").at(-2);
+      return `${locale}/${slugify(slugFromData || slugFromEntry)}`;
+    },
   }),
   schema: z.object({
     title: z.string(),
@@ -39,8 +53,8 @@ const typCollection = defineCollection({
       return z.NEVER;
     }),
   }),
-});
+}));
 
 export const collections = {
-  posts: typCollection,
+  posts: typCollection("posts"),
 };
src/globalI18n.content.ts
@@ -1,10 +1,12 @@
 import { type Dictionary, t } from "intlayer";
+import { SITE_CONFIG } from "./config.ts";
 
 interface Content {
   posts: string;
   about: string;
   recentPosts: string;
   feed: string;
+  description: string;
 }
 
 export default {
@@ -26,6 +28,7 @@ export default {
       en: "Feed",
       "zh-CN": "订阅",
     }),
+    description: t(SITE_CONFIG.description),
   },
   schema: undefined,
 } satisfies Dictionary<Content>;
astro.config.ts
@@ -4,7 +4,7 @@ import sitemap from "@astrojs/sitemap";
 import { intlayer } from "astro-intlayer";
 import { typst } from "astro-typst";
 
-import { WEBSITE } from "./src/config.ts";
+import { DEFAULT_LOCALE, SITE_CONFIG, WEBSITE } from "./src/config.ts";
 
 import { extractImages } from "./src/integrations/extract-images.ts";
 import { rehypeCleanEndnotes } from "./src/utils/plugins/clean-endnotes.ts";
@@ -13,13 +13,27 @@ import { rehypeCleanEndnotes } from "./src/utils/plugins/clean-endnotes.ts";
 export default defineConfig({
   site: `${WEBSITE.protocol}://${WEBSITE.host}`,
   base: WEBSITE.basePath,
+  i18n: {
+    locales: SITE_CONFIG.locales,
+    defaultLocale: DEFAULT_LOCALE,
+    routing: {
+      prefixDefaultLocale: true,
+    },
+  },
   image: {
     // Required by the `constrained` layout that `kp.img` sets by default.
     responsiveStyles: true,
     breakpoints: [640, 960, 1280],
   },
   integrations: [
-    sitemap(),
+    sitemap({
+      i18n: {
+        defaultLocale: DEFAULT_LOCALE,
+        locales: Object.fromEntries(
+          SITE_CONFIG.locales.map((locale) => [locale, locale] as const),
+        ),
+      },
+    }),
     intlayer(),
     typst({
       options: {
deno.lock
@@ -12,6 +12,7 @@
     "npm:astro@*": "7.2.0_@astrojs+markdown-remark@7.2.2_sass@1.102.0",
     "npm:astro@^7.2.0": "7.2.0_@astrojs+markdown-remark@7.2.2_sass@1.102.0",
     "npm:feed@6": "6.0.0",
+    "npm:github-slugger@2": "2.0.0",
     "npm:intlayer@^9.5.2": "9.5.2",
     "npm:sanitize.css@13": "13.0.0",
     "npm:sass@^1.102.0": "1.102.0",
@@ -3878,6 +3879,7 @@
         "npm:astro-typst@~0.12.4",
         "npm:astro@^7.2.0",
         "npm:feed@6",
+        "npm:github-slugger@2",
         "npm:intlayer@^9.5.2",
         "npm:sanitize.css@13",
         "npm:sass@^1.102.0",
intlayer.config.ts
@@ -1,11 +1,16 @@
 import { type IntlayerConfig } from "intlayer";
 
-import { SITE_CONFIG } from "./src/config.ts";
+import { DEFAULT_LOCALE, SITE_CONFIG, WEBSITE } from "./src/config.ts";
 
 const config: IntlayerConfig = {
   internationalization: {
-    locales: [SITE_CONFIG.locale],
-    defaultLocale: SITE_CONFIG.locale,
+    locales: SITE_CONFIG.locales,
+    defaultLocale: DEFAULT_LOCALE,
+  },
+  routing: {
+    mode: "prefix-no-default",
+    enableProxy: false,
+    basePath: WEBSITE.basePath,
   },
 };
 
package.json
@@ -21,6 +21,7 @@
     "astro-intlayer": "^9.5.2",
     "astro-typst": "^0.12.4",
     "feed": "^6.0.0",
+    "github-slugger": "^2.0.0",
     "intlayer": "^9.5.2",
     "sanitize.css": "^13.0.0",
     "sass": "^1.102.0",