- seoSitemapPages (lib/sitemapPages.ts) builds the routes per locale; the module adds them from a pages:resolved hook registered on ready, after @nuxtjs/i18n has localised the app's routes, so no cross-locale copies exist and each name ends in the locale's i18n suffix (L4 WR-05) - a path an app catch-all covers is left to it and logged at build unless seo.htmlSitemap.overCatchAll is true (L4 WR-04) - SitemapLink falls back to the i18n locale and renders only when the path resolves to the layer's sitemap page of that locale - an app with only app.vue no longer gets its router turned on by the pages
221 lines
13 KiB
TypeScript
221 lines
13 KiB
TypeScript
import assert from 'node:assert/strict'
|
|
import test from 'node:test'
|
|
import { htmlSitemapOption, paginationOption } from '../lib/options.ts'
|
|
import { localeCopySuffixes, noindexStaticRoutes, pageMetaRobots, robotsNoindex, routePatterns, routesTemplate, staticRoutes } from '../lib/routes.ts'
|
|
import { DRIFT_PREFIX, driftWarnings, routeServes } from '../server/lib/drift.ts'
|
|
import { ROUTE_REPORT_MAX_BODY, ROUTE_REPORT_MAX_ROUTES, routeReportBody, routeReportOutcome } from '../server/lib/routeReport.ts'
|
|
|
|
const pages = [
|
|
{ path: '/', name: 'index' },
|
|
{ path: '/blog', name: 'blog' },
|
|
{ path: '/posts/:slug()', name: 'posts-slug' },
|
|
{ path: '/:slug(.*)*', name: 'slug' },
|
|
{ path: '/konto', name: 'konto', meta: { requiresAuth: true } },
|
|
{ path: '/szkic', name: 'szkic', meta: { seo: { robots: 'noindex, follow' } } },
|
|
{ path: '/otwarty', name: 'otwarty', meta: { seo: { robots: 'index, follow' } } },
|
|
{ path: '/prywatne', name: 'prywatne' },
|
|
{ path: '/panel', name: 'panel', mode: 'client' },
|
|
{ path: '/mapa-strony', name: 'seo-sitemap-pl' },
|
|
{ path: '/ustawienia', name: 'ustawienia', children: [{ path: '', name: 'ustawienia-index' }, { path: 'profil', name: 'ustawienia-profil' }] },
|
|
]
|
|
|
|
test('pageMetaRobots: seo.robots, then requiresAuth, else the CMS decides', () => {
|
|
assert.equal(pageMetaRobots({ requiresAuth: true }), 'noindex, nofollow')
|
|
assert.equal(pageMetaRobots({ requiresAuth: true, seo: { robots: 'noindex, follow' } }), 'noindex, follow')
|
|
assert.equal(pageMetaRobots({ seo: { robots: ' index, follow ' } }), 'index, follow')
|
|
assert.equal(pageMetaRobots({ requiresAuth: false }), null)
|
|
assert.equal(pageMetaRobots({ seo: { breadcrumbs: false } }), null)
|
|
assert.equal(pageMetaRobots(undefined), null)
|
|
})
|
|
|
|
test('robotsNoindex: noindex and none keep a page out', () => {
|
|
assert.equal(robotsNoindex('noindex, follow'), true)
|
|
assert.equal(robotsNoindex('none'), true)
|
|
assert.equal(robotsNoindex('index, follow'), false)
|
|
assert.equal(robotsNoindex(null), false)
|
|
})
|
|
|
|
test('staticRoutes: indexable paths without parameters', () => {
|
|
assert.deepEqual(staticRoutes(pages, ['/prywatne/**']), ['/', '/blog', '/otwarty', '/ustawienia', '/ustawienia/profil'])
|
|
})
|
|
|
|
test('routePatterns: page paths as patterns, catch-alls marked', () => {
|
|
const out = routePatterns(pages)
|
|
assert.deepEqual(out.find((p) => p.pattern === '/posts/:slug'), { pattern: '/posts/:slug', catchAll: false })
|
|
assert.deepEqual(out.find((p) => p.pattern === '/**'), { pattern: '/**', catchAll: true })
|
|
assert.equal(new Set(out.map((p) => p.pattern)).size, out.length)
|
|
})
|
|
|
|
test('routesTemplate: an ES module with the three lists', () => {
|
|
const src = routesTemplate(['/'], [{ pattern: '/', catchAll: false }], ['/konto'])
|
|
assert.match(src, /export const staticRoutes = \["\/"\]/)
|
|
assert.match(src, /export const routePatterns = \[\{"pattern":"\/","catchAll":false\}\]/)
|
|
assert.match(src, /export const noindexPaths = \["\/konto"\]/)
|
|
assert.match(routesTemplate([], []), /export const noindexPaths = \[\]/)
|
|
})
|
|
|
|
test('noindexStaticRoutes: the static paths the build serves noindex (G1, gap-2 contract)', () => {
|
|
const out = noindexStaticRoutes(pages, ['/prywatne/**'])
|
|
// requiresAuth, a noindex seo.robots and a static page under a noindex pattern
|
|
assert.deepEqual(out, ['/konto', '/prywatne', '/szkic'])
|
|
// never an ordinary page, a page with parameters, a catch-all or the layer's sitemap page
|
|
for (const p of ['/', '/blog', '/otwarty', '/posts/:slug()', '/:slug(.*)*', '/mapa-strony']) assert.ok(!out.includes(p), p)
|
|
// never a path the route report lists as indexable
|
|
const indexable = staticRoutes(pages, ['/prywatne/**'])
|
|
assert.deepEqual(out.filter((p) => indexable.includes(p)), [])
|
|
})
|
|
|
|
test('noindexStaticRoutes: client-only noindex pages kept, patterns never sent', () => {
|
|
const list = [
|
|
{ path: '/panel', name: 'panel', mode: 'client', meta: { requiresAuth: true } },
|
|
{ path: '/p/:id', name: 'p-id', meta: { requiresAuth: true } },
|
|
{ path: '/p/**', name: 'p-all' },
|
|
{ path: '/none', name: 'none', meta: { seo: { robots: 'none' } } },
|
|
]
|
|
assert.deepEqual(noindexStaticRoutes(list, ['/p/**']), ['/none', '/panel'])
|
|
})
|
|
|
|
test('noindexStaticRoutes: the i18n copies of a noindex page are listed with their prefix, sorted', () => {
|
|
const localized = [
|
|
{ path: '/', name: 'index___en' },
|
|
{ path: '/pl', name: 'index___pl' },
|
|
{ path: '/konto', name: 'konto___en', meta: { requiresAuth: true } },
|
|
{ path: '/pl/konto', name: 'konto___pl', meta: { requiresAuth: true } },
|
|
{ path: '/szkic', name: 'szkic___en', meta: { seo: { robots: 'noindex, follow' } } },
|
|
{ path: '/pl/szkic', name: 'szkic___pl', meta: { seo: { robots: 'noindex, follow' } } },
|
|
{ path: '/sitemap', name: 'seo-sitemap-en___en' },
|
|
]
|
|
assert.deepEqual(noindexStaticRoutes(localized, []), ['/konto', '/pl/konto', '/pl/szkic', '/szkic'])
|
|
})
|
|
|
|
test('routeReportBody: within the plugin limits, both lists', () => {
|
|
assert.deepEqual(routeReportBody(['/', '/blog', 'nope', '/' + 'a'.repeat(2048)]).routes, ['/', '/blog'])
|
|
assert.equal(routeReportBody(Array.from({ length: 2500 }, (_, i) => `/p${i}`)).routes.length, ROUTE_REPORT_MAX_ROUTES)
|
|
assert.deepEqual(routeReportBody(['/', '/blog'], ['/konto', 'x', '/' + 'b'.repeat(2048), '/szkic']), { routes: ['/', '/blog'], noindex: ['/konto', '/szkic'] })
|
|
assert.equal(routeReportBody([], Array.from({ length: 2500 }, (_, i) => `/n${i}`)).noindex.length, ROUTE_REPORT_MAX_ROUTES)
|
|
// always sent, also empty
|
|
assert.deepEqual(routeReportBody(['/']), { routes: ['/'], noindex: [] })
|
|
// a path is never in both lists
|
|
assert.deepEqual(routeReportBody(['/', '/konto'], ['/konto', '/szkic']).noindex, ['/szkic'])
|
|
})
|
|
|
|
const bodyBytes = (b: unknown) => Buffer.byteLength(JSON.stringify(b), 'utf8')
|
|
|
|
test('routeReportBody: the JSON body stays within the older plugin limit, noindex dropped first', () => {
|
|
assert.equal(ROUTE_REPORT_MAX_BODY, 2000 * (2048 + 8) + 1024)
|
|
assert.equal(ROUTE_REPORT_MAX_BODY, 4_113_024)
|
|
const routes = Array.from({ length: 2000 }, (_, i) => '/' + String(i).padStart(4, '0') + 'r'.repeat(2043))
|
|
const noindex = Array.from({ length: 100 }, (_, i) => '/' + String(i).padStart(3, '0') + 'n'.repeat(196))
|
|
assert.ok(routes.every((p) => Buffer.byteLength(p) === 2048))
|
|
assert.ok(noindex.every((p) => Buffer.byteLength(p) === 200))
|
|
const body = routeReportBody(routes, noindex)
|
|
assert.equal(body.routes.length, 2000)
|
|
assert.ok(body.noindex.length > 0 && body.noindex.length < 100, String(body.noindex.length))
|
|
assert.deepEqual(body.noindex, noindex.slice(0, body.noindex.length))
|
|
assert.ok(bodyBytes(body) <= ROUTE_REPORT_MAX_BODY)
|
|
// the next noindex entry would not have fitted
|
|
assert.ok(bodyBytes({ routes: body.routes, noindex: noindex.slice(0, body.noindex.length + 1) }) > ROUTE_REPORT_MAX_BODY)
|
|
})
|
|
|
|
test('routeReportBody: small lists come back whole; routes alone over the limit are cut from the end', () => {
|
|
assert.deepEqual(routeReportBody(['/a', '/b'], ['/c']), { routes: ['/a', '/b'], noindex: ['/c'] })
|
|
// 2048 bytes of characters JSON escapes: each path serialises to about twice its size
|
|
const heavy = Array.from({ length: 2000 }, (_, i) => '/' + String(i).padStart(4, '0') + '"'.repeat(2043))
|
|
const body = routeReportBody(heavy, ['/konto'])
|
|
assert.deepEqual(body.noindex, [])
|
|
assert.ok(body.routes.length > 0 && body.routes.length < 2000, String(body.routes.length))
|
|
assert.deepEqual(body.routes, heavy.slice(0, body.routes.length))
|
|
assert.ok(bodyBytes(body) <= ROUTE_REPORT_MAX_BODY)
|
|
assert.ok(bodyBytes({ routes: heavy.slice(0, body.routes.length + 1), noindex: [] }) > ROUTE_REPORT_MAX_BODY)
|
|
})
|
|
|
|
test('routeReportOutcome: sent, absent, refused or one retry', () => {
|
|
assert.equal(routeReportOutcome(204), 'sent')
|
|
assert.equal(routeReportOutcome(200), 'sent')
|
|
assert.equal(routeReportOutcome(404), 'absent')
|
|
assert.equal(routeReportOutcome(401), 'refused')
|
|
assert.equal(routeReportOutcome(403), 'refused')
|
|
assert.equal(routeReportOutcome(503), 'retry')
|
|
assert.equal(routeReportOutcome(null), 'retry')
|
|
})
|
|
|
|
test('options: pagination and HTML sitemap with their checks', () => {
|
|
assert.deepEqual(paginationOption({ form: 'path', param: 'p', segments: { pl: 'strona', en: 'a b' } }), { form: 'path', param: 'p', segments: { pl: 'strona' } })
|
|
assert.deepEqual(paginationOption(undefined), { form: 'query', param: 'page', segments: {} })
|
|
assert.equal(htmlSitemapOption(false), false)
|
|
assert.deepEqual(htmlSitemapOption({ paths: { pl: '/mapa-strony/', en: 'sitemap', de: '/x/:id' } }), { paths: { pl: '/mapa-strony' }, overCatchAll: false })
|
|
})
|
|
|
|
const layer = { pagination: { form: 'query' as const, param: 'page', segments: { pl: 'strona', en: 'page' } }, htmlSitemap: { pl: '/mapa-strony', en: '/en/sitemap' } }
|
|
const routes = routePatterns(pages)
|
|
|
|
test('routeServes: a route of the same shape or a prefixed catch-all; not the root catch-all', () => {
|
|
assert.equal(routeServes({ pattern: '/posts/:slug', catchAll: false }, '/posts/:id'), true)
|
|
assert.equal(routeServes({ pattern: '/posts/:slug', catchAll: false }, '/posts/{slug}'), true)
|
|
assert.equal(routeServes({ pattern: '/docs/**', catchAll: true }, '/docs/:a/:b'), true)
|
|
assert.equal(routeServes({ pattern: '/**', catchAll: true }, '/wpisy/:slug'), false)
|
|
})
|
|
|
|
test('driftWarnings: nothing when the CMS and the build agree', () => {
|
|
const site = {
|
|
routes: [{ type: 'post', pattern: '/posts/:slug' }],
|
|
pagination: { form: 'query' as const, param: 'page', segments: {} },
|
|
html_sitemap: { enabled: true, paths: { pl: '/mapa-strony', en: '/en/sitemap' } },
|
|
}
|
|
assert.deepEqual(driftWarnings({ site, routes, ...layer }), [])
|
|
})
|
|
|
|
test('driftWarnings: one warning per mismatch', () => {
|
|
const site = {
|
|
routes: [{ type: 'journal-post', pattern: '/wpisy/:slug' }],
|
|
pagination: { form: 'path' as const, param: 'page', segments: { pl: 'strona' } },
|
|
html_sitemap: { enabled: true, paths: { pl: '/mapa', en: '/en/sitemap' } },
|
|
}
|
|
const out = driftWarnings({ site, routes, ...layer })
|
|
assert.equal(out.length, 3)
|
|
assert.ok(out.every((w) => w.startsWith(DRIFT_PREFIX)))
|
|
assert.match(out[0]!, /journal-post URLs \(\/wpisy\/:slug\) match no Nuxt route/)
|
|
assert.match(out[1]!, /path form/)
|
|
assert.match(out[2]!, /pl HTML sitemap at \/mapa/)
|
|
})
|
|
|
|
test('driftWarnings: segments and param, a switched-off layer sitemap, a switched-off CMS sitemap', () => {
|
|
const pathLayer = { ...layer, pagination: { form: 'path' as const, param: 'page', segments: { pl: 'strona' } } }
|
|
assert.equal(driftWarnings({ site: { pagination: { form: 'path', segments: { pl: 'str' } } }, routes, ...pathLayer }).length, 1)
|
|
assert.equal(driftWarnings({ site: { pagination: { form: 'query', param: 'p' } }, routes, ...layer }).length, 1)
|
|
assert.equal(driftWarnings({ site: { html_sitemap: { enabled: true, paths: { pl: '/mapa-strony' } } }, routes, ...layer, htmlSitemap: false }).length, 1)
|
|
assert.deepEqual(driftWarnings({ site: { html_sitemap: { enabled: false, paths: { pl: '/x' } } }, routes, ...layer }), [])
|
|
assert.deepEqual(driftWarnings({ site: {}, routes, ...layer }), [])
|
|
})
|
|
|
|
test('routeServes normalises CMS patterns: {param} and :param alike, the query part ignored', () => {
|
|
const route = { pattern: '/blog/:slug', catchAll: false }
|
|
assert.equal(routeServes(route, '/blog/{slug}'), true)
|
|
assert.equal(routeServes(route, '/blog/:slug'), true)
|
|
assert.equal(routeServes(route, '/blog/{slug}?page={page}'), true)
|
|
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'])
|
|
})
|