diff --git a/README.md b/README.md index cf56d69..4ee5021 100644 --- a/README.md +++ b/README.md @@ -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 /registry/routes` as `{"routes": [...]}` (at most 2000 paths) with `Authorization: Bearer `, 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 /registry/routes` as `{"routes": [...]}` (at most 2000 paths) with `Authorization: Bearer `, 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`. diff --git a/lib/routes.ts b/lib/routes.ts index 707713f..97ca3f4 100644 --- a/lib/routes.ts +++ b/lib/routes.ts @@ -42,15 +42,35 @@ export function pageMetaRobots(meta: Record | 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() for (const page of flattenPages(pages) as Array) { + 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 diff --git a/lib/seo-module.ts b/lib/seo-module.ts index 08babbd..335aac6 100644 --- a/lib/seo-module.ts +++ b/lib/seo-module.ts @@ -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({ 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) => { diff --git a/tests/routes.test.ts b/tests/routes.test.ts index 982c023..64fe3c8 100644 --- a/tests/routes.test.ts +++ b/tests/routes.test.ts @@ -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']) +})