mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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
This commit is contained in:
@@ -33,6 +33,34 @@ jobs:
|
|||||||
# fetch isn't rate-limited on shared runner IPs (60 req/hr unauth).
|
# fetch isn't rate-limited on shared runner IPs (60 req/hr unauth).
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
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
|
- name: Deploy to Cloudflare Pages
|
||||||
run: npx wrangler pages deploy apps/landing/dist --project-name snapotter-landing --branch main --commit-dirty=true
|
run: npx wrangler pages deploy apps/landing/dist --project-name snapotter-landing --branch main --commit-dirty=true
|
||||||
env:
|
env:
|
||||||
|
|||||||
@@ -10,13 +10,20 @@ export const SITE = "https://snapotter.com";
|
|||||||
* A trailing "#hash" is split off first and reattached untouched, since
|
* 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
|
* Astro's URL builder would otherwise treat it as part of the path and
|
||||||
* mangle it with a trailing slash (e.g. "/#pricing" -> "/#pricing/").
|
* 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 {
|
export function localizeHref(locale: string, path: string): string {
|
||||||
const hashIndex = path.indexOf("#");
|
const hashIndex = path.indexOf("#");
|
||||||
const pathname = hashIndex === -1 ? path : path.slice(0, hashIndex);
|
const pathname = hashIndex === -1 ? path : path.slice(0, hashIndex);
|
||||||
const hash = hashIndex === -1 ? "" : path.slice(hashIndex);
|
const hash = hashIndex === -1 ? "" : path.slice(hashIndex);
|
||||||
const clean = pathname.startsWith("/") ? pathname.slice(1) : pathname;
|
const clean = pathname.startsWith("/") ? pathname.slice(1) : pathname;
|
||||||
return `${getRelativeLocaleUrl(locale, clean)}${hash}`;
|
return `${getRelativeLocaleUrl(locale, clean, { normalizeLocale: false })}${hash}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -24,6 +24,26 @@ test.describe("landing hreflang, RTL, and banner", () => {
|
|||||||
expect(enFromDe).toBe("https://snapotter.com/faq/");
|
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 <html>", async ({ page }) => {
|
test("Arabic renders dir=rtl on <html>", async ({ page }) => {
|
||||||
await page.goto("/ar/faq");
|
await page.goto("/ar/faq");
|
||||||
await expect(page.locator("html")).toHaveAttribute("dir", "rtl");
|
await expect(page.locator("html")).toHaveAttribute("dir", "rtl");
|
||||||
|
|||||||
Reference in New Issue
Block a user