Commit 92f6e13
Changed files (23)
src
components
widgets
contents
layouts
pages
types
utils
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",