fix(docs): keep the translated locale trees out of the search index (#662)

Search Console flagged four reasons on 2026-07-28: soft 404, both
duplicate-canonical variants, and noindex. All four sat on
docs.snapotter.com; the landing site was clean.

The cause was boilerplate dominance rather than a broken tag.
/tools/video/crop-video carries 1.3 KB of unique body against 3.5 KB of
identical chrome (nav, sidebar, 21-language switcher), so unrelated tool
docs measured 52-60% full-page similarity. Across 20 locales that was
3,640 of 3,822 submitted URLs. Google read the lot as one duplicate
cluster and began electing arbitrary representatives: /changelog became
the canonical for /tools/image/favicon, and /uk/guide/getting-started for
/nl/tools/image/resize. English tool docs indexed 2 of 10 and localized 4
of 10, while the landing page for those same five tools indexed 5 of 5.

Translated pages now emit `noindex, follow` with a self-canonical, and
sitemap.transformItems drops them from the sitemap. Self-canonical rather
than pointing at English, since noindex paired with a cross-canonical
sends two conflicting instructions. hreflang is removed outright: the
annotation only means something between pages that can all be indexed.

Readers see no change. The language switcher and every in-page link
behave exactly as before.

Verified against a real build: sitemap 3,822 to 182 URLs with zero
translated entries, all 3,640 translated files carrying the noindex and
no English file doing so, docs e2e 100 passed.
This commit is contained in:
SnapOtter
2026-07-28 17:08:32 +08:00
committed by GitHub
parent 5f21588f6c
commit 42e1dc9799
3 changed files with 49 additions and 13 deletions
+14 -7
View File
@@ -38,14 +38,21 @@ test.describe("docs i18n (English + committed German locale)", () => {
await expect(target).toBeInViewport();
});
test("hreflang alternates are reciprocal and include x-default", async ({ page }) => {
test("English pages are indexable and self-canonical", async ({ page }) => {
await page.goto("/guide/getting-started");
const en = await page.locator('link[hreflang="en"]').getAttribute("href");
const de = await page.locator('link[hreflang="de"]').getAttribute("href");
const xd = await page.locator('link[hreflang="x-default"]').getAttribute("href");
expect(en).toContain("/guide/getting-started");
expect(de).toContain("/de/guide/getting-started");
expect(xd).toBe(en);
const canonical = await page.locator('link[rel="canonical"]').getAttribute("href");
expect(canonical).toBe("https://docs.snapotter.com/guide/getting-started");
await expect(page.locator('meta[name="robots"]')).toHaveCount(0);
});
// Only English is indexable, so there is no hreflang cluster left to annotate.
// See the transformHead comment in apps/docs/.vitepress/config.mts.
test("translated pages are noindex, self-canonical, and carry no hreflang", async ({ page }) => {
await page.goto("/de/guide/getting-started");
const canonical = await page.locator('link[rel="canonical"]').getAttribute("href");
expect(canonical).toBe("https://docs.snapotter.com/de/guide/getting-started");
await expect(page.locator('meta[name="robots"]')).toHaveAttribute("content", "noindex, follow");
await expect(page.locator("link[hreflang]")).toHaveCount(0);
});
test("pagefind returns results within the German locale", async ({ page }) => {
@@ -267,7 +267,7 @@ test("docs Playwright collection includes explicit Axe coverage", () => {
for (const pageName of ["homepage", "getting started", "configuration", "REST API"]) {
expect(output).toContain(`${pageName} has no Axe violations`);
}
expect(output).toContain("Total: 99 tests in 7 files");
expect(output).toContain("Total: 100 tests in 7 files");
});
test("Turbo passes surface harness overrides through task boundaries", () => {