Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion public/llms-full.txt
Original file line number Diff line number Diff line change
@@ -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.

Expand Down
18 changes: 16 additions & 2 deletions src/app/[locale]/(public)/faq/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -16,12 +17,25 @@ const QUESTION_KEYS = [
'sharing',
] as const;

export async function generateMetadata(): Promise<Metadata> {
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>;
}): Promise<Metadata> {
// `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) },
};
}

Expand Down
15 changes: 15 additions & 0 deletions src/app/[locale]/(public)/glossaire/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)]),
),
},
};
}

Expand Down
14 changes: 12 additions & 2 deletions src/app/[locale]/(public)/legal/cgu/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<Metadata> {
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>;
}): Promise<Metadata> {
// `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) },
};
}

Expand Down
14 changes: 12 additions & 2 deletions src/app/[locale]/(public)/legal/cookies/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<Metadata> {
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>;
}): Promise<Metadata> {
// `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) },
};
}

Expand Down
14 changes: 12 additions & 2 deletions src/app/[locale]/(public)/legal/privacy/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<Metadata> {
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>;
}): Promise<Metadata> {
// `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) },
};
}

Expand Down
28 changes: 24 additions & 4 deletions src/app/sitemap.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,37 @@
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();
const base = SITE.url;

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({
Expand All @@ -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}`,
]),
Expand Down
90 changes: 90 additions & 0 deletions tests/seo/sitemap.test.ts
Original file line number Diff line number Diff line change
@@ -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([]);
});
});
Loading