feat(i18n): 21-language pipeline, landing/docs/API wiring, landing+API translations

Shared Claude Code translation pipeline (scripts/i18n, no API key) plus Astro/VitePress/Scalar i18n wiring. Landing and API reference translated into all 20 languages; docs i18n wiring + English source anchors. The translated docs markdown (apps/docs/<locale>/**, 3,620 files) follows in a companion PR because it exceeds GitHub's per-PR CI file limit.
This commit is contained in:
SnapOtter
2026-07-11 13:01:55 +08:00
committed by GitHub
parent 2e91368816
commit 00b651c9f8
353 changed files with 564607 additions and 2594 deletions
+35
View File
@@ -0,0 +1,35 @@
// tests/e2e-landing/i18n-routing.spec.ts
import { expect, test } from "@playwright/test";
test.describe("landing i18n routing", () => {
test("English keeps its unprefixed URL", async ({ page }) => {
const res = await page.goto("/faq");
expect(res?.status()).toBeLessThan(400);
await expect(page).toHaveURL(/\/faq\/?$/);
await expect(page.locator("html")).toHaveAttribute("lang", "en");
});
test("a prefixed locale route renders (no 404)", async ({ page }) => {
const res = await page.goto("/de/faq");
expect(res?.status()).toBeLessThan(400);
await expect(page.locator("html")).toHaveAttribute("lang", "de");
// Content renders (English fallback is acceptable pre-translation, but the page exists).
await expect(page.getByRole("heading", { level: 1 })).toBeVisible();
});
test("a missing translation falls back to English content plus the banner, not a 404", async ({
page,
}) => {
const res = await page.goto("/de/faq");
expect(res?.status()).toBeLessThan(400);
// Machine-translation banner is present on non-English pages.
await expect(page.locator("[data-mt-banner]")).toBeAttached();
});
test("a localized tool page renders with the translated tool name", async ({ page }) => {
const res = await page.goto("/de/tools/image/resize/");
expect(res?.status()).toBeLessThan(400);
await expect(page.locator("html")).toHaveAttribute("lang", "de");
await expect(page.getByRole("heading", { level: 1 })).toBeVisible();
});
});
+56
View File
@@ -0,0 +1,56 @@
// tests/e2e-landing/i18n-seo-rtl.spec.ts
import { expect, test } from "@playwright/test";
test.describe("landing hreflang, RTL, and banner", () => {
test("emits reciprocal hreflang alternates including x-default", async ({ page }) => {
await page.goto("/faq");
const en = await page.locator('link[rel="alternate"][hreflang="en"]').getAttribute("href");
const de = await page.locator('link[rel="alternate"][hreflang="de"]').getAttribute("href");
const xd = await page
.locator('link[rel="alternate"][hreflang="x-default"]')
.getAttribute("href");
// Astro's `format: "directory"` emits trailing-slash URLs, so the alternates
// carry a trailing slash. What matters is reciprocity and that x-default == en.
expect(en).toBe("https://snapotter.com/faq/");
expect(de).toBe("https://snapotter.com/de/faq/");
expect(xd).toBe("https://snapotter.com/faq/");
expect(xd).toBe(en);
// Reciprocity: the German page points back to the same set.
await page.goto("/de/faq");
const enFromDe = await page
.locator('link[rel="alternate"][hreflang="en"]')
.getAttribute("href");
expect(enFromDe).toBe("https://snapotter.com/faq/");
});
test("Arabic renders dir=rtl on <html>", async ({ page }) => {
await page.goto("/ar/faq");
await expect(page.locator("html")).toHaveAttribute("dir", "rtl");
await expect(page.locator("html")).toHaveAttribute("lang", "ar");
});
test("English page sets og:locale to en_US", async ({ page }) => {
await page.goto("/faq");
await expect(page.locator('meta[property="og:locale"]')).toHaveAttribute("content", "en");
});
test("machine-translation banner appears on de and dismisses persistently", async ({ page }) => {
await page.goto("/de/faq");
const banner = page.locator("[data-mt-banner]");
await expect(banner).toBeVisible();
await banner.locator("[data-mt-dismiss]").click();
await expect(banner).toBeHidden();
const stored = await page.evaluate(() => localStorage.getItem("snapotter:mt-banner"));
expect(stored).toBe("dismissed");
// Reload: banner stays hidden.
await page.reload();
await expect(page.locator("[data-mt-banner]")).toBeHidden();
});
test("English page shows no machine-translation banner", async ({ page }) => {
await page.goto("/faq");
await expect(page.locator("[data-mt-banner]")).toHaveCount(0);
});
});
+27
View File
@@ -0,0 +1,27 @@
// tests/e2e-landing/i18n-switcher.spec.ts
import { expect, test } from "@playwright/test";
test.describe("landing language switcher", () => {
test("opens and lists native language names", async ({ page }) => {
await page.goto("/faq");
await page.locator("[data-switcher-button]").click();
const menu = page.locator("[data-switcher-menu]");
await expect(menu).toBeVisible();
await expect(menu.locator('a[data-locale="de"]')).toHaveText("Deutsch");
await expect(menu.locator('a[data-locale="ar"]')).toHaveText("العربية");
});
test("navigates to the chosen locale and persists it to localStorage", async ({ page }) => {
await page.goto("/faq");
await page.locator("[data-switcher-button]").click();
await page.locator('[data-switcher-menu] a[data-locale="de"]').click();
await expect(page).toHaveURL(/\/de\/faq\/?$/);
const stored = await page.evaluate(() => localStorage.getItem("snapotter:locale"));
expect(stored).toBe("de");
});
test("switcher shows the active locale label on a prefixed page", async ({ page }) => {
await page.goto("/de/faq");
await expect(page.locator("[data-switcher-button]")).toContainText("Deutsch");
});
});