Files
vue-seo-layer/README.md
Jakub Zych 3868f3d74d fix: root files follow the last site answer, a 404 included, while the backend is down
An SEO-less site's robots.txt, sitemap.xml and llms.txt keep reaching
the app during an outage longer than the cache TTL; a backend that never
answered still gets the 503.

quick-261104-xae, review WR-04.
2026-11-05 00:23:19 +01:00

143 lines
30 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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. 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
```json
"vue-seo-layer": "git+https://git.golem15.com/golem15/vue-seo-layer.git#v0.2.0"
```
```ts
// nuxt.config.ts of the app
export default defineNuxtConfig({
extends: ['vue-seo-layer'],
})
```
Pin a tag; the features marked "v0.2.0" below need that tag and, where they read new answer keys, the SEO plugin's v0.2.0 backend. With an older backend the layer behaves as v0.1.1.
The layer's only module is its own local one (`lib/seo-module.ts`); it pulls in no package and no `@nuxtjs/i18n`. It works next to `@nuxtjs/seo`, the Journal layer or an app's own `useSeoMeta`; its options share the `seo` config key with nuxt-seo-utils and use none of its names.
## What it does
- **Resolve.** Every page request is resolved with `GET <apiBase>/resolve?url=<path and query>` before Nuxt renders it, in a Nitro middleware, so the render uses the same answer and makes no second call. Answers are cached (v0.2.0): path and query as the key, `seo.resolveCacheTtl` seconds (default 60), at most 1000 entries, and concurrent requests for one path share one call; the site cache purge empties the cache. In the browser a global route middleware resolves each client-side navigation with the public base (the server's answer travels in the payload, so hydration fetches nothing). Nuxt's own paths (`/_…`, `/api/…`) and asset files are never resolved by the middleware (files with an extension in `resolveFiles`, default `pdf`, are, so an old PDF address can redirect); the route middleware still resolves a page under a dotted path. A call that fails, times out (`timeoutMs`) or answers anything unexpected is ignored: the page renders with its own head (fail open).
- **Redirects.** `redirect` answers 301 or 302 with `Location`, before the page and before Nitro's route cache, so a redirect added for an `swr`-cached page applies on the next request after the resolve cache entry expires or the site cache is purged. A target on the site's own origin (the origin of the answer's `url`) becomes a path, so a staging or preview host keeps the visitor even when the backend's site URL names production; a target on another host stays absolute. Nothing from the request (Host, X-Forwarded-Host, path, query) goes into the Location. The redirect wins over the route, so an old address no page matches still redirects.
- **410.** `gone` answers 410 with the app's error page, also on a route that would render.
- **404 for a bad page number (v0.2.0).** A `not_found` answer with `pagination.invalid` (`?page=abc`) is a 404. Every other `not_found` is left to Nuxt's routing and the pages.
- **Head.** An `ok` answer sets the title (final; the plugin applies its templates, so the app's `titleTemplate` is replaced by `%s`), `description`, `canonical`, `robots`, `og:title`, `og:description`, `og:image`, `og:url`, `twitter:card`, `twitter:site` and the verification meta tags, and from v0.2.0 answers `<html lang>` (`html_lang`), `og:locale`, one `og:locale:alternate` per value, one `<link rel="alternate" hreflang>` per entry of `alternates`, and exactly one `<script type="application/ld+json">` holding one `@graph`. The entry has `tagPriority: 'critical'`, so its tags win over the same tags set by a page, a layer or a module; a value the answer leaves empty adds no tag, and the page's own stays. A canonical, `og:url`, `og:image` or alternate URL the plugin answers as a path (a backend without `golem15.seo.site_url` or `app.url`) is made absolute on the request's origin; set the site URL on the backend so it names the public origin. `not_found` answers leave the head to the page. One app plugin (`seo-head`, through `useCmsSeo`) applies the head on every route; pages and other layers do not set CMS values themselves.
- **One JSON-LD graph (v0.2.0).** The graph holds the CMS nodes (the answer's `jsonld`) plus the nodes pages and layers add with `useSchemaNode(node)`, merged by `@id`: a node whose `@id` the CMS already has fills in only the keys the CMS node lacks; a new `@id` (or none) is appended. No node at all, no script. unhead escapes `<` inside the JSON, so a value cannot end the script element.
- **Robots and staging.** A `staging` answer (the backend outside `SUMMER_ENV=production`) makes every page `noindex, nofollow`, known or not. The `X-Robots-Tag` response header follows the same value, or the page's `x_robots_tag`; a redirect carries none.
- **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 answer it gave is used, a 404 included, so on a site whose backend has no SEO plugin robots.txt and the sitemaps keep coming from the app during an outage. While the backend has not answered since the server started or since the last purge, `/robots.txt`, `/sitemap.xml` and `/llms.txt` still go to it and answer 503 with `Retry-After: 60` when it fails, so a crawler retries instead of reading the app's 404 as an allow-all robots.txt (a staging site's `Disallow: /` lives on the backend); sitemap files and verification files need a site answer. With a backend whose `/site` answers 404 (before v0.2.0, or no SEO plugin), robots.txt and the sitemaps are not proxied (route them to the backend in the site's proxy); there, and while the backend has not answered, 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, which no longer ship a copy since they extend this 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`, and with `@nuxtjs/i18n`'s `prefix_except_default` or `prefix_and_default` without the copies of a page in the other locales, such as `/pl/contact` next to `/contact`: the CMS knows those as the page's translations, which an editor gives the page in SEO › Pages) to `POST <apiBase>/registry/routes` with `Authorization: Bearer <routeReportToken>`, once per process, after the start. The same request carries `noindex`, the static paths the build serves noindex (v0.2.0, G1): pages whose meta has `requiresAuth: true` or a `seo.robots` with `noindex` or `none` (client-only pages included), static pages under a `noindexRoutes` pattern, and the `@nuxtjs/i18n` copies of those pages as their own locale-prefixed paths (`/pl/konto`); never a pattern, never the layer's HTML sitemap pages, never a path in `routes`. The plugin leaves those paths out of its XML and HTML sitemaps:
```
POST /_seo/api/v1/registry/routes
Authorization: Bearer <golem15.seo.route_report_token, else golem15.seo.notfound_token>
Content-Type: application/json
{"routes": ["/", "/blog", "/kontakt"], "noindex": ["/konto", "/pl/konto", "/szkic"]}
```
Each list holds at most 2000 paths of at most 2048 bytes. `noindex` is always sent (also `[]`), so the plugin's set follows each build; a plugin older than the key ignores it. The JSON body stays at or under 4,113,024 bytes, the most an older plugin reads: `noindex` entries are dropped from the end first, then `routes` entries. 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
Module options under the `seo` key of the app's `nuxt.config.ts` (v0.2.0). They are copied to `runtimeConfig.public.seo`, so each can also be set at run time (`NUXT_PUBLIC_SEO_<NAME>`).
| 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` | `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, with the static paths served noindex in the same request's `noindex` list (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' }, overCatchAll: false }` | 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. A path an app catch-all page covers (a root `[...slug].vue`, or a prefixed one such as `pages/pl/[...slug].vue` for `/pl/mapa-strony`) stays the catch-all's too, so a CMS page at `/sitemap` or `/mapa-strony` is not shadowed: the layer registers no sitemap pages for that locale and logs it at build. `overCatchAll: true` serves the layer's pages ahead of the catch-all. With `@nuxtjs/i18n` each locale's pages exist once, at its configured path, named for that locale (`seo-sitemap-pl___pl`), never copied under another locale's prefix. An app without pages of its own (only `app.vue`) gets none: the layer adds them to the app's resolved pages, which such an app does not have, so Nuxt's router stays off (`false` is no longer needed there). |
## Configuration
| Runtime config | Env | Default | Meaning |
|---|---|---|---|
| `seo.apiBase` | `NUXT_SEO_API_BASE` | `http://127.0.0.1:8096/_seo/api/v1` | Server-only base of the plugin's API for SSR calls, the site answer, verification files and 404 reports. |
| `seo.backendBase` | `NUXT_SEO_BACKEND_BASE` | `""` | Server-only origin of the Go binary for robots.txt, llms.txt and the sitemap and IndexNow files (v0.2.0). Empty: `apiBase` without `/_seo/api/v1`. |
| `seo.notfoundToken` | `NUXT_SEO_NOTFOUND_TOKEN` | `""` | The backend's `golem15.seo.notfound_token`. Empty: 404s are not reported. Server-only; not under `NUXT_SITE_*`, which nuxt-site-config publishes in every page's payload. |
| `seo.routeReportToken` | `NUXT_SEO_ROUTE_REPORT_TOKEN` | `""` | Bearer token of the route report to the plugin's page registry (v0.2.0). Empty: `notfoundToken`. |
| `seo.timeoutMs` | `NUXT_SEO_TIMEOUT_MS` | `1500` | How long a call may take before the page renders without it. |
| `seo.resolveCacheTtl` | `NUXT_SEO_RESOLVE_CACHE_TTL` | `60` | Seconds a resolve answer stays cached (v0.2.0). `0` turns the cache off; concurrent requests for one path still share one call. |
| `cachePurge.token` | `NUXT_CACHE_PURGE_TOKEN` | `""` | Bearer token of the site cache purge route, the backend's `site_cache.purge_token`. Shorter than 32 characters: the route answers 404. |
| `cachePurge.allowProxied` | `NUXT_CACHE_PURGE_ALLOW_PROXIED` | `false` | `true` when the backend reaches the site through a TLS-terminating proxy; the bearer token is then the only gate. |
| `siteCache.purgeToken`, `siteCache.allowProxied` | `NUXT_SITE_CACHE_PURGE_TOKEN`, `NUXT_SITE_CACHE_ALLOW_PROXIED` | | Deprecated names of the two above, read when the new ones are unset; the layer strips them from nuxt-site-config's site config and logs one warning. Rotate a token that was ever set under the old name. |
| `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>` with the locale's segment; with `@nuxtjs/i18n` each name ends in `___<locale>`). 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` (and `/<segment>/1` in the path form) a 301 to the unnumbered URL, never to the page's own 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` (`{ en: '/sitemap', pl: '/pl/mapa-strony' }` with `prefix_except_default`): the layer adds the pages after i18n has localised the app's routes, so i18n does not prefix them again.
- **`<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: the path must resolve to the layer's sitemap page of that locale, so no link appears where an app page or a catch-all keeps the path. The locale is the prop, else the resolve answer's, else the `@nuxtjs/i18n` current locale where the app has i18n, else the site default. 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`.
## Composables and utils
- `useSeoAnswer()`: the resolve answer of the current page (`SeoResolveAnswer | null`), shared state.
- `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(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).
- `autolink(html, links, max, self)`, `applyOutbound(html, { siteHost, newTab, nofollowDomains, sponsoredDomains, hint })`, `imageAttrs(html)` (`app/utils/bodyLinks.ts`): the body link rules a content layer runs at SSR on sanitized HTML, in this order (UI-SPEC L7; vue-journal-layer's post body does). `autolink` links each phrase of the resolve answer's `autolinks` at its first occurrence, as a whole word and case-insensitive, at most `max` (`autolink_max`) in the whole text, never inside `a`, `h1`–`h6`, `code`, `pre`, `figcaption` or `button` and never to the page itself (`self`, a path or the answer's `url`); only site paths and http(s) URLs, escaped, as a plain `<a href>`. `applyOutbound` gives an http(s) link to another host the editor's `rel` plus `nofollow` or `sponsored` from the answer's `outbound` domain lists (subdomains included) and, only while `newTab` (`outbound.new_tab`) is on, `target="_blank"`, `rel="noopener"` and, when `hint` is set (`useSeoLabels().value.links.newTab` while `seo.links.newTabHint` is on), a trailing `<span class="seo-visually-hidden">`; internal, relative and non-http links stay as they are. `imageAttrs` adds `loading="lazy"` and `decoding="async"` to body images and keeps their stored width, height and `alt` (`alt=""` included). They expect sanitize-html output (normalised tags, escaped text) and only add escaped markup between tags.
- `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.
## Labels and tokens
`useSeoLabels()` returns the strings of the layer's public components in the page's content locale: the answer's `locale`, then the primary subtag of `html_lang`, then `en`. The layer ships `en` and `pl` (`app/utils/labels.ts`); an app overrides keys or adds a locale in its `app.config.ts`, and component props override both:
```ts
export default defineAppConfig({
seo: { labels: { pl: { sitemap: { title: 'Spis stron' } }, de: { pagination: { next: 'Nächste Seite' } } } },
})
```
`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
```sh
pnpm install
pnpm test # node --test on the helpers, the body link rules, the cache, the purge and the labels
pnpm build:playground
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` (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] [noindex=PATH] [lacksnoindex=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.