From 5711a1973b90e6ed06153a884adcacda0e336e95 Mon Sep 17 00:00:00 2001 From: thierryvm Date: Sun, 26 Jul 2026 02:30:29 +0200 Subject: [PATCH] fix(seo): stop canonicalising English pages to French, and submitting noindex URLs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four defects seo-geo-auditor surfaced during #258, all pre-existing and all silent — nothing failed, Search Console would simply have reported errors weeks later. Cross-locale canonicals. faq and the three legal pages hardcoded `alternates: { canonical: '/faq' }`, never prefixed. When a segment declares alternates, Next REPLACES the parent value instead of recomputing it per locale, so /en/faq emitted a canonical pointing at the French page — telling Google the English version is a duplicate, while the hreflang header for that same URL said otherwise. The glossary index declared no alternates at all and therefore inherited the parent layout's canonical: /glossaire, /en/glossaire and /nl-BE/glossaire each canonicalised to their own homepage. Its sibling [slug] page did this correctly; the index never did. It now emits the same `languages` block too — omitting it would have repeated, on the fix itself, the canonical-without- hreflang defect this commit closes. Noindex pages in the sitemap. The three legal pages set robots.index=false yet were submitted for all five locales: 15 URLs guaranteed to come back as "Submitted URL marked noindex". They are out of PUBLIC_ROUTES. robots.txt is deliberately left alone — disallowing them would stop Google reading the very noindex tag that removes them. Untranslated locales. The sitemap advertised nl-BE, de-DE and es-ES for the marketing routes while their landing copy is still French verbatim. It now iterates LOCALES_VISIBLE. The glossary keeps its own wider GLOSSARY_LOCALES scope, because those three locales genuinely have translated terms — an asymmetry worth stating rather than silently aligning. Locale now comes from `params` rather than `getLocale()`. The latter worked only because the locale layout happens to call `cookies()` for the theme, which forces dynamic rendering; reading the segment removes that hidden coupling and matches the glossary pages. tests/seo/sitemap.test.ts locks all of it: no noindex route, no untranslated locale outside the glossary, no duplicate URL, and no hreflang pointing at a URL the sitemap omits. Verified falsifiable — restoring either regression turns two specs red. Measured on a production build: 0 legal URLs in the sitemap, /en/faq now canonicalises to itself, and the glossary index emits its three hreflang links. Left out on purpose: next-intl's middleware Link header still advertises all five locales, which is broader than what the sitemap now submits. Fixing it means touching proxy.ts, which the routing note reserves for a dedicated PR. public/llms.txt still claims Dutch is an operating language; a one-line fix, tracked separately rather than smuggled in here. --- public/llms-full.txt | 2 +- src/app/[locale]/(public)/faq/page.tsx | 18 +++- src/app/[locale]/(public)/glossaire/page.tsx | 15 ++++ src/app/[locale]/(public)/legal/cgu/page.tsx | 14 ++- .../[locale]/(public)/legal/cookies/page.tsx | 14 ++- .../[locale]/(public)/legal/privacy/page.tsx | 14 ++- src/app/sitemap.ts | 28 +++++- tests/seo/sitemap.test.ts | 90 +++++++++++++++++++ 8 files changed, 182 insertions(+), 13 deletions(-) create mode 100644 tests/seo/sitemap.test.ts diff --git a/public/llms-full.txt b/public/llms-full.txt index 5e566f0..bcef1af 100644 --- a/public/llms-full.txt +++ b/public/llms-full.txt @@ -1,6 +1,6 @@ # ankora.be — Full content export for LLMs -Last generated: 2026-07-25 +Last generated: 2026-07-26 Canonical URL: https://ankora.be License: content available for citation with attribution. Code is proprietary. diff --git a/src/app/[locale]/(public)/faq/page.tsx b/src/app/[locale]/(public)/faq/page.tsx index 3c8fbdf..83fceaa 100644 --- a/src/app/[locale]/(public)/faq/page.tsx +++ b/src/app/[locale]/(public)/faq/page.tsx @@ -4,6 +4,7 @@ import { getTranslations } from 'next-intl/server'; import { Header } from '@/components/layout/Header'; import { Footer } from '@/components/layout/Footer'; import { JsonLd } from '@/components/seo/JsonLd'; +import { buildCanonicalUrl } from '@/lib/glossary'; const QUESTION_KEYS = [ 'bankConnection', @@ -16,12 +17,25 @@ const QUESTION_KEYS = [ 'sharing', ] as const; -export async function generateMetadata(): Promise { +export async function generateMetadata({ + params, +}: { + params: Promise<{ locale: string }>; +}): Promise { + // `params`, not `getLocale()`: the latter works only because the locale + // layout happens to call `cookies()` for the theme, which forces dynamic + // rendering. Reading the segment directly removes that hidden coupling and + // matches how the glossary pages already do it. + const { locale } = await params; const t = await getTranslations('faq'); return { title: t('metaTitle'), description: t('metaDescription'), - alternates: { canonical: '/faq' }, + // Locale-aware: when a segment declares `alternates`, Next REPLACES the + // parent's value instead of recomputing it per locale. Hardcoding '/faq' + // made /en/faq canonicalise to the French page, contradicting the hreflang + // Link header emitted for that same URL. + alternates: { canonical: buildCanonicalUrl('/faq', locale) }, }; } diff --git a/src/app/[locale]/(public)/glossaire/page.tsx b/src/app/[locale]/(public)/glossaire/page.tsx index 3fa7c1d..a85192c 100644 --- a/src/app/[locale]/(public)/glossaire/page.tsx +++ b/src/app/[locale]/(public)/glossaire/page.tsx @@ -23,6 +23,21 @@ export async function generateMetadata({ params }: LocaleParams) { return { title: t('metaTitle'), description: t('metaDescription'), + // Without an explicit `alternates`, Next keeps the PARENT layout's canonical + // — so /glossaire, /en/glossaire and /nl-BE/glossaire each canonicalised to + // their own homepage instead of to themselves, and none of them could rank. + // `glossaire/[slug]` already did this correctly; the index did not. + alternates: { + canonical: buildCanonicalUrl('/glossaire', locale), + // Same `languages` block as `[slug]/page.tsx`. Omitting it here would + // repeat, on the fix itself, the very defect this PR closes: a canonical + // with no matching hreflang, leaving the head to say nothing about the + // other locales. Scoped to GLOSSARY_LOCALES because only those three have + // translated terms. + languages: Object.fromEntries( + GLOSSARY_LOCALES.map((l) => [l, buildCanonicalUrl('/glossaire', l)]), + ), + }, }; } diff --git a/src/app/[locale]/(public)/legal/cgu/page.tsx b/src/app/[locale]/(public)/legal/cgu/page.tsx index 91794fe..c2aa1fe 100644 --- a/src/app/[locale]/(public)/legal/cgu/page.tsx +++ b/src/app/[locale]/(public)/legal/cgu/page.tsx @@ -4,17 +4,27 @@ import { getTranslations } from 'next-intl/server'; import { Header } from '@/components/layout/Header'; import { Footer } from '@/components/layout/Footer'; import { Prose, ProseMeta } from '@/components/layout/Prose'; +import { buildCanonicalUrl } from '@/lib/glossary'; const LAST_UPDATED = '16 avril 2026'; const VERSION = '1.0.0'; -export async function generateMetadata(): Promise { +export async function generateMetadata({ + params, +}: { + params: Promise<{ locale: string }>; +}): Promise { + // `params`, not `getLocale()`: the latter works only because the locale + // layout happens to call `cookies()` for the theme, which forces dynamic + // rendering. Reading the segment directly removes that hidden coupling and + // matches how the glossary pages already do it. + const { locale } = await params; const t = await getTranslations('legal.cgu'); return { title: t('metaTitle'), description: t('metaDescription'), robots: { index: false, follow: true }, - alternates: { canonical: '/legal/cgu' }, + alternates: { canonical: buildCanonicalUrl('/legal/cgu', locale) }, }; } diff --git a/src/app/[locale]/(public)/legal/cookies/page.tsx b/src/app/[locale]/(public)/legal/cookies/page.tsx index 2348673..9e32676 100644 --- a/src/app/[locale]/(public)/legal/cookies/page.tsx +++ b/src/app/[locale]/(public)/legal/cookies/page.tsx @@ -4,16 +4,26 @@ import { getTranslations } from 'next-intl/server'; import { Header } from '@/components/layout/Header'; import { Footer } from '@/components/layout/Footer'; import { Prose, ProseMeta } from '@/components/layout/Prose'; +import { buildCanonicalUrl } from '@/lib/glossary'; const LAST_UPDATED = '16 avril 2026'; -export async function generateMetadata(): Promise { +export async function generateMetadata({ + params, +}: { + params: Promise<{ locale: string }>; +}): Promise { + // `params`, not `getLocale()`: the latter works only because the locale + // layout happens to call `cookies()` for the theme, which forces dynamic + // rendering. Reading the segment directly removes that hidden coupling and + // matches how the glossary pages already do it. + const { locale } = await params; const t = await getTranslations('legal.cookies'); return { title: t('metaTitle'), description: t('metaDescription'), robots: { index: false, follow: true }, - alternates: { canonical: '/legal/cookies' }, + alternates: { canonical: buildCanonicalUrl('/legal/cookies', locale) }, }; } diff --git a/src/app/[locale]/(public)/legal/privacy/page.tsx b/src/app/[locale]/(public)/legal/privacy/page.tsx index 0544ae8..4bbfbd9 100644 --- a/src/app/[locale]/(public)/legal/privacy/page.tsx +++ b/src/app/[locale]/(public)/legal/privacy/page.tsx @@ -6,17 +6,27 @@ import { brand } from '@/lib/brand'; import { Header } from '@/components/layout/Header'; import { Footer } from '@/components/layout/Footer'; import { Prose, ProseMeta } from '@/components/layout/Prose'; +import { buildCanonicalUrl } from '@/lib/glossary'; const LAST_UPDATED = '16 avril 2026'; const VERSION = '1.0.0'; -export async function generateMetadata(): Promise { +export async function generateMetadata({ + params, +}: { + params: Promise<{ locale: string }>; +}): Promise { + // `params`, not `getLocale()`: the latter works only because the locale + // layout happens to call `cookies()` for the theme, which forces dynamic + // rendering. Reading the segment directly removes that hidden coupling and + // matches how the glossary pages already do it. + const { locale } = await params; const t = await getTranslations('legal.privacy'); return { title: t('metaTitle'), description: t('metaDescription'), robots: { index: false, follow: true }, - alternates: { canonical: '/legal/privacy' }, + alternates: { canonical: buildCanonicalUrl('/legal/privacy', locale) }, }; } diff --git a/src/app/sitemap.ts b/src/app/sitemap.ts index 99abda4..861ef5d 100644 --- a/src/app/sitemap.ts +++ b/src/app/sitemap.ts @@ -1,9 +1,29 @@ import type { MetadataRoute } from 'next'; import { SITE } from '@/lib/site'; -import { routing } from '@/i18n/routing'; +import { LOCALES_VISIBLE, routing } from '@/i18n/routing'; import { GLOSSARY_LOCALES, getGlossaryTerms, GLOSSARY_LOCALE_PREFIXES } from '@/lib/glossary'; -const PUBLIC_ROUTES = ['', '/faq', '/legal/cgu', '/legal/privacy', '/legal/cookies'] as const; +/** + * Routes worth submitting for indexing. + * + * `/legal/*` is deliberately absent: those pages set + * `robots: { index: false, follow: true }`, so submitting them made Search + * Console report "Submitted URL marked noindex" on 15 URLs (3 pages × 5 + * locales) — a self-inflicted error report, not a ranking opportunity. + */ +const PUBLIC_ROUTES = ['', '/faq'] as const; + +/** + * Locales the sitemap advertises. + * + * `LOCALES_VISIBLE` (FR + EN), not `routing.locales`. `nl-BE`, `de-DE` and + * `es-ES` resolve — deep links and QA bookmarks keep working — but their + * `landing.*` copy is still French verbatim, so submitting them asks Google to + * index untranslated pages under a Dutch/German/Spanish URL. The glossary + * already scopes itself this way through `GLOSSARY_LOCALES`; this aligns the + * rest. Add a locale back here once its translation is reviewed. + */ +const INDEXABLE_LOCALES = LOCALES_VISIBLE; export default function sitemap(): MetadataRoute.Sitemap { const now = new Date(); @@ -11,7 +31,7 @@ export default function sitemap(): MetadataRoute.Sitemap { const entries: MetadataRoute.Sitemap = []; - for (const locale of routing.locales) { + for (const locale of INDEXABLE_LOCALES) { const prefix = locale === routing.defaultLocale ? '' : `/${locale}`; for (const route of PUBLIC_ROUTES) { entries.push({ @@ -21,7 +41,7 @@ export default function sitemap(): MetadataRoute.Sitemap { priority: route === '' ? 1 : 0.6, alternates: { languages: Object.fromEntries( - routing.locales.map((l) => [ + INDEXABLE_LOCALES.map((l) => [ l, `${base}${l === routing.defaultLocale ? '' : `/${l}`}${route}`, ]), diff --git a/tests/seo/sitemap.test.ts b/tests/seo/sitemap.test.ts new file mode 100644 index 0000000..d8bbb5d --- /dev/null +++ b/tests/seo/sitemap.test.ts @@ -0,0 +1,90 @@ +import { describe, it, expect } from 'vitest'; + +import sitemap from '@/app/sitemap'; +import { GLOSSARY_LOCALES } from '@/lib/glossary'; +import { LOCALES_VISIBLE, routing } from '@/i18n/routing'; + +/** + * Sitemap invariants. + * + * Two defects motivated these, both found by `seo-geo-auditor` and both silent: + * nothing failed, Search Console simply reported errors weeks later. + * + * 1. The three `/legal/*` pages set `robots: { index: false }` yet were + * submitted for all five locales — 15 URLs guaranteed to come back as + * "Submitted URL marked noindex". + * 2. `nl-BE`, `de-DE` and `es-ES` were submitted for the marketing routes + * while their `landing.*` copy is still French verbatim, asking Google to + * index untranslated pages under a localised URL. + * + * The glossary is the deliberate exception: `GLOSSARY_LOCALES` scopes it to the + * three locales whose terms are actually translated, `nl-BE` included. That + * asymmetry is intended — do not "align" it without checking the content first. + */ + +const urls = () => sitemap().map((entry) => entry.url); +const pathOf = (url: string) => url.replace(/^https?:\/\/[^/]+/, '') || '/'; + +describe('sitemap — never submits what robots forbid', () => { + it('excludes the noindex legal pages entirely', () => { + const legal = urls().filter((url) => pathOf(url).includes('/legal/')); + expect( + legal, + 'these pages set robots.index=false — submitting them only produces Search Console errors', + ).toEqual([]); + }); +}); + +describe('sitemap — only advertises locales that are actually translated', () => { + it('limits the marketing routes to the visible locales', () => { + const untranslated = routing.locales.filter( + (locale) => !(LOCALES_VISIBLE as readonly string[]).includes(locale), + ); + + const offending = urls().filter((url) => { + const path = pathOf(url); + // The glossary has its own, deliberately wider, locale scope. + if (path.includes('/glossaire')) return false; + return untranslated.some((locale) => path === `/${locale}` || path.startsWith(`/${locale}/`)); + }); + + expect(offending, 'marketing copy is French verbatim in these locales').toEqual([]); + }); + + it('keeps the glossary on its own translated scope', () => { + const glossary = urls().filter((url) => pathOf(url).includes('/glossaire')); + expect(glossary.length).toBeGreaterThan(0); + + const outOfScope = glossary.filter((url) => { + const path = pathOf(url); + return routing.locales.some( + (locale) => + path.startsWith(`/${locale}/`) && + !(GLOSSARY_LOCALES as readonly string[]).includes(locale), + ); + }); + + expect(outOfScope, 'a glossary URL in a locale with no translated terms').toEqual([]); + }); +}); + +describe('sitemap — shape', () => { + it('emits no duplicate URLs', () => { + const all = urls(); + expect(new Set(all).size).toBe(all.length); + }); + + it('advertises alternates only for locales it actually submits', () => { + const submitted = new Set(urls()); + const dangling = sitemap().flatMap((entry) => + Object.values(entry.alternates?.languages ?? {}).filter( + (href): href is string => typeof href === 'string' && !submitted.has(href), + ), + ); + + expect( + [...new Set(dangling)], + 'hreflang pointing at a URL absent from the sitemap sends mixed signals', + ).toEqual([]); + }); +});