From 67f54347b2ca711f40495fa7fcb05e132ea8f3da Mon Sep 17 00:00:00 2001 From: SnapOtter Date: Sat, 18 Jul 2026 10:47:53 +0800 Subject: [PATCH] fix(landing): keep mixed-case locale casing in emitted URLs (#562) Astro's getRelativeLocaleUrl lowercases the locale segment by default, so landing links and hreflang for zh-CN, zh-TW, and pt-BR were emitted lowercase and 404 on case-sensitive Cloudflare Pages. Pin the casing at the localizeHref chokepoint with normalizeLocale: false, add an e2e hreflang casing guard, and add a deploy-time check that blocks the build if any lowercased locale path leaks into the output. Closes #554 --- .github/workflows/deploy-landing.yml | 28 ++++++++++++++++++++++++++ apps/landing/src/lib/i18n-page.ts | 9 ++++++++- tests/e2e-landing/i18n-seo-rtl.spec.ts | 20 ++++++++++++++++++ 3 files changed, 56 insertions(+), 1 deletion(-) diff --git a/.github/workflows/deploy-landing.yml b/.github/workflows/deploy-landing.yml index 2ddf3e4a..bf48bcdd 100644 --- a/.github/workflows/deploy-landing.yml +++ b/.github/workflows/deploy-landing.yml @@ -33,6 +33,34 @@ jobs: # fetch isn't rate-limited on shared runner IPs (60 req/hr unauth). GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # Astro's getRelativeLocaleUrl lowercases locale segments by default, so a + # regression could re-emit /zh-cn/ etc. in hrefs and hreflang. Those 404 on + # case-sensitive Cloudflare Pages while the built dirs + sitemap keep canonical + # mixed case, and the mismatch is invisible on a case-insensitive dev machine. + # Fail the deploy if any lowercased variant of a mixed-case locale directory + # leaks into the emitted HTML. See #554. + - name: Guard against lowercased locale URLs + run: | + set -uo pipefail + dist=apps/landing/dist + fail=0 + for dir in "$dist"/*/; do + name=$(basename "$dir") + lower=$(printf '%s' "$name" | tr '[:upper:]' '[:lower:]') + [ "$name" = "$lower" ] && continue + hits=$(grep -rIl --include='*.html' "/$lower/" "$dist" || true) + if [ -n "$hits" ]; then + echo "::error::Lowercased locale path /$lower/ found in built HTML (canonical is /$name/); it would 404 on Cloudflare Pages. See #554." + printf '%s\n' "$hits" | head -5 + fail=1 + fi + done + if [ "$fail" -ne 0 ]; then + echo "Deploy blocked: lowercased locale URLs would 404 on case-sensitive hosting." + exit 1 + fi + echo "Locale URL casing OK: no lowercased locale paths in built output." + - name: Deploy to Cloudflare Pages run: npx wrangler pages deploy apps/landing/dist --project-name snapotter-landing --branch main --commit-dirty=true env: diff --git a/apps/landing/src/lib/i18n-page.ts b/apps/landing/src/lib/i18n-page.ts index e18c0354..26ae1131 100644 --- a/apps/landing/src/lib/i18n-page.ts +++ b/apps/landing/src/lib/i18n-page.ts @@ -10,13 +10,20 @@ export const SITE = "https://snapotter.com"; * A trailing "#hash" is split off first and reattached untouched, since * Astro's URL builder would otherwise treat it as part of the path and * mangle it with a trailing slash (e.g. "/#pricing" -> "/#pricing/"). + * + * `normalizeLocale: false` keeps the locale segment identical to its code. + * By default Astro lowercases it (getRelativeLocaleUrl runs normalizeTheLocale, + * so "zh-CN" -> "zh-cn", "pt-BR" -> "pt-br"), but our routes are built from the + * raw codes (getStaticPaths -> /zh-CN/, /pt-BR/) and the sitemap keeps that same + * mixed case. The lowercased links resolve fine on case-insensitive local hosting + * but 404 on case-sensitive hosts like Cloudflare Pages, so we pin the casing here. */ export function localizeHref(locale: string, path: string): string { const hashIndex = path.indexOf("#"); const pathname = hashIndex === -1 ? path : path.slice(0, hashIndex); const hash = hashIndex === -1 ? "" : path.slice(hashIndex); const clean = pathname.startsWith("/") ? pathname.slice(1) : pathname; - return `${getRelativeLocaleUrl(locale, clean)}${hash}`; + return `${getRelativeLocaleUrl(locale, clean, { normalizeLocale: false })}${hash}`; } /** diff --git a/tests/e2e-landing/i18n-seo-rtl.spec.ts b/tests/e2e-landing/i18n-seo-rtl.spec.ts index 8eb6a1a7..1e672b88 100644 --- a/tests/e2e-landing/i18n-seo-rtl.spec.ts +++ b/tests/e2e-landing/i18n-seo-rtl.spec.ts @@ -24,6 +24,26 @@ test.describe("landing hreflang, RTL, and banner", () => { expect(enFromDe).toBe("https://snapotter.com/faq/"); }); + // Regression guard for #554. Astro's getRelativeLocaleUrl lowercases the locale + // segment by default (normalizeTheLocale: "zh-CN" -> "zh-cn"), but the built + // directories and the sitemap keep canonical mixed case. The lowercased links + // resolve on case-insensitive local hosting but 404 on Cloudflare Pages. This + // reads the emitted href string, so it catches the mismatch regardless of the + // local filesystem's case sensitivity (which is why the specs above missed it). + test("mixed-case locales keep their exact casing in hreflang links", async ({ page }) => { + await page.goto("/faq"); + for (const code of ["zh-CN", "zh-TW", "pt-BR"]) { + const href = await page + .locator(`link[rel="alternate"][hreflang="${code}"]`) + .getAttribute("href"); + expect(href, `hreflang ${code} must preserve casing`).toBe( + `https://snapotter.com/${code}/faq/`, + ); + // The lowercased path (the bug) must not appear. + expect(href).not.toContain(code.toLowerCase()); + } + }); + test("Arabic renders dir=rtl on ", async ({ page }) => { await page.goto("/ar/faq"); await expect(page.locator("html")).toHaveAttribute("dir", "rtl");