Files
vue-seo-layer/lib/seo-module.ts
Jakub Zych aee6957bd6 feat: HTML sitemap pages and footer link
- the module registers seo-sitemap-<locale> at seo.htmlSitemap.paths
  (defaults pl /mapa-strony, en /sitemap) and seo-sitemap-group-<locale>
  at <path>/:group (plus <path>/:group/<segment>/:page in the path form);
  a path an app page already has stays the app's
- SeoHtmlSitemapPage (global, overridable): H1, an H2 per non-empty
  group, categories nested, a large group's link to its group page,
  sitemap.empty; group pages with a built trail, page suffix and
  <SeoPagination>; 404 for an unknown group or a page past the last,
  301 for ?page=1, 503 when the sitemap API fails
- GET /api/__seo__/sitemap proxies the sitemap plugin's HTML API (only
  while the CMS has the HTML sitemap on for the locale)
- <SeoSitemapLink>: the footer link while the HTML sitemap is on
- L3 default styles
2026-11-04 12:00:00 +01:00

212 lines
9.1 KiB
TypeScript

// The layer's local Nuxt module (configKey `seo`). It copies the module
// options into runtimeConfig.public.seo, where the layer's app and server
// code read them. It lives in lib/ rather than modules/ so Nuxt's
// local-module scan cannot register it a second time. Layer source never
// uses the ~ or @ aliases: they resolve to the consuming project.
//
// The config key `seo` is shared with nuxt-seo-utils (@nuxtjs/seo) where an
// app still uses it: each module reads only its own keys, so the layer's
// options never use a name nuxt-seo-utils has (enabled, fallbackTitle …).
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'
export interface SeoModuleOptions {
/**
* false turns every CMS call off (no resolve, no head from the CMS, no
* redirects, no root files, no 404 reports), for a site without the SEO
* plugin. An alias of public.seo.enabled (NUXT_PUBLIC_SEO_ENABLED), which
* keeps working; either one false switches the layer off.
*/
cms: boolean
/** Breadcrumbs from the CMS (components in 33-08). */
breadcrumbs: boolean
links: {
/**
* A visually hidden "(opens in a new tab)" hint inside external links
* while the CMS's links_new_tab is on. Copied to
* runtimeConfig.public.seo.links.newTabHint for the body-link pipeline.
*/
newTabHint: boolean
}
/** Serve robots.txt, the sitemaps, llms.txt and the CMS's root files from the backend. */
rootFiles: boolean
/** File extensions the Nitro middleware still resolves, so an old file address can redirect. */
resolveFiles: string[]
/**
* Route-rule patterns ('/p/**', '/konto', '/x/*') the app declares
* noindex: they are served with X-Robots-Tag: noindex, nofollow and may
* render in the browser only (ssr: false, a .client.vue page) without
* failing the build guard (D-70, D-23).
*/
noindexRoutes: string[]
/**
* false for a site that is never indexed (an internal tool, a panel): the
* build guard then checks nothing. Default true.
*/
indexable: boolean
/**
* Report the site's static routes to the CMS's page registry at server
* start (D-33). Needs the route-report token (NUXT_SEO_ROUTE_REPORT_TOKEN,
* else NUXT_SEO_NOTFOUND_TOKEN). Default true.
*/
routeReport: boolean
/**
* The pagination URL form the site's routes are built for (D-40): query
* (?page=2) or path (/blog/strona/2, the segment per locale). The CMS's
* resolve answer wins at runtime; the startup drift check logs a
* difference.
*/
pagination: SeoPaginationOption
/**
* The HTML sitemap pages (D-34), by locale: the overview at the path, a
* group at <path>/<group>. false registers none. A path an app page
* already has is left to the app.
*/
htmlSitemap: SeoHtmlSitemapOption
}
export const SEO_MODULE_DEFAULTS: SeoModuleOptions = {
cms: true,
breadcrumbs: true,
links: { newTabHint: true },
rootFiles: true,
resolveFiles: ['pdf'],
noindexRoutes: [],
indexable: true,
routeReport: true,
pagination: { form: 'query', param: 'page', segments: { pl: 'strona', en: 'page' } },
htmlSitemap: { paths: { pl: '/mapa-strony', en: '/sitemap' } },
}
const strings = (v: unknown): string[] => (Array.isArray(v) ? v.filter((e): e is string => typeof e === 'string' && e !== '') : [])
export default defineNuxtModule<SeoModuleOptions>({
meta: {
name: 'vue-seo-layer',
configKey: 'seo',
},
defaults: SEO_MODULE_DEFAULTS,
setup(options, nuxt) {
const noindexRoutes = strings(options.noindexRoutes)
const indexable = options.indexable !== false
// The app's own ssr flag, read before Nuxt's server setup may rewrite
// a global ssr: false into a '/**' route rule.
const ssr = nuxt.options.ssr !== false
const inlineRouteRules = nuxt.options.experimental?.inlineRouteRules === true
const pagination = paginationOption(options.pagination)
const htmlSitemap = htmlSitemapOption(options.htmlSitemap)
// requiresAuth and seo are read from definePageMeta at build time, so
// the route report leaves signed-in and noindex pages out (D-23).
const extract = new Set(nuxt.options.experimental.extraPageMetaExtractionKeys ?? [])
extract.add('requiresAuth')
extract.add('seo')
nuxt.options.experimental.extraPageMetaExtractionKeys = [...extract]
// Build guard (D-70). Nitro's config hook runs once every layer and
// module has merged its rules into it; there the declared noindex
// routes get their X-Robots-Tag rule. The pages are resolved after
// that, so the check runs when both are known: once the pages are
// resolved, or before the Nitro build for an app without pages. It
// reads the final Nitro rules (nuxt.options.routeRules, nitro.routeRules
// and every layer's), the .client.vue pages and the inline route rules
// flag.
let nitroRules: Record<string, unknown> | null = null
let pages: GuardPage[] | null = null
let checked = false
const logger = useLogger('vue-seo-layer')
const guard = () => {
if (nitroRules === null) return
checked = true
const offenders = findOffenders({
ssr,
indexable,
routeRules: { ...(nuxt.options.routeRules ?? {}), ...nitroRules },
pages: pages ?? [],
noindexRoutes,
inlineRouteRules,
})
if (offenders.length === 0) return
const message = guardMessage(offenders)
// A production build fails; nuxt dev and nuxt prepare (run on
// install) only warn, so a developer can still fix the page.
if (nuxt.options.dev || nuxt.options._prepare) logger.warn(message)
else throw new Error(message)
}
nuxt.hook('nitro:config', (config) => {
config.routeRules ||= {}
const rules = config.routeRules as Record<string, unknown>
for (const pattern of noindexRoutes) rules[pattern] = noindexRule(rules[pattern])
nitroRules = rules
})
nuxt.hook('nitro:init', (nitro) => {
nitroRules = nitro.options.routeRules as Record<string, unknown>
})
nuxt.hook('pages:resolved', (resolved) => {
pages = resolved as GuardPage[]
guard()
})
// The HTML sitemap pages (D-34, P33-32), one overview and one group
// page per locale, each rendering the global SeoHtmlSitemapPage. A path
// an app page already has stays the app's.
if (htmlSitemap !== false) {
const overviewFile = fileURLToPath(new URL('../app/seo-pages/SitemapPage.vue', import.meta.url))
const groupFile = fileURLToPath(new URL('../app/seo-pages/SitemapGroupPage.vue', import.meta.url))
extendPages((list) => {
const taken = new Set(list.map((p) => p.path))
for (const [locale, path] of Object.entries(htmlSitemap.paths)) {
if (taken.has(path)) {
logger.info(`HTML sitemap: the app has its own page at ${path}; the layer's ${locale} sitemap page is not registered`)
continue
}
const meta = { seoSitemap: { locale } }
list.push({ name: `seo-sitemap-${locale}`, path, file: overviewFile, meta })
list.push({ name: `seo-sitemap-group-${locale}`, path: `${path}/:group`, file: groupFile, meta })
if (pagination.form === 'path') {
const segment = pagination.segments[locale] || pagination.param
list.push({ name: `seo-sitemap-group-page-${locale}`, path: `${path}/:group/${segment}/:page(\\d+)`, file: groupFile, meta })
}
}
})
}
// The route artefact (#seo-routes) for the route report and the drift
// check: written from the resolved pages when Nitro bundles the
// server, so no network call happens at build time.
addServerTemplate({
filename: '#seo-routes',
getContents: () => {
const resolved = (pages ?? []) as RoutePage[]
return routesTemplate(staticRoutes(resolved, noindexRoutes), routePatterns(resolved))
},
})
nuxt.hook('nitro:build:before', (nitro) => {
nitroRules = nitro.options.routeRules as Record<string, unknown>
if (!checked || nuxt.options.dev) guard()
})
const runtime = nuxt.options.runtimeConfig as { public: Record<string, unknown> }
const current = (runtime.public.seo ?? {}) as Record<string, unknown>
// Only the layer's own keys: the shared config key may carry
// nuxt-seo-utils options, which stay out of the public runtime config.
runtime.public.seo = {
...current,
cms: options.cms !== false,
breadcrumbs: options.breadcrumbs !== false,
links: { ...((current.links as Record<string, unknown>) ?? {}), newTabHint: options.links?.newTabHint !== false },
rootFiles: options.rootFiles !== false,
resolveFiles: Array.isArray(options.resolveFiles) ? [...options.resolveFiles] : [...SEO_MODULE_DEFAULTS.resolveFiles],
noindexRoutes,
indexable,
routeReport: options.routeReport !== false,
pagination,
htmlSitemap,
}
},
})