back to index

Astro: a working setup

How this blog is built — content collections, @sudoo i18n, and a one-file Cloudflare Pages deploy.

published May 20, 2026 tags #astro #meta

~/posts/astro-best-practices $ cat post.md

/ LANG EN / 中文
/ THEME / /
/

The previous post explains why this blog moved off Jekyll. This one is the boring sequel: what the new setup actually looks like.

I will not pretend the stack is exotic. Astro for the static side, two markdown trees for two languages, GitHub Actions pushing the output to Cloudflare Pages. The interesting parts are in the joints.

Posts as a typed collection

Every post lives at src/content/posts/<locale>/<slug>.md. The schema is one file:

// src/content.config.ts
import { defineCollection, z } from "astro:content";
import { glob } from "astro/loaders";

const posts = defineCollection({
    loader: glob({ pattern: "**/*.md", base: "./src/content/posts" }),
    schema: z.object({
        title: z.string(),
        description: z.string().optional(),
        pubDate: z.coerce.date(),
        updatedDate: z.coerce.date().optional(),
        tags: z.array(z.string()).default([]),
        draft: z.boolean().default(false),
    }),
});

export const collections = { posts };

Two things this buys you for free:

  • The glob loader walks the locale subdirectories, so an entry’s id is en-US/some-slug — locale falls out of the path, no extra frontmatter needed.
  • Zod runs at build time. A missing title, a malformed date, a stray tag that’s not a string — all of those fail pnpm build instead of shipping.

The post-list helpers in src/i18n/utils.ts (splitPostId, postsForLocale, postsWithTag) do the filtering and sorting. The [lang] page templates stay thin.

i18n, the @sudoo way

The interesting refactor recently was switching from ad-hoc string maps to @sudoo/internationalization keyed by an enum. The shape:

// src/i18n/profile.ts
export enum BLOG_PROFILE {
    SITE_TITLE      = "SITE_TITLE",
    NAV_ALL_POSTS   = "NAV_ALL_POSTS",
    POST_PUBLISHED  = "POST_PUBLISHED",
    // …one entry per translatable string
}
// src/i18n/intl.ts
import { SudooInternationalization } from "@sudoo/internationalization";
import { IETF_LOCALE } from "@sudoo/locale";
import { enUSBlogProfile } from "./locale/en-US";
import { zhCNBlogProfile } from "./locale/zh-CN";

export const blogInternationalization =
    SudooInternationalization.create<BLOG_PROFILE>(DEFAULT_LOCALE);

blogInternationalization.merge(IETF_LOCALE.ENGLISH_UNITED_STATES, enUSBlogProfile);
blogInternationalization.merge(IETF_LOCALE.CHINESE_SIMPLIFIED,    zhCNBlogProfile);

The locale tables are Record<BLOG_PROFILE, string>, so TypeScript refuses to compile if a locale forgets a key. Adding a new string is a four-step ritual that the compiler walks you through: add to the enum, add to en-US, add to zh-CN, use it in a template. There is no t("nav.allPosts") waiting to silently return the key string when you mistype it.

Routing is plain Astro:

// astro.config.mjs
i18n: {
    defaultLocale: "en-US",
    locales: ["en-US", "zh-CN"],
    routing: {
        prefixDefaultLocale: true,
        redirectToDefaultLocale: false,
    },
},

prefixDefaultLocale: true is the only sane setting for a bilingual site — every URL starts with /en-US/ or /zh-CN/, no ambiguous root. The locale switcher uses switchLocalePath from src/i18n/utils.ts to swap the first segment in place, so toggling languages on a tag page lands you on the same tag page in the other locale.

Deploy: one workflow file

The deploy is unromantic on purpose:

# .github/workflows/deploy.yml
on:
  push: { branches: [master] }
  workflow_dispatch:

concurrency:
  group: pages-deploy-blog
  cancel-in-progress: false

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v5
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v5
        with: { node-version: 22, cache: pnpm }
      - run: pnpm install --frozen-lockfile
      - run: pnpm build
      - env:
          CLOUDFLARE_API_TOKEN:  ${{ secrets.CLOUDFLARE_API_TOKEN }}
          CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
        run: npx --yes wrangler@latest pages deploy dist --project-name=blog-mengw-io --branch=main

Notes worth keeping:

  • concurrency.cancel-in-progress: false — two pushes in a row should both ship, not race. Cloudflare handles the queueing fine.
  • wrangler pages deploy dist over a Cloudflare → GitHub integration. The integration is faster to set up; the wrangler call is easier to debug, because you can run the same line on your laptop with the same env vars and reproduce the failure.
  • The branch=main flag on a workflow that triggers off master is deliberate: it tells Cloudflare which environment to treat as production, independent of the Git branch name. A small landmine, easy to forget.

Two repo secrets — CLOUDFLARE_API_TOKEN (scoped to Pages: Edit on this project only) and CLOUDFLARE_ACCOUNT_ID — and that is the whole CD story.

back to index