- 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
225 lines
10 KiB
TypeScript
225 lines
10 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, 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 <path>/<group>. 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<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, 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<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,
|
|
}
|
|
},
|
|
})
|