docs: build guard, noindex routes and components

- README: build guard, noindex routes and page meta keys, route report,
  drift check, the new module options, the Components section
  (SeoBreadcrumbs, SeoPagination, usePagination, the HTML sitemap pages
  and their override, SeoSitemapLink), useSeoSite and the new helpers,
  guard:check and the new smoke grammar
This commit is contained in:
Jakub Zych
2026-11-04 12:02:15 +01:00
parent aee6957bd6
commit b051bda080

View File

@@ -1,6 +1,6 @@
# vue-seo-layer
The Nuxt half of the SummerCMS SEO plugin ([sm-seo-plugin](https://git.golem15.com/golem15/sm-seo-plugin), plugin ID `golem15.seo`). For every page it asks the plugin for the redirect decision and the effective SEO, enforces the decision, and sets the head from the answer, including hreflang and one JSON-LD `@graph`. It also serves the site's root files from the backend (verification files, and with SEO v0.2.0 robots.txt, the sitemaps, llms.txt and the IndexNow key), reports 404s to the plugin's 404 log, and owns the shared site cache purge route.
The Nuxt half of the SummerCMS SEO plugin ([sm-seo-plugin](https://git.golem15.com/golem15/sm-seo-plugin), plugin ID `golem15.seo`). For every page it asks the plugin for the redirect decision and the effective SEO, enforces the decision, and sets the head from the answer, including hreflang and one JSON-LD `@graph`. It also serves the site's root files from the backend (verification files, and with SEO v0.2.0 robots.txt, the sitemaps, llms.txt and the IndexNow key), reports 404s to the plugin's 404 log, and owns the shared site cache purge route. From v0.2.0 it also fails a build whose indexable routes would render only in the browser, marks declared and signed-in routes noindex, reports the site's routes to the plugin's page registry, and ships server-rendered breadcrumbs, pagination and an HTML sitemap.
## Install
@@ -31,6 +31,18 @@ The layer's only module is its own local one (`lib/seo-module.ts`); it pulls in
- **404 log.** Each page view answered 404 (a GET that accepts `text/html`, from an unknown route or a page that throws a 404) is reported once with `POST <apiBase>/notfound`, `{"url", "referer"}` and `Authorization: Bearer <notfoundToken>`, after the response, so it never delays it. Without a token nothing is reported.
- **Root files.** With a backend that answers `GET <apiBase>/site` (v0.2.0), `/robots.txt`, `/sitemap.xml`, `/llms.txt` and `/sitemap-<name>.xml` (served from the backend's `/_sitemap/files/`) are proxied to `backendBase`, and so is each one-segment `.html`, `.txt` or `.xml` file the site answer lists in `root_files`: a verification file (`<apiBase>/verification/<name>`) or the IndexNow key file (`/_sitemap/indexnow/<key>.txt`). The backend's status, content type and `X-Robots-Tag` are passed on, with `X-Content-Type-Options: nosniff`; a backend that does not answer or answers 5xx gives 503 with `Retry-After: 60`, so crawlers retry instead of reading a missing robots.txt as "everything allowed". Any other path passes through to the app. The site answer is cached for 300 seconds and cleared by the purge; while the backend is down the last one seen is used. With a backend whose `/site` answers 404 (before v0.2.0, or no SEO plugin), or one not reached since the server started, robots.txt and the sitemaps are not proxied (route them to the backend in the site's proxy) and verification files keep the v0.1.1 lookup: a request for a single-segment `.html`, `.txt` or `.xml` file (`/google0123.html`, `/BingSiteAuth.xml`) is looked up under `GET <apiBase>/verification/<name>` and served with `X-Content-Type-Options: nosniff`, a sandboxing CSP and `X-Robots-Tag: noindex` when the plugin has it; otherwise it falls through to the app's routes. `robots.txt`, `sitemap.xml`, `sitemap.xsl`, `rss.xml`, `feed.xml`, `atom.xml` and `llms.txt` are never looked up that way. `rootFiles: false` turns both off.
- **Site cache purge (v0.2.0).** `POST /api/__site__/cache/purge` (moved here from vue-journal-layer, vue-menu-layer and vue-faq-layer, same contract) removes every Nitro route-cache entry and empties the layer's resolve and site caches, so an admin save shows on the next page view. The backend calls it on loopback after the save commits, with `Authorization: Bearer <cachePurge.token>` and `{"kind","slug"}`; the answer is `{"purged": <n>}`. It answers 404 without a token of at least 32 characters and for any proxied request (X-Forwarded-For, X-Real-IP or Forwarded) unless `cachePurge.allowProxied` is true, and 401 for a missing or wrong bearer. `/api/__site__/**` is never route-cached. While another layer still ships its own copy of the route and that copy answers, `server/plugins/seo-cache-purge.ts` empties the SEO caches after any 200 purge (also after the journal's deprecated `/api/__journal__/cache/purge`).
- **Build guard (v0.2.0, D-70).** An indexable page must render on the server. `nuxt build` (and `nuxt generate`) fails with `[vue-seo-layer] indexable routes must render on the server (D-70): <routes>` when a route rule sets `ssr: false`, a page is a `.client.vue` page or the app sets `ssr: false`, unless the route is declared in `seo.noindexRoutes` (or the app sets `seo.indexable: false`). The guard reads the rules after every layer and module merged them: `routeRules`, `nitro.routeRules` and the rules of each layer; `experimental.inlineRouteRules` is refused, since `defineRouteRules` in a page could turn server rendering off where the guard cannot see it. `nuxt dev` and `nuxt prepare` only warn.
- **Noindex routes (v0.2.0, D-23).** A route-rule pattern in `seo.noindexRoutes` (`'/p/**'`, `'/konto'`, `'/x/*'`) gets `X-Robots-Tag: noindex, nofollow` through its route rule (unless the app set its own header there) and a `robots` meta tag. A page whose meta sets `requiresAuth: true` gets `noindex, nofollow`, and one whose meta sets `seo: { robots: '…' }` gets that value, as meta tag and, during SSR, header, without any SEO code in the page; this is how apps mark signed-in and auth pages. These values win over the CMS's, except on staging. No other page is ever marked noindex by the layer.
```ts
definePageMeta({ requiresAuth: true }) // noindex, nofollow
definePageMeta({ seo: { robots: 'noindex, follow' } })
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 <apiBase>/registry/routes` as `{"routes": [...]}` (at most 2000 paths) with `Authorization: Bearer <routeReportToken>`, 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`.
## Options
@@ -40,10 +52,15 @@ Module options under the `seo` key of the app's `nuxt.config.ts` (v0.2.0). They
| Option | Default | Meaning |
|---|---|---|
| `cms` | `true` | `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`, which keeps working: either one `false` switches the layer off. |
| `breadcrumbs` | `true` | Breadcrumbs from the CMS (`breadcrumbs` of the answer). |
| `breadcrumbs` | `true` | `false` hides `<SeoBreadcrumbs>` everywhere (the answer's trail and its `BreadcrumbList` JSON-LD are unaffected). |
| `links.newTabHint` | `true` | A visually hidden "(opens in a new tab)" inside external links while the CMS's `links_new_tab` is on; copied to `runtimeConfig.public.seo.links.newTabHint` for the body-link rules. |
| `rootFiles` | `true` | Serve robots.txt, the sitemaps, llms.txt and the root files from the backend (see Root files). |
| `resolveFiles` | `['pdf']` | File extensions the Nitro middleware still resolves. |
| `noindexRoutes` | `[]` | Route-rule patterns the app declares noindex: served with `X-Robots-Tag: noindex, nofollow` and a robots meta tag, and allowed to render in the browser only (build guard). |
| `indexable` | `true` | `false` for a site that is never indexed: the build guard checks nothing. |
| `routeReport` | `true` | Report the static routes to the plugin's page registry at server start (needs `seo.routeReportToken` or `seo.notfoundToken`). |
| `pagination` | `{ form: 'query', param: 'page', segments: { pl: 'strona', en: 'page' } }` | The pagination URL form the site is built for: `query` (`/blog?page=2`) or `path` (`/blog/strona/2`, the segment per locale). The resolve and site answers win at run time; the drift check logs a difference. |
| `htmlSitemap` | `{ paths: { pl: '/mapa-strony', en: '/sitemap' } }` | The HTML sitemap pages by locale (full paths, overview at the path, groups at `<path>/<group>`); `false` registers none. A path an app page already has stays the app's. |
## Configuration
@@ -61,6 +78,16 @@ Module options under the `seo` key of the app's `nuxt.config.ts` (v0.2.0). They
| `public.seo.apiBase` | `NUXT_PUBLIC_SEO_API_BASE` | `/_seo/api/v1` | Same-origin base for client-side navigations. The site's proxy must route `GET <base>/resolve` to the backend. Set it to `/api/__seo__` to resolve through the Nuxt server (`GET /api/__seo__/resolve?url=`, the server's cache, no proxy rule needed). |
| `public.seo.enabled` | `NUXT_PUBLIC_SEO_ENABLED` | `true` | `false` turns the layer off: no calls, no head, no redirects, no root files, no reports. Same as the option `cms: false`. |
## Components
All server-rendered (no `<ClientOnly>`, no `.client.vue`, no `onMounted` data); links are `NuxtLink`s, so they render as `<a href>` and navigate on the client. Classes are `.seo-*` and styled by `seo-base.css` with the tokens below.
- **`<SeoBreadcrumbs>`** (props `items?`, `label?`, `hideCurrent?`, `locale?`): the answer's `breadcrumbs` as `nav.seo-breadcrumbs > ol.seo-breadcrumbs__list`, links in `a.seo-breadcrumbs__link`, the last item as `span.seo-breadcrumbs__current` with `aria-current="page"`; separators are CSS-generated (`--seo-breadcrumbs-separator`). It renders nothing with fewer than two items (the home page), when the page meta sets `seo: { breadcrumbs: false }` or when the option `breadcrumbs` is `false`. Only the first instance rendered for a page shows its trail, so an app may place one in its layout while a layer's page renders one too.
- **`<SeoPagination>`** (props `current`, `last`, `labels?` with `label`, `previous`, `next`, `page`, `classPrefix?` default `seo-pagination`, `locale?`): previous and next (left out at the ends), page 1, the last page and five pages around the current one with `…` gap items, the current page as `span` with `aria-current="page"`. Nothing when `last` is 1 or less. `classPrefix` renames every class, so a layer keeps its own class names.
- **`usePagination({ current, last, locale? })`**: what `<SeoPagination>` uses: `items`, `href(page)` and the resolved `form`. Hrefs follow the resolve answer's `pagination`, else the site answer's (when `useSeoSite()` ran on the page), else the option; other query parameters stay and page 1 is the unnumbered URL. It adds `<link rel="prev">` and `<link rel="next">` to the head and hands the page count to the CMS head, which replaces `%pages%` in the CMS title and description.
- **HTML sitemap pages**: the overview at each locale's `htmlSitemap.paths` entry (route name `seo-sitemap-<locale>`) and group pages at `<path>/<group>` (`seo-sitemap-group-<locale>`; in the path pagination form also `<path>/<group>/<segment>/<n>`). They render the global component **`SeoHtmlSitemapPage`** (props `locale`, `group?`, `pathPage?`): an H1 (`sitemap.title`), one `section` with an H2 per non-empty group, the category tree as nested lists, a large group's first entries plus a `sitemap.all` link to its group page, and `sitemap.empty` when nothing is listed; a group page has the breadcrumbs Home › Sitemap › group (built by the layer, the resolver does not know these pages), the H1 `sitemap.groupTitle` with `· sitemap.pageSuffix` on page 2 and later, the list and `<SeoPagination>`. An unknown group or a page past the last answers 404, `?page=1` a 301 to the unnumbered URL, and a failed sitemap API call 503, so no empty page is cached. The pages answer 404 while the CMS has no HTML sitemap for the locale (or answers no site, as before SEO v0.2.0). Data comes from `GET /api/__seo__/sitemap?locale=&group=&page=`, which proxies the sitemap plugin's `GET /_sitemap/api/v1/html` and `/_sitemap/api/v1/html/{group}` on `backendBase`. An app replaces the page markup by registering its own component named `SeoHtmlSitemapPage` (for example `components/global/SeoHtmlSitemapPage.vue`) with the same props. In an app with `@nuxtjs/i18n`, give each locale its full path in `htmlSitemap.paths`.
- **`<SeoSitemapLink>`** (prop `locale?`): `a.seo-sitemap-link` to the locale's HTML sitemap with the `sitemapLink` label, only while the CMS has the HTML sitemap on for that locale and the layer serves it. Place it in the footer.
## Resolve answer
The layer reads the answer of `GET /_seo/api/v1/resolve` (`app/utils/types.ts`): `action` (`ok`, `not_found`, `redirect`, `gone`), `status`, `location`, `url`, `locale`, `staging`, and on `ok` answers `seo` with `title`, `description`, `canonical`, `robots`, `x_robots_tag`, `og` (`title`, `description`, `image`, `url`, and from v0.2.0 `locale`, `locale_alternates`), `twitter` (`card`, `site`) and `verification` (`[{name, content}]`). SEO plugin v0.2.0 adds `html_lang`, `alternates` (`[{hreflang, url}]`, absolute, the page itself and `x-default` included), `jsonld` (one `{"@context", "@graph"}` object or null), `breadcrumbs` (`[{name, url, path}]`), `pagination` (`{form, param, segment, page, invalid}`, only when the request carries a page), `outbound`, `autolinks` and `autolink_max`. Absent keys are treated as empty. The site answer (`GET /_seo/api/v1/site`) carries `site_url`, `default_locale`, `locales`, `unprefixed_locale`, `links.new_tab`, `html_sitemap`, `routes`, `root_files`, `llms`, `pagination` and `home_crumb`.
@@ -71,7 +98,10 @@ The layer reads the answer of `GET /_seo/api/v1/resolve` (`app/utils/types.ts`):
- `resolveSeo(url)`: one resolve call with the server or public base; `null` on any failure. During SSR it takes the answer the Nitro middleware resolved for the request.
- `useCmsSeo(answer)`: the head from an answer ref (the layer's plugin calls it; apps do not).
- `useSchemaNode(node)`: adds a schema.org node (an object, a ref or a getter) to the page's one `@graph` for as long as the calling component lives (v0.2.0).
- `useSeoLabels()`: the label dictionary of the page's content locale (v0.2.0, see Labels and tokens).
- `useSeoLabels(locale?)`: the label dictionary of the page's content locale, or of `locale` when given (v0.2.0, see Labels and tokens).
- `useSeoSite()`: the site answer through `GET /api/__seo__/site` (`{"site": … | null}`, cached on the server), shared under one key on the server and in the browser; `data.value` is `null` with the layer off or an older or unreachable backend (v0.2.0).
- `usePagination(options)`, `useSeoPages()`: see Components (v0.2.0).
- `seoPaginationWindow(current, last)`, `seoPageHref(url, page, form, segment, param)`, `seoPageNumber(url, form, segment, param)`, `seoClaimBreadcrumbs(state, path, id)`, `seoReleaseBreadcrumbs(state, id)`, `seoSitePath(url, ...origins)`, `seoFillPages(head, pages)`: pure helpers behind the components (v0.2.0; prefixed because `app/utils` is auto-imported next to other layers' helpers).
- `mergeGraph(cmsJsonld, nodes)`, `isPagePath(path, resolveFiles)`, `pickLabels(locale, htmlLang, overrides)`, `formatSeoLabel(template, values)`, `seoEnabled(publicSeo)`: pure helpers (v0.2.0).
- `seoHeadInput`, `seoAbsolute`, `seoRedirectTarget`, `seoRobots`, `seoXRobotsTag`, `seoResolvableUrl`, `seoAnswer`, `seoVerificationName`: the pure helpers behind them (`app/utils/seo.ts`), auto-imported.
@@ -85,7 +115,7 @@ export default defineAppConfig({
})
```
`app/assets/css/seo-tokens.css` defines the tokens (`--seo-space-1` to `--seo-space-5`, `--seo-target-size`, `--seo-sitemap-column-width`, `--seo-color-muted`, `--seo-color-accent`, `--seo-color-on-accent`, `--seo-color-border`, `--seo-font-size-small`, `--seo-breadcrumbs-separator`) and `seo-base.css` the base styles, which target only `.seo-*` classes (`.seo-visually-hidden` and the focus ring of layer links). Both load before the app's CSS; override a token on `:root:root` in the app's own CSS.
`app/assets/css/seo-tokens.css` defines the tokens (`--seo-space-1` to `--seo-space-5`, `--seo-target-size`, `--seo-sitemap-column-width`, `--seo-color-muted`, `--seo-color-accent`, `--seo-color-on-accent`, `--seo-color-border`, `--seo-font-size-small`, `--seo-breadcrumbs-separator`) and `seo-base.css` the base styles, which target only `.seo-*` classes (`.seo-visually-hidden`, the focus ring of layer links, and the breadcrumbs, pagination and HTML sitemap defaults; long names wrap and are never truncated). Both load before the app's CSS; override a token on `:root:root` in the app's own CSS.
## Development
@@ -93,8 +123,9 @@ export default defineAppConfig({
pnpm install
pnpm test # node --test on the helpers, the cache, the purge and the labels
pnpm build:playground
pnpm smoke:ssr # the playground against testing/fixture-api.mjs, six runs
pnpm smoke:ssr # the playground against testing/fixture-api.mjs, seven runs
pnpm purge:check # testing/purge-check.mjs and purge-token-check.mjs
pnpm guard:check # testing/guard-check.mjs: builds the fixture apps under testing/guard
```
`testing/fixture-api.mjs` stands in for the plugin's public API and the Go binary's root files: resolve answers from `testing/fixtures/seo/resolve.json` and `testing/fixtures/resolve.json`, the site answer from `testing/fixtures/seo/site.json`, the 404 report with `FIXTURE_NOTFOUND_TOKEN`, verification files, root files and the IndexNow key under `testing/fixtures/seo/`. `testing/smoke-ssr.mjs` checks status, body, content type and headers per line (`contains=`, `notcontains=`, `count=TEXT:N`, `type=`, `header=Name: value`, `noheader=`, `reqheader=`, each repeatable), `report` and `noreport` lines for the 404 reports, and `resolves PATH N` and `apicalls N` lines for the calls the API saw; `--no-site` makes the API answer `/site` with 404, as an older backend does. `pnpm smoke:ssr` runs `testing/expect.txt` (the v0.1.1 behaviour, against a backend without `/site`), `testing/expect-seo.txt`, `testing/expect-seo-off.txt` (with `NUXT_PUBLIC_SEO_CMS=false` and with `NUXT_PUBLIC_SEO_ENABLED=false`), `testing/expect-seo-down.txt` (the backend unreachable) and `testing/expect-seo-notoken.txt`. `testing/purge-check.mjs` proves on the route-cached playground (`/blog/**` swr) that a purge shows a changed CMS answer at once and that the route refuses unauthenticated, wrong-token and proxied requests; `testing/purge-token-check.mjs` that the purge token never reaches a page or the log.
`testing/fixture-api.mjs` stands in for the plugin's public API and the Go binary's root files: resolve answers from `testing/fixtures/seo/resolve.json` and `testing/fixtures/resolve.json`, the site answer from `testing/fixtures/seo/site.json` (or another file with `--site-file`), the 404 report and the route report with `FIXTURE_NOTFOUND_TOKEN`, the HTML sitemap from `testing/fixtures/seo/html.json`, verification files, root files and the IndexNow key under `testing/fixtures/seo/`. `testing/smoke-ssr.mjs` checks status, body, content type and headers per line (`contains=`, `notcontains=`, `count=TEXT:N`, `type=`, `header=Name: value`, `noheader=`, `reqheader=`, each repeatable), `report` and `noreport` lines for the 404 reports, `routereport N [has=PATH] [lacks=PATH]` for the route report, `log TEXT:N` for the server's output, and `resolves PATH N` and `apicalls N` lines for the calls the API saw; `--no-site` makes the API answer `/site` with 404, as an older backend does. `pnpm smoke:ssr` runs `testing/expect.txt` (the v0.1.1 behaviour, against a backend without `/site`), `testing/expect-seo.txt`, `testing/expect-seo-off.txt` (with `NUXT_PUBLIC_SEO_CMS=false` and with `NUXT_PUBLIC_SEO_ENABLED=false`), `testing/expect-seo-down.txt` (the backend unreachable), `testing/expect-seo-notoken.txt` and `testing/expect-seo-drift.txt` (a site answer that differs from the build, with `NUXT_PUBLIC_SEO_ROUTE_REPORT=false`). `testing/guard-check.mjs` builds four fixture apps that extend the layer and expects the build guard to fail `indexable-ssr-false` (a Nuxt and a Nitro route rule), `client-page` (a `.client.vue` page) and `global-ssr-false`, and to pass `noindex-ok`, whose server then answers the declared route with `X-Robots-Tag: noindex, nofollow`. `testing/purge-check.mjs` proves on the route-cached playground (`/blog/**` swr) that a purge shows a changed CMS answer at once and that the route refuses unauthenticated, wrong-token and proxied requests; `testing/purge-token-check.mjs` that the purge token never reaches a page or the log.