fix: leave the i18n copies of a page in other locales out of the route report

With prefix_except_default (or prefix_and_default) @nuxtjs/i18n adds a copy
of every page per other locale (/pl/contact next to /contact). The route
report sent them as pages of their own, so the CMS registry got a second,
untranslated page for each. They are the page's translations: the report
now skips routes named with another locale's suffix (contact___pl).
This commit is contained in:
Jakub Zych
2026-11-04 20:13:05 +01:00
parent 75cc100336
commit b3a0078fb0
4 changed files with 50 additions and 7 deletions

View File

@@ -40,7 +40,7 @@ The layer's only module is its own local one (`lib/seo-module.ts`); it pulls in
definePageMeta({ seo: { breadcrumbs: false } }) // no visible breadcrumbs here
```
- **Route report (v0.2.0, D-33).** At server start the layer posts its static routes (pages without parameters, without the noindex ones above, without `noindexRoutes`) to `POST <apiBase>/registry/routes` as `{"routes": [...]}` (at most 2000 paths) with `Authorization: Bearer <routeReportToken>`, once per process, after the start; a failure is tried once more after 60 s, a 404 (a backend without the registry) or a refused token is logged and not retried. Nothing is sent without a token, with `routeReport: false`, with the layer off or while prerendering. The route list is written at build time into the server template `#seo-routes`; no network call happens at build time.
- **Route report (v0.2.0, D-33).** At server start the layer posts its static routes (pages without parameters, without the noindex ones above, without `noindexRoutes`, and with `@nuxtjs/i18n`'s `prefix_except_default` or `prefix_and_default` without the copies of a page in the other locales, such as `/pl/contact` next to `/contact`: the CMS knows those as the page's translations, which an editor gives the page in SEO › Pages) to `POST <apiBase>/registry/routes` as `{"routes": [...]}` (at most 2000 paths) with `Authorization: Bearer <routeReportToken>`, once per process, after the start; a failure is tried once more after 60 s, a 404 (a backend without the registry) or a refused token is logged and not retried. Nothing is sent without a token, with `routeReport: false`, with the layer off or while prerendering. The route list is written at build time into the server template `#seo-routes`; no network call happens at build time.
- **Drift check (v0.2.0, D-20c).** At server start the layer compares the site answer's content-type URL patterns (`routes`), pagination form, parameter and segments and HTML sitemap paths with its build-time routes and the `pagination` and `htmlSitemap` options, and logs one `[vue-seo-layer] drift: …` warning per mismatch. A CMS pattern counts as served by a route of the same shape or a catch-all under a static prefix; a catch-all at the root does not. The check never blocks or fails the start, and a backend without a site answer is skipped.
- **Breadcrumbs, pagination and the HTML sitemap (v0.2.0).** See Components.
- **Visitor address.** The server calls forward the visitor's `X-Forwarded-For` plus the address of the peer that reached Nuxt, also on route-cached (`swr`) renders, so the plugin's per-IP throttles (`seo-resolve`, `seo-notfound`, 600 a minute) keep one bucket per visitor. List the Nuxt server's address in the backend's `http.trusted_proxies`.

View File

@@ -42,15 +42,35 @@ export function pageMetaRobots(meta: Record<string, unknown> | undefined | null)
/** The layer's own HTML sitemap pages, which the CMS knows already. */
const isLayerPage = (name: unknown) => typeof name === 'string' && name.startsWith('seo-sitemap-')
/**
* The route-name suffixes of @nuxtjs/i18n's copies of a page in the locales
* other than the default one (`contact___pl` with the default separator):
* with prefix_except_default or prefix_and_default the CMS knows those
* addresses as the page's translations, not as pages of their own. Nothing
* without i18n, with no_prefix or without a default locale.
*/
export function localeCopySuffixes(i18n: unknown): string[] {
if (!i18n || typeof i18n !== 'object') return []
const o = i18n as { strategy?: unknown; defaultLocale?: unknown; locales?: unknown; routesNameSeparator?: unknown }
const strategy = typeof o.strategy === 'string' ? o.strategy : 'prefix_except_default'
const def = typeof o.defaultLocale === 'string' ? o.defaultLocale : ''
if (strategy === 'no_prefix' || def === '' || !Array.isArray(o.locales)) return []
const sep = typeof o.routesNameSeparator === 'string' && o.routesNameSeparator !== '' ? o.routesNameSeparator : '___'
const codes = o.locales.map((l) => (typeof l === 'string' ? l : l && typeof l === 'object' ? (l as { code?: unknown }).code : undefined))
return codes.filter((c): c is string => typeof c === 'string' && c !== '' && c !== def).map((c) => `${sep}${c}`)
}
/**
* The paths without parameters that may be indexed, sorted: no page whose
* meta keeps it out of the index (requiresAuth, a noindex seo.robots), none
* under seo.noindexRoutes, none of the layer's HTML sitemap pages and no
* client-only page.
* under seo.noindexRoutes, none of the layer's HTML sitemap pages, no
* client-only page and no i18n copy of a page in another locale (a route
* whose name ends in one of localeCopies, see localeCopySuffixes).
*/
export function staticRoutes(pages: readonly RoutePage[], noindexRoutes: readonly string[]): string[] {
export function staticRoutes(pages: readonly RoutePage[], noindexRoutes: readonly string[], localeCopies: readonly string[] = []): string[] {
const out = new Set<string>()
for (const page of flattenPages(pages) as Array<RoutePage & { fullPath: string }>) {
if (typeof page.name === 'string' && localeCopies.some((suffix) => page.name!.endsWith(suffix))) continue
// A parent with children renders through one of them.
if (Array.isArray(page.children) && page.children.some((c) => c.path === '')) continue
const path = page.fullPath

View File

@@ -12,7 +12,7 @@ import { fileURLToPath } from 'node:url'
import { addServerTemplate, defineNuxtModule, extendPages, useLogger } from 'nuxt/kit'
import { findOffenders, guardMessage, noindexRule, type GuardPage } from './guard.ts'
import { htmlSitemapOption, paginationOption, type SeoHtmlSitemapOption, type SeoPaginationOption } from './options.ts'
import { routePatterns, routesTemplate, staticRoutes, type RoutePage } from './routes.ts'
import { localeCopySuffixes, routePatterns, routesTemplate, staticRoutes, type RoutePage } from './routes.ts'
export interface SeoModuleOptions {
/**
@@ -182,7 +182,8 @@ export default defineNuxtModule<SeoModuleOptions>({
filename: '#seo-routes',
getContents: () => {
const resolved = (pages ?? []) as RoutePage[]
return routesTemplate(staticRoutes(resolved, noindexRoutes), routePatterns(resolved))
const i18n = (nuxt.options as { i18n?: unknown }).i18n
return routesTemplate(staticRoutes(resolved, noindexRoutes, localeCopySuffixes(i18n)), routePatterns(resolved))
},
})
nuxt.hook('nitro:build:before', (nitro) => {

View File

@@ -1,7 +1,7 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { htmlSitemapOption, paginationOption } from '../lib/options.ts'
import { pageMetaRobots, robotsNoindex, routePatterns, routesTemplate, staticRoutes } from '../lib/routes.ts'
import { localeCopySuffixes, pageMetaRobots, robotsNoindex, routePatterns, routesTemplate, staticRoutes } from '../lib/routes.ts'
import { DRIFT_PREFIX, driftWarnings, routeServes } from '../server/lib/drift.ts'
import { ROUTE_REPORT_MAX_ROUTES, routeReportBody, routeReportOutcome } from '../server/lib/routeReport.ts'
@@ -124,3 +124,25 @@ test('routeServes normalises CMS patterns: {param} and :param alike, the query p
assert.equal(routeServes(route, '/blog/{category}/{slug}'), false)
assert.equal(routeServes({ pattern: '/docs/**', catchAll: true }, '/docs/{a}/{b}'), true)
})
test('localeCopySuffixes: the other locales of a prefixed strategy, none without one', () => {
const locales = [{ code: 'en' }, { code: 'pl' }, 'de']
assert.deepEqual(localeCopySuffixes({ strategy: 'prefix_except_default', defaultLocale: 'en', locales }), ['___pl', '___de'])
assert.deepEqual(localeCopySuffixes({ defaultLocale: 'en', locales, routesNameSeparator: '__' }), ['__pl', '__de'])
assert.deepEqual(localeCopySuffixes({ strategy: 'prefix_and_default', defaultLocale: 'pl', locales }), ['___en', '___de'])
assert.deepEqual(localeCopySuffixes({ strategy: 'no_prefix', defaultLocale: 'en', locales }), [])
assert.deepEqual(localeCopySuffixes({ strategy: 'prefix_except_default', locales }), [])
assert.deepEqual(localeCopySuffixes(undefined), [])
})
test('staticRoutes leaves out the i18n copies of a page in the other locales', () => {
const localized = [
{ path: '/', name: 'index___en' },
{ path: '/pl', name: 'index___pl' },
{ path: '/contact', name: 'contact___en' },
{ path: '/pl/contact', name: 'contact___pl' },
{ path: '/pl/konto', name: 'konto___pl', meta: { requiresAuth: true } },
]
assert.deepEqual(staticRoutes(localized, [], ['___pl']), ['/', '/contact'])
assert.deepEqual(staticRoutes(localized, []), ['/', '/contact', '/pl', '/pl/contact'])
})