// 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, useLogger } from 'nuxt/kit' import { findOffenders, guardMessage, noindexRule, type GuardPage } from './guard.ts' import { htmlSitemapOption, paginationOption, type SeoHtmlSitemapOption, type SeoPaginationOption } from './options.ts' import { localeCopySuffixes, noindexStaticRoutes, routePatterns, routesTemplate, staticRoutes, type RoutePage } from './routes.ts' import { isSitemapRoute, seoSitemapPages, sitemapSkipMessage } from './sitemapPages.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 /. false registers none. A path an app page * already has is left to the app, and so is a path an app catch-all page * covers unless overCatchAll is true. With @nuxtjs/i18n each locale's * pages exist once, at its configured path. */ 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({ 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 | 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 for (const pattern of noindexRoutes) rules[pattern] = noindexRule(rules[pattern]) nitroRules = rules }) nuxt.hook('nitro:init', (nitro) => { nitroRules = nitro.options.routeRules as Record }) nuxt.hook('pages:resolved', (resolved) => { pages = resolved as GuardPage[] guard() }) // The HTML sitemap pages (D-34, P33-32, P33-68), one overview and one // group page per locale, each rendering the global SeoHtmlSitemapPage // (lib/sitemapPages.ts). Added in a pages:resolved hook that runs after // @nuxtjs/i18n has localised the app's routes, in either scanPageMeta // mode, so i18n never copies these routes into other locales (L4 WR-05). // i18n registers its page hook from its modules:done handler, which runs // after this module's (this layer's module is set up first), so the hook // is registered on ready: Nuxt calls it once every modules:done handler // has run and before the build resolves the pages. A path an app page // already has stays the app's, a path an app catch-all covers stays the // catch-all's unless htmlSitemap.overCatchAll is true (L4 WR-04). if (htmlSitemap !== false) { const files = { overview: fileURLToPath(new URL('../app/seo-pages/SitemapPage.vue', import.meta.url)), group: fileURLToPath(new URL('../app/seo-pages/SitemapGroupPage.vue', import.meta.url)), } nuxt.hook('ready', () => { nuxt.hook('pages:resolved', (resolved) => { const list = resolved as unknown as RoutePage[] // A dev regeneration may hand back the routes added before. for (let i = list.length - 1; i >= 0; i--) if (isSitemapRoute(list[i]!, files)) list.splice(i, 1) const i18n = (nuxt.options as { i18n?: unknown }).i18n const { routes, skipped } = seoSitemapPages({ htmlSitemap, pagination, pages: list, i18n, files }) for (const skip of skipped) logger.info(sitemapSkipMessage(skip)) list.push(...(routes as unknown as RoutePage[])) }) }) } // The route artefact (#seo-routes) for the route report (indexable and // noindex static paths) 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[] const i18n = (nuxt.options as { i18n?: unknown }).i18n return routesTemplate( staticRoutes(resolved, noindexRoutes, localeCopySuffixes(i18n)), routePatterns(resolved), noindexStaticRoutes(resolved, noindexRoutes), ) }, }) nuxt.hook('nitro:build:before', (nitro) => { nitroRules = nitro.options.routeRules as Record if (!checked || nuxt.options.dev) guard() }) const runtime = nuxt.options.runtimeConfig as { public: Record } const current = (runtime.public.seo ?? {}) as Record // 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) ?? {}), 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, } }, })