<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>ZeroOne — English</title><description>Practical technical articles about web development, infrastructure, and AI.</description><link>https://laplusda.com/</link><language>en</language><item><title>Dockerize an Astro Static Site: Build dist, Serve It with Caddy</title><link>https://laplusda.com/en/posts/dockerize-astro-static-site-caddy/</link><guid isPermaLink="true">https://laplusda.com/en/posts/dockerize-astro-static-site-caddy/</guid><description>Build and run an Astro static site in Docker with a multi-stage Dockerfile that installs dependencies reproducibly and serves only the generated dist output.</description><pubDate>Sat, 01 Aug 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;An Astro static site does not need Node.js at runtime. Its production artifact is the generated &lt;code&gt;dist/&lt;/code&gt; directory, so a container can build the site with Node in one stage and serve only that output in the final stage. That boundary keeps build dependencies out of the web-server image and makes a failed deployment easier to diagnose.&lt;/p&gt;
&lt;p&gt;This guide targets the primary question behind “Dockerize an Astro static site”: how to build &lt;code&gt;dist&lt;/code&gt; reproducibly, serve it from a small web-server image, and verify that the container is returning the generated site.&lt;/p&gt;
&lt;h2&gt;Use a two-stage Dockerfile&lt;/h2&gt;
&lt;p&gt;Astro&apos;s deployment guide identifies &lt;code&gt;astro build&lt;/code&gt; (or the package-script equivalent) as the build command and &lt;code&gt;dist&lt;/code&gt; as the publish directory. Docker multi-stage builds let the final image copy just that artifact instead of carrying the package manager, source files, and &lt;code&gt;node_modules&lt;/code&gt; into production.&lt;/p&gt;
&lt;p&gt;For a pnpm project, add this &lt;code&gt;Dockerfile&lt;/code&gt; at the repository root:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;FROM node:22-slim AS build
WORKDIR /app

# Copy dependency metadata first so this layer can be cached.
COPY package.json pnpm-lock.yaml ./
RUN npm install --global pnpm@9.14.4
RUN pnpm install --frozen-lockfile

COPY . .
RUN pnpm build

FROM caddy:2-alpine
COPY --from=build /app/dist /usr/share/caddy

EXPOSE 80
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The order is deliberate. A content or component change does not invalidate the dependency-install layer as long as &lt;code&gt;package.json&lt;/code&gt; and &lt;code&gt;pnpm-lock.yaml&lt;/code&gt; remain unchanged. The final &lt;code&gt;caddy&lt;/code&gt; stage receives only &lt;code&gt;/app/dist&lt;/code&gt;; it has no project source or Node package tree to execute.&lt;/p&gt;
&lt;p&gt;Use the Node and pnpm versions your repository supports rather than copying these numbers blindly. In this site, the package manifest currently declares pnpm 9.14.4 and Astro 5.12.8, so the build-stage command should match that lockfile contract.&lt;/p&gt;
&lt;h2&gt;Keep the Docker build context small&lt;/h2&gt;
&lt;p&gt;A &lt;code&gt;.dockerignore&lt;/code&gt; controls what Docker sends to the builder. At minimum, exclude local dependencies and output that the container will regenerate:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;node_modules
.astro
dist
.git
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Add project-specific generated folders as needed. Do not add a blanket &lt;code&gt;.env*&lt;/code&gt; rule without first checking whether your static build requires non-secret, build-time public values. Secrets should not be baked into a static site image: values exposed to client-side JavaScript become public in the generated output.&lt;/p&gt;
&lt;p&gt;Docker&apos;s documentation recommends multi-stage builds to separate build-time and runtime dependencies, and &lt;code&gt;.dockerignore&lt;/code&gt; keeps unnecessary local files out of the build context. Those are separate safeguards: the first keeps the final image focused, while the second reduces what is available during the build.&lt;/p&gt;
&lt;h2&gt;Build and verify the container locally&lt;/h2&gt;
&lt;p&gt;From the repository root, build the final stage and map its HTTP port:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;docker build -t astro-static-site .
docker run --rm --publish 8080:80 astro-static-site
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;In another terminal, confirm that the web server is returning the generated HTML:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;curl --head http://localhost:8080/
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then open &lt;code&gt;http://localhost:8080/&lt;/code&gt; and test one non-homepage route. If the homepage works but a page with a trailing slash returns 404, inspect the corresponding path in &lt;code&gt;dist/&lt;/code&gt; before changing the web-server configuration. With Astro&apos;s usual static output, a route such as &lt;code&gt;/about/&lt;/code&gt; should result in a matching generated directory and &lt;code&gt;index.html&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;For a production check, run the same build command outside Docker first. This is especially useful for content collections, image transforms, and search-index generation because it tells you whether the failure belongs to Astro or to the container build.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;pnpm build
test -f dist/index.html
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If your site has language-specific output, test one of those paths too. &lt;a href=&quot;/en/posts/astro-multilingual-blog-without-url-migration/&quot;&gt;Build an Astro Multilingual Blog Without Migrating Existing URLs&lt;/a&gt; includes concrete output checks for &lt;code&gt;/en/&lt;/code&gt; routes.&lt;/p&gt;
&lt;h2&gt;Do not use this pattern for on-demand routes&lt;/h2&gt;
&lt;p&gt;This Dockerfile is for a static Astro deployment. It is not a substitute for an adapter when the site uses on-demand rendering, server endpoints, sessions, or other server-side behavior. Astro&apos;s deployment documentation calls for the appropriate adapter in that case; the runtime image must start the adapter&apos;s server instead of serving a fixed &lt;code&gt;dist/&lt;/code&gt; directory.&lt;/p&gt;
&lt;p&gt;That distinction also narrows debugging:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Symptom&lt;/th&gt;
&lt;th&gt;Check first&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;pnpm build&lt;/code&gt; fails&lt;/td&gt;
&lt;td&gt;Dependencies, content schema, or Astro configuration&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Docker build fails at &lt;code&gt;pnpm install&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Lockfile, package-manager version, or network access to the registry&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Container starts but returns 404&lt;/td&gt;
&lt;td&gt;The copied &lt;code&gt;dist/&lt;/code&gt; path and the server&apos;s document root&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A dynamic endpoint is missing&lt;/td&gt;
&lt;td&gt;Whether the project needs an Astro adapter rather than static serving&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The practical rule is simple: build the static artifact once, copy that artifact into the final image, and verify the generated paths before treating the issue as a hosting problem.&lt;/p&gt;
&lt;h2&gt;FAQ&lt;/h2&gt;
&lt;h3&gt;Q: Do I need Node.js in the final Docker image for a static Astro site?&lt;/h3&gt;
&lt;p&gt;A: No. A static Astro build produces files in &lt;code&gt;dist/&lt;/code&gt;, which a web server can serve directly. Keep Node.js in the build stage unless your project uses on-demand rendering through an adapter.&lt;/p&gt;
&lt;h3&gt;Q: Why copy package files before the rest of the Astro project?&lt;/h3&gt;
&lt;p&gt;A: Docker caches layers. Copying &lt;code&gt;package.json&lt;/code&gt; and the lockfile before application files allows the dependency-install layer to be reused when only content or source files change.&lt;/p&gt;
&lt;h3&gt;Q: Can I use Nginx instead of Caddy?&lt;/h3&gt;
&lt;p&gt;A: Yes. The relevant boundary is the same: copy the completed &lt;code&gt;dist/&lt;/code&gt; directory into a static web-server image and ensure that the image&apos;s document root matches the copied location. Use the server image and configuration your platform supports.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;References:&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://docs.astro.build/en/guides/deploy/&quot;&gt;Astro: Deploy your Astro site&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://docs.docker.com/build/building/multi-stage/&quot;&gt;Docker: Multi-stage builds&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://docs.docker.com/build/building/best-practices/&quot;&gt;Docker: Building best practices&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://hub.docker.com/_/caddy&quot;&gt;Caddy Docker image&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
</content:encoded></item><item><title>Build an Astro Multilingual Blog Without Migrating Existing URLs</title><link>https://laplusda.com/en/posts/astro-multilingual-blog-without-url-migration/</link><guid isPermaLink="true">https://laplusda.com/en/posts/astro-multilingual-blog-without-url-migration/</guid><description>Add English routes to an existing Astro blog while preserving indexed default-language URLs, content boundaries, feeds, and canonical metadata.</description><pubDate>Fri, 31 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Adding a second language to an established Astro blog is not mainly a translation problem. The risky part is changing URLs that search engines, readers, and external links already know. This guide shows how to add an English section under &lt;code&gt;/en/&lt;/code&gt; while every existing default-language URL stays where it is.&lt;/p&gt;
&lt;p&gt;The central rule is simple: treat locale as content data and URL policy, not as a string inferred late in rendering.&lt;/p&gt;
&lt;h2&gt;Start with the URL contract&lt;/h2&gt;
&lt;p&gt;Suppose an existing Traditional Chinese article is already published at:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;/posts/astro-sitemap/
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Moving it to &lt;code&gt;/zh-tw/posts/astro-sitemap/&lt;/code&gt; would create a migration project: redirects, canonical changes, sitemap churn, and a period in which crawlers reconcile old and new URLs. None of that is necessary just to publish English content.&lt;/p&gt;
&lt;p&gt;Astro supports an unprefixed default locale and prefixed secondary locales. The relevant configuration is:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// astro.config.mjs
export default defineConfig({
  site: &quot;https://example.com/&quot;,
  trailingSlash: &quot;always&quot;,
  i18n: {
    defaultLocale: &quot;zh-TW&quot;,
    locales: [&quot;zh-TW&quot;, &quot;en&quot;],
    routing: {
      prefixDefaultLocale: false,
      redirectToDefaultLocale: false,
    },
  },
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;With this policy, the observable route contract becomes:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Content&lt;/th&gt;
&lt;th&gt;Repository entry slug&lt;/th&gt;
&lt;th&gt;Public URL&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Existing default-language post&lt;/td&gt;
&lt;td&gt;&lt;code&gt;astro-sitemap&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/posts/astro-sitemap/&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;New English post&lt;/td&gt;
&lt;td&gt;&lt;code&gt;en/pagefind-multilingual-search-astro&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/en/posts/pagefind-multilingual-search-astro/&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The &lt;code&gt;en/&lt;/code&gt; segment in the content collection keeps source files organized. It must not leak into the public slug, or the site will produce &lt;code&gt;/en/posts/en/...&lt;/code&gt;.&lt;/p&gt;
&lt;h2&gt;Put locale rules in one module&lt;/h2&gt;
&lt;p&gt;Route files, post cards, related posts, RSS, and structured data all need the same mapping. A small locale module prevents each caller from inventing its own version.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;export const DEFAULT_LOCALE = &quot;zh-TW&quot; as const;
export const SUPPORTED_LOCALES = [DEFAULT_LOCALE, &quot;en&quot;] as const;
export type Locale = (typeof SUPPORTED_LOCALES)[number];

export function normalizeLocale(value?: string | null): Locale {
  if (!value?.trim()) return DEFAULT_LOCALE;
  if (value === &quot;en&quot;) return &quot;en&quot;;
  if (value === &quot;zh-TW&quot; || value === &quot;zh_TW&quot;) return &quot;zh-TW&quot;;
  throw new Error(`Unsupported locale: ${value}`);
}

export function getPublicPostSlug(slug: string, locale: Locale): string {
  if (locale === &quot;en&quot;) {
    if (!slug.startsWith(&quot;en/&quot;)) {
      throw new Error(`English post must live below en/: ${slug}`);
    }
    return slug.slice(3);
  }

  if (slug.startsWith(&quot;en/&quot;)) {
    throw new Error(`Default-language post cannot use en/: ${slug}`);
  }
  return slug;
}

export function getPostPath(slug: string, locale: Locale): string {
  const prefix = locale === &quot;en&quot; ? &quot;/en&quot; : &quot;&quot;;
  return `${prefix}/posts/${getPublicPostSlug(slug, locale)}/`;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Throwing on unknown locales is intentional. A silent fallback can publish a third language into the wrong index or create a canonical URL that does not match the route.&lt;/p&gt;
&lt;h2&gt;Filter content before sorting it&lt;/h2&gt;
&lt;p&gt;A multilingual homepage is only one visible part of the system. Previous/next navigation, category counts, tag counts, and related-post scoring can all cross language boundaries if filtering happens after those values are calculated.&lt;/p&gt;
&lt;p&gt;Filter the collection first:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;async function getSortedPosts(locale: Locale = DEFAULT_LOCALE) {
  const posts = await getCollection(&quot;posts&quot;, ({ data }) =&amp;gt; !data.draft);

  const localizedPosts = posts.filter(
    (post) =&amp;gt; normalizeLocale(post.data.lang) === locale,
  );

  return localizedPosts.sort(
    (a, b) =&amp;gt; b.data.published.valueOf() - a.data.published.valueOf(),
  );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Only after this step should you assign previous and next posts or calculate related content. Keep the locale argument defaulted to the original language so unchanged callers preserve their behavior.&lt;/p&gt;
&lt;h2&gt;Generate explicit English routes&lt;/h2&gt;
&lt;p&gt;Astro file-based routes make the public boundary clear. Keep the original list route at &lt;code&gt;src/pages/[...page].astro&lt;/code&gt;, then add an English list route at &lt;code&gt;src/pages/en/[...page].astro&lt;/code&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;---
import type { GetStaticPaths } from &quot;astro&quot;;
import { getSortedPosts } from &quot;../../utils/content-utils&quot;;

export const getStaticPaths = (async ({ paginate }) =&amp;gt; {
  const posts = await getSortedPosts(&quot;en&quot;);
  return paginate(posts, { pageSize: 10 });
}) satisfies GetStaticPaths;
---
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The English article route performs the same locale-specific query and removes the repository prefix when producing route parameters:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;return englishPosts.map((entry) =&amp;gt; ({
  params: { slug: getPublicPostSlug(entry.slug, &quot;en&quot;) },
  props: { entry },
}));
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Pass &lt;code&gt;lang=&quot;en&quot;&lt;/code&gt; from this route into the shared layout and UI components. Do not ask components to inspect &lt;code&gt;window.location&lt;/code&gt; or browser preferences. Explicit props also work during static rendering and make missing translations easier to detect.&lt;/p&gt;
&lt;h2&gt;Keep feeds and metadata locale-specific&lt;/h2&gt;
&lt;p&gt;Create &lt;code&gt;/en/rss.xml&lt;/code&gt; from &lt;code&gt;getSortedPosts(&quot;en&quot;)&lt;/code&gt;; keep &lt;code&gt;/rss.xml&lt;/code&gt; on the default query. Every feed item link should use the same &lt;code&gt;getPostPath&lt;/code&gt; mapping as article cards and Schema.org data.&lt;/p&gt;
&lt;p&gt;Each page also needs an absolute self-canonical. An English article should resolve to:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;link rel=&quot;canonical&quot; href=&quot;https://example.com/en/posts/example-slug/&quot;&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Do not add &lt;code&gt;hreflang&lt;/code&gt; merely because two pages discuss Astro. It describes localized alternatives of the same page. Independent English topics should remain self-canonical without an alternate pair. The decision rules are covered in &lt;a href=&quot;/en/posts/hreflang-independent-multilingual-content/&quot;&gt;Hreflang for Independent Multilingual Content&lt;/a&gt;.&lt;/p&gt;
&lt;h2&gt;Verify the generated site, not only TypeScript&lt;/h2&gt;
&lt;p&gt;Static-site bugs often appear in output paths or HTML rather than in type checking. After &lt;code&gt;pnpm check&lt;/code&gt;, run the production build and inspect the artifacts:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;pnpm build

test -f dist/index.html
test -f dist/en/index.html
test -f dist/rss.xml
test -f dist/en/rss.xml

rg &apos;/en/posts/en/&apos; dist
rg &apos;&amp;lt;html lang=&quot;en&quot;&apos; dist/en
rg &apos;rel=&quot;canonical&quot;&apos; dist/en/posts
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The first &lt;code&gt;rg&lt;/code&gt; command should return no matches. Also inspect the sitemap and confirm that old default-language paths are unchanged.&lt;/p&gt;
&lt;p&gt;Pagefind can use the same &lt;code&gt;&amp;lt;html lang&amp;gt;&lt;/code&gt; boundary to separate search indexes. &lt;a href=&quot;/en/posts/pagefind-multilingual-search-astro/&quot;&gt;Pagefind Multilingual Search in Astro&lt;/a&gt; shows how to validate that part of the build.&lt;/p&gt;
&lt;p&gt;The practical takeaway is to preserve public URLs first, then make locale explicit at every content and URL boundary. Once routes, queries, metadata, and feeds share that contract, English posts can be independent without destabilizing the existing site.&lt;/p&gt;
&lt;h2&gt;FAQ&lt;/h2&gt;
&lt;h3&gt;Q: Can I add English pages without moving existing Astro URLs?&lt;/h3&gt;
&lt;p&gt;A: Yes. Keep the established default-language routes unprefixed and add English routes under &lt;code&gt;/en/&lt;/code&gt;. The important part is to make locale explicit in content queries, URL helpers, metadata, feeds, and related-content calculations so the two sections remain separate.&lt;/p&gt;
&lt;h3&gt;Q: Why does an English Astro post sometimes become &lt;code&gt;/en/posts/en/...&lt;/code&gt;?&lt;/h3&gt;
&lt;p&gt;A: This happens when the content entry slug already contains the repository-only &lt;code&gt;en/&lt;/code&gt; prefix and the route builder adds another English prefix without removing it. Convert the entry slug to a public slug before composing the route, then verify that the production output contains no &lt;code&gt;/en/posts/en/&lt;/code&gt; paths.&lt;/p&gt;
&lt;h3&gt;Q: Do all English and Traditional Chinese posts need hreflang links?&lt;/h3&gt;
&lt;p&gt;A: No. Add &lt;code&gt;hreflang&lt;/code&gt; only when the URLs are equivalent localized versions of the same article. Independent articles should remain self-canonical even when they share tags or discuss the same framework.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;References:&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://docs.astro.build/en/guides/internationalization/&quot;&gt;Astro internationalization routing&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://docs.astro.build/en/reference/modules/astro-i18n/&quot;&gt;Astro i18n API reference&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
</content:encoded></item><item><title>Hreflang for Independent Multilingual Content: When Pages Are Not Translations</title><link>https://laplusda.com/en/posts/hreflang-independent-multilingual-content/</link><guid isPermaLink="true">https://laplusda.com/en/posts/hreflang-independent-multilingual-content/</guid><description>Use hreflang only for equivalent localized pages, while keeping independent multilingual articles self-canonical and correctly indexed.</description><pubDate>Fri, 31 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;An English article and a Traditional Chinese article can cover the same technology without being localized versions of the same page. Connecting them with &lt;code&gt;hreflang&lt;/code&gt; anyway sends a stronger equivalence signal than the content supports.&lt;/p&gt;
&lt;p&gt;For a multilingual technical blog with independently planned articles, the safe default is a self-canonical page with no &lt;code&gt;hreflang&lt;/code&gt;. Add reciprocal alternates only when the two URLs genuinely represent the same article for different language audiences.&lt;/p&gt;
&lt;h2&gt;Canonical and hreflang answer different questions&lt;/h2&gt;
&lt;p&gt;A canonical link identifies the preferred URL for the current page:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;link
  rel=&quot;canonical&quot;
  href=&quot;https://example.com/en/posts/pagefind-multilingual-search-astro/&quot;
&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;An &lt;code&gt;hreflang&lt;/code&gt; alternate identifies another localized version of the same content:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;link
  rel=&quot;alternate&quot;
  hreflang=&quot;zh-Hant&quot;
  href=&quot;https://example.com/posts/pagefind-multilingual-search-astro/&quot;
&amp;gt;
&amp;lt;link
  rel=&quot;alternate&quot;
  hreflang=&quot;en&quot;
  href=&quot;https://example.com/en/posts/pagefind-multilingual-search-astro/&quot;
&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Google Search Central describes &lt;code&gt;hreflang&lt;/code&gt; as a way to identify localized variations of a page. It also expects every version to list the other versions, including itself. A one-way link is not a complete relationship.&lt;/p&gt;
&lt;p&gt;The canonical on each localized page should normally point to that page itself. Do not canonicalize an English translation to the Traditional Chinese original; doing so conflicts with the intent to index both language versions.&lt;/p&gt;
&lt;h2&gt;Decide whether two articles are equivalents&lt;/h2&gt;
&lt;p&gt;Use content intent, not shared tags, to make the decision.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Relationship&lt;/th&gt;
&lt;th&gt;Self-canonical&lt;/th&gt;
&lt;th&gt;Reciprocal hreflang&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Direct translation with the same examples and purpose&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Localized adaptation with the same reader task and equivalent main content&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Two tutorials about Astro with different problems&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;English article inspired by a shorter Chinese note&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Only one language version exists&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;For example, an English guide about preserving legacy URLs and a Chinese post about generating an Astro sitemap both belong to an Astro/SEO cluster. They are useful internal links, but they are not language alternatives. Each should remain self-canonical without an &lt;code&gt;hreflang&lt;/code&gt; connection.&lt;/p&gt;
&lt;h2&gt;Represent translation as optional content data&lt;/h2&gt;
&lt;p&gt;Folder names and matching slugs are weak evidence of equivalence. Use an explicit optional key in the content schema:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;const postsCollection = defineCollection({
  schema: z.object({
    title: z.string(),
    lang: z.enum([&quot;en&quot;, &quot;zh-TW&quot;, &quot;zh_TW&quot;, &quot;&quot;]).optional(),
    translationKey: z.string().trim().min(1).optional(),
  }),
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Independent articles omit &lt;code&gt;translationKey&lt;/code&gt;. A verified pair receives the same value:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# Traditional Chinese article
lang: zh-TW
translationKey: astro-i18n-routing
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;# English article
lang: en
translationKey: astro-i18n-routing
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This model makes the editorial decision visible and reviewable. It also allows translated titles and different slugs without relying on filename equality.&lt;/p&gt;
&lt;h2&gt;Build alternates only for complete pairs&lt;/h2&gt;
&lt;p&gt;Group posts by non-empty &lt;code&gt;translationKey&lt;/code&gt;, then require one unambiguous entry for each supported locale.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;type Locale = &quot;zh-TW&quot; | &quot;en&quot;;

for (const post of posts) {
  const key = post.data.translationKey?.trim();
  if (!key) continue;

  const locale = normalizeLocale(post.data.lang);
  const group = groups.get(key) ?? new Map&amp;lt;Locale, Post&amp;gt;();

  if (group.has(locale)) {
    throw new Error(`Duplicate translation key: ${key} (${locale})`);
  }

  group.set(locale, post);
  groups.set(key, group);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;When a group has both &lt;code&gt;zh-TW&lt;/code&gt; and &lt;code&gt;en&lt;/code&gt;, create two alternates and pass them into the page layout. When only one side exists, emit nothing. This avoids publishing a relation to a missing URL.&lt;/p&gt;
&lt;p&gt;The resulting Astro layout can render absolute URLs:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{alternates.map((alternate) =&amp;gt; (
  &amp;lt;link
    rel=&quot;alternate&quot;
    hreflang={alternate.hreflang}
    href={new URL(alternate.path, Astro.site).href}
  /&amp;gt;
))}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Fail the build when the same locale uses one translation key twice. Choosing the first match would make the output depend on collection order and could connect the wrong article.&lt;/p&gt;
&lt;h2&gt;Keep independent English articles unpaired&lt;/h2&gt;
&lt;p&gt;For an English-first experiment, it is acceptable for every initial article to have no equivalent Chinese page. Give each English URL:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;an absolute self-canonical containing &lt;code&gt;/en/posts/&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;html lang=&quot;en&quot;&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;BlogPosting.inLanguage&lt;/code&gt; set to &lt;code&gt;en&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;its own English title, description, and body;&lt;/li&gt;
&lt;li&gt;no article-level &lt;code&gt;hreflang&lt;/code&gt; until an equivalent page exists.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;These technical boundaries work alongside the URL and query design in &lt;a href=&quot;/en/posts/astro-multilingual-blog-without-url-migration/&quot;&gt;Build an Astro Multilingual Blog Without Migrating Existing URLs&lt;/a&gt;. If the site uses Pagefind, the same root language value can also keep &lt;a href=&quot;/en/posts/pagefind-multilingual-search-astro/&quot;&gt;multilingual search results separated&lt;/a&gt;.&lt;/p&gt;
&lt;h2&gt;Verify the generated HTML&lt;/h2&gt;
&lt;p&gt;Check output files after the production build. Source components cannot prove which conditional tags were rendered.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;pnpm build

rg &apos;rel=&quot;canonical&quot;&apos; dist/en/posts
rg &apos;hreflang=&apos; dist/en/posts
rg &apos;&quot;inLanguage&quot;:&quot;en&quot;&apos; dist/en/posts
rg &apos;/en/posts/en/&apos; dist
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;For a pilot containing only independent English articles, the &lt;code&gt;hreflang&lt;/code&gt; search should return no matches. The double-prefix search should also be empty.&lt;/p&gt;
&lt;p&gt;If you later add a translation pair, inspect both generated pages and verify that:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;both pages remain self-canonical;&lt;/li&gt;
&lt;li&gt;both list the English and Traditional Chinese alternates;&lt;/li&gt;
&lt;li&gt;alternate URLs are absolute and return successful pages;&lt;/li&gt;
&lt;li&gt;no third page shares the key in either locale.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The editorial test comes before the markup test: if two articles do not solve the same reader problem with equivalent main content, do not pair them. A shared topic is an internal-link relationship, not automatically a language-alternate relationship.&lt;/p&gt;
&lt;h2&gt;FAQ&lt;/h2&gt;
&lt;h3&gt;Q: Does every page on a multilingual site need hreflang?&lt;/h3&gt;
&lt;p&gt;A: No. A page needs &lt;code&gt;hreflang&lt;/code&gt; only when another URL is an equivalent localized version for a different language or region. A multilingual site can contain many independent self-canonical pages with no alternate relationship.&lt;/p&gt;
&lt;h3&gt;Q: Should an English translation canonicalize to the original article?&lt;/h3&gt;
&lt;p&gt;A: Normally, no. If both localized pages are intended to be indexed, each should use a self-canonical URL and list the reciprocal language alternates. Canonicalizing the English page to the original sends a conflicting consolidation signal.&lt;/p&gt;
&lt;h3&gt;Q: Should I publish hreflang when only one side of a translation pair exists?&lt;/h3&gt;
&lt;p&gt;A: Wait until both localized URLs exist and can reference each other. An incomplete one-way relationship does not represent a complete alternate set, so the safer output is no article-level &lt;code&gt;hreflang&lt;/code&gt; until the pair is ready.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;References:&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://developers.google.com/search/docs/specialty/international/localized-versions&quot;&gt;Google Search Central: Tell Google about localized versions of your page&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
</content:encoded></item><item><title>Pagefind Multilingual Search in Astro: Keep Language Results Separate</title><link>https://laplusda.com/en/posts/pagefind-multilingual-search-astro/</link><guid isPermaLink="true">https://laplusda.com/en/posts/pagefind-multilingual-search-astro/</guid><description>Configure Pagefind multilingual search in an Astro static site so English and Traditional Chinese pages use separate language-aware indexes.</description><pubDate>Fri, 31 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;A single search box can quietly break the experience of a multilingual blog. An English query may return Traditional Chinese articles, category counts may imply content that is not visible, and a translated placeholder can hide the fact that the underlying index is still mixed.&lt;/p&gt;
&lt;p&gt;Pagefind has a simpler boundary available: the &lt;code&gt;lang&lt;/code&gt; attribute on each generated HTML document. In an Astro static build, that lets one Pagefind build produce language-specific indexes without running a second search service.&lt;/p&gt;
&lt;h2&gt;How Pagefind chooses a language index&lt;/h2&gt;
&lt;p&gt;Pagefind&apos;s multilingual documentation describes two matching steps:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;During indexing, Pagefind reads the &lt;code&gt;&amp;lt;html lang&amp;gt;&lt;/code&gt; value and indexes each detected language independently.&lt;/li&gt;
&lt;li&gt;In the browser, Pagefind reads the current page&apos;s language and loads the matching index.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;That means these pages belong to different search indexes:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;!-- Existing Traditional Chinese page --&amp;gt;
&amp;lt;html lang=&quot;zh-TW&quot;&amp;gt;

&amp;lt;!-- English section --&amp;gt;
&amp;lt;html lang=&quot;en&quot;&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You do not need to model language as a custom Pagefind filter for this behavior. You also should not pass &lt;code&gt;--force-language&lt;/code&gt;, because that option deliberately opts out of multilingual indexing and creates one language index for the build.&lt;/p&gt;
&lt;h2&gt;Make Astro render the canonical locale&lt;/h2&gt;
&lt;p&gt;The language boundary should come from the route&apos;s content query, not from browser detection. Define the supported values once:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;export const SUPPORTED_LOCALES = [&quot;zh-TW&quot;, &quot;en&quot;] as const;
export type Locale = (typeof SUPPORTED_LOCALES)[number];
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then pass the route locale into the root layout:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;---
interface Props {
  lang?: &quot;zh-TW&quot; | &quot;en&quot;;
}

const { lang = &quot;zh-TW&quot; } = Astro.props;
---

&amp;lt;html lang={lang}&amp;gt;
  &amp;lt;slot /&amp;gt;
&amp;lt;/html&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The default-language route can keep its existing URL and default prop. The &lt;code&gt;/en/&lt;/code&gt; list and &lt;code&gt;/en/posts/.../&lt;/code&gt; routes must pass &lt;code&gt;lang=&quot;en&quot;&lt;/code&gt; explicitly.&lt;/p&gt;
&lt;p&gt;This approach matters for static generation. There is no browser request language when Astro writes files into &lt;code&gt;dist/&lt;/code&gt;, so client-side locale detection cannot determine which Pagefind index a document should enter.&lt;/p&gt;
&lt;h2&gt;Keep content discovery separated too&lt;/h2&gt;
&lt;p&gt;Pagefind only controls search. It does not prevent a mixed homepage or cross-language related posts. Before rendering either locale, filter the content collection by the same canonical locale used by the layout.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;const englishPosts = allPosts.filter(
  (post) =&amp;gt; normalizeLocale(post.data.lang) === &quot;en&quot;,
);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Apply that filter before sorting, pagination, previous/next assignment, tag aggregation, and related-post scoring. This gives the search index and visible navigation the same language boundary.&lt;/p&gt;
&lt;p&gt;For a complete route and content mapping, see &lt;a href=&quot;/en/posts/astro-multilingual-blog-without-url-migration/&quot;&gt;Build an Astro Multilingual Blog Without Migrating Existing URLs&lt;/a&gt;.&lt;/p&gt;
&lt;h2&gt;Localize the search interface with a prop&lt;/h2&gt;
&lt;p&gt;Pagefind can select an index from the document language, but your own Svelte or Astro search controls still need localized labels. Pass the locale into the search island instead of deriving it from the URL:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;script lang=&quot;ts&quot;&amp;gt;
  let { lang = &quot;zh-TW&quot; }: { lang?: &quot;zh-TW&quot; | &quot;en&quot; } = $props();

  const copy = lang === &quot;en&quot;
    ? {
        placeholder: &quot;Search articles&quot;,
        empty: &quot;No matching articles&quot;,
      }
    : {
        placeholder: translatedSearchPlaceholder,
        empty: translatedEmptyState,
      };
&amp;lt;/script&amp;gt;

&amp;lt;input aria-label={copy.placeholder} placeholder={copy.placeholder} /&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;When the island initializes Pagefind on an English document, the client library sees &lt;code&gt;lang=&quot;en&quot;&lt;/code&gt; on the root element. If a single-page navigation system replaces the document content, verify that it also updates the root language or reinitializes Pagefind when the language changes. Pagefind&apos;s API documentation notes that dynamically changing page language requires reinitialization.&lt;/p&gt;
&lt;h2&gt;Validate the production indexes&lt;/h2&gt;
&lt;p&gt;Development-mode mock search results do not prove that the production index is separated. Run the full build, then inspect Pagefind&apos;s output.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;pnpm build

find dist/pagefind -maxdepth 2 -type f | sort
rg &apos;lang=&quot;en&quot;&apos; dist/en/index.html
rg &apos;lang=&quot;zh-TW&quot;&apos; dist/index.html
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Pagefind&apos;s build log should report both detected languages. The generated index filenames include language-specific metadata; exact hashed names can change between builds, so assertions should inspect content or use Pagefind&apos;s API rather than hard-code a hash.&lt;/p&gt;
&lt;p&gt;For a browser-level check, open &lt;code&gt;/en/&lt;/code&gt;, search for a distinctive English phrase from an English article, and confirm that the returned URLs begin with &lt;code&gt;/en/posts/&lt;/code&gt;. Then open &lt;code&gt;/&lt;/code&gt; and search for a distinctive Traditional Chinese phrase. The result sets should remain in their own locale.&lt;/p&gt;
&lt;p&gt;You can also use the Pagefind JavaScript API from a small verification script or browser console:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;const search = await pagefind.search(&quot;multilingual index&quot;);
const results = await Promise.all(search.results.map((item) =&amp;gt; item.data()));

console.table(results.map((item) =&amp;gt; ({
  url: item.url,
  title: item.meta.title,
})));
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Run that code from the locale whose index you want to test. Pagefind initializes against the current document language.&lt;/p&gt;
&lt;h2&gt;Diagnose a mixed-language result&lt;/h2&gt;
&lt;p&gt;If an English page returns Traditional Chinese results, check these in order:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Inspect the generated English HTML, not the Astro source, and confirm &lt;code&gt;&amp;lt;html lang=&quot;en&quot;&amp;gt;&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Confirm the English article itself has &lt;code&gt;lang: en&lt;/code&gt; and was generated below &lt;code&gt;/en/posts/&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Check that Pagefind was not built with &lt;code&gt;--force-language&lt;/code&gt; or &lt;code&gt;PAGEFIND_FORCE_LANGUAGE&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Clear stale static assets and browser caches after rebuilding.&lt;/li&gt;
&lt;li&gt;If navigation changes languages without a full page load, reinitialize Pagefind after the root &lt;code&gt;lang&lt;/code&gt; value changes.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The important distinction is that localized UI and localized search are separate concerns. Translate the controls with locale props, but let a correct &lt;code&gt;&amp;lt;html lang&amp;gt;&lt;/code&gt; value define Pagefind&apos;s index boundary.&lt;/p&gt;
&lt;h2&gt;FAQ&lt;/h2&gt;
&lt;h3&gt;Q: Does Pagefind automatically create separate language indexes?&lt;/h3&gt;
&lt;p&gt;A: Pagefind separates detected languages during one indexing run when the generated HTML documents have accurate &lt;code&gt;lang&lt;/code&gt; attributes. In the browser, it uses the current document language to select the matching index, so the production HTML is the boundary to verify.&lt;/p&gt;
&lt;h3&gt;Q: Should I use &lt;code&gt;--force-language&lt;/code&gt; for a multilingual Pagefind site?&lt;/h3&gt;
&lt;p&gt;A: No. &lt;code&gt;--force-language&lt;/code&gt; deliberately forces the build into one language index, which bypasses Pagefind&apos;s multilingual separation. Let each generated page declare its actual language instead.&lt;/p&gt;
&lt;h3&gt;Q: Why does an English Pagefind search return pages in another language?&lt;/h3&gt;
&lt;p&gt;A: Start by inspecting the generated English page for &lt;code&gt;&amp;lt;html lang=&quot;en&quot;&amp;gt;&lt;/code&gt;. Then check for &lt;code&gt;--force-language&lt;/code&gt;, stale Pagefind assets, and client-side navigation that changes page content without updating the root language or reinitializing Pagefind.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;References:&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://pagefind.app/docs/multilingual/&quot;&gt;Pagefind multilingual search&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://pagefind.app/docs/api/&quot;&gt;Pagefind JavaScript API&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://pagefind.app/docs/config-options/&quot;&gt;Pagefind CLI configuration options&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
</content:encoded></item></channel></rss>