25 Commits

Author SHA1 Message Date
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
Jakub Zych
973142a714 chore: version 0.2.0 and install pin
- package.json version 0.2.0; the README install line already pins #v0.2.0
2026-11-04 22:54:59 +01:00
Jakub Zych
8cdf35929a fix: root files answer 503 while the backend has not answered
- rootFileTargets gives robots.txt, sitemap.xml and llms.txt their backend
  target also in state unknown without a last answer, so the failing fetch
  answers 503 with Retry-After: 60 instead of the app's 404 (L4 WR-08)
- an older backend (absent) still passes through; sitemap files and
  verification files still need a site answer
- expect-seo-down.txt expects the 503s and states the staging reason
2026-11-04 22:49:40 +01:00
Jakub Zych
a651de4675 fix: HTML sitemap pages once per locale, never over a catch-all by default
- 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
2026-11-04 22:47:58 +01:00
Jakub Zych
7588aa53b0 fix: HTML sitemap page 1 redirects with the locale's segment
- seoFirstPageRedirect answers the unnumbered URL or null when the target
  is the URL itself, so /mapa-strony/<group>/strona/1 no longer loops (L4 WR-01)
- SeoHtmlSitemapPage takes the segment from the site answer, else the build
  option, else the param, and redirects only when the helper answers a target
2026-11-04 22:41:18 +01:00
Jakub Zych
7a26692001 feat: the route report sends the noindex static paths (tracer)
- noindexStaticRoutes: requiresAuth, noindex/none seo.robots and static
  pages under seo.noindexRoutes, i18n copies included, never patterns or
  the layer's HTML sitemap pages (G1, gap-2 contract)
- #seo-routes exports noindexPaths; routeReportBody(paths, noindex) answers
  {routes, noindex} with the same limits and keeps the JSON body within the
  older plugin's 4,113,024 bytes, dropping noindex entries first
- smoke grammar noindex= and lacksnoindex=; README carries the contract
2026-11-04 22:40:38 +01:00
Jakub Zych
b3a0078fb0 fix: leave the i18n copies of a page in other locales out of the route report
With prefix_except_default (or prefix_and_default) @nuxtjs/i18n adds a copy
of every page per other locale (/pl/contact next to /contact). The route
report sent them as pages of their own, so the CMS registry got a second,
untranslated page for each. They are the page's translations: the report
now skips routes named with another locale's suffix (contact___pl).
2026-11-04 20:13:05 +01:00
Jakub Zych
75cc100336 test: usePagination hrefs, head links and page count; drift pattern forms
- the query and path forms from the resolve answer, the site answer and
  the layer option, page 1 unnumbered
- prev and next links at the ends, %pages% from the page count
- routeServes reads {param} and :param alike and ignores the query part
2026-11-04 19:47:18 +01:00
Jakub Zych
7af91fc5e4 docs: body link utilities and the app-without-pages note
- autolink, applyOutbound and imageAttrs: the body link rules a content
  layer runs at SSR on sanitized HTML (UI-SPEC L7)
- htmlSitemap: an app without pages sets false, since the sitemap pages
  would turn the router on
- the purge route's copies in the journal, menu and FAQ layers are gone
2026-11-04 14:08:22 +01:00
Jakub Zych
db44ca4d72 feat: body link utilities (auto-links, outbound rules, image attributes)
app/utils/bodyLinks.ts, pure and run by content layers at SSR on
sanitized body HTML (UI-SPEC L7 steps 2 to 4):

- autolink(html, links, max, self): first occurrence of each CMS phrase,
  whole words, case-insensitive, at most max per text, never inside a,
  headings, code, pre, figcaption or button, never to the page itself;
  only site paths and http(s) URLs, escaped, as a plain <a href>
- applyOutbound(html, {siteHost, newTab, nofollowDomains,
  sponsoredDomains, hint}): editor rel kept, nofollow/sponsored from the
  domain lists, target=_blank, rel noopener and the visually hidden hint
  only while new tab is on; internal links untouched
- imageAttrs(html): loading=lazy and decoding=async, stored width,
  height and alt="" kept
2026-11-04 13:40:47 +01:00
Jakub Zych
b051bda080 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
2026-11-04 12:02:15 +01:00
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
Jakub Zych
f2d13d6264 feat: pagination component and composable
- <SeoPagination> (UI-SPEC L2): previous and next (left out at the
  ends), page 1, the last page and five pages around the current one with
  gap items; nothing when last <= 1; props current, last, labels,
  classPrefix, locale
- usePagination(): hrefs in the CMS form (resolve answer, then site
  answer, then seo.pagination), other query parameters kept, page 1
  unnumbered, <link rel=prev/next> in the head
- the CMS head fills %pages% in the title and description once the
  listing's page count is known (seoFillPages)
- seoPaginationWindow, seoPageHref, seoPageNumber; L2 default styles
2026-11-04 11:59:51 +01:00
Jakub Zych
ebb0c8084e feat: breadcrumbs component
- <SeoBreadcrumbs> (UI-SPEC L1): the resolve answer's trail as nav > ol
  with server-rendered links and the last item as aria-current text;
  nothing with fewer than two items, with page meta seo.breadcrumbs false
  or with the option seo.breadcrumbs false; props items, label,
  hideCurrent, locale
- one trail per page: the first rendered instance claims it
  (seoClaimBreadcrumbs, kept per path)
- seoSitePath: same-origin CMS URLs become paths, non-http(s) URLs are
  never links; useSeoLabels takes an optional locale
- L1 default styles with CSS-generated separators
2026-11-04 11:59:17 +01:00
Jakub Zych
3abc986f42 feat: route report and drift check at server start
- the module writes #seo-routes (static paths and route patterns) from the
  resolved pages; options seo.routeReport, seo.pagination and
  seo.htmlSitemap reach the public runtime config
- seo-route-report: POST /registry/routes once per process with the
  route-report token, one retry after 60 s on failure, nothing without a
  token, with the layer off or with routeReport false
- seo-drift: one "[vue-seo-layer] drift:" warning per mismatch between
  the CMS's site answer (URL patterns, pagination, HTML sitemap paths)
  and the build
- GET /api/__seo__/site and useSeoSite()
- smoke grammar routereport and log, fixture registry route, drift run
2026-11-04 11:49:21 +01:00
Jakub Zych
9cc2cda924 feat: noindex for declared and signed-in routes
- app plugin seo-route-robots: a page whose meta sets requiresAuth: true
  gets robots noindex, nofollow, a page with seo.robots gets its value,
  a path under seo.noindexRoutes gets noindex, nofollow; meta tag above
  the CMS head entry and X-Robots-Tag during SSR, never on staging
- requiresAuth and seo added to Nuxt's page meta extraction keys
- playground /konto, /szkic and /prywatne with their smoke lines
2026-11-04 11:49:14 +01:00
Jakub Zych
b4680af0e3 feat: the build fails when an indexable route renders only in the browser (tracer)
- lib/guard.ts: pure checks (isNoindex with Nitro route-rule patterns,
  findOffenders over the app's ssr flag, every merged route rule,
  .client.vue pages and experimental.inlineRouteRules)
- module options seo.noindexRoutes and seo.indexable; declared noindex
  routes get X-Robots-Tag: noindex, nofollow through their route rule
- a production build fails with the D-70 message; nuxt dev and
  nuxt prepare only warn
- testing/guard-check.mjs builds four fixture apps (guard:check)
2026-11-04 11:42:08 +01:00
Jakub Zych
6636cde70a fix: root files fall through until the backend has given a site answer
With the SEO backend unreachable since start, /robots.txt and /sitemap.xml
answered 503 where v0.1.1 left them to the app (a consumer's smoke expects
the app's 404). The root proxies now need a site answer; the last one seen
is kept while the backend is down, so a failure after it is still 503.
2026-11-04 09:31:39 +01:00
Jakub Zych
c2357cd7f9 docs: README
- options (cms alias of public.seo.enabled, breadcrumbs, links.newTabHint,
  rootFiles, resolveFiles), the new runtime keys and the moved purge keys
- the resolve and site answer contract, one JSON-LD graph, root files,
  the purge route, 404 report, labels and tokens, the smoke runs
2026-11-04 09:29:02 +01:00
Jakub Zych
aa7480e003 feat: SEO labels, tokens and base styles
- useSeoLabels(): the L8 dictionary (en, pl) of the page's content locale
  (resolve locale, then html_lang, then en), overridable and extendable in
  app.config.ts under seo.labels; no i18n dependency
- seo-tokens.css (spacing, colour, small type, breadcrumb separator) and
  seo-base.css (.seo-visually-hidden, the focus ring of layer links), loaded
  before the app's CSS and styling only .seo-* classes
2026-11-04 09:27:57 +01:00
Jakub Zych
d1629439c7 feat: root files from the CMS and the 404 report
- server/middleware/seo-root.ts proxies /robots.txt, /sitemap.xml,
  /sitemap-<name>.xml, /llms.txt and the verification and IndexNow key
  files the site answer lists in root_files, with the backend's status,
  content type and X-Robots-Tag; a backend failure is 503 Retry-After 60
- server/lib/site.ts caches GET /_seo/api/v1/site (300 s, cleared by the
  purge); a backend without it (404) keeps the v0.1.1 behaviour: no root
  proxies, the verification lookup by name
- the 404 report and the verification lookup honour seo.cms and rootFiles
- smoke runs for the layer off (seo.cms and the v0.1.1 enabled switch), the
  CMS down (fail open) and no 404 token; npm run purge:check
2026-11-04 09:26:00 +01:00
Jakub Zych
691fa09b0f feat: shared site cache purge route clears the SEO caches
- POST /api/__site__/cache/purge, its library and the site config plugin
  moved from vue-journal-layer with the same contract (token rules, 404 for
  proxied requests unless allowProxied, deprecated siteCache names); the
  route also clears the resolve cache
- server/plugins/seo-cache-purge.ts clears the SEO caches after any 200
  purge, so an app whose journal, menu or FAQ layer copy of the route wins
  keeps fresh SEO answers until those copies are removed
- cachePurge and siteCache runtime keys; /api/__site__/** never cached
- testing/purge-check.mjs (CMS answer change, plus a redirect added for a
  route-cached page applying at once) and purge-token-check.mjs
2026-11-04 09:23:15 +01:00
Jakub Zych
e3baa7028c feat: redirect and 410 enforcement, X-Robots-Tag and the resolve cache
- the Nitro middleware answers redirect (301/302, Location from the answer
  only, same-site targets as paths as in v0.1.1) and gone (410) before the
  render and the route cache, sets X-Robots-Tag, and answers 404 for a
  pagination.invalid answer; other not_found answers stay with Nuxt
- resolve answers cached in an LRU (seo.resolveCacheTtl, default 60 s,
  1000 entries) with in-flight de-duplication; a clear detaches loads in
  flight so no answer fetched before a purge is stored after it
- the playground route-caches /blog/** (swr 300)
2026-11-04 09:20:20 +01:00
Jakub Zych
160b53b2f5 feat: CMS head and one JSON-LD graph on every server-rendered page (tracer)
- Nitro middleware resolves page requests once and hands the answer to the
  render (event.context.seo); the route middleware reuses it
- useCmsSeo: html lang, hreflang links, og:locale and exactly one JSON-LD
  script with one @graph (CMS nodes plus useSchemaNode nodes, merged by @id)
- local module (configKey seo): cms (alias of public.seo.enabled),
  breadcrumbs, links.newTabHint, rootFiles, resolveFiles
- GET /api/__seo__/resolve for client navigation through the Nuxt server
- smoke harness: count=, exact header=, noheader=, reqheader=, repeated
  options, resolves/apicalls/noreport lines, --no-site; v0.1.1 suite runs
  unchanged against a backend without /site
2026-11-04 09:15:26 +01:00
Jakub Zych
7d52c5d2e5 fix: canonical and social URLs answered as paths become absolute on the request origin 2026-10-31 13:53:30 +01:00
114 changed files with 7311 additions and 201 deletions

View File

@@ -1,3 +1,8 @@
<template>
<!-- A layout instance of the breadcrumbs: the catch-all page renders one
too, and only the first renders its trail. The footer carries the
HTML sitemap link. -->
<SeoBreadcrumbs />
<NuxtPage />
<footer><SeoSitemapLink /></footer>
</template>

View File

@@ -0,0 +1,25 @@
<script setup lang="ts">
// A CMS-driven catch-all page: it renders what the SEO answer knows and is
// a 404 for anything else, as a registry page would. It sets its own title
// so the smoke can see the CMS title win, adds a layer node to the one
// JSON-LD graph (merged by @id with the CMS's nodes) and prints a label of
// the page's content locale. It also renders breadcrumbs, as a layer page
// does, next to the instance in app.vue: only one trail renders.
const route = useRoute()
const answer = useSeoAnswer()
if (answer.value?.action !== 'ok') throw createError({ statusCode: 404, statusMessage: 'Page not found', fatal: true })
useSeoMeta({ title: 'Own catch-all title' })
const url = answer.value.url
useSchemaNode({ '@id': `${url}#webpage`, name: 'Layer name loses', inLanguage: 'pl' })
useSchemaNode({ '@type': 'ItemList', '@id': `${url}#itemlist`, itemListElement: [] })
const labels = useSeoLabels()
const slug = computed(() => ([] as string[]).concat(route.params.slug ?? []).join('/'))
</script>
<template>
<main>
<SeoBreadcrumbs />
<h1>{{ slug }}</h1>
<p class="seo-visually-hidden">{{ labels.breadcrumbs.label }}</p>
</main>
</template>

View File

@@ -0,0 +1,16 @@
<script setup lang="ts">
// A listing with numbered pages: 14 pages, or ?pages=N for another count
// (?pages=1 renders no pagination). The CMS answers the title with %pages%
// on page 2 and later; usePagination (through <SeoPagination>) fills it.
const route = useRoute()
const last = computed(() => Math.max(1, Number(route.query.pages ?? 14) || 1))
const current = computed(() => seoPageNumber(route.fullPath) ?? 1)
useSeoMeta({ title: 'Own blog title' })
</script>
<template>
<main>
<h1>Blog</h1>
<SeoPagination :current="current" :last="last" />
</main>
</template>

View File

@@ -0,0 +1,11 @@
<script setup lang="ts">
// A signed-in page: requiresAuth in its meta makes it noindex, nofollow
// (meta tag and X-Robots-Tag) and keeps it out of the route report, with
// no SEO code on the page (D-23).
definePageMeta({ requiresAuth: true })
useSeoMeta({ title: 'Konto' })
</script>
<template>
<main><h1>Konto</h1></main>
</template>

View File

@@ -0,0 +1,9 @@
<script setup lang="ts">
// Under seo.noindexRoutes ('/prywatne/**' in nuxt.config.ts): noindex
// through the route rule and the meta tag, left out of the route report.
useSeoMeta({ title: 'Prywatne' })
</script>
<template>
<main><h1>Prywatne</h1></main>
</template>

View File

@@ -0,0 +1,7 @@
<script setup lang="ts">
useSeoMeta({ title: 'Relative own' })
</script>
<template>
<main><h1>Relative</h1></main>
</template>

View File

@@ -0,0 +1,9 @@
<script setup lang="ts">
// A page that sets its own robots value through its meta (seo.robots).
definePageMeta({ seo: { robots: 'noindex, follow' } })
useSeoMeta({ title: 'Szkic' })
</script>
<template>
<main><h1>Szkic</h1></main>
</template>

View File

@@ -1,9 +1,27 @@
// Local playground: an app that extends the layer and sets its own head on
// each page (pages/), so the smoke can see the layer's tags win where the
// resolve answer has a value and the page's stay where it has none.
// testing/smoke-ssr.mjs builds it against the fixture API.
// testing/smoke-ssr.mjs builds it against the fixture API. The /blog pages
// are route-cached (swr) as on a production site, so the smoke and
// testing/purge-check.mjs see redirects, 410 and purges apply to cached
// pages. /prywatne/** is declared noindex; the HTML sitemap paths follow
// the fixture site answer (testing/fixtures/seo/site.json), so the startup
// drift check finds nothing.
export default defineNuxtConfig({
extends: ['..'],
compatibilityDate: '2025-07-15',
app: { head: { titleTemplate: '%s | Playground' } },
routeRules: {
// Not '/**': a route-cached render has no Host header, so the v0.1.1
// check that a path canonical becomes absolute on the request origin
// (testing/expect.txt, /relative) needs uncached pages.
'/blog/**': { swr: 300 },
},
seo: {
noindexRoutes: ['/prywatne/**'],
// The playground renders everything else through a root [...slug].vue,
// so the layer's sitemap pages would be left to it (L4 WR-04);
// overCatchAll serves them ahead of the catch-all for the smoke.
htmlSitemap: { paths: { pl: '/mapa-strony', en: '/en/sitemap' }, overCatchAll: true },
},
})

126
README.md
View File

@@ -1,6 +1,12 @@
# 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. It also reports 404s to the plugin's 404 log and serves the verification files at the site root.
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
@@ -9,48 +15,128 @@ export default defineNuxtConfig({
})
```
```json
"vue-seo-layer": "git+https://git.golem15.com/golem15/vue-seo-layer.git#v0.1.0"
```
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 declares no Nuxt modules and works next to `@nuxtjs/seo`, the Journal layer or an app's own `useSeoMeta`.
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.** A global route middleware calls `GET <apiBase>/resolve?url=<path and query>` before the page runs, on the server for the first request and in the browser on each client-side navigation (the server's answer travels in the payload, so hydration fetches nothing). Nuxt's own paths (`/_…`, `/api/…`) are never resolved. A call that fails, times out (`timeoutMs`) or answers anything unexpected is ignored: the page renders with its own head.
- **Redirects.** `redirect` answers 301 or 302 with `Location`. 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. The redirect wins over the route, so an old address no page matches still redirects.
- **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.
- **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. 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. `not_found` answers (pages the plugin does not know: listings, search, author pages, static pages without a URL rule) leave the head to the page.
- **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.
- **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 with `POST <apiBase>/notfound`, `{"url", "referer"}` and `Authorization: Bearer <notfoundToken>`, after the response. Without a token nothing is reported.
- **Verification files.** 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.
- **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 and 404 reports. |
| `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. |
| `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. |
| `public.seo.enabled` | `NUXT_PUBLIC_SEO_ENABLED` | `true` | `false` turns the layer off: no calls, no head, no redirects, no reports. |
| `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`. |
The plugin serves `/robots.txt` itself; route that path to the backend (and turn off another robots.txt route, such as the one of `@nuxtjs/robots`).
## 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.
- `seoHeadInput`, `seoRedirectTarget`, `seoRobots`, `seoXRobotsTag`, `seoResolvableUrl`, `seoAnswer`, `seoVerificationName`: the pure helpers behind them (`app/utils/seo.ts`), auto-imported.
- `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
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 and testing/expect.txt
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 (resolve answers from `testing/fixtures/resolve.json`, the 404 report with `FIXTURE_NOTFOUND_TOKEN`, verification files from `testing/fixtures/verification/`). `testing/smoke-ssr.mjs` checks status, body, content type and headers (`header=Name: text`) per line, and `report PATH` lines require a 404 report for `PATH`.
`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.

8
app/app.config.ts Normal file
View File

@@ -0,0 +1,8 @@
// The layer's app config. seo.labels.<locale> overrides or adds label
// dictionaries (UI-SPEC L8, app/utils/labels.ts); an app sets its own in
// its app.config.ts, which Nuxt merges over this one.
export default defineAppConfig({
seo: {
labels: {},
},
})

128
app/assets/css/seo-base.css Normal file
View File

@@ -0,0 +1,128 @@
/* SEO layer base styles. Only .seo-* classes, never a bare element or
another layer's classes; values come from seo-tokens.css, and the app's
own CSS (loaded after the layers') wins. Long names and titles wrap and
are never truncated: the full text stays in the HTML. */
/* Visually hidden, still read by screen readers (the new-tab hint inside
external links, labels of icon-only controls). */
.seo-visually-hidden {
position: absolute !important;
width: 1px !important;
height: 1px !important;
padding: 0 !important;
margin: -1px !important;
overflow: hidden !important;
clip: rect(0, 0, 0, 0) !important;
clip-path: inset(50%) !important;
white-space: nowrap !important;
border: 0 !important;
}
/* The focus ring of every layer link: 2px in the accent colour. */
.seo-breadcrumbs a:focus-visible,
.seo-pagination a:focus-visible,
.seo-sitemap a:focus-visible,
.seo-sitemap-link:focus-visible {
outline: 2px solid var(--seo-color-accent);
outline-offset: 2px;
}
/* L1 breadcrumbs: a wrapping row, separators generated here (never read by
screen readers), the current page muted and bold. */
.seo-breadcrumbs {
font-size: var(--seo-font-size-small);
}
.seo-breadcrumbs__list {
display: flex;
flex-wrap: wrap;
align-items: center;
list-style: none;
margin: 0 0 var(--seo-space-4);
padding: 0;
}
.seo-breadcrumbs__item {
overflow-wrap: anywhere;
}
.seo-breadcrumbs__item + .seo-breadcrumbs__item::before {
content: var(--seo-breadcrumbs-separator) / '';
color: var(--seo-color-muted);
padding-inline: var(--seo-space-2);
}
.seo-breadcrumbs__link {
display: inline-block;
padding-block: var(--seo-space-1);
}
.seo-breadcrumbs__current {
color: var(--seo-color-muted);
font-weight: 600;
}
/* L2 pagination: previous, the page list and next in a wrapping row;
44px targets; the current page on the accent colour. */
.seo-pagination {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--seo-space-3);
margin-top: var(--seo-space-5);
}
.seo-pagination__pages {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--seo-space-1);
list-style: none;
margin: 0;
padding: 0;
}
.seo-pagination__prev,
.seo-pagination__next,
.seo-pagination__pages a,
.seo-pagination__current,
.seo-pagination__gap {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: var(--seo-target-size);
min-height: var(--seo-target-size);
padding-inline: var(--seo-space-2);
box-sizing: border-box;
}
.seo-pagination__current {
background: var(--seo-color-accent);
color: var(--seo-color-on-accent);
font-weight: 600;
}
.seo-pagination__gap {
color: var(--seo-color-muted);
}
/* L3 HTML sitemap: lists in columns, room between groups; headings keep
the site's styles (margin only). */
.seo-sitemap__title {
margin-block: 0 var(--seo-space-4);
}
.seo-sitemap__group + .seo-sitemap__group {
margin-top: var(--seo-space-4);
}
.seo-sitemap__group-title {
margin-block: 0 var(--seo-space-3);
}
.seo-sitemap__list {
columns: var(--seo-sitemap-column-width);
column-gap: var(--seo-space-3);
margin: 0;
padding-inline-start: var(--seo-space-4);
}
.seo-sitemap__list .seo-sitemap__list {
columns: auto;
}
.seo-sitemap__item {
break-inside: avoid;
margin-bottom: var(--seo-space-1);
overflow-wrap: anywhere;
}
.seo-sitemap__more,
.seo-sitemap__empty {
margin-block: var(--seo-space-3) 0;
}

View File

@@ -0,0 +1,28 @@
/* SEO layer design tokens (UI-SPEC L8). Every visual value of the layer's
public components comes from these. The defaults match the journal and
menu layer defaults, so an untouched site looks consistent. An app
overrides any of them in its own CSS, e.g. on :root:root. The layer sets
no font family, heading size or body size: everything else inherits from
the site. */
:root {
/* Spacing */
--seo-space-1: 0.25rem; /* gap between pagination items; breadcrumb link padding (24px target) */
--seo-space-2: 0.5rem; /* room around the breadcrumb separator; pagination link inline padding */
--seo-space-3: 1rem; /* gap between previous, the page list and next; sitemap list gaps */
--seo-space-4: 1.5rem; /* margin under the breadcrumbs; gap between sitemap groups */
--seo-space-5: 2.5rem; /* margin above the pagination (copies --journal-space-5) */
--seo-target-size: 2.75rem; /* minimum pagination link size (WCAG 2.5.5; copies --menu-toggle-size) */
--seo-sitemap-column-width: 16rem; /* HTML sitemap column width */
/* Colour */
--seo-color-muted: #59636e; /* separators, current breadcrumb, pagination gap, sitemap counts (6.2:1 on white) */
--seo-color-accent: #0969da; /* current pagination page, focus ring of layer links */
--seo-color-on-accent: #ffffff; /* text on the accent colour (5.0:1) */
--seo-color-border: #d1d9e0; /* not used by default; reserved for themes */
/* Type */
--seo-font-size-small: 0.875em; /* breadcrumbs */
/* Breadcrumbs */
--seo-breadcrumbs-separator: '›'; /* CSS-generated, never read by screen readers */
}

View File

@@ -0,0 +1,80 @@
<script setup lang="ts">
// The visible breadcrumb trail (UI-SPEC L1, D-86): the resolve answer's
// breadcrumbs, the same list the CMS puts in BreadcrumbList, as nav > ol
// with server-rendered links and the current page as text. Renders nothing
// with fewer than two items (the home page), when the page meta sets
// seo: { breadcrumbs: false } or when the option seo.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.
// Separators are CSS-generated (seo-base.css), never DOM characters. Links
// use NuxtLink's custom slot, so only the last item carries aria-current.
import { seoClaimBreadcrumbs, seoReleaseBreadcrumbs, type SeoBreadcrumbClaim } from '../../utils/breadcrumbClaim.ts'
import { seoSitePath } from '../../utils/sitePath.ts'
import type { SeoBreadcrumb } from '../../utils/types.ts'
const props = withDefaults(
defineProps<{
/** Overrides the resolve answer's trail. */
items?: SeoBreadcrumb[] | null
/** Overrides the breadcrumbs.label label (the nav's aria-label). */
label?: string
/** Leaves the last item (the current page) out of the visible list. */
hideCurrent?: boolean
/** The labels' locale (default: the page's content locale). */
locale?: string
}>(),
{ items: null, label: '', hideCurrent: false, locale: '' },
)
const config = useRuntimeConfig()
const route = useRoute()
const answer = useSeoAnswer()
const labels = useSeoLabels(() => props.locale)
const claim = useState<SeoBreadcrumbClaim | null>('seo-layer:breadcrumbs', () => null)
const id = useId() ?? 'seo-breadcrumbs'
const trail = computed(() => {
const source = props.items ?? (answer.value?.action === 'ok' ? answer.value.breadcrumbs : null) ?? []
const origin = answer.value?.url
return (Array.isArray(source) ? source : [])
.filter((item) => item && typeof item.name === 'string' && item.name !== '')
.map((item) => ({ name: item.name, to: (typeof item.path === 'string' && item.path.startsWith('/') && !item.path.startsWith('//') ? item.path : '') || seoSitePath(item.url, origin) }))
})
const enabled = computed(() => {
const option = (config.public.seo as { breadcrumbs?: unknown } | undefined)?.breadcrumbs
const meta = (route.meta as { seo?: { breadcrumbs?: unknown } }).seo?.breadcrumbs
return option !== false && option !== 'false' && meta !== false
})
const shown = computed(() => enabled.value && trail.value.length >= 2)
const owns = ref(false)
const take = () => {
if (!shown.value) {
claim.value = seoReleaseBreadcrumbs(claim.value, id)
owns.value = false
return
}
const next = seoClaimBreadcrumbs(claim.value, route.path, id)
claim.value = next.state
owns.value = next.owns
}
take()
watch([() => route.path, shown], take)
const visible = computed(() => (props.hideCurrent ? trail.value.slice(0, -1) : trail.value))
</script>
<template>
<nav v-if="owns && shown" class="seo-breadcrumbs" :aria-label="label || labels.breadcrumbs.label">
<ol class="seo-breadcrumbs__list">
<li v-for="(item, i) in visible" :key="i" class="seo-breadcrumbs__item">
<span v-if="!hideCurrent && i === visible.length - 1" class="seo-breadcrumbs__current" aria-current="page">{{ item.name }}</span>
<NuxtLink v-else-if="item.to" v-slot="link" :to="item.to" custom>
<a class="seo-breadcrumbs__link" :href="link.href" @click="link.navigate">{{ item.name }}</a>
</NuxtLink>
<span v-else class="seo-breadcrumbs__text">{{ item.name }}</span>
</li>
</ol>
</nav>
</template>

View File

@@ -0,0 +1,56 @@
<script setup lang="ts">
// Numbered, server-rendered page links (UI-SPEC L2, D-40, D-42): previous
// and next (left out at the ends), page 1, the last page and five pages
// around the current one with gap items, in the CMS-chosen URL form
// (usePagination, which also adds <link rel="prev/next"> to the head and
// fills %pages% in the CMS title). Renders nothing when last ≤ 1.
// classPrefix renames every class, so a layer keeps its own markup classes
// (vue-journal-layer renders it with class-prefix="journal-pagination").
// Links render through NuxtLink's custom slot: client navigation without
// the router's active classes and aria-current, which would otherwise mark
// every page of the same path (the router ignores the query).
import { formatSeoLabel, type SeoLabels } from '../../utils/labels.ts'
import { SEO_PAGINATION_GAP } from '../../utils/paginationWindow.ts'
const props = withDefaults(
defineProps<{
current: number
last: number
/** Overrides the pagination.* labels; page takes {page}. */
labels?: Partial<SeoLabels['pagination']>
classPrefix?: string
/** The listing's locale (labels and the path segment; default: the page's content locale). */
locale?: string
}>(),
{ labels: () => ({}), classPrefix: 'seo-pagination', locale: '' },
)
const dictionary = useSeoLabels(() => props.locale)
const l = computed(() => ({ ...dictionary.value.pagination, ...Object.fromEntries(Object.entries(props.labels ?? {}).filter(([, v]) => typeof v === 'string' && v !== '')) }))
const { current, last, items, href } = usePagination({ current: () => props.current, last: () => props.last, locale: () => props.locale })
const c = (part: string) => `${props.classPrefix}__${part}`
</script>
<template>
<nav v-if="last > 1" :class="classPrefix" :aria-label="l.label">
<NuxtLink v-if="current > 1" v-slot="link" :to="href(current - 1)" custom>
<a :class="c('prev')" :href="link.href" rel="prev" @click="link.navigate">{{ l.previous }}</a>
</NuxtLink>
<ol :class="c('pages')">
<template v-for="(item, i) in items" :key="item === SEO_PAGINATION_GAP ? `gap-${i}` : item">
<li v-if="item === SEO_PAGINATION_GAP" :class="c('gap')" aria-hidden="true">…</li>
<li v-else-if="item === current">
<span :class="c('current')" aria-current="page">{{ item }}</span>
</li>
<li v-else>
<NuxtLink v-slot="link" :to="href(item)" custom>
<a :href="link.href" :aria-label="formatSeoLabel(l.page, { page: item })" @click="link.navigate">{{ item }}</a>
</NuxtLink>
</li>
</template>
</ol>
<NuxtLink v-if="current < last" v-slot="link" :to="href(current + 1)" custom>
<a :class="c('next')" :href="link.href" rel="next" @click="link.navigate">{{ l.next }}</a>
</NuxtLink>
</nav>
</template>

View File

@@ -0,0 +1,42 @@
<script setup lang="ts">
// The footer link to the HTML sitemap of the page's locale (UI-SPEC L4,
// D-34): rendered only while the CMS has the HTML sitemap on for that
// locale and the layer serves it (seo.htmlSitemap), that is while the path
// resolves to the layer's sitemap page of that locale (not taken by an app
// page or a catch-all, L4 WR-04, WR-05). The locale: the prop, the resolve
// answer, its html_lang, the @nuxtjs/i18n current locale where the app has
// it (the layer does not depend on i18n), the site default. No style of
// its own beyond the inherited link colour.
const props = withDefaults(defineProps<{ locale?: string }>(), { locale: '' })
const config = useRuntimeConfig()
const router = useRouter()
const answer = useSeoAnswer()
const { data: site } = await useSeoSite()
const i18n = (useNuxtApp() as { $i18n?: { locale?: { value?: unknown } } }).$i18n
const primary = (code: string | undefined | null) => (code ?? '').toLowerCase().split(/[-_]/)[0] ?? ''
const i18nLocale = () => {
const value = i18n?.locale?.value
return typeof value === 'string' ? value : ''
}
const locale = computed(() => props.locale || answer.value?.locale || primary(answer.value?.html_lang) || i18nLocale() || site.value?.default_locale || '')
const labels = useSeoLabels(locale)
const href = computed(() => {
const html = site.value?.html_sitemap
if (!html || html.enabled !== true || !html.paths || !html.paths[locale.value]) return ''
const own = (config.public.seo as { htmlSitemap?: { paths?: Record<string, string> } | false } | undefined)?.htmlSitemap
if (!own || typeof own !== 'object') return ''
const path = own.paths?.[locale.value] ?? ''
if (!path) return ''
// Only the layer's sitemap page of this locale, never an app page or a
// catch-all at that path, nor another locale's copy.
const target = router.resolve(path)
const served = target.matched.some((r) => (r.meta?.seoSitemap as { locale?: string } | undefined)?.locale === locale.value)
return served ? path : ''
})
</script>
<template>
<NuxtLink v-if="href" class="seo-sitemap-link" :to="href">{{ labels.sitemapLink }}</NuxtLink>
</template>

View File

@@ -0,0 +1,24 @@
<script setup lang="ts">
// One list of the HTML sitemap (UI-SPEC L3): an entry per link, children
// (the category tree) nested as a list inside their parent's item, no
// headings below the group's H2.
import { seoSitePath } from '../../utils/sitePath.ts'
import type { SeoSitemapEntry } from '../../utils/types.ts'
const props = defineProps<{ entries: SeoSitemapEntry[]; origin?: string }>()
const rows = computed(() =>
(Array.isArray(props.entries) ? props.entries : [])
.filter((e) => e && typeof e.title === 'string' && e.title !== '')
.map((e) => ({ title: e.title, to: seoSitePath(e.url, props.origin), children: Array.isArray(e.children) ? e.children : [] })),
)
</script>
<template>
<ul v-if="rows.length > 0" class="seo-sitemap__list">
<li v-for="(row, i) in rows" :key="i" class="seo-sitemap__item">
<NuxtLink v-if="row.to" :to="row.to">{{ row.title }}</NuxtLink>
<span v-else>{{ row.title }}</span>
<SitemapList v-if="row.children.length > 0" :entries="row.children" :origin="origin" />
</li>
</ul>
</template>

View File

@@ -0,0 +1,102 @@
<script setup lang="ts">
// The HTML sitemap (UI-SPEC L3, D-34), rendered by the layer's sitemap
// pages (app/seo-pages). Without group: the overview (an H1, an H2 per
// non-empty group, categories nested, a large group's first entries plus
// a link to its group page, sitemap.empty when nothing is listed). With
// group: one page of that group (breadcrumbs Home › Sitemap › group, the
// H1 with the page suffix on page 2 and later, the list, <SeoPagination>).
// An unknown group or a page past the last is a 404, ?page=1 a 301 to the
// unnumbered URL, a failed API call a 503, so no empty page is cached.
//
// Registered globally under the stable name SeoHtmlSitemapPage: an app
// overrides it by registering its own component of that name.
import { formatSeoLabel } from '../../utils/labels.ts'
import { seoFirstPageRedirect, seoPageNumber } from '../../utils/pageHref.ts'
import { seoSitePath } from '../../utils/sitePath.ts'
import type { SeoBreadcrumb, SeoSitemapGroupPage, SeoSitemapOverview } from '../../utils/types.ts'
const props = withDefaults(defineProps<{ locale: string; group?: string; pathPage?: string }>(), { group: '', pathPage: '' })
const route = useRoute()
const config = useRuntimeConfig()
const { data: site } = await useSeoSite()
const labels = useSeoLabels(() => props.locale)
const own = (config.public.seo as { htmlSitemap?: { paths?: Record<string, string> } | false } | undefined)?.htmlSitemap
const basePath = (own && typeof own === 'object' ? own.paths?.[props.locale] : '') || route.path
// The page number of a group page: from the path-form route's :page, else
// from the query parameter.
const param = site.value?.pagination?.param || 'page'
const ownPagination = (config.public.seo as { pagination?: { segments?: Record<string, string> } } | undefined)?.pagination
// The locale's path segment, as the path-form route was registered (L4 WR-01).
const segment = site.value?.pagination?.segments?.[props.locale] || ownPagination?.segments?.[props.locale] || param
let page = 1
if (props.group) {
const parsed = props.pathPage ? (/^\d{1,6}$/.test(props.pathPage) ? Number(props.pathPage) : null) : seoPageNumber(route.fullPath, 'query', '', param)
if (parsed === null || parsed < 1) throw createError({ statusCode: 404, statusMessage: 'Page Not Found', fatal: true })
page = parsed
const explicitFirst = props.pathPage === '1' || route.query[param] === '1'
// A 301 to the unnumbered URL, never to the page's own URL.
const target = explicitFirst ? seoFirstPageRedirect(route.fullPath, props.pathPage ? 'path' : 'query', segment, param) : null
if (target) await navigateTo(target, { redirectCode: 301 })
}
const { data, error } = await useFetch<SeoSitemapOverview | SeoSitemapGroupPage>('/api/__seo__/sitemap', {
key: `seo-layer:sitemap:${props.locale}:${props.group}:${page}`,
query: props.group ? { locale: props.locale, group: props.group, page } : { locale: props.locale },
})
if (error.value || !data.value) {
const status = (error.value as { statusCode?: number } | null)?.statusCode
throw createError({ statusCode: status === 404 ? 404 : 503, statusMessage: status === 404 ? 'Page Not Found' : 'Sitemap unavailable', fatal: true })
}
const origin = site.value?.site_url || useRequestURL().origin
const overview = computed(() => (props.group ? null : (data.value as SeoSitemapOverview)))
const groupPage = computed(() => (props.group ? (data.value as SeoSitemapGroupPage) : null))
const groups = computed(() => overview.value?.groups ?? [])
const title = computed(() => {
if (!groupPage.value) return labels.value.sitemap.title
const base = formatSeoLabel(labels.value.sitemap.groupTitle, { group: groupPage.value.title })
return page >= 2 ? `${base} · ${formatSeoLabel(labels.value.sitemap.pageSuffix, { page })}` : base
})
useSeoMeta({ title })
// The resolver does not know the group pages, so their trail is built here.
const crumbs = computed((): SeoBreadcrumb[] => {
if (!groupPage.value) return []
const out: SeoBreadcrumb[] = []
const home = site.value?.home_crumb
if (home && typeof home.name === 'string' && home.name) {
const url = typeof home.url === 'string' ? home.url : '/'
out.push({ name: home.name, url, path: seoSitePath(url, origin) || '/' })
}
out.push({ name: labels.value.sitemap.title, url: basePath, path: basePath })
out.push({ name: groupPage.value.title, url: route.path, path: route.path })
return out
})
</script>
<template>
<div>
<SeoBreadcrumbs v-if="groupPage" :items="crumbs" :locale="locale" />
<div class="seo-sitemap">
<h1 class="seo-sitemap__title">{{ title }}</h1>
<template v-if="overview">
<section v-for="g in groups" :key="g.key" class="seo-sitemap__group" :aria-labelledby="`seo-sitemap-${g.slug}`">
<h2 :id="`seo-sitemap-${g.slug}`" class="seo-sitemap__group-title">{{ g.title }}</h2>
<SeoSitemapList :entries="g.entries" :origin="origin" />
<p v-if="g.more" class="seo-sitemap__more">
<NuxtLink :to="`${basePath}/${g.slug}`">{{ formatSeoLabel(labels.sitemap.all, { group: g.title, count: g.total }) }}</NuxtLink>
</p>
</section>
<p v-if="groups.length === 0" class="seo-sitemap__empty">{{ labels.sitemap.empty }}</p>
</template>
<template v-else-if="groupPage">
<SeoSitemapList :entries="groupPage.entries" :origin="origin" />
<SeoPagination :current="groupPage.page" :last="groupPage.pages" :locale="locale" />
</template>
</div>
</div>
</template>

View File

@@ -0,0 +1,33 @@
// The page head from the CMS resolve answer: title, description, robots,
// canonical, Open Graph (with og:locale and its alternates), X/Twitter,
// verification meta, <html lang>, hreflang links and exactly one JSON-LD
// script holding one @graph (the CMS nodes plus those added through
// useSchemaNode, merged by @id). A listing's %pages% in the CMS title and
// description becomes its number of pages once usePagination() knows it. One entry with tagPriority 'critical', so
// its tags win over the same tags a page, a layer or a module sets; a value
// the answer lacks adds no tag and the page's own stays. The layer's app
// plugin (seo-head) is the only caller: pages and other layers never set CMS
// values themselves.
import type { Ref } from 'vue'
import { mergeGraph } from '../utils/graph.ts'
import { seoFillPages, seoHeadInput } from '../utils/seo.ts'
import type { SeoResolveAnswer } from '../utils/types.ts'
export function useCmsSeo(answer: Ref<SeoResolveAnswer | null>): void {
const origin = useRequestURL().origin
const nodes = useSchemaNodes()
const pages = useSeoPages()
useHead(
computed(() => {
const head = seoFillPages(seoHeadInput(answer.value, origin), pages.value)
const cmsGraph = answer.value?.action === 'ok' ? answer.value.jsonld : null
const graph = mergeGraph(cmsGraph, nodes())
// unhead serializes the object and escapes "<" inside it, so a value
// such as "</script>" cannot end the script element.
const script = graph ? [{ type: 'application/ld+json', key: 'seo-jsonld', innerHTML: graph }] : []
return { ...head, script }
}),
{ tagPriority: 'critical' },
)
}

View File

@@ -0,0 +1,71 @@
// Page links of a listing in the CMS-chosen URL form (D-40, D-42, UI-SPEC
// L2). The form comes from the resolve answer's pagination (a request with
// a page number), else the site answer's (when useSeoSite() ran on this
// page), else the layer's seo.pagination option; the path segment is the
// one of the page's locale. Page 1 is the unnumbered URL; the other query
// parameters stay.
//
// It also adds <link rel="prev"> and <link rel="next"> to the head and
// hands the number of pages to the CMS head (useCmsSeo), which replaces
// %pages% in the CMS title and description (D-41). Robots, canonical and
// the title and description suffixes come from the resolver.
//
// const { items, href } = usePagination({ current: page, last: lastPage })
import type { MaybeRefOrGetter } from 'vue'
import { paginationOption } from '../../lib/options.ts'
import { seoPageHref, type SeoPaginationForm } from '../utils/pageHref.ts'
import { seoPaginationWindow } from '../utils/paginationWindow.ts'
import type { SeoSiteAnswer } from '../utils/types.ts'
import { SEO_SITE_KEY } from './useSeoSite.ts'
/** The number of pages of the current listing, for the CMS head's %pages%. */
export function useSeoPages() {
return useState<number | null>('seo-layer:pages', () => null)
}
const primary = (code: string | undefined | null) => (code ?? '').toLowerCase().split(/[-_]/)[0] ?? ''
export interface UsePaginationOptions {
current: MaybeRefOrGetter<number>
last: MaybeRefOrGetter<number>
/** The listing's locale, for the path segment (default: the page's content locale). */
locale?: MaybeRefOrGetter<string | undefined>
}
export function usePagination(opts: UsePaginationOptions) {
const route = useRoute()
const answer = useSeoAnswer()
const config = useRuntimeConfig()
const { data: site } = useNuxtData<SeoSiteAnswer | null>(SEO_SITE_KEY)
const option = paginationOption((config.public.seo as { pagination?: unknown } | undefined)?.pagination)
const current = computed(() => Math.max(1, Math.trunc(Number(toValue(opts.current)) || 1)))
const last = computed(() => Math.max(0, Math.trunc(Number(toValue(opts.last)) || 0)))
const form = computed((): { form: SeoPaginationForm; param: string; segment: string } => {
const locale = toValue(opts.locale) || answer.value?.locale || primary(answer.value?.html_lang) || site.value?.default_locale || ''
const a = answer.value?.pagination
if (a && (a.form === 'query' || a.form === 'path')) return { form: a.form, param: a.param || option.param, segment: a.segment || '' }
const s = site.value?.pagination
if (s && (s.form === 'query' || s.form === 'path')) return { form: s.form, param: s.param || option.param, segment: s.segments?.[locale] || s.segments?.[primary(locale)] || '' }
return { form: option.form, param: option.param, segment: option.segments[locale] || option.segments[primary(locale)] || '' }
})
const href = (page: number) => seoPageHref(route.fullPath, page, form.value.form, form.value.segment, form.value.param)
const items = computed(() => seoPaginationWindow(current.value, last.value))
const pages = useSeoPages()
watch(last, (n) => (pages.value = n > 0 ? n : null), { immediate: true })
useHead(
computed(() => {
const link: Array<{ rel: string; href: string; key: string }> = []
if (last.value > 1 && current.value > 1 && current.value <= last.value) link.push({ rel: 'prev', href: href(current.value - 1), key: 'seo-pagination-prev' })
if (last.value > 1 && current.value < last.value) link.push({ rel: 'next', href: href(current.value + 1), key: 'seo-pagination-next' })
return { link }
}),
)
return { current, last, items, href, form }
}

View File

@@ -0,0 +1,44 @@
// Request-scoped schema.org nodes a page or another layer adds to the page's
// one JSON-LD @graph (an ItemList on a listing, a FAQPage under a FAQ block).
// useCmsSeo merges them with the CMS's nodes by @id (mergeGraph), so a page
// never carries a second JSON-LD script. The list lives on the Nuxt app
// instance, not in useState: it is rebuilt by the components' setup on the
// server and again on the client, so it never travels in the payload.
import type { MaybeRefOrGetter, ShallowRef } from 'vue'
import type { SeoSchemaNode } from '../utils/types.ts'
interface SchemaEntry {
id: number
node: MaybeRefOrGetter<SeoSchemaNode | null | undefined>
}
type AppWithNodes = { _seoSchemaNodes?: ShallowRef<SchemaEntry[]>; _seoSchemaNodeSeq?: number }
function store(): { list: ShallowRef<SchemaEntry[]>; app: AppWithNodes } {
const app = useNuxtApp() as unknown as AppWithNodes
app._seoSchemaNodes ??= shallowRef<SchemaEntry[]>([])
return { list: app._seoSchemaNodes, app }
}
/** The nodes registered for the current page, resolved (refs and getters read). */
export function useSchemaNodes(): () => SeoSchemaNode[] {
const { list } = store()
return () => list.value.map((e) => toValue(e.node)).filter((n): n is SeoSchemaNode => !!n && typeof n === 'object')
}
/**
* Adds `node` (a plain object, a ref or a getter; null adds nothing) to the
* page's @graph for as long as the calling component lives. A node with the
* @id of a CMS node is merged into it, the CMS values first.
*/
export function useSchemaNode(node: MaybeRefOrGetter<SeoSchemaNode | null | undefined>): void {
const { list, app } = store()
const id = (app._seoSchemaNodeSeq = (app._seoSchemaNodeSeq ?? 0) + 1)
list.value = [...list.value, { id, node }]
if (import.meta.client) {
onScopeDispose(() => {
list.value = list.value.filter((e) => e.id !== id)
})
}
}

View File

@@ -0,0 +1,24 @@
// The layer's labels (UI-SPEC L8) in the page's content locale: the resolve
// answer's locale, then html_lang's primary subtag, then en. An app
// overrides or adds locales in its app.config.ts:
//
// export default defineAppConfig({ seo: { labels: { pl: { sitemap: { title: 'Spis stron' } } } } })
//
// Component props override both.
import type { ComputedRef, MaybeRefOrGetter } from 'vue'
import { pickLabels, type SeoLabelOverrides, type SeoLabels } from '../utils/labels.ts'
/**
* The labels of the page's content locale, or of `locale` when given and
* not empty (the HTML sitemap pages know theirs from the route).
*/
export function useSeoLabels(locale?: MaybeRefOrGetter<string | undefined | null>): ComputedRef<SeoLabels> {
const answer = useSeoAnswer()
const appConfig = useAppConfig() as { seo?: { labels?: SeoLabelOverrides } }
return computed(() => {
const own = toValue(locale)
if (own) return pickLabels(own, '', appConfig.seo?.labels ?? {})
return pickLabels(answer.value?.locale, answer.value?.html_lang, appConfig.seo?.labels ?? {})
})
}

View File

@@ -1,5 +1,8 @@
// The resolve call and the shared answer of the current page. On the server
// it calls the internal base (runtimeConfig.seo.apiBase) and forwards the
// it takes the answer the Nitro middleware (server/middleware/seo.ts) already
// resolved for this request through its cache (event.context.seo), so a page
// costs one resolve call; for a request the middleware did not resolve (a
// path it skips, such as a dotted author slug) it calls the internal base (runtimeConfig.seo.apiBase) and forwards the
// visitor's x-forwarded-for plus the Nuxt peer, so the backend's per-IP
// throttle (seo-resolve, 600 a minute) keeps one bucket per visitor, also on
// route-cached renders. In the browser it calls the same-origin public base.
@@ -19,8 +22,10 @@ export async function resolveSeo(url: string): Promise<SeoResolveAnswer | null>
const timeout = Number((config.seo as { timeoutMs?: number } | undefined)?.timeoutMs) || 1500
try {
if (import.meta.server) {
const base = (config.seo as { apiBase: string }).apiBase
const event = useRequestEvent()
const resolved = event?.context.seoResolved as { url: string; answer: SeoResolveAnswer | null } | undefined
if (resolved && resolved.url === url) return resolved.answer
const base = (config.seo as { apiBase: string }).apiBase
const recorded = event?.context.seoForwardedFor as string | undefined
const headers: Record<string, string> = recorded ? { 'x-forwarded-for': recorded } : {}
return seoAnswer(await $fetch('/resolve', { baseURL: base, query: { url }, headers, timeout }))

View File

@@ -0,0 +1,22 @@
// The CMS's site answer (pagination form and segments, the home crumb, the
// HTML sitemap paths, the content URL patterns …), fetched once per page
// through GET /api/__seo__/site and shared by every caller on the server
// and in the browser (stable key, so the payload carries it to hydration).
// `data.value` is null with the layer off, with a backend before SEO
// v0.2.0 or with the backend down.
//
// const { data: site } = await useSeoSite()
import type { SeoSiteAnswer } from '../utils/types.ts'
export const SEO_SITE_KEY = 'seo-layer:site'
export function useSeoSite() {
return useFetch('/api/__seo__/site', {
key: SEO_SITE_KEY,
transform: (res: { site?: SeoSiteAnswer | null } | null): SeoSiteAnswer | null =>
res && typeof res === 'object' && res.site && typeof res.site === 'object' ? res.site : null,
default: () => null,
dedupe: 'defer',
})
}

View File

@@ -3,12 +3,12 @@
// answer is kept in shared state for the head plugin; on hydration the
// server's answer comes with the payload and nothing is fetched again.
import { seoRedirectTarget, seoResolvableUrl, seoXRobotsTag } from '../utils/seo.ts'
import { seoEnabled, seoRedirectTarget, seoResolvableUrl, seoXRobotsTag } from '../utils/seo.ts'
export default defineNuxtRouteMiddleware(async (to) => {
const nuxtApp = useNuxtApp()
const config = useRuntimeConfig()
if ((config.public.seo as { enabled?: boolean } | undefined)?.enabled === false) return
if (!seoEnabled(config.public.seo)) return
if (import.meta.client && nuxtApp.isHydrating && nuxtApp.payload.serverRendered) return
const state = useSeoAnswer()

View File

@@ -1,14 +1,18 @@
// One head entry for the whole app, following the resolve answer of the
// current page. tagPriority 'critical' makes its tags win over the same
// tags a page or another module sets (unhead keeps the lower weight), so
// editor values from the SEO plugin override the content defaults; a value
// the answer lacks leaves the page's own tag in place.
// current page (useCmsSeo). tagPriority 'critical' makes its tags win over
// the same tags a page or another module sets (unhead keeps the lower
// weight), so editor values from the SEO plugin override the content
// defaults; a value the answer lacks leaves the page's own tag in place.
//
// The answer comes from the route middleware (seo.global.ts): during SSR it
// is the one the Nitro middleware already resolved (event.context.seo), kept
// in useState so hydration fetches nothing; on client navigation it is
// fetched again.
import { seoHeadInput } from '../utils/seo.ts'
import { seoEnabled } from '../utils/seo.ts'
export default defineNuxtPlugin(() => {
const config = useRuntimeConfig()
if ((config.public.seo as { enabled?: boolean } | undefined)?.enabled === false) return
const answer = useSeoAnswer()
useHead(computed(() => seoHeadInput(answer.value)), { tagPriority: 'critical' })
if (!seoEnabled(config.public.seo)) return
useCmsSeo(useSeoAnswer())
})

View File

@@ -0,0 +1,50 @@
// Noindex for the routes an app declares (D-23, D-21), without page code:
//
// - a page whose meta sets requiresAuth: true (a signed-in or auth page)
// gets robots "noindex, nofollow";
// - a page whose meta sets seo: { robots: '...' } gets that value;
// - a path under seo.noindexRoutes gets "noindex, nofollow" (its route
// rule already sends the header; this adds the meta tag).
//
// The value goes into a robots meta tag that wins over the CMS's (a lower
// tagPriority than the head plugin's 'critical') and, during SSR, into the
// X-Robots-Tag header once the navigation is done, after the route
// middleware. Every other page keeps the CMS robots; on staging the CMS's
// noindex, nofollow stays. Works with the layer off too: it reads no CMS.
import { isNoindex } from '../../lib/guard.ts'
import { pageMetaRobots } from '../../lib/routes.ts'
/** Wins over the CMS head entry (tagPriority 'critical', -8). */
const ROUTE_ROBOTS_PRIORITY = -10
export default defineNuxtPlugin((nuxtApp) => {
const config = useRuntimeConfig()
const declared = (config.public.seo as { noindexRoutes?: unknown } | undefined)?.noindexRoutes
const noindexRoutes = Array.isArray(declared) ? declared.filter((p): p is string => typeof p === 'string') : []
const router = useRouter()
const answer = useSeoAnswer()
const robotsFor = (route: { path: string; meta: Record<string, unknown> }): string | null => {
if (answer.value?.staging === true) return null
const own = pageMetaRobots(route.meta)
if (own) return own
return isNoindex(route.path, noindexRoutes) ? 'noindex, nofollow' : null
}
useHead(
computed(() => {
const robots = robotsFor(router.currentRoute.value)
return robots ? { meta: [{ name: 'robots', content: robots }] } : {}
}),
{ tagPriority: ROUTE_ROBOTS_PRIORITY },
)
if (import.meta.server) {
router.afterEach((to, _from, failure) => {
if (failure) return
const robots = robotsFor(to)
if (robots) nuxtApp.runWithContext(() => (useResponseHeader('X-Robots-Tag').value = robots))
})
}
})

View File

@@ -0,0 +1,15 @@
<script setup lang="ts">
// One group page of the HTML sitemap (registered by lib/seo-module.ts at
// <path>/:group, and in the path pagination form also at
// <path>/:group/<segment>/:page; names seo-sitemap-group-<locale>). It
// renders the global component SeoHtmlSitemapPage, which an app may
// override.
const route = useRoute()
const locale = String((route.meta.seoSitemap as { locale?: string } | undefined)?.locale ?? '')
const group = computed(() => String(route.params.group ?? ''))
const pathPage = computed(() => String(route.params.page ?? ''))
</script>
<template>
<SeoHtmlSitemapPage :key="`${group}:${pathPage}:${route.query.page ?? ''}`" :locale="locale" :group="group" :path-page="pathPage" />
</template>

View File

@@ -0,0 +1,11 @@
<script setup lang="ts">
// The HTML sitemap overview of one locale (registered by lib/seo-module.ts
// at seo.htmlSitemap.paths[locale], name seo-sitemap-<locale>). It renders
// the global component SeoHtmlSitemapPage, which an app may override.
const route = useRoute()
const locale = String((route.meta.seoSitemap as { locale?: string } | undefined)?.locale ?? '')
</script>
<template>
<SeoHtmlSitemapPage :locale="locale" />
</template>

331
app/utils/bodyLinks.ts Normal file
View File

@@ -0,0 +1,331 @@
// Body link utilities for content layers (UI-SPEC L7 steps 2 to 4; D-82,
// D-83, D-71): phrase auto-links from the resolve answer, the outbound link
// rules and the body image attributes. A content layer (vue-journal-layer's
// gate) runs them at SSR, in this order, on sanitized HTML:
//
// html = imageAttrs(applyOutbound(autolink(clean, answer.autolinks, answer.autolink_max, selfPath), {...}))
//
// They work on sanitize-html's normalised output only: tags written as
// <name attr="value">, text and attribute values escaped (& < > and " as
// entities) and no comments, so a split into tags and text is exact.
// Markup is only ever added between tags (an auto-link inside a text node,
// the new-tab hint before an anchor closes) or as escaped attribute values
// built from validated input; nothing is taken from request input (T-33-39,
// T-33-40). Pure, no Vue or Nuxt imports, so `node --test` drives it and
// every layer may call it (app/utils is auto-imported).
/** One phrase of the CMS auto-link table (resolve answer `autolinks`). */
export interface SeoAutolink {
phrase: string
url: string
}
/** The outbound rules (resolve answer `outbound` plus the layer options). */
export interface SeoOutboundOptions {
/** The site's own host (with its port when it has one): links to it are internal. */
siteHost: string
/** links_new_tab: target="_blank" and rel="noopener" on outbound links. */
newTab: boolean
/** Domains whose links get rel="nofollow" (subdomains included). */
nofollowDomains?: readonly string[] | null
/** Domains whose links get rel="sponsored" (subdomains included). */
sponsoredDomains?: readonly string[] | null
/**
* The visually hidden new-tab hint, e.g. "(opens in a new tab)", added
* inside each link that opens in a new tab; '' or absent adds none.
*/
hint?: string | null
}
// Elements whose text is never auto-linked.
const SKIP = new Set(['a', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'code', 'pre', 'figcaption', 'button'])
// Elements without an end tag.
const VOID = new Set(['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'source', 'track', 'wbr'])
const TAG = /(<[^>]+>)/
const TAG_NAME = /^<\/?\s*([A-Za-z][A-Za-z0-9-]*)/
const ENTITY = /&(?:#[0-9]+|#x[0-9a-f]+|[a-z][a-z0-9]*);/gi
const WORD_CHAR = /[\p{L}\p{N}_]/u
function escapeText(value: string): string {
return value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}
function escapeAttr(value: string): string {
return escapeText(value).replace(/"/g, '&quot;')
}
/** Attribute values as sanitize-html writes them, decoded for reading. */
function decodeAttr(value: string): string {
return value
.replace(/&quot;/g, '"')
.replace(/&#39;|&apos;/g, "'")
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&amp;/g, '&')
}
const escapeRegex = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
/** A tag's name in lower case and whether it opens, closes or stands alone. */
function tagInfo(tag: string): { name: string; kind: 'open' | 'close' | 'void' } | null {
const m = TAG_NAME.exec(tag)
if (!m || !m[1]) return null
const name = m[1].toLowerCase()
if (tag.startsWith('</')) return { name, kind: 'close' }
if (VOID.has(name) || /\/\s*>$/.test(tag)) return { name, kind: 'void' }
return { name, kind: 'open' }
}
/** Keeps the stack of open elements in step with one tag. */
function track(stack: string[], tag: string): void {
const info = tagInfo(tag)
if (!info || info.kind === 'void') return
if (info.kind === 'open') {
stack.push(info.name)
return
}
const at = stack.lastIndexOf(info.name)
if (at !== -1) stack.length = at
}
/** A URL an auto-link may point at: a site path ("/x", not "//x") or an http(s) URL. */
function linkableUrl(url: unknown): url is string {
if (typeof url !== 'string' || url === '' || /[\s\x00-\x1f]/.test(url)) return false
if (url.startsWith('/')) return !url.startsWith('//') && !url.startsWith('/\\')
try {
const u = new URL(url)
return u.protocol === 'http:' || u.protocol === 'https:'
} catch {
return false
}
}
/** The path and query of a URL or path, without a trailing slash; the host when absolute. */
function pageKey(url: string): { host: string | null; path: string } | null {
try {
const absolute = /^https?:\/\//i.test(url)
const u = new URL(url, 'https://self.invalid')
const path = (u.pathname.replace(/\/+$/, '') || '/') + u.search
return { host: absolute ? u.host.toLowerCase() : null, path }
} catch {
return null
}
}
/** True when `url` is the page at `self` (a path or an absolute URL). */
function isSelf(url: string, self: string): boolean {
const a = pageKey(url)
const b = pageKey(self)
if (!a || !b) return false
if (a.host && b.host && a.host !== b.host) return false
return a.path === b.path
}
/** The first whole-word, case-insensitive match of `needle` in escaped `text`, never inside an entity. */
function findPhrase(text: string, needle: string): number {
const entities: Array<[number, number]> = []
for (const m of text.matchAll(ENTITY)) entities.push([m.index, m.index + m[0].length])
const re = new RegExp(escapeRegex(needle), 'giu')
for (const m of text.matchAll(re)) {
const start = m.index
const end = start + m[0].length
const before = text.slice(0, start)
const after = text.slice(end)
// A word character next to the match, or a match starting or ending on
// a character that cannot border a word, breaks the whole-word rule.
if (before !== '' && WORD_CHAR.test(before.at(-1)!) && WORD_CHAR.test(m[0][0]!)) continue
if (after !== '' && WORD_CHAR.test(after[0]!) && WORD_CHAR.test(m[0].at(-1)!)) continue
if (entities.some(([s, e]) => (start > s && start < e) || (end > s && end < e))) continue
return start
}
return -1
}
/**
* Phrase auto-links (C22, UI-SPEC L7 step 2): each phrase of `links` is
* linked at its first occurrence in the text, case-insensitive and as a
* whole word, and at most `max` links are added to the whole HTML. Never
* inside a, h1–h6, code, pre, figcaption or button, never to the page itself
* (`self`, its path or absolute URL) and only to a site path or an http(s)
* URL. A phrase's regex characters match literally. The anchor is a plain
* <a href="…"> with no class or rel, like an editor link.
*/
export function autolink(html: string, links: readonly SeoAutolink[] | null | undefined, max: number, self: string): string {
if (!html || !Array.isArray(links) || links.length === 0) return html
const limit = Math.max(0, Math.trunc(Number(max) || 0))
if (limit === 0) return html
const candidates = links.filter((l) => l && typeof l.phrase === 'string' && l.phrase.trim() !== '' && linkableUrl(l.url) && !isSelf(l.url, self))
if (candidates.length === 0) return html
const stack: string[] = []
const done = new Set<string>()
let used = 0
return html
.split(TAG)
.map((part) => {
if (part.startsWith('<')) {
track(stack, part)
return part
}
if (part === '' || used >= limit || stack.some((t) => SKIP.has(t))) return part
// Every remaining phrase's first match in this text, taken in document
// order (a longer phrase first at the same place) while none overlaps
// an earlier one and the limit allows.
const found: Array<{ start: number; end: number; url: string; key: string }> = []
for (const link of candidates) {
const key = link.phrase.trim().toLowerCase()
if (done.has(key) || done.has(`url:${link.url}`)) continue
const needle = escapeText(link.phrase.trim())
const start = findPhrase(part, needle)
if (start >= 0) found.push({ start, end: start + needle.length, url: link.url, key })
}
found.sort((a, b) => a.start - b.start || b.end - a.end)
const hits: typeof found = []
for (const f of found) {
if (used >= limit) break
if (done.has(f.key) || done.has(`url:${f.url}`)) continue
if (hits.some((h) => f.start < h.end && f.end > h.start)) continue
hits.push(f)
done.add(f.key)
done.add(`url:${f.url}`)
used++
}
if (hits.length === 0) return part
hits.sort((a, b) => a.start - b.start)
let out = ''
let at = 0
for (const h of hits) {
out += `${part.slice(at, h.start)}<a href="${escapeAttr(h.url)}">${part.slice(h.start, h.end)}</a>`
at = h.end
}
return out + part.slice(at)
})
.join('')
}
interface Attr {
name: string
value: string | null
}
const ATTR = /\s+([^\s"'>/=]+)(?:\s*=\s*"([^"]*)")?/g
function parseAttrs(tag: string, name: string): Attr[] {
const body = tag.slice(1 + name.length).replace(/\/?\s*>$/, '')
const out: Attr[] = []
for (const m of body.matchAll(ATTR)) out.push({ name: m[1]!.toLowerCase(), value: m[2] ?? null })
return out
}
function writeTag(name: string, attrs: readonly Attr[], selfClosing: boolean): string {
const parts = attrs.map((a) => (a.value === null ? ` ${a.name}` : ` ${a.name}="${a.value}"`))
return `<${name}${parts.join('')}${selfClosing ? ' />' : '>'}`
}
/** True when `host` is `domain` or one of its subdomains. */
function inDomains(host: string, domains: readonly string[] | null | undefined): boolean {
if (!Array.isArray(domains)) return false
return domains.some((d) => {
const domain = typeof d === 'string' ? d.trim().toLowerCase().replace(/^\.+/, '') : ''
return domain !== '' && (host === domain || host.endsWith(`.${domain}`))
})
}
/** The host of an outbound link (another host over http(s), or protocol-relative), else null. */
function outboundHost(href: string, siteHost: string): string | null {
let u: URL
try {
if (href.startsWith('//')) u = new URL(`https:${href}`)
else if (/^https?:\/\//i.test(href)) u = new URL(href)
else return null
} catch {
return null
}
const site = siteHost.trim().toLowerCase()
const host = u.host.toLowerCase()
const hostname = u.hostname.toLowerCase()
if (site !== '' && (host === site || (!site.includes(':') && hostname === site))) return null
return hostname
}
/**
* The outbound link rules (UI-SPEC L7 step 3, D-83): an http(s) link to
* another host keeps its editor rel values and gains nofollow or sponsored
* from the domain lists; while `newTab` is on it also gets target="_blank",
* rel noopener and, when `hint` is set, a trailing
* <span class="seo-visually-hidden"> hint. Internal, relative, fragment and
* non-http links are left exactly as they are. target is never read from
* the input: the sanitizer refuses it, and only this step adds it.
*/
export function applyOutbound(html: string, opts: SeoOutboundOptions): string {
if (!html) return html
const hint = typeof opts.hint === 'string' ? opts.hint.trim() : ''
// One entry per open <a>: whether its end tag takes the hint.
const anchors: boolean[] = []
return html
.split(TAG)
.map((part) => {
if (!part.startsWith('<')) return part
const info = tagInfo(part)
if (!info || info.name !== 'a') return part
if (info.kind === 'close') {
const withHint = anchors.pop()
return withHint ? `<span class="seo-visually-hidden"> ${escapeText(hint)}</span>${part}` : part
}
if (info.kind === 'void') return part
const attrs = parseAttrs(part, 'a')
const href = attrs.find((a) => a.name === 'href')
const host = href && href.value !== null ? outboundHost(decodeAttr(href.value).trim(), opts.siteHost) : null
if (!host) {
anchors.push(false)
return part
}
const relAttr = attrs.find((a) => a.name === 'rel')
const tokens = (relAttr?.value ? decodeAttr(relAttr.value) : '').split(/\s+/).filter(Boolean)
const add = (token: string) => {
if (!tokens.some((t) => t.toLowerCase() === token)) tokens.push(token)
}
if (inDomains(host, opts.nofollowDomains)) add('nofollow')
if (inDomains(host, opts.sponsoredDomains)) add('sponsored')
const out = attrs.filter((a) => a.name !== 'target')
if (opts.newTab) {
out.push({ name: 'target', value: '_blank' })
add('noopener')
}
if (tokens.length > 0) {
const value = escapeAttr(tokens.join(' '))
const at = out.findIndex((a) => a.name === 'rel')
if (at === -1) out.push({ name: 'rel', value })
else out[at] = { name: 'rel', value }
}
anchors.push(opts.newTab && hint !== '')
return writeTag('a', out, false)
})
.join('')
}
/**
* Body image attributes (UI-SPEC L7 step 4, D-71): every img gains
* loading="lazy" and decoding="async" unless it already sets them; stored
* width, height and alt (alt="" of a decorative image included) are kept
* as they are.
*/
export function imageAttrs(html: string): string {
if (!html) return html
return html
.split(TAG)
.map((part) => {
if (!part.startsWith('<')) return part
const info = tagInfo(part)
if (!info || info.name !== 'img' || info.kind === 'close') return part
const attrs = parseAttrs(part, 'img')
const has = (name: string) => attrs.some((a) => a.name === name)
if (has('loading') && has('decoding')) return part
const out = [...attrs]
if (!has('loading')) out.push({ name: 'loading', value: 'lazy' })
if (!has('decoding')) out.push({ name: 'decoding', value: 'async' })
return writeTag('img', out, /\/\s*>$/.test(part))
})
.join('')
}

View File

@@ -0,0 +1,27 @@
// One visible breadcrumb trail per page (UI-SPEC L1): an app may place
// <SeoBreadcrumbs> in its layout while a layer's page renders one too; the
// first instance that renders in a request claims the page and later ones
// render nothing. The claim is kept per path, so a client navigation hands
// it out again (the layout instance, set up first, claims first). Pure, so
// `node --test` drives it; names start with `seo` (app/utils is
// auto-imported).
export interface SeoBreadcrumbClaim {
path: string
id: string
}
/**
* Whether instance `id` owns the trail of `path`, and the claim to keep:
* no claim or one for another path is taken over; the claim of this path
* belongs to the instance that made it.
*/
export function seoClaimBreadcrumbs(state: SeoBreadcrumbClaim | null | undefined, path: string, id: string): { state: SeoBreadcrumbClaim; owns: boolean } {
if (!state || state.path !== path) return { state: { path, id }, owns: true }
return { state, owns: state.id === id }
}
/** Releases the claim of `id` (an instance that stops rendering its trail). */
export function seoReleaseBreadcrumbs(state: SeoBreadcrumbClaim | null | undefined, id: string): SeoBreadcrumbClaim | null {
return state && state.id === id ? null : (state ?? null)
}

50
app/utils/graph.ts Normal file
View File

@@ -0,0 +1,50 @@
// The one JSON-LD @graph of a page: the CMS's nodes (the resolve answer's
// jsonld) plus the nodes layers and pages add through useSchemaNode, merged
// by @id. Pure, so `node --test` drives it directly.
import type { SeoJsonLd, SeoSchemaNode } from './types.ts'
const SCHEMA_CONTEXT = 'https://schema.org'
function isNode(value: unknown): value is SeoSchemaNode {
return value !== null && typeof value === 'object' && !Array.isArray(value)
}
/** The nodes of a jsonld value: its @graph, or the value itself when it is a single node. */
function nodesOf(jsonld: unknown): SeoSchemaNode[] {
if (!isNode(jsonld)) return []
const graph = (jsonld as SeoJsonLd)['@graph']
if (Array.isArray(graph)) return graph.filter(isNode)
const { '@context': _context, ...node } = jsonld as Record<string, unknown>
return Object.keys(node).length > 0 ? [node as SeoSchemaNode] : []
}
/**
* Merges the CMS graph and the layer nodes into one {"@context", "@graph"}
* object, or null when there is no node at all (then the page gets no
* JSON-LD script). A layer node whose @id matches a node already in the
* graph is merged into it, the values already there first (the CMS is the
* editor's source of truth; the layer fills in what it lacks); a node
* without @id or with a new @id is appended. The order of first appearance
* is kept, so the output is stable between renders.
*/
export function mergeGraph(cmsJsonld: unknown, nodes: readonly unknown[] = []): { '@context': string; '@graph': SeoSchemaNode[] } | null {
const graph: SeoSchemaNode[] = []
const byId = new Map<string, number>()
const add = (node: SeoSchemaNode) => {
const id = typeof node['@id'] === 'string' && node['@id'] !== '' ? node['@id'] : null
const { '@context': _context, ...clean } = node as Record<string, unknown>
if (id !== null && byId.has(id)) {
const at = byId.get(id) as number
graph[at] = { ...clean, ...graph[at] } as SeoSchemaNode
return
}
if (id !== null) byId.set(id, graph.length)
graph.push({ ...clean } as SeoSchemaNode)
}
for (const node of nodesOf(cmsJsonld)) add(node)
for (const node of nodes) if (isNode(node)) add(node)
if (graph.length === 0) return null
const context = isNode(cmsJsonld) && typeof (cmsJsonld as SeoJsonLd)['@context'] === 'string' ? ((cmsJsonld as SeoJsonLd)['@context'] as string) : SCHEMA_CONTEXT
return { '@context': context, '@graph': graph }
}

87
app/utils/labels.ts Normal file
View File

@@ -0,0 +1,87 @@
// The layer's label dictionary (UI-SPEC L8): the strings of the public
// layer components in en and pl, without an i18n dependency. useSeoLabels()
// picks the page's content locale; an app overrides or adds locales in
// app.config.ts under seo.labels.<locale>, and component props override
// both. Breadcrumb names and sitemap group headings come from the CMS and
// are never translated here. Pure, so `node --test` drives it directly.
export interface SeoLabels {
breadcrumbs: { label: string }
pagination: { label: string; previous: string; next: string; page: string }
sitemap: { title: string; groupTitle: string; pageSuffix: string; all: string; empty: string }
sitemapLink: string
links: { newTab: string }
}
export type SeoLabelOverrides = Record<string, DeepPartial<SeoLabels>>
type DeepPartial<T> = { [K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K] }
/** The built-in dictionaries. {name} marks a placeholder (formatSeoLabel). */
export const SEO_LABELS: Readonly<Record<'en' | 'pl', SeoLabels>> = Object.freeze({
en: {
breadcrumbs: { label: 'Breadcrumbs' },
pagination: { label: 'Pagination', previous: 'Previous page', next: 'Next page', page: 'Page {page}' },
sitemap: {
title: 'Sitemap',
groupTitle: 'Sitemap: {group}',
pageSuffix: 'Page {page}',
all: '{group}: all ({count})',
empty: 'There are no pages to list yet.',
},
sitemapLink: 'Sitemap',
links: { newTab: '(opens in a new tab)' },
},
pl: {
breadcrumbs: { label: 'Ścieżka nawigacji' },
pagination: { label: 'Stronicowanie', previous: 'Poprzednia strona', next: 'Następna strona', page: 'Strona {page}' },
sitemap: {
title: 'Mapa strony',
groupTitle: 'Mapa strony: {group}',
pageSuffix: 'Strona {page}',
all: '{group}: wszystkie ({count})',
empty: 'Nie ma jeszcze stron do wyświetlenia.',
},
sitemapLink: 'Mapa strony',
links: { newTab: '(otwiera się w nowej karcie)' },
},
})
/** A copy of `base` with every string of `over` at a key `base` has. */
function merge<T>(base: T, over: unknown): T {
if (!over || typeof over !== 'object' || Array.isArray(over)) return base
const out = { ...(base as Record<string, unknown>) }
for (const [key, value] of Object.entries(over as Record<string, unknown>)) {
if (!(key in out)) continue
const current = out[key]
if (typeof current === 'string') {
if (typeof value === 'string') out[key] = value
} else if (current && typeof current === 'object') {
out[key] = merge(current, value)
}
}
return out as T
}
const primary = (code: string) => code.toLowerCase().split(/[-_]/)[0] ?? ''
/**
* The dictionary for a page: the first of the resolve answer's locale, its
* primary subtag and html_lang's primary subtag that the layer or the app
* overrides know, else en. Missing keys come from en; an app override for
* the chosen locale wins key by key.
*/
export function pickLabels(locale: string | undefined | null, htmlLang: string | undefined | null, overrides: SeoLabelOverrides = {}): SeoLabels {
const known = (code: string) => code !== '' && (code in SEO_LABELS || (overrides && Object.prototype.hasOwnProperty.call(overrides, code)))
const l = (locale ?? '').toLowerCase()
const candidates = [l, primary(l), primary(htmlLang ?? '')]
const code = candidates.find(known) ?? 'en'
const builtIn = (SEO_LABELS as Record<string, SeoLabels>)[code]
const base = code === 'en' ? SEO_LABELS.en : merge(SEO_LABELS.en, builtIn)
return merge(base, overrides?.[code])
}
/** `template` with each {name} replaced by values[name]; unknown placeholders stay. */
export function formatSeoLabel(template: string, values: Record<string, string | number>): string {
return template.replace(/\{(\w+)\}/g, (m, name: string) => (name in values ? String(values[name]) : m))
}

62
app/utils/pageHref.ts Normal file
View File

@@ -0,0 +1,62 @@
// Page URLs in the CMS-chosen pagination form (D-40, UI-SPEC L2): query
// (/blog?page=3) or path (/blog/strona/3, the segment per locale). Page 1
// is always the unnumbered URL; other query parameters stay. Built with
// URLSearchParams, never by string concatenation of request input, and
// rendered through Vue bindings (T-33-35). Pure, so `node --test` drives
// it; names start with `seo` because app/utils is auto-imported.
export type SeoPaginationForm = 'query' | 'path'
const PAGE_PATH = /^(.*?)\/([^/]+)\/(\d+)$/
/**
* The URL of page `page` for the listing at `url` (a path with an optional
* query; a page number already in it, in either form, is replaced). `param`
* is the query parameter, `segment` the path segment of the page's locale
* (the param when empty).
*/
export function seoPageHref(url: string, page: number, form: SeoPaginationForm = 'query', segment = '', param = 'page'): string {
const [rawPath = '/', rawQuery = ''] = (url.split('#')[0] ?? '').split('?')
const query = new URLSearchParams(rawQuery)
query.delete(param)
const seg = segment || param
let path = rawPath || '/'
const numbered = PAGE_PATH.exec(path)
if (form === 'path' && numbered && numbered[2] === seg) path = numbered[1] || '/'
if (form === 'path' && page > 1) path = `${path.replace(/\/+$/, '')}/${encodeURIComponent(seg)}/${page}`
if (form === 'query' && page > 1) query.set(param, String(page))
const qs = query.toString()
return qs ? `${path}?${qs}` : path
}
/**
* Where an explicit page 1 (/mapa-strony/posty/strona/1, /blog?page=1)
* redirects: the unnumbered URL, built with the locale's segment, or null
* when that target is the URL itself (a segment the URL does not carry), so
* a page never answers a 301 to its own address (L4 WR-01).
*/
export function seoFirstPageRedirect(fullPath: string, form: SeoPaginationForm, segment = '', param = 'page'): string | null {
const target = seoPageHref(fullPath, 1, form, segment, param)
return target === fullPath ? null : target
}
/**
* The page number a URL carries in the given form: 1 without one, the
* number for a positive integer, null for anything else (a 404).
*/
export function seoPageNumber(url: string, form: SeoPaginationForm = 'query', segment = '', param = 'page'): number | null {
const [rawPath = '/', rawQuery = ''] = (url.split('#')[0] ?? '').split('?')
let raw: string | null = null
if (form === 'path') {
const m = PAGE_PATH.exec(rawPath)
if (m && m[2] === (segment || param)) raw = m[3] ?? null
} else {
const values = new URLSearchParams(rawQuery).getAll(param)
if (values.length > 1) return null
raw = values[0] ?? null
}
if (raw === null) return 1
if (!/^\d+$/.test(raw)) return null
const n = Number(raw)
return Number.isSafeInteger(n) && n >= 1 ? n : null
}

39
app/utils/pagePath.ts Normal file
View File

@@ -0,0 +1,39 @@
// Which requests the Nitro SEO middleware resolves. Pure, so `node --test`
// drives it directly; the name starts with `is` like Nuxt's own helpers and
// is unique enough for the auto-import into the consuming app.
/**
* File extensions that are never pages: build assets, images, fonts, data
* and the root files (robots.txt, sitemap.xml, verification files), which
* server/middleware/seo-root.ts handles.
*/
const ASSET_EXTENSIONS = new Set([
'js', 'mjs', 'cjs', 'css', 'map', 'json', 'xml', 'txt', 'html', 'htm', 'xsl', 'webmanifest',
'ico', 'png', 'jpg', 'jpeg', 'gif', 'webp', 'avif', 'svg', 'bmp', 'tif', 'tiff',
'woff', 'woff2', 'ttf', 'otf', 'eot',
'mp3', 'mp4', 'webm', 'ogg', 'wav', 'mov',
'pdf', 'zip', 'gz', 'br', 'wasm',
])
/**
* True when `path` (path and query, as h3's event.path) is a page the SEO
* middleware resolves: not Nuxt's or the layers' internal paths (/_nuxt,
* /__nuxt_error, /_journal, /_seo …), not /api, and not a file with one of
* the asset extensions, unless the extension is listed in `resolveFiles`
* (module option seo.resolveFiles, default ['pdf'], so an old PDF address
* can still redirect). Pages a Nuxt route renders under a dotted path (an
* author slug with ".com") are still resolved by the route middleware.
*/
export function isPagePath(path: string, resolveFiles: readonly string[] = []): boolean {
if (typeof path !== 'string' || !path.startsWith('/') || path.startsWith('//')) return false
const pathname = (path.split('?')[0] ?? '').split('#')[0] ?? ''
if (pathname.startsWith('/_')) return false
if (pathname === '/api' || pathname.startsWith('/api/')) return false
const last = pathname.slice(pathname.lastIndexOf('/') + 1)
const dot = last.lastIndexOf('.')
if (dot > 0) {
const ext = last.slice(dot + 1).toLowerCase()
if (ASSET_EXTENSIONS.has(ext) && !resolveFiles.map((e) => e.toLowerCase()).includes(ext)) return false
}
return true
}

View File

@@ -0,0 +1,35 @@
// The page links of <SeoPagination> (UI-SPEC L2, D-42): five consecutive
// pages around the current one, plus page 1 and the last page when they
// fall outside, with a gap item where pages are skipped, so every page is
// within two hops of page 1 for a crawler. Pure, so `node --test` drives
// it. Names start with `seo` because app/utils is auto-imported into the
// consuming app (vue-journal-layer has its own paginationWindow).
export const SEO_PAGINATION_GAP = 'gap' as const
export type SeoPaginationItem = number | typeof SEO_PAGINATION_GAP
/**
* The items for page `current` of `last`: page numbers in order and 'gap'
* between page 1 or the last page and the window when pages are skipped.
* Empty when there is nothing to paginate (last < 1).
*/
export function seoPaginationWindow(current: number, last: number, size = 5): SeoPaginationItem[] {
if (!Number.isInteger(last) || last < 1) return []
const cur = Math.min(Math.max(Number.isInteger(current) ? current : 1, 1), last)
const width = Math.min(Math.max(1, size), last)
let start = Math.max(1, cur - Math.floor(width / 2))
if (start + width - 1 > last) start = last - width + 1
const end = start + width - 1
const out: SeoPaginationItem[] = []
if (start > 1) {
out.push(1)
if (start > 2) out.push(SEO_PAGINATION_GAP)
}
for (let p = start; p <= end; p++) out.push(p)
if (end < last) {
if (end < last - 1) out.push(SEO_PAGINATION_GAP)
out.push(last)
}
return out
}

View File

@@ -79,17 +79,44 @@ export function seoXRobotsTag(answer: SeoResolveAnswer | null): string {
interface SeoHeadInput {
title?: string
titleTemplate?: string
meta: Array<{ name?: string; property?: string; content: string }>
link: Array<{ rel: string; href: string }>
htmlAttrs?: { lang: string }
meta: Array<{ name?: string; property?: string; content: string; key?: string }>
link: Array<{ rel: string; href: string; hreflang?: string; key?: string }>
}
/**
* False when the layer is switched off: the runtime config
* public.seo.enabled (NUXT_PUBLIC_SEO_ENABLED, since v0.1.0) or its alias
* public.seo.cms (module option seo.cms, NUXT_PUBLIC_SEO_CMS) is false or
* "false". Either one turns every CMS call off.
*/
export function seoEnabled(publicSeo: unknown): boolean {
if (!publicSeo || typeof publicSeo !== 'object') return true
const p = publicSeo as { enabled?: unknown; cms?: unknown }
const off = (v: unknown) => v === false || v === 'false'
return !off(p.enabled) && !off(p.cms)
}
/**
* `value` made absolute on `origin` when it is a path ("/x"); anything else
* is returned as it is. The plugin answers paths when the backend has no
* site URL (golem15.seo.site_url, app.url), and a canonical or social URL
* must be absolute.
*/
export function seoAbsolute(value: string, origin: string | undefined): string {
if (!origin || !value.startsWith('/') || value.startsWith('//')) return value
return origin.replace(/\/+$/, '') + value
}
/**
* The head of an answer. Only non-empty values become tags, so the page's
* own tag stays wherever the answer has none. The title is final (the
* plugin applies its templates), hence the plain titleTemplate. A not_found
* answer, or no answer, contributes only the staging robots.
* answer, or no answer, contributes only the staging robots. A canonical,
* og:url or og:image that is a path is made absolute on `origin` (the
* request's origin).
*/
export function seoHeadInput(answer: SeoResolveAnswer | null): SeoHeadInput {
export function seoHeadInput(answer: SeoResolveAnswer | null, origin?: string): SeoHeadInput {
const head: SeoHeadInput = { meta: [], link: [] }
const robots = seoRobots(answer)
if (robots) head.meta.push({ name: 'robots', content: robots })
@@ -108,8 +135,8 @@ export function seoHeadInput(answer: SeoResolveAnswer | null): SeoHeadInput {
name('description', seo.description)
property('og:title', seo.og?.title)
property('og:description', seo.og?.description)
property('og:image', seo.og?.image)
property('og:url', seo.og?.url)
property('og:image', seo.og?.image && seoAbsolute(seo.og.image, origin))
property('og:url', seo.og?.url && seoAbsolute(seo.og.url, origin))
name('twitter:card', seo.twitter?.card)
name('twitter:site', seo.twitter?.site)
for (const tag of Array.isArray(seo.verification) ? seo.verification : []) {
@@ -117,7 +144,20 @@ export function seoHeadInput(answer: SeoResolveAnswer | null): SeoHeadInput {
head.meta.push({ name: tag.name, content: tag.content })
}
}
if (seo.canonical) head.link.push({ rel: 'canonical', href: seo.canonical })
if (seo.canonical) head.link.push({ rel: 'canonical', href: seoAbsolute(seo.canonical, origin) })
// SEO plugin v0.2.0 keys; an older backend leaves them out and adds no tag.
if (answer && typeof answer.html_lang === 'string' && answer.html_lang) head.htmlAttrs = { lang: answer.html_lang }
property('og:locale', seo.og?.locale)
const alternateLocales = Array.isArray(seo.og?.locale_alternates) ? seo.og.locale_alternates : []
alternateLocales.forEach((locale, i) => {
if (typeof locale === 'string' && locale) head.meta.push({ property: 'og:locale:alternate', content: locale, key: `og-locale-alt-${i}` })
})
for (const alt of Array.isArray(answer?.alternates) ? answer.alternates : []) {
if (alt && typeof alt.hreflang === 'string' && alt.hreflang && typeof alt.url === 'string' && alt.url) {
head.link.push({ rel: 'alternate', hreflang: alt.hreflang, href: seoAbsolute(alt.url, origin), key: `alt-${alt.hreflang}` })
}
}
return head
}
@@ -134,3 +174,19 @@ export function seoVerificationName(path: string, reserved: readonly string[]):
if (!m || !m[1]) return null
return reserved.includes(m[1].toLowerCase()) ? null : m[1]
}
/**
* The head with the CMS's %pages% placeholder (the number of pages of a
* listing, which only the frontend knows; D-41) replaced in the title and
* every meta content. Unchanged while `pages` is unknown (null): only a
* page that renders <SeoPagination> or calls usePagination() sets it.
*/
export function seoFillPages<T extends { title?: string; meta: Array<{ content: string }> }>(head: T, pages: number | null | undefined): T {
if (typeof pages !== 'number' || !Number.isInteger(pages) || pages < 1) return head
const fill = (v: string) => v.split('%pages%').join(String(pages))
return {
...head,
...(typeof head.title === 'string' ? { title: fill(head.title) } : {}),
meta: head.meta.map((m) => (m.content.includes('%pages%') ? { ...m, content: fill(m.content) } : m)),
}
}

26
app/utils/sitePath.ts Normal file
View File

@@ -0,0 +1,26 @@
// Links to the site's own pages from absolute CMS URLs: a URL on the site
// URL's origin (or on the request origin) becomes its path, so it renders
// as a NuxtLink; another http(s) URL stays as it is, and anything else
// (javascript:, data: …) becomes '' so it is never rendered as a link.
// Pure; names start with `seo` because app/utils is auto-imported.
export function seoSitePath(url: string, ...origins: Array<string | undefined | null>): string {
if (typeof url !== 'string' || url === '') return ''
if (url.startsWith('/') && !url.startsWith('//')) return url
let parsed: URL
try {
parsed = new URL(url)
} catch {
return ''
}
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') return ''
for (const o of origins) {
if (!o) continue
try {
if (new URL(o).origin === parsed.origin) return `${parsed.pathname}${parsed.search}` || '/'
} catch {
// not a URL
}
}
return url
}

View File

@@ -17,7 +17,16 @@ export interface SeoHead {
canonical: string
robots: string
x_robots_tag: string
og: { title: string; description: string; image: string; url: string }
og: {
title: string
description: string
image: string
url: string
/** og:locale of the page (pl_PL); absent from backends before SEO v0.2.0. */
locale?: string
/** og:locale:alternate values, one per other translated locale. */
locale_alternates?: string[]
}
twitter: { card: string; site: string }
verification: SeoMetaTag[]
}
@@ -35,4 +44,100 @@ export interface SeoResolveAnswer {
staging: boolean
/** Set on ok answers only. */
seo: SeoHead | null
// The keys below are additive (SEO plugin v0.2.0); an older backend leaves
// them out and every consumer treats an absent key as empty.
/** The <html lang> value (pl, pl-PL), set on ok answers. */
html_lang?: string
/** hreflang alternates including the page itself and x-default; absolute URLs. */
alternates?: SeoAlternate[]
/** One {"@context", "@graph"} object, or null. */
jsonld?: SeoJsonLd | null
breadcrumbs?: SeoBreadcrumb[]
/** Present only when the request carries a page number. */
pagination?: SeoPaginationAnswer
outbound?: SeoOutbound
autolinks?: Array<{ phrase: string; url: string }>
autolink_max?: number
}
export interface SeoAlternate {
hreflang: string
url: string
}
/** A schema.org node; @id links nodes across the graph. */
export type SeoSchemaNode = Record<string, unknown> & { '@id'?: string; '@type'?: string | string[] }
export interface SeoJsonLd {
'@context'?: string
'@graph'?: SeoSchemaNode[]
}
export interface SeoBreadcrumb {
name: string
url: string
path: string
}
export interface SeoPaginationAnswer {
form?: 'query' | 'path'
param?: string
segment?: string
page?: number
/** A page value that is not a page number (?page=abc): the layer answers 404. */
invalid?: boolean
}
export interface SeoOutbound {
new_tab?: boolean
nofollow_domains?: string[]
sponsored_domains?: string[]
}
/** GET /_seo/api/v1/site. Every key may be absent on an older backend. */
export interface SeoSiteAnswer {
site_url?: string
default_locale?: string
locales?: string[]
unprefixed_locale?: string
links?: { new_tab?: boolean }
html_sitemap?: { enabled?: boolean; paths?: Record<string, string>; page_size?: number }
routes?: Array<{ type: string; pattern: string }>
/** Root file names the backend serves (verification files, the IndexNow key file). */
root_files?: string[]
llms?: boolean
pagination?: { form?: 'query' | 'path'; param?: string; segments?: Record<string, string> }
home_crumb?: { name?: string; url?: string } | null
}
/** One link of the HTML sitemap (GET /_sitemap/api/v1/html); children nest the category tree. */
export interface SeoSitemapEntry {
title: string
url: string
children?: SeoSitemapEntry[]
}
/** A group of the HTML sitemap overview. */
export interface SeoSitemapGroup {
key: string
slug: string
title: string
total: number
entries: SeoSitemapEntry[]
/** True when the group has more than the overview lists: link its group page. */
more: boolean
}
/** The overview (GET /_sitemap/api/v1/html?locale=). */
export interface SeoSitemapOverview {
groups: SeoSitemapGroup[]
}
/** One page of a group (GET /_sitemap/api/v1/html/{group}?locale=&page=). */
export interface SeoSitemapGroupPage {
group: string
title: string
page: number
pages: number
entries: SeoSitemapEntry[]
}

163
lib/guard.ts Normal file
View File

@@ -0,0 +1,163 @@
// The SSR build guard (D-70): an indexable page must render on the server,
// so a crawler sees its content without running JavaScript. The module
// (lib/seo-module.ts) feeds these pure checks with what Nuxt knows once the
// layers are merged: the app's ssr flag, every route rule (nuxt.options
// routeRules, nitro routeRules and the rules of every layer and module), the
// resolved pages (a .client.vue page has mode 'client') and the
// experimental inline route rules flag. A route that is meant to render in
// the browser only builds when the app declares it noindex in
// seo.noindexRoutes, or when the whole site is not indexable
// (seo.indexable: false). Pure TypeScript, so `node --test` drives it.
export const GUARD_MESSAGE = '[vue-seo-layer] indexable routes must render on the server (D-70)'
/** What the guard reads from the merged Nuxt configuration. */
export interface GuardInput {
/** nuxt.options.ssr as the app set it (false: the whole app renders in the browser). */
ssr: boolean
/** seo.indexable: false means the site is never indexed, so nothing is checked. */
indexable: boolean
/** Every route rule after the layers merged, by pattern. */
routeRules: Record<string, unknown>
/** The resolved pages (children included); a .client.vue page has mode 'client'. */
pages: GuardPage[]
/** seo.noindexRoutes: route-rule patterns the app declares noindex. */
noindexRoutes: string[]
/** experimental.inlineRouteRules: defineRouteRules in pages, which the guard refuses. */
inlineRouteRules: boolean
}
export interface GuardPage {
path: string
mode?: string
children?: GuardPage[]
}
/** Segments of a path (without its query) or pattern, without empty ones. */
const segments = (p: string) => p.split('/').filter((s) => s !== '')
const isParam = (s: string) => s === '*' || s.startsWith(':')
const isCatchAll = (s: string) => s === '**' || s.startsWith('**:')
/**
* True when the route-rule pattern `rule` covers every path `target`
* stands for. `target` is a concrete path or itself a pattern: `**` (or
* `**:name`) matches the rest of the path including nothing, `*` and
* `:name` one segment, anything else the same segment. So '/x/**' covers
* '/x', '/x/a' and '/x/*'; '/x/*' covers '/x/a' and '/x/:id' but not
* '/x/**' or '/x'. These are Nitro's route-rule semantics.
*/
export function patternCovers(rule: string, target: string): boolean {
const r = segments(rule)
const t = segments(target)
for (let i = 0; i < r.length; i++) {
const seg = r[i]!
if (isCatchAll(seg)) return true
const other = t[i]
if (other === undefined) return false
if (isCatchAll(other)) return false
if (isParam(seg)) continue
if (isParam(other) || other !== seg) return false
}
return r.length === t.length
}
/**
* True when `path` (a concrete path or a route pattern) lies under one of
* the declared noindex patterns (seo.noindexRoutes), so it may render in
* the browser only.
*/
export function isNoindex(path: string, noindexRoutes: readonly string[]): boolean {
return noindexRoutes.some((p) => typeof p === 'string' && p !== '' && patternCovers(p, path))
}
/**
* A Nuxt page path as a route-rule pattern: ':slug()' and ':slug' become
* ':slug', a catch-all (':slug(.*)*', '[...slug]') becomes '**', an
* optional parameter (':slug?', '[[slug]]') stays a parameter of its own
* segment (the variant without it is returned by pagePatterns).
*/
export function pagePattern(path: string): string {
const out = segments(path).map((seg) => {
if (/^:[\w-]+\(\.\*\)\*$/.test(seg) || /^:[\w-]+\*$/.test(seg)) return '**'
const m = /^:([\w-]+)(?:\([^)]*\))?\??$/.exec(seg)
if (m) return `:${m[1]}`
return seg
})
return '/' + out.join('/')
}
/**
* Every pattern a page path stands for: one, or two when its last segment
* is optional (with and without it).
*/
export function pagePatterns(path: string): string[] {
const segs = segments(path)
const last = segs[segs.length - 1]
const main = pagePattern(path)
if (last && /^:[\w-]+(?:\([^)]*\))?\?$/.test(last)) {
return [main, pagePattern('/' + segs.slice(0, -1).join('/'))]
}
return [main]
}
/** The pages with their full paths, children joined to their parents. */
export function flattenPages(pages: readonly GuardPage[], parent = ''): Array<GuardPage & { fullPath: string }> {
const out: Array<GuardPage & { fullPath: string }> = []
for (const page of pages) {
const own = typeof page.path === 'string' ? page.path : ''
const fullPath = own.startsWith('/') ? own : `${parent.replace(/\/+$/, '')}/${own}`.replace(/\/+$/, '') || '/'
out.push({ ...page, fullPath })
if (Array.isArray(page.children) && page.children.length > 0) out.push(...flattenPages(page.children, fullPath))
}
return out
}
const rendersOnClient = (rule: unknown) => rule !== null && typeof rule === 'object' && (rule as { ssr?: unknown }).ssr === false
/**
* The guard's findings: one entry per route that would be served without
* server rendering although it is indexable. Empty when the build may go
* on. A global ssr: false is reported once (Nuxt also turns it into a
* '/**' rule, which is then not repeated).
*/
export function findOffenders(input: GuardInput): string[] {
if (input.indexable === false) return []
const noindex = Array.isArray(input.noindexRoutes) ? input.noindexRoutes : []
const out: string[] = []
const add = (s: string) => {
if (!out.includes(s)) out.push(s)
}
if (input.inlineRouteRules) add('experimental.inlineRouteRules (remove it: defineRouteRules can turn server rendering off where the guard cannot see it)')
const globalOff = input.ssr === false && !isNoindex('/**', noindex)
if (globalOff) add('ssr: false (the whole app)')
for (const [pattern, rule] of Object.entries(input.routeRules ?? {})) {
if (!rendersOnClient(rule)) continue
if (globalOff && pattern === '/**') continue
if (!isNoindex(pattern, noindex)) add(pattern)
}
for (const page of flattenPages(input.pages ?? [])) {
if (page.mode !== 'client') continue
for (const pattern of pagePatterns(page.fullPath)) {
if (!isNoindex(pattern, noindex)) add(`${page.fullPath} (.client.vue page)`)
}
}
return out
}
/** The build error for the offenders. */
export function guardMessage(offenders: readonly string[]): string {
return (
`${GUARD_MESSAGE}: ${offenders.join(', ')}. ` +
'Declare a route that may render in the browser only in seo.noindexRoutes (it is then served noindex), ' +
'or set seo.indexable: false for a site that is never indexed.'
)
}
/** The route rule of a declared noindex pattern: X-Robots-Tag unless the app set its own. */
export function noindexRule(rule: unknown): Record<string, unknown> {
const base = rule !== null && typeof rule === 'object' ? (rule as Record<string, unknown>) : {}
const headers = base.headers !== null && typeof base.headers === 'object' ? (base.headers as Record<string, string>) : {}
const own = Object.keys(headers).some((k) => k.toLowerCase() === 'x-robots-tag')
return { ...base, headers: own ? headers : { ...headers, 'x-robots-tag': 'noindex, nofollow' } }
}

41
lib/options.ts Normal file
View File

@@ -0,0 +1,41 @@
// The layer's pagination and HTML-sitemap module options normalised for
// the runtime config (lib/seo-module.ts) and their readers in app and
// server code. Pure, so `node --test` drives it.
export interface SeoPaginationOption {
form: 'query' | 'path'
param: string
segments: Record<string, string>
}
/**
* The HTML sitemap pages by locale, or false for none. overCatchAll: serve
* the layer's pages ahead of an app catch-all page that covers the path
* (default false: the catch-all keeps the path, L4 WR-04).
*/
export type SeoHtmlSitemapOption = { paths: Record<string, string>; overCatchAll: boolean } | false
/** The pagination option with its defaults, as the runtime config carries it. */
export function paginationOption(value: unknown): SeoPaginationOption {
const v = value !== null && typeof value === 'object' ? (value as Record<string, unknown>) : {}
const segments: Record<string, string> = {}
const raw = v.segments !== null && typeof v.segments === 'object' ? (v.segments as Record<string, unknown>) : {}
for (const [locale, segment] of Object.entries(raw)) if (typeof segment === 'string' && /^[\p{L}\p{N}_-]+$/u.test(segment)) segments[locale] = segment
return {
form: v.form === 'path' ? 'path' : 'query',
param: typeof v.param === 'string' && /^[A-Za-z0-9_-]+$/.test(v.param) ? v.param : 'page',
segments,
}
}
/** The HTML sitemap option: false, or the paths (each '/' plus at least one character) and overCatchAll. */
export function htmlSitemapOption(value: unknown): SeoHtmlSitemapOption {
if (value === false) return false
const v = value !== null && typeof value === 'object' ? (value as Record<string, unknown>) : {}
const paths: Record<string, string> = {}
const raw = v.paths !== null && typeof v.paths === 'object' ? (v.paths as Record<string, unknown>) : {}
for (const [locale, path] of Object.entries(raw)) {
if (typeof path === 'string' && /^\/[^?#\s]+$/.test(path) && !path.includes(':') && !path.includes('*')) paths[locale] = path.replace(/\/+$/, '')
}
return { paths, overCatchAll: v.overCatchAll === true }
}

127
lib/routes.ts Normal file
View File

@@ -0,0 +1,127 @@
// The routes the layer tells the CMS about (D-33) and compares with it at
// server start (D-20c). Built at build time from the resolved pages, whose
// meta carries requiresAuth and seo (the module adds both to Nuxt's page
// meta extraction keys), and written into the server template #seo-routes
// for the route report and the drift check. Pure TypeScript, so
// `node --test` drives it.
import { flattenPages, isNoindex, pagePattern, type GuardPage } from './guard.ts'
export interface RoutePage extends GuardPage {
name?: string
meta?: Record<string, unknown>
children?: RoutePage[]
}
export interface RoutePattern {
/** A route-rule style pattern: '/blog/:slug', '/docs/**', '/'. */
pattern: string
/** True when the route ends in a catch-all ('[...slug]'). */
catchAll: boolean
}
/** True when a robots value keeps the page out of the index. */
export const robotsNoindex = (robots: unknown) => typeof robots === 'string' && /(^|[\s,])(noindex|none)($|[\s,])/i.test(robots)
/**
* The robots value a page asks for through its meta: seo.robots when set,
* else 'noindex, nofollow' for a page with requiresAuth: true (a signed-in
* or auth page, D-23), else null (the CMS decides).
*/
export function pageMetaRobots(meta: Record<string, unknown> | undefined | null): string | null {
if (!meta || typeof meta !== 'object') return null
const seo = meta.seo
if (seo && typeof seo === 'object') {
const robots = (seo as { robots?: unknown }).robots
if (typeof robots === 'string' && robots.trim() !== '') return robots.trim()
}
if (meta.requiresAuth === true) return 'noindex, nofollow'
return null
}
/** The layer's own HTML sitemap pages, which the CMS knows already. */
const isLayerPage = (name: unknown) => typeof name === 'string' && name.startsWith('seo-sitemap-')
/**
* The route-name suffixes of @nuxtjs/i18n's copies of a page in the locales
* other than the default one (`contact___pl` with the default separator):
* with prefix_except_default or prefix_and_default the CMS knows those
* addresses as the page's translations, not as pages of their own. Nothing
* without i18n, with no_prefix or without a default locale.
*/
export function localeCopySuffixes(i18n: unknown): string[] {
if (!i18n || typeof i18n !== 'object') return []
const o = i18n as { strategy?: unknown; defaultLocale?: unknown; locales?: unknown; routesNameSeparator?: unknown }
const strategy = typeof o.strategy === 'string' ? o.strategy : 'prefix_except_default'
const def = typeof o.defaultLocale === 'string' ? o.defaultLocale : ''
if (strategy === 'no_prefix' || def === '' || !Array.isArray(o.locales)) return []
const sep = typeof o.routesNameSeparator === 'string' && o.routesNameSeparator !== '' ? o.routesNameSeparator : '___'
const codes = o.locales.map((l) => (typeof l === 'string' ? l : l && typeof l === 'object' ? (l as { code?: unknown }).code : undefined))
return codes.filter((c): c is string => typeof c === 'string' && c !== '' && c !== def).map((c) => `${sep}${c}`)
}
/**
* The paths without parameters that may be indexed, sorted: no page whose
* meta keeps it out of the index (requiresAuth, a noindex seo.robots), none
* under seo.noindexRoutes, none of the layer's HTML sitemap pages, no
* client-only page and no i18n copy of a page in another locale (a route
* whose name ends in one of localeCopies, see localeCopySuffixes).
*/
export function staticRoutes(pages: readonly RoutePage[], noindexRoutes: readonly string[], localeCopies: readonly string[] = []): string[] {
const out = new Set<string>()
for (const page of flattenPages(pages) as Array<RoutePage & { fullPath: string }>) {
if (typeof page.name === 'string' && localeCopies.some((suffix) => page.name!.endsWith(suffix))) continue
// A parent with children renders through one of them.
if (Array.isArray(page.children) && page.children.some((c) => c.path === '')) continue
const path = page.fullPath
if (path.includes(':') || path.includes('*')) continue
if (page.mode === 'client' || isLayerPage(page.name)) continue
if (robotsNoindex(pageMetaRobots(page.meta))) continue
if (isNoindex(path, noindexRoutes)) continue
out.add(path)
}
return [...out].sort()
}
/**
* The paths without parameters the build serves noindex, sorted (G1, the
* route report's `noindex` list): pages whose meta keeps them out of the
* index (requiresAuth, a noindex or none seo.robots) and static pages under
* seo.noindexRoutes. Client-only pages count (they are noindex by the
* guard's rule) and so do the @nuxtjs/i18n copies in other locales, which
* are listed as their own locale-prefixed paths ('/pl/konto'). Never a
* pattern, never a path with a parameter or a catch-all, never one of the
* layer's HTML sitemap pages, and never a path staticRoutes lists.
*/
export function noindexStaticRoutes(pages: readonly RoutePage[], noindexRoutes: readonly string[]): string[] {
const out = new Set<string>()
for (const page of flattenPages(pages) as Array<RoutePage & { fullPath: string }>) {
if (Array.isArray(page.children) && page.children.some((c) => c.path === '')) continue
const path = page.fullPath
if (path.includes(':') || path.includes('*')) continue
if (isLayerPage(page.name)) continue
if (robotsNoindex(pageMetaRobots(page.meta)) || isNoindex(path, noindexRoutes)) out.add(path)
}
return [...out].sort()
}
/** Every page's route pattern ('/posts/:slug', '/**' for a catch-all), sorted. */
export function routePatterns(pages: readonly RoutePage[]): RoutePattern[] {
const seen = new Map<string, RoutePattern>()
for (const page of flattenPages(pages)) {
const pattern = pagePattern(page.fullPath)
if (!seen.has(pattern)) seen.set(pattern, { pattern, catchAll: pattern.endsWith('/**') })
}
return [...seen.values()].sort((a, b) => a.pattern.localeCompare(b.pattern))
}
/** The body of the server template #seo-routes. */
export function routesTemplate(staticPaths: readonly string[], patterns: readonly RoutePattern[], noindexPaths: readonly string[] = []): string {
return [
'// Generated by vue-seo-layer (lib/routes.ts) from the resolved pages.',
`export const staticRoutes = ${JSON.stringify(staticPaths)}`,
`export const routePatterns = ${JSON.stringify(patterns)}`,
`export const noindexPaths = ${JSON.stringify(noindexPaths)}`,
'',
].join('\n')
}

224
lib/seo-module.ts Normal file
View File

@@ -0,0 +1,224 @@
// 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,
}
},
})

94
lib/sitemapPages.ts Normal file
View File

@@ -0,0 +1,94 @@
// The HTML sitemap pages the layer registers (D-34, P33-68): one overview
// at seo.htmlSitemap.paths[locale], one group page at <path>/:group and, in
// the path pagination form, <path>/:group/<segment>/:page with the locale's
// segment. The module (lib/seo-module.ts) calls this from a pages:resolved
// hook that runs after @nuxtjs/i18n has localised the app's routes, so
// i18n never copies these routes into other locales (L4 WR-05): with i18n
// (any strategy but no_prefix) each route is named
// <name><routesNameSeparator><locale>, the name i18n reads the route's
// locale from, and exists once, at the configured path. A path an app page
// already has is the app's ('taken'); a path an app catch-all page covers
// ('[...slug].vue' at the root, or under a prefix) stays the catch-all's
// unless overCatchAll is true (L4 WR-04), because a static route would win
// over it in vue-router and shadow the CMS page there. Pure TypeScript, so
// `node --test` drives it.
import { flattenPages, pagePattern, patternCovers } from './guard.ts'
import type { SeoHtmlSitemapOption, SeoPaginationOption } from './options.ts'
import type { RoutePage } from './routes.ts'
/** Every route the layer adds starts with this name. */
export const SITEMAP_PAGE_PREFIX = 'seo-sitemap-'
export interface SitemapPagesInput {
htmlSitemap: SeoHtmlSitemapOption
pagination: SeoPaginationOption
/** The resolved pages (after @nuxtjs/i18n), the layer's own routes left out. */
pages: readonly RoutePage[]
/** nuxt.options.i18n when the app configures @nuxtjs/i18n. */
i18n?: unknown
/** The overview and group page components. */
files: { overview: string; group: string }
}
export interface SitemapRoute {
name: string
path: string
file: string
meta: { seoSitemap: { locale: string } }
}
export interface SitemapSkip {
locale: string
path: string
reason: 'taken' | 'catch-all'
}
/** True for a route the layer added (a former run of the hook). */
export const isSitemapRoute = (page: { name?: unknown; file?: unknown }, files: { overview: string; group: string }) =>
typeof page.name === 'string' && page.name.startsWith(SITEMAP_PAGE_PREFIX) && (page.file === files.overview || page.file === files.group)
/** The route-name suffix of a locale with @nuxtjs/i18n active, '' without. */
function localeSuffix(i18n: unknown, locale: string): string {
if (!i18n || typeof i18n !== 'object') return ''
const o = i18n as { strategy?: unknown; routesNameSeparator?: unknown }
if (o.strategy === 'no_prefix') return ''
const sep = typeof o.routesNameSeparator === 'string' && o.routesNameSeparator !== '' ? o.routesNameSeparator : '___'
return `${sep}${locale}`
}
/** The routes to add and the locales left out, with the reason. */
export function seoSitemapPages(input: SitemapPagesInput): { routes: SitemapRoute[]; skipped: SitemapSkip[] } {
const routes: SitemapRoute[] = []
const skipped: SitemapSkip[] = []
const html = input.htmlSitemap
if (html === false) return { routes, skipped }
const pages = flattenPages(input.pages).filter((p) => !isSitemapRoute(p as RoutePage, input.files))
const taken = new Set(pages.map((p) => p.fullPath))
const catchAlls = pages.map((p) => pagePattern(p.fullPath)).filter((p) => p.endsWith('/**'))
for (const [locale, path] of Object.entries(html.paths)) {
if (taken.has(path)) {
skipped.push({ locale, path, reason: 'taken' })
continue
}
if (!html.overCatchAll && catchAlls.some((pattern) => patternCovers(pattern, path))) {
skipped.push({ locale, path, reason: 'catch-all' })
continue
}
const suffix = localeSuffix(input.i18n, locale)
const meta = { seoSitemap: { locale } }
routes.push({ name: `${SITEMAP_PAGE_PREFIX}${locale}${suffix}`, path, file: input.files.overview, meta })
routes.push({ name: `${SITEMAP_PAGE_PREFIX}group-${locale}${suffix}`, path: `${path}/:group`, file: input.files.group, meta })
if (input.pagination.form === 'path') {
const segment = input.pagination.segments[locale] || input.pagination.param
routes.push({ name: `${SITEMAP_PAGE_PREFIX}group-page-${locale}${suffix}`, path: `${path}/:group/${segment}/:page(\\d+)`, file: input.files.group, meta })
}
}
return { routes, skipped }
}
/** The build log line of a skipped locale. */
export function sitemapSkipMessage(skip: SitemapSkip): string {
if (skip.reason === 'taken') return `HTML sitemap: the app has its own page at ${skip.path}; the layer's ${skip.locale} sitemap page is not registered`
return `HTML sitemap: ${skip.locale} ${skip.path} is rendered by the app's catch-all page; the layer's page is not registered (set seo.htmlSitemap.overCatchAll to serve it ahead of the catch-all)`
}

View File

@@ -1,43 +1,105 @@
// vue-seo-layer: the frontend half of the SummerCMS SEO plugin
// (golem15.seo). For every page it asks GET /_seo/api/v1/resolve?url=…,
// enforces the redirect and 410 decisions, and sets the head (title,
// description, canonical, robots, Open Graph, X/Twitter, verification) from
// the answer with priority over the page's own tags. It also reports 404s to
// the plugin's log and serves the verification files at the site root. An
// app consumes it with `extends: ['vue-seo-layer']`.
// description, canonical, robots, Open Graph, X/Twitter, verification,
// hreflang, <html lang> and one JSON-LD @graph) from the answer with priority
// over the page's own tags. It also reports 404s to the plugin's log and
// serves the site's root files (verification files and, with a backend that
// answers /_seo/api/v1/site, robots.txt, the sitemaps, llms.txt and the
// IndexNow key). An app consumes it with `extends: ['vue-seo-layer']`.
//
// Every path below is absolute and built from this file's directory: a
// relative or ~ path in a layer config resolves against the consuming
// project, not the layer. The layer declares no Nuxt modules.
// project, not the layer. The layer's only module is its own local one
// (lib/seo-module.ts); it pulls in no package.
import { fileURLToPath } from 'node:url'
const currentDir = fileURLToPath(new URL('.', import.meta.url))
export default defineNuxtConfig({
compatibilityDate: '2025-07-15',
devtools: { enabled: false },
modules: [`${currentDir}lib/seo-module.ts`],
// Tokens first, then the base styles that use them (only .seo-* classes).
// The app's own css array loads after these, so its overrides win.
css: [`${currentDir}app/assets/css/seo-tokens.css`, `${currentDir}app/assets/css/seo-base.css`],
runtimeConfig: {
seo: {
// Server-only base for SSR calls (NUXT_SEO_API_BASE). It never comes
// from request input.
apiBase: 'http://127.0.0.1:8096/_seo/api/v1',
// Server-only origin of the Go binary for the root files
// (NUXT_SEO_BACKEND_BASE). Empty: apiBase without /_seo/api/v1, so
// http://127.0.0.1:8096 by default.
backendBase: '',
// Server-only bearer token of POST /notfound (NUXT_SEO_NOTFOUND_TOKEN),
// the backend's golem15.seo.notfound_token. Empty: 404s are not
// reported. Not under NUXT_SITE_: nuxt-site-config copies every
// NUXT_SITE_* variable into the site config, which is published in
// every page's payload.
notfoundToken: '',
// Server-only bearer token of the route report
// (NUXT_SEO_ROUTE_REPORT_TOKEN). Empty: notfoundToken is used.
routeReportToken: '',
// How long a resolve call may take before the page renders without it
// (NUXT_SEO_TIMEOUT_MS).
timeoutMs: 1500,
// Seconds a resolve answer stays in the layer's cache
// (NUXT_SEO_RESOLVE_CACHE_TTL); the site cache purge clears it. 0
// turns the cache off.
resolveCacheTtl: 60,
},
// The shared site cache purge route POST /api/__site__/cache/purge
// (moved here from the journal, menu and FAQ layers, same contract).
cachePurge: {
// Server-only bearer token (NUXT_CACHE_PURGE_TOKEN), the backend's
// site_cache.purge_token. Shorter than 32 characters, the route
// answers 404. Not under NUXT_SITE_: nuxt-site-config copies every
// NUXT_SITE_* variable into the site config, which is published in
// every page's payload.
token: '',
// NUXT_CACHE_PURGE_ALLOW_PROXIED: set it to true when the backend
// reaches the site through a TLS-terminating proxy (an https
// site_cache.purge_url on another host), which adds X-Forwarded-For,
// X-Real-IP or Forwarded. The route then accepts proxied requests and
// the bearer token is the only gate; unset, it answers 404 to any
// proxied request.
allowProxied: false,
},
// Deprecated (one release): the earlier names of cachePurge.token
// (NUXT_SITE_CACHE_PURGE_TOKEN) and cachePurge.allowProxied
// (NUXT_SITE_CACHE_ALLOW_PROXIED), read when the new ones are unset. The
// layer's server/plugins/site-cache-purge.ts strips them from the site
// config and logs a deprecation warning.
siteCache: {
purgeToken: '',
allowProxied: false,
},
public: {
seo: {
// Same-origin base for browser navigations (NUXT_PUBLIC_SEO_API_BASE).
// The site's proxy routes GET <base>/resolve to the backend.
// The site's proxy routes GET <base>/resolve to the backend; set it
// to /api/__seo__ to resolve through the Nuxt server instead.
apiBase: '/_seo/api/v1',
// NUXT_PUBLIC_SEO_ENABLED=false turns the layer off: no resolve
// calls, no head, no redirects, no 404 reports.
// calls, no head, no redirects, no root files, no 404 reports. The
// module option seo.cms (NUXT_PUBLIC_SEO_CMS) is an alias.
enabled: true,
},
},
},
// The layer's routes and the shared site routes are never route-cached.
// Nitro wraps every handler matching an app rule such as '/**': { swr } in
// its cached handler, which keys by path, does not skip POST and hides
// request headers, so the purge route would answer from the cache without
// running.
routeRules: {
'/api/__seo__/**': { cache: false },
'/api/__site__/**': { cache: false },
},
})

View File

@@ -1,6 +1,6 @@
{
"name": "vue-seo-layer",
"version": "0.1.0",
"version": "0.2.0",
"private": true,
"type": "module",
"main": "./nuxt.config.ts",
@@ -9,7 +9,9 @@
"build:playground": "nuxt build .playground",
"test": "node --test --test-reporter=tap tests/*.test.ts",
"dev:prepare": "nuxt prepare .playground",
"smoke:ssr": "node testing/smoke-ssr.mjs --output .playground/.output --fixtures testing/fixtures --expect testing/expect.txt"
"smoke:ssr": "node testing/smoke-ssr.mjs --output .playground/.output --fixtures testing/fixtures --expect testing/expect.txt --no-site && node testing/smoke-ssr.mjs --output .playground/.output --fixtures testing/fixtures --expect testing/expect-seo.txt && node testing/smoke-ssr.mjs --output .playground/.output --fixtures testing/fixtures --expect testing/expect-seo-off.txt --env NUXT_PUBLIC_SEO_CMS=false && node testing/smoke-ssr.mjs --output .playground/.output --fixtures testing/fixtures --expect testing/expect-seo-off.txt --env NUXT_PUBLIC_SEO_ENABLED=false && node testing/smoke-ssr.mjs --output .playground/.output --fixtures testing/fixtures --expect testing/expect-seo-down.txt --env NUXT_SEO_API_BASE=http://127.0.0.1:1/_seo/api/v1 && node testing/smoke-ssr.mjs --output .playground/.output --fixtures testing/fixtures --expect testing/expect-seo-notoken.txt --env NUXT_SEO_NOTFOUND_TOKEN= && node testing/smoke-ssr.mjs --output .playground/.output --fixtures testing/fixtures --expect testing/expect-seo-drift.txt --site-file site-drift.json --env NUXT_PUBLIC_SEO_ROUTE_REPORT=false",
"guard:check": "node testing/guard-check.mjs",
"purge:check": "node testing/purge-check.mjs --output .playground/.output --fixtures testing/fixtures --path /blog/lwia-grzywa && node testing/purge-token-check.mjs --output .playground/.output --fixtures testing/fixtures --path /blog/lwia-grzywa"
},
"engines": {
"node": ">=22.18"

View File

@@ -0,0 +1,24 @@
// GET /api/__seo__/resolve?url=<path and query>: the resolve answer through
// the layer's server (and its cache), for client-side navigation on a site
// whose proxy does not route /_seo/api/v1 to the backend. The browser uses
// it when public.seo.apiBase (NUXT_PUBLIC_SEO_API_BASE) is "/api/__seo__";
// the default stays the backend's same-origin /_seo/api/v1.
//
// 404 when the layer is off; 422 for a url that is not a path; 502 when the
// backend gives no usable answer. Never route-cached ('/api/__seo__/**'
// cache false) and never indexed.
import { seoServerConfig } from '../../lib/config.ts'
import { resolveCached } from '../../lib/resolve.ts'
export default defineEventHandler(async (event) => {
setResponseHeaders(event, { 'cache-control': 'no-store', 'x-robots-tag': 'noindex' })
if (!seoServerConfig(useRuntimeConfig(event)).enabled) throw createError({ statusCode: 404, statusMessage: 'Not Found' })
const url = getQuery(event).url
if (typeof url !== 'string' || !url.startsWith('/') || url.startsWith('//') || url.length > 2048) {
throw createError({ statusCode: 422, statusMessage: 'Bad url' })
}
const answer = await resolveCached(event, url)
if (!answer) throw createError({ statusCode: 502, statusMessage: 'No SEO answer' })
return answer
})

View File

@@ -0,0 +1,18 @@
// GET /api/__seo__/site: the CMS's site answer (GET <apiBase>/site, SEO
// plugin v0.2.0) through the layer's server and its cache, for useSeoSite()
// on the server and in the browser. Always 200 with {site: <answer> | null}:
// null with the layer off, with a backend before v0.2.0 (its /site answers
// 404) or with a backend that has not answered since start. Never
// route-cached ('/api/__seo__/**' cache false) and never indexed.
import { seoServerConfig } from '../../lib/config.ts'
import { siteAnswer } from '../../lib/site.ts'
export default defineEventHandler(async (event) => {
setResponseHeaders(event, { 'cache-control': 'no-store', 'x-robots-tag': 'noindex' })
const cfg = seoServerConfig(useRuntimeConfig(event))
if (!cfg.enabled) return { site: null }
const forwarded = event.context.seoForwardedFor as string | undefined
const state = await siteAnswer(cfg, forwarded ? { 'x-forwarded-for': forwarded } : {})
return { site: state.state === 'ok' ? state.site : null }
})

View File

@@ -0,0 +1,62 @@
// GET /api/__seo__/sitemap?locale=&group=&page=: the HTML sitemap (D-34)
// from the sitemap plugin, for the layer's HTML sitemap pages
// (SeoHtmlSitemapPage). Without group: the overview of the locale; with
// group: one page of that group.
//
// 404 the layer is off, the backend has no site answer (an older SEO
// plugin) or the HTML sitemap is off or has no path for the
// locale, a malformed parameter, an unknown group or a page past
// the last (the plugin's 404 is passed through)
// 503 the backend did not answer or answered anything else (with
// Retry-After: 60), so the page renders an error, never an empty
// sitemap that a route cache could keep
//
// The target is the server-only backend base plus a checked group slug;
// only locale and page travel as query values. Never route-cached
// ('/api/__seo__/**' cache false) and never indexed.
import { seoServerConfig } from '../../lib/config.ts'
import { HTML_SITEMAP_GROUP, HTML_SITEMAP_LOCALE, htmlSitemapGroupPage, htmlSitemapOverview, htmlSitemapPage } from '../../lib/htmlSitemap.ts'
import { siteAnswer } from '../../lib/site.ts'
const notFound = () => createError({ statusCode: 404, statusMessage: 'Not Found' })
export default defineEventHandler(async (event) => {
setResponseHeaders(event, { 'cache-control': 'no-store', 'x-robots-tag': 'noindex' })
const unavailable = () => {
setResponseHeader(event, 'retry-after', '60')
return createError({ statusCode: 503, statusMessage: 'Sitemap unavailable' })
}
const cfg = seoServerConfig(useRuntimeConfig(event))
if (!cfg.enabled) throw notFound()
const q = getQuery(event)
const locale = typeof q.locale === 'string' ? q.locale : ''
const group = typeof q.group === 'string' ? q.group : ''
const page = htmlSitemapPage(q.page)
if (!HTML_SITEMAP_LOCALE.test(locale) || (group !== '' && !HTML_SITEMAP_GROUP.test(group)) || page === null) throw notFound()
const forwarded = event.context.seoForwardedFor as string | undefined
const headers: Record<string, string> = forwarded ? { 'x-forwarded-for': forwarded } : {}
const state = await siteAnswer(cfg, headers)
if (state.state === 'unknown') throw unavailable()
if (state.state === 'absent') throw notFound()
const html = state.site.html_sitemap
if (!html || html.enabled !== true || !html.paths || !html.paths[locale]) throw notFound()
const path = group ? `/_sitemap/api/v1/html/${encodeURIComponent(group)}` : '/_sitemap/api/v1/html'
let res: { status: number; _data?: unknown }
try {
res = await $fetch.raw(path, {
baseURL: cfg.backendBase,
query: group ? { locale, page } : { locale },
headers,
timeout: Math.max(cfg.timeoutMs, 3000),
ignoreResponseError: true,
})
} catch {
throw unavailable()
}
if (res.status === 404) throw notFound()
if (res.status !== 200 || !res._data || typeof res._data !== 'object') throw unavailable()
return group ? htmlSitemapGroupPage(res._data, group) : htmlSitemapOverview(res._data)
})

55
server/api/__site__/cache/purge.post.ts vendored Normal file
View File

@@ -0,0 +1,55 @@
// POST /api/__site__/cache/purge: removes every Nitro route-cache entry
// (pages, _payload.json and feeds), so an admin save shows on the next page
// view. The backend calls it on loopback after the save commits, with
// `Authorization: Bearer <runtimeConfig.cachePurge.token>`
// (NUXT_CACHE_PURGE_TOKEN) and the body {"kind":"<kind>","slug":"<slug>"};
// the answer is {"purged":<n>}. The kind and slug only reach the log line.
//
// 404 without a token of at least 32 characters, and for any request that
// came through a reverse proxy (X-Forwarded-For, X-Real-IP or Forwarded)
// unless runtimeConfig.cachePurge.allowProxied
// (NUXT_CACHE_PURGE_ALLOW_PROXIED) is true or "true": a backend that reaches
// the site through a TLS-terminating proxy opts in, and the bearer token is
// then the only gate. 401 for a missing or wrong bearer token. The
// '/api/__site__/**' route rule keeps this route out of the route cache.
//
// Deprecated for one release: siteCache.purgeToken
// (NUXT_SITE_CACHE_PURGE_TOKEN) and siteCache.allowProxied
// (NUXT_SITE_CACHE_ALLOW_PROXIED), read when the new names are unset (see
// resolveSitePurgeConfig).
//
// The purge also empties the SEO layer's caches (the resolve answers and
// the site answer), so a purged page renders with a fresh CMS answer.
//
// Moved here from vue-journal-layer, vue-menu-layer and vue-faq-layer, which
// ship an older copy of this file until they extend this layer; Nitro keeps
// one handler per route and method, so server/plugins/seo-cache-purge.ts
// also clears the SEO caches when one of those copies answers.
import { clearSeoCaches } from '../../../lib/resolve.ts'
import { authorizeSitePurge, parseSitePurgeEvent, purgeSiteRouteCache, resolveSitePurgeConfig } from '../../../lib/siteCachePurge.ts'
const PROXY_HEADERS = ['x-forwarded-for', 'x-real-ip', 'forwarded']
export default defineEventHandler(async (event) => {
setResponseHeader(event, 'cache-control', 'no-store')
const { token, allowProxied } = resolveSitePurgeConfig(useRuntimeConfig(event))
const verdict = authorizeSitePurge({
token,
authorization: getRequestHeader(event, 'authorization'),
proxied: PROXY_HEADERS.some((name) => getRequestHeader(event, name) !== undefined),
allowProxied,
})
if (verdict === 'disabled' || verdict === 'proxied') {
throw createError({ statusCode: 404, statusMessage: 'Not Found' })
}
if (verdict === 'unauthorized') {
throw createError({ statusCode: 401, statusMessage: 'Unauthorized' })
}
const { kind, slug } = parseSitePurgeEvent(await readBody(event).catch(() => null))
const purged = await purgeSiteRouteCache(useStorage('cache'))
clearSeoCaches()
console.info(`[site-cache] purge: removed ${purged} route cache entries (${kind} ${slug})`)
return { purged }
})

83
server/lib/config.ts Normal file
View File

@@ -0,0 +1,83 @@
// The SEO layer's settings as the server code reads them from runtime
// config. Pure TypeScript outside Nitro's auto-import directories, so
// `node --test`, the middleware and the routes import it directly.
import { seoEnabled } from '../../app/utils/seo.ts'
export interface SeoServerConfig {
/** False when public.seo.enabled or its alias public.seo.cms is false: no CMS call at all. */
enabled: boolean
/** Server-only base of the plugin's API (NUXT_SEO_API_BASE), without a trailing slash. */
apiBase: string
/** Server-only origin of the Go binary for the root files (NUXT_SEO_BACKEND_BASE). */
backendBase: string
/** How long a call may take before the page renders without it (NUXT_SEO_TIMEOUT_MS). */
timeoutMs: number
/** Seconds a resolve answer stays cached (NUXT_SEO_RESOLVE_CACHE_TTL); 0 turns the cache off. */
resolveCacheTtl: number
/** Bearer token of POST /notfound (NUXT_SEO_NOTFOUND_TOKEN); empty: no reports. */
notfoundToken: string
/** Bearer token of the route report; falls back to notfoundToken. */
routeReportToken: string
/** Module option seo.routeReport (NUXT_PUBLIC_SEO_ROUTE_REPORT): report the static routes at start. */
routeReport: boolean
/** Module option seo.rootFiles: serve robots.txt, the sitemaps, llms.txt and the listed root files. */
rootFiles: boolean
/** Module option seo.resolveFiles: file extensions the middleware still resolves (['pdf']). */
resolveFiles: string[]
}
export const DEFAULT_SEO_API_BASE = 'http://127.0.0.1:8096/_seo/api/v1'
export const DEFAULT_RESOLVE_CACHE_TTL = 60
const API_SUFFIX = /\/_seo\/api\/v1\/?$/
function section(config: unknown, name: string): Record<string, unknown> {
if (config === null || typeof config !== 'object') return {}
const value = (config as Record<string, unknown>)[name]
return value !== null && typeof value === 'object' ? (value as Record<string, unknown>) : {}
}
const str = (v: unknown) => (typeof v === 'string' ? v.trim() : typeof v === 'number' ? String(v) : '')
function num(v: unknown, fallback: number): number {
if (v === '' || v === null || v === undefined) return fallback
const n = Number(v)
return Number.isFinite(n) && n >= 0 ? n : fallback
}
/**
* The backend origin for root files: backendBase when set, else apiBase
* without its /_seo/api/v1 suffix (so an app that only sets
* NUXT_SEO_API_BASE needs nothing else), else apiBase's origin.
*/
export function seoBackendBase(backendBase: string, apiBase: string): string {
if (backendBase) return backendBase.replace(/\/+$/, '')
if (API_SUFFIX.test(apiBase)) return apiBase.replace(API_SUFFIX, '')
try {
return new URL(apiBase).origin
} catch {
return ''
}
}
/** Reads the layer's settings from a runtime config object (useRuntimeConfig(event)). */
export function seoServerConfig(config: unknown): SeoServerConfig {
const seo = section(config, 'seo')
const pub = section(section(config, 'public'), 'seo')
const apiBase = (str(seo.apiBase) || DEFAULT_SEO_API_BASE).replace(/\/+$/, '')
const notfoundToken = str(seo.notfoundToken)
const resolveFiles = Array.isArray(pub.resolveFiles) ? pub.resolveFiles.filter((e): e is string => typeof e === 'string') : ['pdf']
return {
enabled: seoEnabled(pub),
apiBase,
backendBase: seoBackendBase(str(seo.backendBase), apiBase),
timeoutMs: num(seo.timeoutMs, 1500) || 1500,
resolveCacheTtl: num(seo.resolveCacheTtl, DEFAULT_RESOLVE_CACHE_TTL),
notfoundToken,
routeReportToken: str(seo.routeReportToken) || notfoundToken,
routeReport: pub.routeReport !== false && pub.routeReport !== 'false',
rootFiles: pub.rootFiles !== false && pub.rootFiles !== 'false',
resolveFiles,
}
}

84
server/lib/drift.ts Normal file
View File

@@ -0,0 +1,84 @@
// The startup drift check (D-20c, RESEARCH Open Question 9): Nuxt routes
// and the layer's pagination and HTML-sitemap options are fixed at build
// time, while the CMS decides content URL patterns, the pagination form and
// the HTML-sitemap paths. At server start the layer compares the two and
// logs one warning per mismatch, so an editor's change in the CMS that the
// frontend does not follow is visible. Pure, so `node --test` drives it.
import { patternCovers } from '../../lib/guard.ts'
import type { RoutePattern } from '../../lib/routes.ts'
import type { SeoSiteAnswer } from '../../app/utils/types.ts'
export const DRIFT_PREFIX = '[vue-seo-layer] drift:'
export interface LayerPaginationOption {
form: 'query' | 'path'
param: string
segments: Record<string, string>
}
export interface DriftInput {
site: SeoSiteAnswer
routes: readonly RoutePattern[]
pagination: LayerPaginationOption
/** seo.htmlSitemap.paths, or false when the layer serves no HTML sitemap. */
htmlSitemap: Record<string, string> | false
}
/** A CMS URL pattern in route-rule form: '{slug}' and ':slug' alike become ':slug'. */
const cmsPattern = (p: string) => p.replace(/\{(\w+)\}/g, ':$1').split('?')[0]!
/**
* True when a Nuxt route serves the CMS pattern: a route of the same shape
* (a parameter stands for any segment), or a catch-all under a static
* prefix ('/docs/**'). A catch-all at the root ('[...slug].vue') is a
* generic page renderer and does not count as a route for a content type.
*/
export function routeServes(route: RoutePattern, pattern: string): boolean {
if (route.catchAll && route.pattern === '/**') return false
return patternCovers(route.pattern, cmsPattern(pattern))
}
/** One message per mismatch between the CMS's site answer and the build. */
export function driftWarnings(input: DriftInput): string[] {
const out: string[] = []
const site = input.site ?? {}
for (const r of Array.isArray(site.routes) ? site.routes : []) {
if (!r || typeof r.pattern !== 'string' || r.pattern === '') continue
if (!input.routes.some((route) => routeServes(route, r.pattern))) {
out.push(`${DRIFT_PREFIX} the CMS's ${r.type || 'content'} URLs (${r.pattern}) match no Nuxt route`)
}
}
const p = site.pagination
if (p && typeof p === 'object') {
const form = p.form === 'path' ? 'path' : p.form === 'query' ? 'query' : null
if (form && form !== input.pagination.form) {
out.push(`${DRIFT_PREFIX} the CMS paginates in the ${form} form, the layer was built with ${input.pagination.form} (seo.pagination.form)`)
} else if (form === 'query' && typeof p.param === 'string' && p.param && p.param !== input.pagination.param) {
out.push(`${DRIFT_PREFIX} the CMS's page parameter is ${p.param}, the layer's is ${input.pagination.param} (seo.pagination.param)`)
} else if (form === 'path') {
for (const [locale, segment] of Object.entries(p.segments ?? {})) {
const own = input.pagination.segments[locale]
if (typeof segment === 'string' && segment && own !== segment) {
out.push(`${DRIFT_PREFIX} the CMS's ${locale} page segment is ${segment}, the layer's is ${own ?? 'unset'} (seo.pagination.segments)`)
}
}
}
}
const html = site.html_sitemap
if (html && html.enabled === true && html.paths && typeof html.paths === 'object') {
if (input.htmlSitemap === false) {
out.push(`${DRIFT_PREFIX} the CMS has an HTML sitemap, the layer serves none (seo.htmlSitemap is false)`)
} else {
for (const [locale, path] of Object.entries(html.paths)) {
if (typeof path !== 'string' || path === '') continue
const own = input.htmlSitemap[locale]
if (own !== path) out.push(`${DRIFT_PREFIX} the CMS serves the ${locale} HTML sitemap at ${path}, the layer at ${own ?? 'no path'} (seo.htmlSitemap.paths)`)
}
}
}
return out
}

59
server/lib/htmlSitemap.ts Normal file
View File

@@ -0,0 +1,59 @@
// The HTML sitemap answers of the sitemap plugin (GET
// <backendBase>/_sitemap/api/v1/html?locale= and
// /_sitemap/api/v1/html/{group}?locale=&page=) reduced to the fields the
// layer renders, plus the checks of the layer's proxy route. Pure, so
// `node --test` drives it.
import type { SeoSitemapEntry, SeoSitemapGroup, SeoSitemapGroupPage, SeoSitemapOverview } from '../../app/utils/types.ts'
export const HTML_SITEMAP_LOCALE = /^[a-z]{2,3}(?:[-_][A-Za-z0-9]{2,8})*$/
export const HTML_SITEMAP_GROUP = /^[a-z0-9][a-z0-9-]{0,63}$/
/** Nesting deeper than this (a category tree) is cut off. */
const MAX_DEPTH = 8
const str = (v: unknown) => (typeof v === 'string' ? v : '')
const int = (v: unknown) => (typeof v === 'number' && Number.isInteger(v) && v >= 0 ? v : 0)
function entries(value: unknown, depth = 0): SeoSitemapEntry[] {
if (!Array.isArray(value) || depth > MAX_DEPTH) return []
const out: SeoSitemapEntry[] = []
for (const e of value) {
if (!e || typeof e !== 'object') continue
const o = e as Record<string, unknown>
const title = str(o.title)
if (!title) continue
const children = entries(o.children, depth + 1)
out.push(children.length > 0 ? { title, url: str(o.url), children } : { title, url: str(o.url) })
}
return out
}
/** The overview with only its non-empty groups. */
export function htmlSitemapOverview(value: unknown): SeoSitemapOverview {
const groups: SeoSitemapGroup[] = []
const raw = value && typeof value === 'object' ? (value as { groups?: unknown }).groups : null
for (const g of Array.isArray(raw) ? raw : []) {
if (!g || typeof g !== 'object') continue
const o = g as Record<string, unknown>
const slug = str(o.slug)
const list = entries(o.entries)
if (!HTML_SITEMAP_GROUP.test(slug) || list.length === 0) continue
groups.push({ key: str(o.key) || slug, slug, title: str(o.title) || slug, total: int(o.total) || list.length, entries: list, more: o.more === true })
}
return { groups }
}
/** One page of a group. */
export function htmlSitemapGroupPage(value: unknown, group: string): SeoSitemapGroupPage {
const o = value && typeof value === 'object' ? (value as Record<string, unknown>) : {}
const page = int(o.page) || 1
return { group, title: str(o.title) || group, page, pages: Math.max(int(o.pages), page), entries: entries(o.entries) }
}
/** A page query value: 1 when absent, the number for 1 to 999999, else null. */
export function htmlSitemapPage(raw: unknown): number | null {
if (raw === undefined || raw === null || raw === '') return 1
if (typeof raw !== 'string' || !/^\d{1,6}$/.test(raw)) return null
const n = Number(raw)
return n >= 1 ? n : null
}

104
server/lib/lru.ts Normal file
View File

@@ -0,0 +1,104 @@
// A small in-memory LRU with a TTL, a size cap and in-flight
// de-duplication, for the resolve and site answers. Pure TypeScript (no
// Nitro imports), so `node --test` drives it directly.
//
// clear() (the site cache purge) bumps a generation: a load that started
// before it still answers its caller, but its value is never stored, and a
// load after it never joins the older one. So no request after a purge sees
// an answer fetched before it.
export interface LruOptions {
/** Most entries kept; the least recently used goes first. */
max: number
/** How long an entry is fresh, in milliseconds; 0 or less stores nothing. */
ttlMs: number
/** The clock (Date.now); tests pass their own. */
now?: () => number
}
export interface Lru<V> {
get(key: string): V | undefined
set(key: string, value: V): void
delete(key: string): void
/** Empties the cache and detaches the loads in flight. */
clear(): void
readonly size: number
/**
* The cached value for `key`, else the loader's, shared by concurrent
* callers of the same key. The value is stored when `cacheable` accepts
* it (default: always); a rejected loader stores nothing.
*/
load(key: string, loader: () => Promise<V>, cacheable?: (value: V) => boolean): Promise<V>
}
export function createLru<V>({ max, ttlMs, now = Date.now }: LruOptions): Lru<V> {
const entries = new Map<string, { value: V; expires: number }>()
const inflight = new Map<string, Promise<V>>()
let generation = 0
const limit = Math.max(1, Math.floor(max))
const get = (key: string): V | undefined => {
const hit = entries.get(key)
if (!hit) return undefined
if (hit.expires <= now()) {
entries.delete(key)
return undefined
}
// Map keeps insertion order: re-inserting marks the key most recent.
entries.delete(key)
entries.set(key, hit)
return hit.value
}
const set = (key: string, value: V) => {
if (ttlMs <= 0) return
entries.delete(key)
entries.set(key, { value, expires: now() + ttlMs })
while (entries.size > limit) {
const oldest = entries.keys().next().value
if (oldest === undefined) break
entries.delete(oldest)
}
}
return {
get,
set,
delete: (key) => void entries.delete(key),
clear() {
entries.clear()
inflight.clear()
generation++
},
get size() {
for (const [key, e] of entries) if (e.expires <= now()) entries.delete(key)
return entries.size
},
load(key, loader, cacheable = () => true) {
const hit = get(key)
if (hit !== undefined) return Promise.resolve(hit)
const pending = inflight.get(key)
if (pending) return pending
const started = generation
// The loader starts at once; one that throws at once becomes a
// rejection, which settles after `p` is assigned and leaves no entry
// in flight.
let first: Promise<V>
try {
first = Promise.resolve(loader())
} catch (err) {
first = Promise.reject(err)
}
const p: Promise<V> = first
.then((value) => {
if (generation === started && cacheable(value)) set(key, value)
return value
})
.finally(() => {
if (inflight.get(key) === p) inflight.delete(key)
})
inflight.set(key, p)
return p
},
}
}

69
server/lib/resolve.ts Normal file
View File

@@ -0,0 +1,69 @@
// The server side of the resolve call: GET <apiBase>/resolve?url=<path and
// query> through an LRU (path and query as key, seo.resolveCacheTtl seconds,
// default 60, at most 1000 entries, concurrent requests for one key share
// one call), forwarding the visitor's address so the backend's per-IP throttle
// (seo-resolve) keeps one bucket per visitor, also on route-cached renders
// (server/plugins/seo-client-address.ts records it). Any failure (timeout,
// 4xx, 5xx, an unexpected body) answers null and the page renders without
// CMS head tags (fail open) and is not cached; with the layer switched off
// nothing is called. The site cache purge clears the cache (clearSeoCaches).
//
// Nitro auto-imports (useRuntimeConfig, $fetch) are used inside the
// functions only, so `node --test` can import this file.
import type { H3Event } from 'h3'
import { seoAnswer } from '../../app/utils/seo.ts'
import type { SeoResolveAnswer } from '../../app/utils/types.ts'
import { seoServerConfig, type SeoServerConfig } from './config.ts'
import { createLru, type Lru } from './lru.ts'
import { clearSiteCache, siteCacheSize } from './site.ts'
/** Most resolve answers kept. */
export const RESOLVE_CACHE_MAX = 1000
let cache: Lru<SeoResolveAnswer | null> | null = null
let cacheTtl = -1
function resolveCache(ttlSeconds: number): Lru<SeoResolveAnswer | null> {
if (!cache || cacheTtl !== ttlSeconds) {
cache = createLru<SeoResolveAnswer | null>({ max: RESOLVE_CACHE_MAX, ttlMs: ttlSeconds * 1000 })
cacheTtl = ttlSeconds
}
return cache
}
/** Empties the layer's SEO caches (resolve answers and the site answer); the site cache purge calls it. */
export function clearSeoCaches(): void {
cache?.clear()
clearSiteCache()
}
/** How many answers the layer's caches hold (tests and diagnostics). */
export function seoCacheSizes(): { resolve: number; site: number } {
return { resolve: cache?.size ?? 0, site: siteCacheSize() }
}
/** The x-forwarded-for value the SEO calls carry for this request. */
export function seoForwardedFor(event: H3Event): Record<string, string> {
const recorded = event.context.seoForwardedFor as string | undefined
return recorded ? { 'x-forwarded-for': recorded } : {}
}
async function fetchResolve(cfg: SeoServerConfig, url: string, headers: Record<string, string>): Promise<SeoResolveAnswer | null> {
try {
return seoAnswer(await $fetch('/resolve', { baseURL: cfg.apiBase, query: { url }, headers, timeout: cfg.timeoutMs }))
} catch {
return null
}
}
/**
* The resolve answer for `url` (path and query), or null on any failure or
* when the layer is off (public.seo.enabled or seo.cms false).
*/
export async function resolveCached(event: H3Event, url: string): Promise<SeoResolveAnswer | null> {
const cfg = seoServerConfig(useRuntimeConfig(event))
if (!cfg.enabled) return null
const headers = seoForwardedFor(event)
return resolveCache(cfg.resolveCacheTtl).load(url, () => fetchResolve(cfg, url, headers), (answer) => answer !== null)
}

74
server/lib/routeReport.ts Normal file
View File

@@ -0,0 +1,74 @@
// The route report (D-33): at server start the layer posts its static
// routes and the static paths it serves noindex (#seo-routes, built from
// the pages) to the SEO plugin's POST /_seo/api/v1/registry/routes with the
// route-report token, so the CMS's page registry knows every frontend page
// and its sitemaps leave the noindex ones out (G1). Pure helpers for the
// Nitro plugin server/plugins/seo-route-report.ts, so `node --test` drives
// them.
/** The plugin's limits: at most 2000 paths of at most 2048 bytes each, per list. */
export const ROUTE_REPORT_MAX_ROUTES = 2000
export const ROUTE_REPORT_MAX_BYTES = 2048
/**
* The most body a plugin built before the `noindex` key reads
* (sm-seo-plugin's maxRouteReportBody: MaxReportedRoutes*(MaxRouteBytes+8)+1024,
* 4,113,024 bytes); the serialised JSON body never exceeds it.
*/
export const ROUTE_REPORT_MAX_BODY = ROUTE_REPORT_MAX_ROUTES * (ROUTE_REPORT_MAX_BYTES + 8) + 1024
/** The one retry after a failed report waits this long. */
export const ROUTE_REPORT_RETRY_MS = 60_000
export interface RouteReportBody {
routes: string[]
noindex: string[]
}
const withinLimits = (paths: readonly string[]) =>
paths.filter((p) => typeof p === 'string' && p.startsWith('/') && Buffer.byteLength(p, 'utf8') <= ROUTE_REPORT_MAX_BYTES).slice(0, ROUTE_REPORT_MAX_ROUTES)
/** The longest prefix of `paths` whose JSON array entries fit into `room` bytes (commas included). */
function fitting(paths: readonly string[], room: number): string[] {
let used = 0
let n = 0
for (const p of paths) {
const size = Buffer.byteLength(JSON.stringify(p), 'utf8') + (n > 0 ? 1 : 0)
if (used + size > room) break
used += size
n++
}
return paths.slice(0, n)
}
/**
* The request body: both lists filtered to paths starting with / of at most
* 2048 bytes and cut to 2000 entries; a path in `routes` is never repeated in
* `noindex`. `noindex` is always sent (also []), so the plugin's set follows
* each build. The serialised body stays at or under ROUTE_REPORT_MAX_BODY:
* `noindex` entries are dropped from the end first, then `routes` entries.
*/
export function routeReportBody(paths: readonly string[], noindexPaths: readonly string[] = []): RouteReportBody {
const routes = withinLimits(paths)
const listed = new Set(routes)
let noindex = withinLimits(noindexPaths.filter((p) => !listed.has(p)))
const routesSize = Buffer.byteLength(JSON.stringify({ routes, noindex: [] }), 'utf8')
if (routesSize > ROUTE_REPORT_MAX_BODY) {
// Every noindex entry is gone and the body is still too large: cut routes.
const empty = Buffer.byteLength(JSON.stringify({ routes: [], noindex: [] }), 'utf8')
return { routes: fitting(routes, ROUTE_REPORT_MAX_BODY - empty), noindex: [] }
}
noindex = fitting(noindex, ROUTE_REPORT_MAX_BODY - routesSize)
return { routes, noindex }
}
/**
* What an answer means: sent (2xx), absent (404: a backend without the
* page registry, or with both tokens empty; never retried), refused (401 or
* 403: the token is wrong; never retried) or retry (no answer, or any other
* status: tried once more after ROUTE_REPORT_RETRY_MS).
*/
export function routeReportOutcome(status: number | null): 'sent' | 'absent' | 'refused' | 'retry' {
if (status !== null && status >= 200 && status < 300) return 'sent'
if (status === 404) return 'absent'
if (status === 401 || status === 403) return 'refused'
return 'retry'
}

116
server/lib/site.ts Normal file
View File

@@ -0,0 +1,116 @@
// The site answer (GET <apiBase>/site, SEO plugin v0.2.0) and the root-file
// mapping built on it. The answer is cached for SITE_CACHE_TTL_MS; the site
// cache purge clears it. Three states:
//
// ok the backend answered: its root_files list decides which
// one-segment files are proxied
// absent the backend answered 404 (no SEO plugin, or one before
// v0.2.0): the layer keeps its v0.1.1 behaviour (verification
// files looked up by name, robots.txt and the sitemaps left to
// the site's proxy)
// unknown the backend did not answer, or answered something other than
// a 200 object or a 404: the last definite answer (ok or absent)
// seen since the server started or the last purge is then used;
// with none, robots.txt, sitemap.xml and llms.txt still go to the
// backend (a failure is a 503 with Retry-After) and every other
// path behaves as with absent; nothing is cached
//
// Nitro auto-imports ($fetch, useRuntimeConfig) are used inside the
// functions only, so `node --test` can import this file.
import type { SeoSiteAnswer } from '../../app/utils/types.ts'
import type { SeoServerConfig } from './config.ts'
import { createLru } from './lru.ts'
export const SITE_CACHE_TTL_MS = 300_000
export type SeoSiteState = { state: 'ok'; site: SeoSiteAnswer } | { state: 'absent' } | { state: 'unknown' }
// The clock is read at call time, so a test can move Date.now.
const cache = createLru<SeoSiteState>({ max: 1, ttlMs: SITE_CACHE_TTL_MS, now: () => Date.now() })
/** The last definite answer (ok or absent), used while the backend is unknown. */
let lastKnown: SeoSiteState | null = null
/** Empties the site cache (the purge calls it through clearSeoCaches). */
export function clearSiteCache(): void {
cache.clear()
lastKnown = null
}
export function siteCacheSize(): number {
return cache.size
}
/** The site state from a fetch result: status and parsed body. */
export function siteState(status: number | null, body: unknown): SeoSiteState {
if (status === 200 && body !== null && typeof body === 'object' && !Array.isArray(body)) return { state: 'ok', site: body as SeoSiteAnswer }
if (status === 404) return { state: 'absent' }
return { state: 'unknown' }
}
/** The cached site state for this server (cfg.enabled is the caller's check). */
export async function siteAnswer(cfg: SeoServerConfig, headers: Record<string, string>): Promise<SeoSiteState> {
const got = await cache.load(
'site',
async () => {
try {
const res = await $fetch.raw('/site', { baseURL: cfg.apiBase, headers, timeout: cfg.timeoutMs, ignoreResponseError: true })
return siteState(res.status, res._data)
} catch {
return { state: 'unknown' } as SeoSiteState
}
},
(s) => s.state !== 'unknown',
)
if (got.state !== 'unknown') lastKnown = got
else if (lastKnown) return lastKnown
return got
}
/** A one-segment file name the site answer may list: [A-Za-z0-9._-]+ ending .html, .txt or .xml. */
const ROOT_FILE = /^\/([A-Za-z0-9][A-Za-z0-9._-]*\.(?:html|txt|xml))$/
/** The IndexNow key file name: 8 to 128 of [A-Za-z0-9-], then .txt. */
const INDEXNOW_KEY_FILE = /^[A-Za-z0-9-]{8,128}\.txt$/
/** Sitemap files of the index: sitemap-<slug>-<n>.xml, sitemap-news.xml. */
const SITEMAP_FILE = /^\/(sitemap-[a-z0-9-]+\.xml)$/
const FIXED = new Set(['/robots.txt', '/sitemap.xml', '/llms.txt'])
export interface RootTarget {
kind: 'root' | 'sitemap' | 'verification' | 'indexnow'
/** Absolute backend URL built from the server-only bases and an allow-listed name only. */
url: string
}
/**
* Where a root file request goes on the backend, in the order to try (the
* first answer that is not 404 is served), or null when the layer does not
* serve the path. With a site answer (state ok, possibly the remembered
* one while the backend is down): robots.txt, sitemap.xml, llms.txt and sitemap-<name>.xml always,
* any other one-segment .html, .txt or .xml file when root_files lists it
* (a verification file, then the IndexNow key file). With a backend never
* reached since start (unknown, no last answer): robots.txt, sitemap.xml
* and llms.txt only, so the failing fetch answers 503 with Retry-After and
* a crawler never reads the app's 404 as an allow-all robots.txt (a staging
* site's Disallow lives on the backend, L4 WR-08); every other path passes
* through. With an older backend or one without the SEO plugin (absent,
* the site answer is 404, possibly the remembered one while the backend is
* down) every path passes through, as in v0.1.1. Unknown here means the
* backend has not answered since start or the last purge. Nothing from the request but the
* matched name reaches the URL.
*/
export function rootFileTargets(path: string, site: SeoSiteState, backendBase: string, apiBase: string): RootTarget[] | null {
if (site.state === 'absent') return null
const pathname = path.split('?')[0] ?? ''
if (FIXED.has(pathname)) return [{ kind: 'root', url: `${backendBase}${pathname}` }]
if (site.state !== 'ok') return null
const sitemap = SITEMAP_FILE.exec(pathname)
if (sitemap) return [{ kind: 'sitemap', url: `${backendBase}/_sitemap/files/${sitemap[1]}` }]
const file = ROOT_FILE.exec(pathname)
if (!file || !file[1]) return null
const name = file[1]
const listed = Array.isArray(site.site.root_files) ? site.site.root_files : []
if (!listed.includes(name)) return null
const targets: RootTarget[] = [{ kind: 'verification', url: `${apiBase}/verification/${encodeURIComponent(name)}` }]
if (INDEXNOW_KEY_FILE.test(name)) targets.push({ kind: 'indexnow', url: `${backendBase}/_sitemap/indexnow/${encodeURIComponent(name)}` })
return targets
}

View File

@@ -0,0 +1,208 @@
// Shared site cache purge: the authorization rules of the purge route
// POST /api/__site__/cache/purge, the purge of Nitro's route cache, the
// parsing of the purge request body, the purge settings read from runtime
// config (new and deprecated names) and the removal of the purge keys from
// nuxt-site-config's site config. Pure TypeScript outside Nitro's and Nuxt's
// auto-import directories: vue-journal-layer, vue-menu-layer and
// vue-faq-layer ship this file byte for byte, and `node --test`, the route
// and the Nitro plugin import it directly.
import { createHash, timingSafeEqual } from 'node:crypto'
/** The shortest purge token the route accepts; with a shorter one it is off. */
export const MIN_SITE_PURGE_TOKEN_LENGTH = 32
/** The cache storage base of Nitro's route cache (routeRules cache and swr). */
export const SITE_ROUTE_CACHE_BASE = 'nitro:routes'
/** Longest slug the purge log line carries. */
const MAX_SLUG_LENGTH = 200
/** A purge kind: lower-case letters and underscores, up to 40 characters. */
const KIND_PATTERN = /^[a-z_]{1,40}$/
export type SitePurgeVerdict = 'ok' | 'disabled' | 'proxied' | 'unauthorized'
export interface SitePurgeAuthInput {
/** The configured token (see resolveSitePurgeConfig). */
token: unknown
/** The request's Authorization header, if any. */
authorization?: string | null
/** True when the request carried X-Forwarded-For, X-Real-IP or Forwarded. */
proxied: boolean
/**
* The allowProxied setting (see resolveSitePurgeConfig): when true a
* proxied request is not refused, and the bearer token is the only gate.
* For a backend that reaches the site through a TLS-terminating proxy,
* which adds forwarding headers.
*/
allowProxied?: boolean
}
function digest(value: string): Buffer {
return createHash('sha256').update(value, 'utf8').digest()
}
/**
* Decides a purge request: `disabled` when no token of at least
* MIN_SITE_PURGE_TOKEN_LENGTH characters is configured, `proxied` when the
* request came through a reverse proxy (checked before the token) unless
* allowProxied is true, `ok` when the Bearer value equals the token (compared
* in constant time over sha256 digests) and `unauthorized` otherwise. With
* allowProxied the token is the only gate, so it must stay long and secret.
*/
export function authorizeSitePurge({ token, authorization, proxied, allowProxied }: SitePurgeAuthInput): SitePurgeVerdict {
if (typeof token !== 'string' || token.length < MIN_SITE_PURGE_TOKEN_LENGTH) return 'disabled'
if (proxied && allowProxied !== true) return 'proxied'
const m = /^Bearer[ \t]+(\S+)[ \t]*$/i.exec(authorization ?? '')
if (!m) return 'unauthorized'
return timingSafeEqual(digest(m[1]), digest(token)) ? 'ok' : 'unauthorized'
}
/** The part of a Nitro/unstorage storage the purge uses. */
export interface SitePurgeStorage {
getKeys(base?: string): Promise<string[]> | string[]
removeItem(key: string): Promise<unknown> | unknown
}
/**
* Removes every entry of Nitro's route cache (pages, their _payload.json and
* feeds) from storage, which is `useStorage('cache')`, and returns how many
* it removed. Other cache groups (cached functions, handlers) stay.
*/
export async function purgeSiteRouteCache(storage: SitePurgeStorage): Promise<number> {
const keys = await storage.getKeys(SITE_ROUTE_CACHE_BASE)
let removed = 0
for (const key of keys) {
if (!key.startsWith(SITE_ROUTE_CACHE_BASE + ':')) continue
await storage.removeItem(key)
removed++
}
return removed
}
export interface SitePurgeEvent {
kind: string
slug: string
}
/**
* Reads the kind and slug of a purge request body for the log line: kind is
* kept when it is lower-case letters and underscores up to 40 characters
* (faq, menu, menu_entry, post, category, ...), else it is unknown; the slug
* loses control characters and is cut to 200 characters.
*/
export function parseSitePurgeEvent(body: unknown): SitePurgeEvent {
if (body === null || typeof body !== 'object' || Array.isArray(body)) return { kind: 'unknown', slug: '' }
const raw = body as Record<string, unknown>
const kind = typeof raw.kind === 'string' && KIND_PATTERN.test(raw.kind) ? raw.kind : 'unknown'
// eslint-disable-next-line no-control-regex
const slug = typeof raw.slug === 'string' ? raw.slug.replace(/[\u0000-\u001f\u007f-\u009f]/g, '').slice(0, MAX_SLUG_LENGTH) : ''
return { kind, slug }
}
/**
* The site config keys nuxt-site-config derives from the deprecated
* variables NUXT_SITE_CACHE_PURGE_TOKEN and NUXT_SITE_CACHE_ALLOW_PROXIED: it
* copies every NUXT_SITE_* variable into the site config, camel-cased, and
* Nuxt serializes the site config into every page's payload.
*/
export const SITE_CONFIG_PURGE_KEYS: readonly string[] = Object.freeze(['cachePurgeToken', 'cacheAllowProxied'])
/**
* The globalThis flag that keeps the deprecation warning to one per process,
* however many layers ship the Nitro plugin.
*/
export const SITE_PURGE_WARNED = Symbol.for('summercms.siteCachePurge.deprecationWarned')
export type SitePurgeDeprecated = 'token' | 'allowProxied'
export interface SitePurgeConfig {
/** The token the route checks; authorizeSitePurge decides whether it is usable. */
token: unknown
/** True when a proxied request may reach the token check. */
allowProxied: boolean
/** The settings that came from a deprecated name. */
deprecated: SitePurgeDeprecated[]
}
function section(config: unknown, name: string): Record<string, unknown> {
if (config === null || typeof config !== 'object') return {}
const value = (config as Record<string, unknown>)[name]
return value !== null && typeof value === 'object' ? (value as Record<string, unknown>) : {}
}
const isTrue = (value: unknown) => value === true || value === 'true'
/**
* Reads the purge settings from runtime config. The current names are
* `cachePurge.token` (NUXT_CACHE_PURGE_TOKEN) and `cachePurge.allowProxied`
* (NUXT_CACHE_PURGE_ALLOW_PROXIED). Deprecated for one release:
* `siteCache.purgeToken` (NUXT_SITE_CACHE_PURGE_TOKEN) and
* `siteCache.allowProxied` (NUXT_SITE_CACHE_ALLOW_PROXIED). A non-empty new
* token wins over the old one; allowProxied is on when either flag is true or
* "true". `deprecated` lists the settings taken from an old name.
*/
export function resolveSitePurgeConfig(config: unknown): SitePurgeConfig {
const current = section(config, 'cachePurge')
const old = section(config, 'siteCache')
const deprecated: SitePurgeDeprecated[] = []
let token: unknown = current.token
if (typeof token !== 'string' || token === '') {
if (typeof old.purgeToken === 'string' && old.purgeToken !== '') {
token = old.purgeToken
deprecated.push('token')
} else if (token === undefined) {
token = old.purgeToken
}
}
let allowProxied = isTrue(current.allowProxied)
if (!allowProxied && isTrue(old.allowProxied)) {
allowProxied = true
deprecated.push('allowProxied')
}
return { token, allowProxied, deprecated }
}
/**
* Removes SITE_CONFIG_PURGE_KEYS from every entry of a nuxt-site-config stack
* (the array of partial site configs it merges per request) and returns how
* many keys it removed. Anything that is not an array of objects is left
* alone and counts 0.
*/
export function redactSiteConfigStack(stack: unknown): number {
if (!Array.isArray(stack)) return 0
let removed = 0
for (const entry of stack) {
if (entry === null || typeof entry !== 'object') continue
for (const key of SITE_CONFIG_PURGE_KEYS) {
if (Object.prototype.hasOwnProperty.call(entry, key)) {
delete (entry as Record<string, unknown>)[key]
removed++
}
}
}
return removed
}
const DEPRECATED_NAMES: Record<SitePurgeDeprecated, [string, string]> = {
token: ['NUXT_SITE_CACHE_PURGE_TOKEN', 'NUXT_CACHE_PURGE_TOKEN'],
allowProxied: ['NUXT_SITE_CACHE_ALLOW_PROXIED', 'NUXT_CACHE_PURGE_ALLOW_PROXIED'],
}
/**
* The one-line deprecation warning for the settings resolveSitePurgeConfig
* took from an old name, or '' when there are none. It names variables
* only, never a value.
*/
export function sitePurgeDeprecationMessage(deprecated: readonly SitePurgeDeprecated[]): string {
const names = [...new Set(deprecated)].filter((d) => d in DEPRECATED_NAMES).map((d) => `${DEPRECATED_NAMES[d][0]} -> ${DEPRECATED_NAMES[d][1]}`)
if (names.length === 0) return ''
return (
`[site-cache] deprecated purge settings in use, rename them: ${names.join(', ')}. ` +
'The old names work until the next minor release. Earlier layer versions published every NUXT_SITE_* value ' +
'in the page payload through nuxt-site-config, so rotate a token that was ever set under NUXT_SITE_CACHE_PURGE_TOKEN.'
)
}

View File

@@ -0,0 +1,74 @@
// Serves the site-root files from the CMS backend: /robots.txt,
// /sitemap.xml, /sitemap-<name>.xml (the Go sitemap's files under
// /_sitemap/files/), /llms.txt, and each verification file and the IndexNow
// key file the site answer lists in root_files. The backend's status,
// content type and X-Robots-Tag are passed on; a backend that fails (no
// answer, 5xx) gives 503 with Retry-After: 60, so a crawler retries instead
// of reading a missing robots.txt as "everything allowed". Any other path
// passes through.
//
// Once the backend has given its site answer (SEO v0.2.0) every file above
// is proxied. The last answer, ok or absent, is kept while the backend is
// down, so on a backend without the SEO plugin the files keep reaching the
// app during an outage. Only while the backend has not answered since the
// server started or the last purge are robots.txt, sitemap.xml and
// llms.txt proxied by their fixed names: the failing fetch answers 503
// with Retry-After, so a staging site never exposes the app's 404 as an
// allow-all robots.txt (L4 WR-08). With a backend before v0.2.0 (the site
// answer is 404) nothing is proxied here, as in v0.1.1: robots.txt and the
// sitemaps stay with the site's proxy (or fall through to the app); there
// and while the backend is unknown, verification files keep the v0.1.1
// lookup (seo-verification.ts). Off with
// the module option seo.rootFiles false or the layer switched off. The
// backend URLs come from the server-only bases and an allow-listed name only
// (rootFileTargets), never from the request.
import { seoServerConfig } from '../lib/config.ts'
import { seoForwardedFor } from '../lib/resolve.ts'
import { rootFileTargets, siteAnswer } from '../lib/site.ts'
const RETRY_AFTER = '60'
export default defineEventHandler(async (event) => {
if (event.method !== 'GET' && event.method !== 'HEAD') return
const pathname = event.path.split('?')[0] ?? ''
// Cheap test before any call: only one-segment .html, .txt and .xml paths.
if (!/^\/[A-Za-z0-9][A-Za-z0-9._-]*\.(?:html|txt|xml)$/.test(pathname)) return
const cfg = seoServerConfig(useRuntimeConfig(event))
if (!cfg.enabled || !cfg.rootFiles) return
const headers = seoForwardedFor(event)
const targets = rootFileTargets(pathname, await siteAnswer(cfg, headers), cfg.backendBase, cfg.apiBase)
if (!targets) return
let res: Awaited<ReturnType<typeof $fetch.raw>> | null = null
let kind = targets[0]?.kind
for (const target of targets) {
res = await $fetch
.raw(target.url, { responseType: 'text', ignoreResponseError: true, redirect: 'manual', headers, timeout: cfg.timeoutMs })
.catch(() => null)
kind = target.kind
if (!res || res.status !== 404) break
}
if (!res || res.status >= 500) {
setResponseStatus(event, 503, 'Service Unavailable')
setResponseHeaders(event, { 'retry-after': RETRY_AFTER, 'cache-control': 'no-store', 'content-type': 'text/plain; charset=utf-8' })
return event.method === 'HEAD' ? '' : 'Service Unavailable'
}
setResponseStatus(event, res.status)
const out: Record<string, string> = {
'content-type': res.headers.get('content-type') ?? 'text/plain; charset=utf-8',
'x-content-type-options': 'nosniff',
}
const robots = res.headers.get('x-robots-tag')
if (robots) out['x-robots-tag'] = robots
if (kind === 'verification' && res.status === 200) {
// The v0.1.1 headers of a verification file.
out['content-security-policy'] = res.headers.get('content-security-policy') ?? "default-src 'none'; sandbox"
out['x-robots-tag'] = robots ?? 'noindex'
}
const cacheControl = res.headers.get('cache-control')
if (cacheControl) out['cache-control'] = cacheControl
setResponseHeaders(event, out)
return event.method === 'HEAD' ? '' : ((res._data as string | undefined) ?? '')
})

View File

@@ -4,8 +4,16 @@
// them. Only a single-segment .html, .txt or .xml path is looked up, never a
// name the site serves itself; anything the backend does not answer 200
// falls through to the normal routes.
//
// This is the v0.1.1 lookup, for a backend without the site answer (SEO
// plugin before v0.2.0, /site answers 404) or one that does not answer it
// now. With a site answer, seo-root.ts serves exactly the files its
// root_files lists and every other name passes through untouched.
import { seoVerificationName } from '../../app/utils/seo.ts'
import { seoServerConfig } from '../lib/config.ts'
import { seoForwardedFor } from '../lib/resolve.ts'
import { siteAnswer } from '../lib/site.ts'
const RESERVED = ['robots.txt', 'sitemap.xml', 'sitemap.xsl', 'rss.xml', 'feed.xml', 'atom.xml', 'llms.txt']
@@ -13,15 +21,17 @@ export default defineEventHandler(async (event) => {
if (event.method !== 'GET' && event.method !== 'HEAD') return
const name = seoVerificationName(event.path, RESERVED)
if (!name) return
const config = useRuntimeConfig(event)
if ((config.public.seo as { enabled?: boolean } | undefined)?.enabled === false) return
const seo = config.seo as { apiBase: string; timeoutMs?: number }
const cfg = seoServerConfig(useRuntimeConfig(event))
if (!cfg.enabled || !cfg.rootFiles) return
const headers = seoForwardedFor(event)
if ((await siteAnswer(cfg, headers)).state === 'ok') return
const res = await $fetch
.raw(`/verification/${encodeURIComponent(name)}`, {
baseURL: seo.apiBase,
baseURL: cfg.apiBase,
responseType: 'text',
ignoreResponseError: true,
timeout: Number(seo.timeoutMs) || 1500,
headers,
timeout: cfg.timeoutMs,
})
.catch(() => null)
if (!res || res.status !== 200) return

47
server/middleware/seo.ts Normal file
View File

@@ -0,0 +1,47 @@
// Resolves every page request through the CMS (resolveCached: LRU and the
// visitor's X-Forwarded-For) and enforces the decision before Nuxt renders
// the page and before Nitro's route cache, so swr-cached pages honour a new
// redirect or 410:
//
// redirect 301 or 302 with Location from the answer (seoRedirectTarget:
// a target on the site's own origin becomes a path, another
// host stays absolute); nothing from the request (Host, path,
// query) ever goes into the Location
// gone 410 with the app's error page
// not_found left to Nuxt's routing, except a pagination.invalid answer
// (?page=abc), which is a 404
//
// X-Robots-Tag follows the answer (staging: noindex, nofollow everywhere;
// else the page's x_robots_tag), never on a redirect. Skipped: Nuxt's and
// the layers' internal paths (/_nuxt, /__nuxt_error, /_seo …), /api and
// asset files (isPagePath). The answer stays in event.context.seo for the
// render, which then makes no second call (useSeoResolve). With the CMS
// unreachable the page renders normally (fail open).
import { isPagePath } from '../../app/utils/pagePath.ts'
import { seoRedirectTarget, seoXRobotsTag } from '../../app/utils/seo.ts'
import { seoServerConfig } from '../lib/config.ts'
import { resolveCached } from '../lib/resolve.ts'
export default defineEventHandler(async (event) => {
if (event.method !== 'GET' && event.method !== 'HEAD') return
const cfg = seoServerConfig(useRuntimeConfig(event))
if (!cfg.enabled) return
const url = event.path
if (!isPagePath(url, cfg.resolveFiles)) return
const answer = await resolveCached(event, url)
event.context.seo = answer
event.context.seoResolved = { url, answer }
if (!answer) return
const target = seoRedirectTarget(answer)
if (target) return sendRedirect(event, target.to, target.code)
const robots = seoXRobotsTag(answer)
if (robots) setResponseHeader(event, 'x-robots-tag', robots)
if (answer.action === 'gone') throw createError({ statusCode: 410, statusMessage: 'Gone' })
if (answer.action === 'not_found' && answer.pagination?.invalid === true) {
throw createError({ statusCode: 404, statusMessage: 'Page Not Found' })
}
})

View File

@@ -0,0 +1,22 @@
// Clears the SEO layer's caches after every successful site cache purge,
// whichever layer's copy of POST /api/__site__/cache/purge answered it.
//
// vue-journal-layer, vue-menu-layer and vue-faq-layer ship their own copy of
// the shared purge route until they extend this layer, and Nitro keeps one
// handler per route and method, so in an app that extends several of them
// this layer's route (which clears the caches itself) may not be the one
// that runs. The journal's deprecated POST /api/__journal__/cache/purge
// purges the route cache too, so it clears them as well. Clearing twice is
// harmless.
import { clearSeoCaches } from '../lib/resolve.ts'
const PURGE_PATHS = new Set(['/api/__site__/cache/purge', '/api/__journal__/cache/purge'])
export default defineNitroPlugin((nitroApp) => {
nitroApp.hooks.hook('afterResponse', (event) => {
if (event.method !== 'POST') return
if (!PURGE_PATHS.has((event.path.split('?')[0] ?? '').replace(/\/+$/, ''))) return
if (event.node.res.statusCode === 200) clearSeoCaches()
})
})

View File

@@ -0,0 +1,40 @@
// The startup drift check (D-20c): compares the CMS's content-type URL
// patterns, pagination form and segments and HTML-sitemap paths (the site
// answer, GET <apiBase>/site) with the build's routes (#seo-routes) and the
// layer's seo.pagination and seo.htmlSitemap options, and logs one
// "[vue-seo-layer] drift: ..." warning per mismatch. It runs once after the
// plugin returns, never blocks the start and never fails it; a backend
// without a site answer (before SEO v0.2.0) or one that does not answer is
// skipped. Off with the layer off or while prerendering.
import { routePatterns } from '#seo-routes'
import { seoServerConfig } from '../lib/config.ts'
import { driftWarnings, type LayerPaginationOption } from '../lib/drift.ts'
import { siteAnswer } from '../lib/site.ts'
export default defineNitroPlugin(() => {
if (import.meta.prerender) return
const config = useRuntimeConfig()
const cfg = seoServerConfig(config)
if (!cfg.enabled) return
const pub = ((config.public as Record<string, unknown>).seo ?? {}) as {
pagination?: LayerPaginationOption
htmlSitemap?: { paths?: Record<string, string> } | false
}
const pagination: LayerPaginationOption = {
form: pub.pagination?.form === 'path' ? 'path' : 'query',
param: pub.pagination?.param || 'page',
segments: { ...(pub.pagination?.segments ?? {}) },
}
const htmlSitemap = pub.htmlSitemap === false || (pub.htmlSitemap as unknown) === 'false' ? false : { ...(pub.htmlSitemap?.paths ?? {}) }
setTimeout(async () => {
try {
const state = await siteAnswer(cfg, {})
if (state.state !== 'ok') return
for (const warning of driftWarnings({ site: state.site, routes: routePatterns, pagination, htmlSitemap })) console.warn(warning)
} catch (err) {
console.warn('[vue-seo-layer] drift check failed:', (err as Error)?.message ?? err)
}
}, 0).unref?.()
})

View File

@@ -4,11 +4,14 @@
// skips afterResponse, so both the `error` hook and `afterResponse` report,
// once per request. Only page views count: GET requests that accept
// text/html, never Nuxt's own paths (/_nuxt, /__nuxt_error, …) or the API
// routes, and nothing without the token configured. A failed report is
// logged once and never touches the response.
// routes, and nothing without the token configured or with the layer off
// (public.seo.enabled or seo.cms false). The report never delays the
// response: it is sent after it (event.waitUntil keeps it alive). A failed
// report is logged once and never touches the response.
import type { H3Event } from 'h3'
import { seoResolvableUrl } from '../../app/utils/seo.ts'
import { seoServerConfig } from '../lib/config.ts'
let warned = false
@@ -16,10 +19,9 @@ function report(event: H3Event) {
if (event.context.seoNotFoundReported) return
if (event.method !== 'GET') return
if (!(getRequestHeader(event, 'accept') ?? '').includes('text/html')) return
const config = useRuntimeConfig(event)
if ((config.public.seo as { enabled?: boolean } | undefined)?.enabled === false) return
const seo = config.seo as { apiBase: string; notfoundToken?: string; timeoutMs?: number }
const token = seo.notfoundToken?.trim()
const seo = seoServerConfig(useRuntimeConfig(event))
if (!seo.enabled) return
const token = seo.notfoundToken
if (!token) return
const url = seoResolvableUrl(event.path)
if (!url) return
@@ -32,7 +34,7 @@ function report(event: H3Event) {
method: 'POST',
body: { url: url.slice(0, 2048), referer: referer.slice(0, 1024) },
headers: { authorization: `Bearer ${token}`, ...(forwarded ? { 'x-forwarded-for': forwarded } : {}) },
timeout: Number(seo.timeoutMs) || 1500,
timeout: seo.timeoutMs,
}).catch((err: unknown) => {
if (!warned) {
warned = true

View File

@@ -0,0 +1,54 @@
// Reports the site's static routes to the CMS's page registry once per
// server process (D-33): POST <apiBase>/registry/routes with
// `Authorization: Bearer <route-report token>` and
// {routes: [path, ...], noindex: [path, ...]}. The paths come from the build
// (#seo-routes): `routes` are the pages without parameters that may be
// indexed; `noindex` the static paths the build serves noindex (requiresAuth,
// a noindex or none seo.robots, a static page under seo.noindexRoutes, their
// i18n copies), so the CMS leaves them out of its sitemaps (G1). `noindex`
// is always sent, also empty; a plugin older than the key ignores it. It
// never delays the start: the report goes out after the plugin returns, a
// failure (no answer, a 5xx) is tried once more after 60 s, and a 404 (a
// backend without the registry) or a refused token is logged once and not
// retried. Nothing is sent with the layer off (seo.cms, public.seo.enabled),
// with seo.routeReport false, without a token (NUXT_SEO_ROUTE_REPORT_TOKEN,
// else NUXT_SEO_NOTFOUND_TOKEN) or while prerendering. The token stays in the
// server-only runtime config and goes to the configured apiBase only.
import { noindexPaths, staticRoutes } from '#seo-routes'
import { seoServerConfig } from '../lib/config.ts'
import { ROUTE_REPORT_RETRY_MS, routeReportBody, routeReportOutcome } from '../lib/routeReport.ts'
export default defineNitroPlugin(() => {
if (import.meta.prerender) return
const cfg = seoServerConfig(useRuntimeConfig())
if (!cfg.enabled || !cfg.routeReport || !cfg.routeReportToken) return
const body = routeReportBody(staticRoutes, noindexPaths)
const send = async (attempt: number): Promise<void> => {
let status: number | null = null
let reason = ''
try {
const res = await $fetch.raw('/registry/routes', {
baseURL: cfg.apiBase,
method: 'POST',
body,
headers: { authorization: `Bearer ${cfg.routeReportToken}` },
timeout: Math.max(cfg.timeoutMs, 5000),
ignoreResponseError: true,
})
status = res.status
} catch (err) {
reason = (err as Error)?.message ?? String(err)
}
const outcome = routeReportOutcome(status)
if (outcome === 'sent') console.info(`[vue-seo-layer] route report: ${body.routes.length} routes and ${body.noindex.length} noindex paths sent`)
else if (outcome === 'absent') console.info('[vue-seo-layer] route report: the CMS has no page registry (404), nothing reported')
else if (outcome === 'refused') console.warn(`[vue-seo-layer] route report: the CMS refused the token (${status}); set NUXT_SEO_ROUTE_REPORT_TOKEN to golem15.seo.route_report_token`)
else if (attempt === 1) {
console.warn(`[vue-seo-layer] route report failed (${status ?? reason}); trying again in ${ROUTE_REPORT_RETRY_MS / 1000} s`)
setTimeout(() => void send(2), ROUTE_REPORT_RETRY_MS).unref?.()
} else console.warn(`[vue-seo-layer] route report failed again (${status ?? reason}); not retried before the next start`)
}
setTimeout(() => void send(1), 0).unref?.()
})

View File

@@ -0,0 +1,48 @@
// Keeps the site cache purge settings out of the public site config.
//
// nuxt-site-config (pulled in by @nuxtjs/seo) copies every NUXT_SITE_* and
// NUXT_PUBLIC_SITE_* environment variable into the site config, camel-cased,
// and Nuxt serializes the site config into every page's payload (and into
// window.__NUXT_SITE_CONFIG__ on client-only pages). The purge token's
// earlier name NUXT_SITE_CACHE_PURGE_TOKEN therefore reached every page as
// cachePurgeToken, and NUXT_SITE_CACHE_ALLOW_PROXIED as cacheAllowProxied.
// The settings now live under NUXT_CACHE_PURGE_TOKEN and
// NUXT_CACHE_PURGE_ALLOW_PROXIED, outside that prefix. The old names keep
// working for one release, so this plugin also removes the two keys from
// every entry of the site config stack on nuxt-site-config's
// `site-config:init` hook, before anything reads it. The stripping stays
// with the new names too: a site that still sets an old variable, or sets
// both, must not publish it.
//
// When a setting comes from an old name, the plugin logs one deprecation
// warning per process (globalThis[SITE_PURGE_WARNED], so a site extending
// several layers that ship this file warns once). The warning names
// variables, never a value.
//
// Without nuxt-site-config the hook is never called and the plugin does
// nothing else. Shared: several layers ship this file byte for byte;
// running it more than once is harmless.
import { SITE_PURGE_WARNED, redactSiteConfigStack, resolveSitePurgeConfig, sitePurgeDeprecationMessage } from '../lib/siteCachePurge.ts'
interface SiteConfigInitContext {
siteConfig?: { stack?: unknown }
}
type LooseHooks = { hook: (name: string, fn: (ctx: SiteConfigInitContext | undefined) => void) => unknown }
export default defineNitroPlugin((nitroApp) => {
const g = globalThis as Record<symbol, unknown>
if (!g[SITE_PURGE_WARNED]) {
const message = sitePurgeDeprecationMessage(resolveSitePurgeConfig(useRuntimeConfig()).deprecated)
if (message) {
g[SITE_PURGE_WARNED] = true
console.warn(message)
}
}
// The hook name is untyped in an app without nuxt-site-config.
;(nitroApp.hooks as unknown as LooseHooks).hook('site-config:init', (ctx) => {
redactSiteConfigStack(ctx?.siteConfig?.stack)
})
})

10
server/seo-routes.d.ts vendored Normal file
View File

@@ -0,0 +1,10 @@
// The server template the layer's module writes from the resolved pages
// (lib/routes.ts, addServerTemplate).
declare module '#seo-routes' {
/** Paths without parameters that may be indexed (the route report). */
export const staticRoutes: string[]
/** Every page's route pattern (the drift check). */
export const routePatterns: Array<{ pattern: string; catchAll: boolean }>
/** Static paths the build serves noindex (the route report's noindex list, G1). */
export const noindexPaths: string[]
}

View File

@@ -0,0 +1,17 @@
# The CMS unreachable since the server started (NUXT_SEO_API_BASE on a
# closed port): pages render normally with their own head (fail open).
# robots.txt, sitemap.xml and llms.txt answer 503 with Retry-After, so a
# crawler retries: falling through to the app would serve a 404 that reads
# as an allow-all robots.txt, also on a staging site whose Disallow lives
# on the backend (L4 WR-08). With no site answer ever seen, sitemap files
# and verification files still fall through to the app as in v0.1.1.
200 /about contains=<title>Own about | Playground</title> notcontains=About us | Acme noheader=X-Robots-Tag count=application/ld+json:0
200 /plain contains=<title>Plain page | Playground</title>
503 /robots.txt header=Retry-After: 60
503 /sitemap.xml header=Retry-After: 60
503 /llms.txt header=Retry-After: 60
404 /sitemap-posts-1.xml
404 /google0123.html
# The HTML sitemap with the backend down is a 503 (never an empty page).
503 /mapa-strony
apicalls 0

View File

@@ -0,0 +1,16 @@
# The startup drift check against a site answer that differs from the
# build (testing/fixtures/seo/site-drift.json): a content type whose URL
# pattern no Nuxt route serves, the path pagination form against the
# layer's query form and another pl HTML sitemap path. One warning each;
# the server still starts and serves pages. Run with
# NUXT_PUBLIC_SEO_ROUTE_REPORT=false, so no route report is sent.
200 /plain contains=<title>Plain page | Playground</title>
log [vue-seo-layer] drift::3
log [vue-seo-layer] drift: the CMS's journal-post URLs (/wpisy/:slug) match no Nuxt route:1
log [vue-seo-layer] drift: the CMS paginates in the path form, the layer was built with query (seo.pagination.form):1
log [vue-seo-layer] drift: the CMS serves the pl HTML sitemap at /mapa, the layer at /mapa-strony (seo.htmlSitemap.paths):1
routereport 0
# The site answer here has no en path either way; the pl link follows the
# layer's path.
200 /blog/lwia-grzywa contains=<a href="/mapa-strony" class="seo-sitemap-link">

View File

@@ -0,0 +1,5 @@
# No 404 token (NUXT_SEO_NOTFOUND_TOKEN empty): a 404 page sends no report.
404 /kontakt
noreport /kontakt
# Without a token the route report is not sent either.
routereport 0

View File

@@ -0,0 +1,16 @@
# The layer switched off: seo.cms false (NUXT_PUBLIC_SEO_CMS=false) or the
# v0.1.1 switch public.seo.enabled false (NUXT_PUBLIC_SEO_ENABLED=false).
# No resolve, no CMS head, no redirect, no root files, no 404 report: the
# pages keep their own head and the API sees no request at all.
200 /about contains=<title>Own about | Playground</title> contains=href="https://own-canonical.test/about" noheader=X-Robots-Tag
200 /plain?page=abc contains=<title>Plain page | Playground</title>
404 /stary noheader=Location
404 /robots.txt
404 /google1234.html
404 /google0123.html
404 /kontakt
# The layer's HTML sitemap pages and footer link need the CMS.
404 /mapa-strony
200 /plain notcontains=seo-sitemap-link
apicalls 0
noreport /kontakt

129
testing/expect-seo.txt Normal file
View File

@@ -0,0 +1,129 @@
# SSR expectations of the SEO layer's v0.2.0 features against the fixture
# SEO API (testing/fixtures, answers in testing/fixtures/seo). Grammar in
# testing/smoke-ssr.mjs. testing/expect.txt keeps the v0.1.1 behaviour and
# runs against a backend without /site (--no-site).
# Tracer: a page rendered by Nuxt carries the CMS title, canonical, hreflang
# and one JSON-LD graph from the resolve answer.
200 /blog/lwia-grzywa contains=<title>Lwia grzywa | Example</title> count=application/ld+json:1 count=rel="canonical":1 contains=hreflang="x-default" contains=lang="pl"><head>
200 /blog/lwia-grzywa contains=<link rel="alternate" hreflang="en" href="https://example.test/en/blog/lions-mane" contains=hreflang="pl"
200 /blog/lwia-grzywa contains=<meta property="og:locale" content="pl_PL"> contains=<meta property="og:locale:alternate" content="en_US">
200 /blog/lwia-grzywa notcontains=Own catch-all title
# One graph: the CMS nodes, the layer's node merged by @id (CMS values win),
# a new layer node appended; "<" inside the JSON cannot end the script.
200 /blog/lwia-grzywa count=data-hid="seo-jsonld":1 count="@type":"WebPage":1 contains="name":"Lwia grzywa" notcontains=Layer name loses contains="inLanguage":"pl" contains=#itemlist
200 /blog/lwia-grzywa notcontains=</script><script>alert
# Redirect decision before the page (and its route cache): the Location
# comes from the answer only; a target on the site's origin becomes a path,
# another host stays absolute; a crafted Host or X-Forwarded-Host changes
# nothing. No X-Robots-Tag on a redirect.
301 /stary header=Location: /nowy noheader=X-Robots-Tag
301 /stary reqheader=Host: evil.test reqheader=X-Forwarded-Host: evil.test reqheader=X-Forwarded-Proto: http header=Location: /nowy
302 /stary-zewnetrzny header=Location: https://partner.test/oferta?a=1
# 410 with the app's error page, also on staging with the noindex header.
410 /usuniety
410 /usuniety-staging header=X-Robots-Tag: noindex, nofollow
# A page's x_robots_tag becomes the header.
200 /blog/ukryty header=X-Robots-Tag: noindex, nofollow contains=<meta name="robots" content="noindex, nofollow">
# not_found: a pagination.invalid answer is a 404 (also on a page that would
# render); any other not_found is left to Nuxt's routing.
404 /plain?page=abc
200 /plain?page=2 contains=<title>Plain page | Playground</title>
404 /nieznana-strona
# The resolve cache: every request above to the tracer page made one call.
resolves /blog/lwia-grzywa 1
resolves /stary 1
# The Nuxt-server resolve route answers through the same cache.
200 /api/__seo__/resolve?url=/blog/lwia-grzywa contains="title":"Lwia grzywa | Example" header=X-Robots-Tag: noindex
resolves /blog/lwia-grzywa 1
422 /api/__seo__/resolve?url=https://evil.test/
# Root files from the backend, with its status, content type and
# X-Robots-Tag; a backend failure is 503 with Retry-After.
200 /robots.txt contains=Disallow: /konto type=text/plain header=X-Content-Type-Options: nosniff
200 /sitemap.xml contains=<sitemapindex type=application/xml
200 /sitemap-posts-1.xml contains=<urlset type=application/xml header=X-Robots-Tag: noindex
200 /llms.txt contains=Fixture llms.txt header=X-Robots-Tag: noindex
404 /sitemap-missing-1.xml
503 /sitemap-fail-1.xml header=Retry-After: 60
# Listed root files: a verification file and the IndexNow key file.
200 /google1234.html contains=google-site-verification: google1234.html header=X-Robots-Tag: noindex header=X-Content-Type-Options: nosniff
200 /a1b2c3d4e5f60718293a4b5c6d7e8f90.txt contains=a1b2c3d4e5f60718293a4b5c6d7e8f90 type=text/plain
# Not listed: passes through to Nuxt, which renders its 404 (the route
# middleware resolves it; no verification lookup is made).
404 /random.txt
404 /google0123.html
404 /kontakt
# 404 pages report themselves once with the bearer token; a 410 or a
# redirect does not.
report /kontakt
report /nieznana-strona
noreport /usuniety
noreport /stary
# Labels in the page's content locale (pl answer: the pl dictionary).
200 /blog/lwia-grzywa contains=<p class="seo-visually-hidden">Ścieżka nawigacji</p>
# Declared noindex without page code: a page whose meta requires sign-in,
# a page with its own seo.robots and a path under seo.noindexRoutes get the
# meta tag and the header; an ordinary page keeps the CMS robots.
200 /konto contains=<meta name="robots" content="noindex, nofollow"> count=name="robots":1 header=X-Robots-Tag: noindex, nofollow
200 /szkic contains=<meta name="robots" content="noindex, follow"> header=X-Robots-Tag: noindex, follow
200 /prywatne contains=<meta name="robots" content="noindex, nofollow"> header=X-Robots-Tag: noindex, nofollow
200 /plain noheader=X-Robots-Tag count=name="robots":0
200 /blog/lwia-grzywa contains=<meta name="robots" content="index, follow">
# The site answer through the layer's server, for useSeoSite().
200 /api/__seo__/site contains="site_url":"https://example.test" header=X-Robots-Tag: noindex
# The route report at server start: the static paths with the token, not
# the signed-in page, the page with a noindex seo.robots or the declared
# noindex route; those three go in its noindex list (G1), never an
# indexable path or the layer's HTML sitemap page. The build matches the
# site answer: no drift warning.
routereport 1 has=/ has=/blog has=/plain has=/about lacks=/konto lacks=/szkic lacks=/prywatne lacks=/mapa-strony noindex=/konto noindex=/szkic noindex=/prywatne lacksnoindex=/ lacksnoindex=/mapa-strony
log [vue-seo-layer] drift::0
# L1 breadcrumbs: a layout instance (app.vue) and a page instance render
# exactly one trail, server-rendered links, the last item as text with
# aria-current; none on the home page (one item) or a page without a trail.
200 /blog/lwia-grzywa count=<nav class="seo-breadcrumbs":1 contains=<a class="seo-breadcrumbs__link" href="/">Start</a> contains=<span class="seo-breadcrumbs__current" aria-current="page">Lwia grzywa</span> count=aria-current="page":1
200 / count=seo-breadcrumbs:0
200 /plain count=seo-breadcrumbs:0
# L2 pagination: numbered links in the CMS form, page 1 unnumbered, five
# pages around the current one plus the first and the last with two gaps,
# rel prev/next anchors and head links, and %pages% filled in the CMS title
# and description (the payload keeps the raw answer); one page renders
# none. aria-current: the current page and the breadcrumbs' last item.
200 /blog?page=5 count=<nav class="seo-pagination":1 contains=<a class="seo-pagination__prev" href="/blog?page=4" rel="prev"> contains=<a class="seo-pagination__next" href="/blog?page=6" rel="next"> contains=<span class="seo-pagination__current" aria-current="page">5</span> count=aria-current="page":2
200 /blog?page=5 contains=<a href="/blog" aria-label="Strona 1">1</a> contains=<a href="/blog?page=14" aria-label="Strona 14">14</a> count=seo-pagination__gap:2
200 /blog?page=5 contains=<link rel="prev" href="/blog?page=4" contains=<link rel="next" href="/blog?page=6" contains=<title>Blog – strona 5 z 14 | Example</title> contains=content="Wpisy o grzybach, strona 5 z 14" notcontains=z %pages% |</title> notcontains=content="Wpisy o grzybach, strona 5 z %pages%"
200 /blog?page=14&sort=new contains=<a class="seo-pagination__prev" href="/blog?sort=new&amp;page=13" rel="prev"> count=seo-pagination__next:0 count=rel="next":0
200 /blog?pages=1 count=seo-pagination:0 count=rel="prev":0
# L3 HTML sitemap: H1, one H2 per non-empty group (the empty one left out),
# the category tree nested, a large group's link to its group page; a URL
# that is not http(s) renders as text; long titles stay whole.
200 /mapa-strony contains=<h1 class="seo-sitemap__title">Mapa strony</h1> count=<h2:3 notcontains=Tagi contains=<a href="/mapa-strony/posts" class="">Wpisy: wszystkie (1234)</a> notcontains=href="javascript: notcontains=<script>alert
200 /mapa-strony contains=Grzyby</a><ul class="seo-sitemap__list"> contains=<a href="/grzyby/lecznicze" contains=musi się zawinąć</a> count=<h1:1
# A group page: built trail, H1 with the page suffix, pagination; ?page=1
# is a 301 to the unnumbered URL; an unknown group or a page past the last
# is a 404; a failing sitemap API is a 503 (never an empty cached page).
200 /mapa-strony/posts?page=2 contains=<h1 class="seo-sitemap__title">Mapa strony: Wpisy · Strona 2</h1> contains=<a class="seo-breadcrumbs__link" href="/mapa-strony">Mapa strony</a> contains=<span class="seo-breadcrumbs__current" aria-current="page">Wpisy</span> count=<nav class="seo-breadcrumbs":1 count=<nav class="seo-pagination":1 contains=href="/mapa-strony/posts?page=3"
200 /mapa-strony/posts contains=<h1 class="seo-sitemap__title">Mapa strony: Wpisy</h1> contains=Lwia grzywa
301 /mapa-strony/posts?page=1 header=Location: /mapa-strony/posts
404 /mapa-strony/nope
404 /mapa-strony/posts?page=9
404 /mapa-strony/posts?page=abc
503 /mapa-strony/fail
# Every group empty: the empty message (en labels on the en path).
200 /en/sitemap contains=<h1 class="seo-sitemap__title">Sitemap</h1> contains=<p class="seo-sitemap__empty">There are no pages to list yet.</p> count=<h2:0
# A locale the CMS has no HTML sitemap path for.
404 /sitemap
# L4: the footer link to the locale's HTML sitemap.
200 /blog/lwia-grzywa contains=<footer><a href="/mapa-strony" class="seo-sitemap-link">Mapa strony</a></footer>

View File

@@ -49,3 +49,8 @@ report /posts/nope
200 /google0123.html contains=google-site-verification: google0123.html header=X-Content-Type-Options: nosniff
404 /unknown0123.html
404 /robots.txt
# A backend without a site URL answers paths: they become absolute on the
# request's origin.
200 /relative contains=<link rel="canonical" href="http://127.0.0.1:
200 /relative contains=/img/rel.png"> notcontains=content="/img/rel.png"

View File

@@ -1,20 +1,40 @@
// A small node:http stand-in for the SEO plugin's public API
// (/_seo/api/v1):
// (/_seo/api/v1) and the Go binary's root files:
//
// GET /resolve?url=… the answer for that exact url from
// <dir>/resolve.json, else not_found
// POST /notfound 204 with `Authorization: Bearer <token>`,
// 401 with another token, 404 without one
// GET /verification/<name> <dir>/verification/<name>, else 404
// GET /_seo/api/v1/resolve?url=… the answer for that exact url from
// <dir>/seo/resolve.json, then
// <dir>/resolve.json, else not_found
// GET /_seo/api/v1/site <dir>/seo/site.json, or <dir>/seo/<siteFile>
// (404 without the file or with
// { site: false }: an older backend)
// POST /_seo/api/v1/notfound 204 with `Authorization: Bearer <token>`,
// 401 with another token, 404 without one
// POST /_seo/api/v1/registry/routes the route report: the same token rules;
// 404 with { site: false } (an older
// backend has no page registry)
// GET /_seo/api/v1/verification/<name> <dir>/verification/<name> or
// <dir>/seo/verification/<name>, else 404
// GET /robots.txt, /llms.txt, <dir>/seo/root/<name>, else 404
// /sitemap.xml
// GET /_sitemap/files/<file> <dir>/seo/root/<file>, else 404;
// sitemap-fail-*.xml answers 500
// GET /_sitemap/indexnow/<key>.txt <dir>/seo/indexnow/<key>.txt, else 404
// GET /_sitemap/api/v1/html?locale= the HTML sitemap overview from
// <dir>/seo/html.json (overview.<locale>),
// else 404
// GET /_sitemap/api/v1/html/<group> one group page (groups.<locale>.<group>.<page>,
// ?locale=&page= page 1 by default), else 404; the
// group fail answers 500
//
// It records every request (with the parsed JSON body of a POST) so a smoke
// run can assert header forwarding and the 404 reports.
// run can assert header forwarding, resolve counts and the 404 reports.
// `reload()` reads the fixture files again (purge-check changes them).
//
// Plain .mjs with JSDoc types: Node does not strip TypeScript under
// node_modules, and consuming apps may import this file from there.
import { createServer } from 'node:http'
import { readFileSync } from 'node:fs'
import { existsSync, readFileSync } from 'node:fs'
import { join } from 'node:path'
const PREFIX = '/_seo/api/v1'
@@ -24,26 +44,68 @@ export const FIXTURE_NOTFOUND_TOKEN = 'fixture-seo-notfound-token-0123456789'
/**
* @typedef {{ method: string, url: string, xff: string | null, authorization: string | null, body: unknown }} FixtureRequest
* @typedef {{ url: string, requests: FixtureRequest[], close: () => Promise<void> }} FixtureApi
* @typedef {{ url: string, requests: FixtureRequest[], resolveCount: (target?: string) => number, reload: () => void, close: () => Promise<void> }} FixtureApi
*/
/** @param {string} file @returns {any} */
function readJson(file) {
return existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : null
}
/** @param {string} name */
function contentType(name) {
if (name.endsWith('.xml')) return 'application/xml; charset=utf-8'
if (name.endsWith('.html')) return 'text/html; charset=utf-8'
return 'text/plain; charset=utf-8'
}
/**
* Starts the fixture API on 127.0.0.1.
* @param {{ dir: string, port?: number, token?: string }} opts
* @param {{ dir: string, port?: number, token?: string, site?: boolean, siteFile?: string }} opts
* @returns {Promise<FixtureApi>}
*/
export function startFixtureApi({ dir, port = 0, token = FIXTURE_NOTFOUND_TOKEN }) {
export function startFixtureApi({ dir, port = 0, token = FIXTURE_NOTFOUND_TOKEN, site = true, siteFile = 'site.json' }) {
/** @type {FixtureRequest[]} */
const requests = []
/** @type {Record<string, unknown>} */
const answers = JSON.parse(readFileSync(join(dir, 'resolve.json'), 'utf8'))
let answers = {}
/** @type {unknown} */
let siteAnswer = null
/** @type {any} */
let html = {}
const load = () => {
html = readJson(join(dir, 'seo', 'html.json')) ?? {}
answers = { ...(readJson(join(dir, 'resolve.json')) ?? {}), ...(readJson(join(dir, 'seo', 'resolve.json')) ?? {}) }
siteAnswer = site ? readJson(join(dir, 'seo', siteFile)) : null
}
load()
/** @param {string} file @param {string} name */
const fileAnswer = (file, name) => {
if (/^sitemap-fail-/.test(name)) return { status: 500, text: 'backend failure', type: 'text/plain' }
if (!existsSync(file)) return { status: 404, body: { error: 'not_found' } }
return { status: 200, text: readFileSync(file, 'utf8'), type: contentType(name), robots: name.startsWith('sitemap-') || name === 'llms.txt' ? 'noindex' : undefined }
}
/**
* @param {import('node:http').IncomingMessage} req
* @returns {{ status: number, body?: unknown, text?: string, type?: string }}
* @returns {{ status: number, body?: unknown, text?: string, type?: string, robots?: string }}
*/
const route = (req) => {
const url = new URL(req.url ?? '/', 'http://fixture.local')
const root = /^\/(robots\.txt|llms\.txt|sitemap\.xml)$/.exec(url.pathname)
if (root && req.method === 'GET') return fileAnswer(join(dir, 'seo', 'root', root[1]), root[1])
const files = /^\/_sitemap\/files\/(sitemap-[a-z0-9-]+\.xml)$/.exec(url.pathname)
if (files && req.method === 'GET') return fileAnswer(join(dir, 'seo', 'root', files[1]), files[1])
const key = /^\/_sitemap\/indexnow\/([A-Za-z0-9-]{8,128}\.txt)$/.exec(url.pathname)
if (key && req.method === 'GET') return fileAnswer(join(dir, 'seo', 'indexnow', key[1]), key[1])
const htmlGroup = /^\/_sitemap\/api\/v1\/html(?:\/([a-z0-9-]+))?$/.exec(url.pathname)
if (htmlGroup && req.method === 'GET') {
const locale = url.searchParams.get('locale') ?? ''
if (htmlGroup[1] === 'fail') return { status: 500, body: { error: 'backend failure' } }
const hit = htmlGroup[1] ? html.groups?.[locale]?.[htmlGroup[1]]?.[url.searchParams.get('page') ?? '1'] : html.overview?.[locale]
return hit ? { status: 200, body: hit } : { status: 404, body: { error: 'not_found' } }
}
if (!url.pathname.startsWith(PREFIX + '/')) return { status: 404, body: { error: 'not_found' } }
const path = url.pathname.slice(PREFIX.length)
if (path === '/resolve' && req.method === 'GET') {
@@ -53,17 +115,23 @@ export function startFixtureApi({ dir, port = 0, token = FIXTURE_NOTFOUND_TOKEN
if (hit) return { status: 200, body: hit }
return { status: 200, body: { action: 'not_found', status: 404, url: target, locale: '', staging: false, seo: null } }
}
if (path === '/site' && req.method === 'GET') {
return siteAnswer ? { status: 200, body: siteAnswer } : { status: 404, body: { error: 'not_found' } }
}
if (path === '/notfound' && req.method === 'POST') {
if (!token) return { status: 404, body: { error: 'not_found' } }
return req.headers.authorization === `Bearer ${token}` ? { status: 204 } : { status: 401, body: { error: 'unauthorized' } }
}
if (path === '/registry/routes' && req.method === 'POST') {
if (!site || !token) return { status: 404, body: { error: 'not_found' } }
return req.headers.authorization === `Bearer ${token}` ? { status: 204 } : { status: 401, body: { error: 'unauthorized' } }
}
const v = /^\/verification\/([A-Za-z0-9._-]+)$/.exec(path)
if (v && req.method === 'GET') {
try {
return { status: 200, text: readFileSync(join(dir, 'verification', v[1]), 'utf8'), type: 'text/html; charset=utf-8' }
} catch {
return { status: 404, body: { error: 'not_found' } }
for (const file of [join(dir, 'verification', v[1]), join(dir, 'seo', 'verification', v[1])]) {
if (existsSync(file)) return { status: 200, text: readFileSync(file, 'utf8'), type: 'text/html; charset=utf-8' }
}
return { status: 404, body: { error: 'not_found' } }
}
return { status: 404, body: { error: 'not_found' } }
}
@@ -91,7 +159,7 @@ export function startFixtureApi({ dir, port = 0, token = FIXTURE_NOTFOUND_TOKEN
out = { status: 500, body: { error: String(err) } }
}
if (out.text !== undefined) {
res.writeHead(out.status, { 'content-type': out.type ?? 'text/plain' })
res.writeHead(out.status, { 'content-type': out.type ?? 'text/plain', ...(out.robots ? { 'x-robots-tag': out.robots } : {}) })
res.end(out.text)
return
}
@@ -100,6 +168,14 @@ export function startFixtureApi({ dir, port = 0, token = FIXTURE_NOTFOUND_TOKEN
})
})
/** Resolve calls so far, for one target url or (without one) all of them. @param {string} [target] */
const resolveCount = (target) =>
requests.filter((r) => {
if (r.method !== 'GET') return false
const u = new URL(r.url, 'http://fixture.local')
return u.pathname === `${PREFIX}/resolve` && (target === undefined || u.searchParams.get('url') === target)
}).length
return new Promise((resolve, reject) => {
server.once('error', reject)
server.listen(port, '127.0.0.1', () => {
@@ -107,6 +183,8 @@ export function startFixtureApi({ dir, port = 0, token = FIXTURE_NOTFOUND_TOKEN
resolve({
url: `http://127.0.0.1:${addr.port}`,
requests,
resolveCount,
reload: load,
close: () => new Promise((done) => server.close(() => done())),
})
})

View File

@@ -1,38 +1,158 @@
{
"/": {
"action": "ok", "status": 200, "url": "https://acme.test/", "locale": "en", "staging": false,
"action": "ok",
"status": 200,
"url": "https://acme.test/",
"locale": "en",
"staging": false,
"seo": {
"title": "Home | Acme", "description": "Acme home", "canonical": "https://acme.test/",
"robots": "index, follow", "x_robots_tag": "index, follow",
"og": { "title": "Home | Acme", "description": "Acme home", "image": "https://acme.test/home.png", "url": "https://acme.test/" },
"twitter": { "card": "summary_large_image", "site": "@acme" },
"title": "Home | Acme",
"description": "Acme home",
"canonical": "https://acme.test/",
"robots": "index, follow",
"x_robots_tag": "index, follow",
"og": {
"title": "Home | Acme",
"description": "Acme home",
"image": "https://acme.test/home.png",
"url": "https://acme.test/"
},
"twitter": {
"card": "summary_large_image",
"site": "@acme"
},
"verification": []
}
},
"/about": {
"action": "ok", "status": 200, "url": "https://acme.test/about", "locale": "en", "staging": false,
"action": "ok",
"status": 200,
"url": "https://acme.test/about",
"locale": "en",
"staging": false,
"seo": {
"title": "About us | Acme", "description": "Who we are", "canonical": "https://acme.test/about",
"robots": "index, follow, max-image-preview:large", "x_robots_tag": "index, follow, max-image-preview:large",
"og": { "title": "About us | Acme", "description": "Who we are", "image": "", "url": "https://acme.test/about" },
"twitter": { "card": "summary", "site": "@acme" },
"verification": [{ "name": "google-site-verification", "content": "fixture-google-code" }]
"title": "About us | Acme",
"description": "Who we are",
"canonical": "https://acme.test/about",
"robots": "index, follow, max-image-preview:large",
"x_robots_tag": "index, follow, max-image-preview:large",
"og": {
"title": "About us | Acme",
"description": "Who we are",
"image": "",
"url": "https://acme.test/about"
},
"twitter": {
"card": "summary",
"site": "@acme"
},
"verification": [
{
"name": "google-site-verification",
"content": "fixture-google-code"
}
]
}
},
"/partial": {
"action": "ok", "status": 200, "url": "https://acme.test/partial", "locale": "en", "staging": false,
"action": "ok",
"status": 200,
"url": "https://acme.test/partial",
"locale": "en",
"staging": false,
"seo": {
"title": "", "description": "", "canonical": "https://acme.test/partial",
"robots": "", "x_robots_tag": "",
"og": { "title": "", "description": "", "image": "", "url": "" },
"twitter": { "card": "", "site": "" },
"title": "",
"description": "",
"canonical": "https://acme.test/partial",
"robots": "",
"x_robots_tag": "",
"og": {
"title": "",
"description": "",
"image": "",
"url": ""
},
"twitter": {
"card": "",
"site": ""
},
"verification": []
}
},
"/staging": { "action": "not_found", "status": 404, "url": "https://acme.test/staging", "locale": "", "staging": true, "seo": null },
"/old-path": { "action": "redirect", "status": 301, "location": "https://acme.test/about", "url": "https://acme.test/old-path", "locale": "", "staging": false, "seo": null },
"/old-temp?ref=1": { "action": "redirect", "status": 302, "location": "https://acme.test/about?x=1", "url": "https://acme.test/old-temp?ref=1", "locale": "", "staging": false, "seo": null },
"/moved": { "action": "redirect", "status": 301, "location": "https://other.test/x", "url": "https://acme.test/moved", "locale": "", "staging": false, "seo": null },
"/gone-path": { "action": "gone", "status": 410, "url": "https://acme.test/gone-path", "locale": "", "staging": false, "seo": null },
"/posts/gone-post": { "action": "gone", "status": 410, "url": "https://acme.test/posts/gone-post", "locale": "", "staging": false, "seo": null }
"/staging": {
"action": "not_found",
"status": 404,
"url": "https://acme.test/staging",
"locale": "",
"staging": true,
"seo": null
},
"/old-path": {
"action": "redirect",
"status": 301,
"location": "https://acme.test/about",
"url": "https://acme.test/old-path",
"locale": "",
"staging": false,
"seo": null
},
"/old-temp?ref=1": {
"action": "redirect",
"status": 302,
"location": "https://acme.test/about?x=1",
"url": "https://acme.test/old-temp?ref=1",
"locale": "",
"staging": false,
"seo": null
},
"/moved": {
"action": "redirect",
"status": 301,
"location": "https://other.test/x",
"url": "https://acme.test/moved",
"locale": "",
"staging": false,
"seo": null
},
"/gone-path": {
"action": "gone",
"status": 410,
"url": "https://acme.test/gone-path",
"locale": "",
"staging": false,
"seo": null
},
"/posts/gone-post": {
"action": "gone",
"status": 410,
"url": "https://acme.test/posts/gone-post",
"locale": "",
"staging": false,
"seo": null
},
"/relative": {
"action": "ok",
"status": 200,
"url": "/relative",
"locale": "en",
"staging": false,
"seo": {
"title": "Relative | Acme",
"description": "",
"canonical": "/relative",
"robots": "",
"x_robots_tag": "",
"og": {
"title": "",
"description": "",
"image": "/img/rel.png",
"url": "/relative"
},
"twitter": {
"card": "",
"site": ""
},
"verification": []
}
}
}

View File

@@ -0,0 +1,127 @@
{
"overview": {
"pl": {
"groups": [
{
"key": "pages",
"slug": "strony",
"title": "Strony",
"total": 2,
"more": false,
"entries": [
{
"title": "O nas",
"url": "https://example.test/about"
},
{
"title": "Kontakt",
"url": "https://example.test/kontakt"
}
]
},
{
"key": "categories",
"slug": "kategorie",
"title": "Kategorie",
"total": 3,
"more": false,
"entries": [
{
"title": "Grzyby",
"url": "https://example.test/grzyby",
"children": [
{
"title": "Lecznicze",
"url": "https://example.test/grzyby/lecznicze"
},
{
"title": "Bardzo długa nazwa kategorii, która nie mieści się w jednej kolumnie i musi się zawinąć",
"url": "https://example.test/grzyby/dluga"
}
]
}
]
},
{
"key": "posts",
"slug": "posts",
"title": "Wpisy",
"total": 1234,
"more": true,
"entries": [
{
"title": "Lwia grzywa",
"url": "https://example.test/blog/lwia-grzywa"
},
{
"title": "<script>alert(1)</script>",
"url": "javascript:alert(1)"
}
]
},
{
"key": "tags",
"slug": "tagi",
"title": "Tagi",
"total": 0,
"more": false,
"entries": []
}
]
},
"en": {
"groups": [
{
"key": "pages",
"slug": "pages",
"title": "Pages",
"total": 0,
"more": false,
"entries": []
}
]
}
},
"groups": {
"pl": {
"posts": {
"1": {
"group": "posts",
"title": "Wpisy",
"page": 1,
"pages": 3,
"entries": [
{
"title": "Lwia grzywa",
"url": "https://example.test/blog/lwia-grzywa"
}
]
},
"2": {
"group": "posts",
"title": "Wpisy",
"page": 2,
"pages": 3,
"entries": [
{
"title": "Soplówka",
"url": "https://example.test/blog/soplowka"
}
]
},
"3": {
"group": "posts",
"title": "Wpisy",
"page": 3,
"pages": 3,
"entries": [
{
"title": "Reishi",
"url": "https://example.test/blog/reishi"
}
]
}
}
}
}
}

View File

@@ -0,0 +1 @@
a1b2c3d4e5f60718293a4b5c6d7e8f90

View File

@@ -0,0 +1,274 @@
{
"/blog/lwia-grzywa": {
"action": "ok",
"status": 200,
"url": "https://example.test/blog/lwia-grzywa",
"locale": "pl",
"staging": false,
"seo": {
"title": "Lwia grzywa | Example",
"description": "Lwia grzywa | Example opis",
"canonical": "https://example.test/blog/lwia-grzywa",
"robots": "index, follow",
"x_robots_tag": "",
"og": {
"title": "Lwia grzywa | Example",
"description": "Lwia grzywa | Example opis",
"image": "https://example.test/img/og.png",
"url": "https://example.test/blog/lwia-grzywa",
"locale": "pl_PL",
"locale_alternates": [
"en_US"
]
},
"twitter": {
"card": "summary_large_image",
"site": "@example"
},
"verification": []
},
"html_lang": "pl",
"alternates": [
{
"hreflang": "pl",
"url": "https://example.test/blog/lwia-grzywa"
},
{
"hreflang": "en",
"url": "https://example.test/en/blog/lions-mane"
},
{
"hreflang": "x-default",
"url": "https://example.test/blog/lwia-grzywa"
}
],
"jsonld": {
"@context": "https://schema.org",
"@graph": [
{
"@type": "WebPage",
"@id": "https://example.test/blog/lwia-grzywa#webpage",
"url": "https://example.test/blog/lwia-grzywa",
"name": "Lwia grzywa",
"isPartOf": {
"@id": "https://example.test/#website"
}
},
{
"@type": "BlogPosting",
"@id": "https://example.test/blog/lwia-grzywa#article",
"headline": "Lwia grzywa </script><script>alert(1)</script>",
"mainEntityOfPage": {
"@id": "https://example.test/blog/lwia-grzywa#webpage"
}
}
]
},
"breadcrumbs": [
{
"name": "Start",
"url": "https://example.test/",
"path": "/"
},
{
"name": "Blog",
"url": "https://example.test/blog",
"path": "/blog"
},
{
"name": "Lwia grzywa",
"url": "https://example.test/blog/lwia-grzywa",
"path": "/blog/lwia-grzywa"
}
]
},
"/stary": {
"action": "redirect",
"status": 301,
"location": "https://example.test/nowy",
"url": "https://example.test/stary",
"locale": "",
"staging": false,
"seo": null
},
"/stary-zewnetrzny": {
"action": "redirect",
"status": 302,
"location": "https://partner.test/oferta?a=1",
"url": "https://example.test/stary-zewnetrzny",
"locale": "",
"staging": false,
"seo": null
},
"/usuniety": {
"action": "gone",
"status": 410,
"url": "https://example.test/usuniety",
"locale": "",
"staging": false,
"seo": null
},
"/usuniety-staging": {
"action": "gone",
"status": 410,
"url": "https://example.test/usuniety-staging",
"locale": "",
"staging": true,
"seo": null
},
"/plain?page=abc": {
"action": "not_found",
"status": 404,
"url": "https://example.test/plain?page=abc",
"locale": "pl",
"staging": false,
"seo": null,
"pagination": {
"form": "query",
"param": "page",
"segment": "",
"page": 0,
"invalid": true
}
},
"/blog/ukryty": {
"action": "ok",
"status": 200,
"url": "https://example.test/blog/ukryty",
"locale": "pl",
"staging": false,
"seo": {
"title": "Ukryty | Example",
"description": "Lwia grzywa | Example opis",
"canonical": "https://example.test/blog/ukryty",
"robots": "noindex, nofollow",
"x_robots_tag": "noindex, nofollow",
"og": {
"title": "Lwia grzywa | Example",
"description": "Lwia grzywa | Example opis",
"image": "https://example.test/img/og.png",
"url": "https://example.test/blog/ukryty",
"locale": "pl_PL",
"locale_alternates": [
"en_US"
]
},
"twitter": {
"card": "summary_large_image",
"site": "@example"
},
"verification": []
},
"html_lang": "pl",
"alternates": [],
"jsonld": null,
"breadcrumbs": [
{
"name": "Start",
"url": "https://example.test/",
"path": "/"
},
{
"name": "Blog",
"url": "https://example.test/blog",
"path": "/blog"
},
{
"name": "Lwia grzywa",
"url": "https://example.test/blog/lwia-grzywa",
"path": "/blog/lwia-grzywa"
}
]
},
"/blog": {
"action": "ok",
"status": 200,
"url": "https://example.test/blog",
"locale": "pl",
"staging": false,
"seo": {
"title": "Blog | Example",
"description": "Wpisy o grzybach",
"canonical": "https://example.test/blog",
"robots": "index, follow",
"x_robots_tag": "",
"og": {
"title": "Blog | Example",
"description": "Wpisy o grzybach",
"image": "",
"url": "https://example.test/blog",
"locale": "pl_PL",
"locale_alternates": []
},
"twitter": {
"card": "summary",
"site": ""
},
"verification": []
},
"html_lang": "pl",
"alternates": [],
"jsonld": null,
"breadcrumbs": [
{
"name": "Start",
"url": "https://example.test/",
"path": "/"
},
{
"name": "Blog",
"url": "https://example.test/blog",
"path": "/blog"
}
]
},
"/blog?page=5": {
"action": "ok",
"status": 200,
"url": "https://example.test/blog?page=5",
"locale": "pl",
"staging": false,
"seo": {
"title": "Blog – strona 5 z %pages% | Example",
"description": "Wpisy o grzybach, strona 5 z %pages%",
"canonical": "https://example.test/blog?page=5",
"robots": "index, follow",
"x_robots_tag": "",
"og": {
"title": "Blog – strona 5 z %pages% | Example",
"description": "Wpisy o grzybach, strona 5 z %pages%",
"image": "",
"url": "https://example.test/blog?page=5",
"locale": "pl_PL",
"locale_alternates": []
},
"twitter": {
"card": "summary",
"site": ""
},
"verification": []
},
"html_lang": "pl",
"alternates": [],
"jsonld": null,
"breadcrumbs": [
{
"name": "Start",
"url": "https://example.test/",
"path": "/"
},
{
"name": "Blog",
"url": "https://example.test/blog",
"path": "/blog"
}
],
"pagination": {
"form": "query",
"param": "page",
"segment": "",
"page": 5,
"invalid": false
}
}
}

View File

@@ -0,0 +1,3 @@
# Example
> Fixture llms.txt

View File

@@ -0,0 +1,3 @@
User-agent: *
Disallow: /konto
Sitemap: https://example.test/sitemap.xml

View File

@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"><url><loc>https://example.test/blog/lwia-grzywa</loc></url></urlset>

View File

@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="UTF-8"?>
<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"><sitemap><loc>https://example.test/sitemap-posts-1.xml</loc></sitemap></sitemapindex>

View File

@@ -0,0 +1,16 @@
{
"site_url": "https://example.test",
"default_locale": "pl",
"locales": ["pl", "en"],
"unprefixed_locale": "pl",
"links": { "new_tab": true },
"html_sitemap": { "enabled": true, "paths": { "pl": "/mapa", "en": "/en/sitemap" }, "page_size": 100 },
"routes": [
{ "type": "post", "pattern": "/posts/:slug" },
{ "type": "journal-post", "pattern": "/wpisy/:slug" }
],
"root_files": [],
"llms": false,
"pagination": { "form": "path", "param": "page", "segments": { "pl": "strona", "en": "page" } },
"home_crumb": { "name": "Start", "url": "https://example.test/" }
}

View File

@@ -0,0 +1,13 @@
{
"site_url": "https://example.test",
"default_locale": "pl",
"locales": ["pl", "en"],
"unprefixed_locale": "pl",
"links": { "new_tab": true },
"html_sitemap": { "enabled": true, "paths": { "pl": "/mapa-strony", "en": "/en/sitemap" }, "page_size": 100 },
"routes": [{ "type": "post", "pattern": "/posts/:slug" }],
"root_files": ["google1234.html", "a1b2c3d4e5f60718293a4b5c6d7e8f90.txt"],
"llms": true,
"pagination": { "form": "query", "param": "page", "segments": {} },
"home_crumb": { "name": "Start", "url": "https://example.test/" }
}

View File

@@ -0,0 +1 @@
google-site-verification: google1234.html

107
testing/guard-check.mjs Normal file
View File

@@ -0,0 +1,107 @@
#!/usr/bin/env node
// Build-guard check (D-70): builds the fixture apps under testing/guard,
// each extending this layer, and checks that the guard fails the build of
// every app with an indexable route that renders only in the browser and
// passes the one that declares that route noindex:
//
// indexable-ssr-false '/x/**' (Nuxt routeRules) and '/y/**' (Nitro
// routeRules) with ssr: false: exit 1 naming both
// client-page app/pages/panel.client.vue: exit 1 naming /panel
// global-ssr-false ssr: false for the whole app: exit 1
// noindex-ok '/x/**' with ssr: false declared in
// seo.noindexRoutes: exit 0; its server answers /x/a
// with X-Robots-Tag: noindex, nofollow and / without
// the header
//
// node testing/guard-check.mjs
//
// Each build runs the layer's own nuxt binary with the fixture as its
// working directory. Prints "guard-check: ok (<n> checks)" or one
// "guard-check: FAIL ..." line per failure and exits 1.
import { spawn } from 'node:child_process'
import { existsSync } from 'node:fs'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { startNuxtServer } from './nuxt-server.mjs'
const here = dirname(fileURLToPath(import.meta.url))
const layerRoot = resolve(here, '..')
const nuxtBin = join(layerRoot, 'node_modules', '.bin', 'nuxt')
const MESSAGE = '[vue-seo-layer] indexable routes must render on the server (D-70)'
/** @param {string} fixture @returns {Promise<{ code: number, log: string }>} */
function build(fixture) {
return new Promise((done, fail) => {
const child = spawn(nuxtBin, ['build'], {
cwd: join(here, 'guard', fixture),
env: { ...process.env, NODE_ENV: 'production', NUXT_TELEMETRY_DISABLED: '1' },
stdio: ['ignore', 'pipe', 'pipe'],
})
let log = ''
child.stdout.on('data', (b) => (log += b))
child.stderr.on('data', (b) => (log += b))
child.once('error', fail)
child.once('exit', (code) => done({ code: code ?? 1, log }))
})
}
async function main() {
if (!existsSync(nuxtBin)) throw new Error(`${nuxtBin} does not exist; run pnpm install in the layer first`)
/** @type {string[]} */
const failures = []
let checks = 0
const check = (/** @type {boolean} */ ok, /** @type {string} */ what) => {
checks++
if (!ok) failures.push(what)
}
/** @type {Array<{ fixture: string, names: string[] }>} */
const offenders = [
{ fixture: 'indexable-ssr-false', names: ['/x/**', '/y/**'] },
{ fixture: 'client-page', names: ['/panel'] },
{ fixture: 'global-ssr-false', names: ['ssr: false'] },
]
for (const { fixture, names } of offenders) {
const { code, log } = await build(fixture)
check(code !== 0, `${fixture}: the build passed, want it to fail`)
const line = log.split('\n').find((l) => l.includes(MESSAGE)) ?? ''
check(line !== '', `${fixture}: the build log lacks "${MESSAGE}"\n${log.slice(-2000)}`)
for (const name of names) check(line.includes(name), `${fixture}: the guard message does not name ${name}: ${line}`)
}
const ok = await build('noindex-ok')
check(ok.code === 0, `noindex-ok: the build failed (exit ${ok.code})\n${ok.log.slice(-2000)}`)
check(!ok.log.includes(MESSAGE), 'noindex-ok: the build log carries the guard message')
if (ok.code === 0) {
const server = await startNuxtServer({
outputDir: join(here, 'guard', 'noindex-ok', '.output'),
// No backend: the header comes from the route rule alone.
apiBase: 'http://127.0.0.1:1/_seo/api/v1',
})
try {
const declared = await fetch(`${server.url}/x/a`, { redirect: 'manual', headers: { Accept: 'text/html' } })
await declared.text()
check(declared.status === 200, `noindex-ok: /x/a answered ${declared.status}, want 200`)
const header = declared.headers.get('x-robots-tag')
check(header === 'noindex, nofollow', `noindex-ok: /x/a X-Robots-Tag is ${JSON.stringify(header)}, want "noindex, nofollow"`)
const home = await fetch(`${server.url}/`, { redirect: 'manual', headers: { Accept: 'text/html' } })
await home.text()
check(home.status === 200, `noindex-ok: / answered ${home.status}, want 200`)
check(home.headers.get('x-robots-tag') === null, `noindex-ok: / carries X-Robots-Tag ${JSON.stringify(home.headers.get('x-robots-tag'))}, want none`)
} finally {
await server.close()
}
}
if (failures.length > 0) {
for (const f of failures) console.error(`guard-check: FAIL ${f}`)
process.exit(1)
}
console.log(`guard-check: ok (${checks} checks)`)
}
main().catch((err) => {
console.error(`guard-check: FAIL setup: ${err instanceof Error ? err.message : String(err)}`)
process.exit(1)
})

View File

@@ -0,0 +1,3 @@
<template>
<main><h1>Guard fixture</h1></main>
</template>

View File

@@ -0,0 +1,3 @@
<template>
<main><h1>Panel</h1></main>
</template>

View File

@@ -0,0 +1,7 @@
// Build-guard fixture (testing/guard-check.mjs): a .client.vue page
// (app/pages/panel.client.vue) is not rendered on the server. The build
// must fail with the D-70 message.
export default defineNuxtConfig({
extends: ['../../..'],
compatibilityDate: '2025-07-15',
})

View File

@@ -0,0 +1,3 @@
<template>
<main><h1>Guard fixture</h1></main>
</template>

View File

@@ -0,0 +1,7 @@
// Build-guard fixture (testing/guard-check.mjs): the whole app renders in
// the browser. The build must fail with the D-70 message.
export default defineNuxtConfig({
extends: ['../../..'],
compatibilityDate: '2025-07-15',
ssr: false,
})

View File

@@ -0,0 +1,3 @@
<template>
<main><h1>Guard fixture</h1></main>
</template>

View File

@@ -0,0 +1,15 @@
// Build-guard fixture (testing/guard-check.mjs): an indexable route that
// renders only in the browser, once as a Nuxt route rule and once as a
// Nitro route rule. The build must fail with the D-70 message naming both.
export default defineNuxtConfig({
extends: ['../../..'],
compatibilityDate: '2025-07-15',
routeRules: {
'/x/**': { ssr: false },
},
nitro: {
routeRules: {
'/y/**': { ssr: false },
},
},
})

View File

@@ -0,0 +1,3 @@
<template>
<main><h1>Guard fixture</h1></main>
</template>

View File

@@ -0,0 +1,13 @@
// Build-guard fixture (testing/guard-check.mjs): the same browser-only
// route, declared noindex. The build passes and the route is served with
// X-Robots-Tag: noindex, nofollow.
export default defineNuxtConfig({
extends: ['../../..'],
compatibilityDate: '2025-07-15',
routeRules: {
'/x/**': { ssr: false },
},
seo: {
noindexRoutes: ['/x/**'],
},
})

77
testing/nuxt-server.mjs Normal file
View File

@@ -0,0 +1,77 @@
// Boots a built Nuxt server (`<outputDir>/server/index.mjs`) on 127.0.0.1
// against a given SEO API base and waits until it answers HTTP. The same
// helper as vue-journal-layer's testing/nuxt-server.mjs, with the SEO base.
// Plain .mjs with JSDoc types so it runs from node_modules unchanged.
import { spawn } from 'node:child_process'
import { createServer } from 'node:net'
import { existsSync } from 'node:fs'
import { join, resolve } from 'node:path'
/** @returns {Promise<number>} */
function freePort() {
return new Promise((done, fail) => {
const srv = createServer()
srv.once('error', fail)
srv.listen(0, '127.0.0.1', () => {
const { port } = /** @type {import('node:net').AddressInfo} */ (srv.address())
srv.close(() => done(port))
})
})
}
/**
* The returned `output()` gives the server's captured stdout and stderr so
* far, for checks on what the server logged.
*
* @param {{ outputDir: string, apiBase: string, port?: number, env?: Record<string, string>, timeoutMs?: number }} opts
* @returns {Promise<{ url: string, close: () => Promise<void>, output: () => string }>}
*/
export async function startNuxtServer({ outputDir, apiBase, port = 0, env = {}, timeoutMs = 30000 }) {
const entry = join(resolve(outputDir), 'server', 'index.mjs')
if (!existsSync(entry)) throw new Error(`nuxt-server: ${entry} does not exist; run pnpm build:playground first`)
const listenPort = port || (await freePort())
const url = `http://127.0.0.1:${listenPort}`
const child = spawn(process.execPath, [entry], {
env: {
...process.env,
NODE_ENV: 'production',
NITRO_HOST: '127.0.0.1',
NITRO_PORT: String(listenPort),
NUXT_SEO_API_BASE: apiBase,
...env,
},
stdio: ['ignore', 'pipe', 'pipe'],
})
let log = ''
child.stdout.on('data', (b) => (log += b))
child.stderr.on('data', (b) => (log += b))
let exited = false
child.once('exit', () => (exited = true))
const close = () =>
new Promise((/** @type {(v?: void) => void} */ done) => {
if (exited) return done()
child.once('exit', () => done())
child.kill('SIGTERM')
setTimeout(() => {
if (!exited) child.kill('SIGKILL')
}, 5000).unref()
})
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
if (exited) throw new Error(`nuxt-server: server exited before answering\n${log}`)
try {
// A Nuxt-internal path: it touches no page, so the boot probe never
// resolves through the SEO API or fills a route cache.
await fetch(url + '/_nuxt/', { redirect: 'manual', signal: AbortSignal.timeout(2000) })
return { url, close, output: () => log }
} catch {
await new Promise((r) => setTimeout(r, 200))
}
}
await close()
throw new Error(`nuxt-server: no answer on ${url} within ${timeoutMs} ms\n${log}`)
}

216
testing/purge-check.mjs Normal file
View File

@@ -0,0 +1,216 @@
#!/usr/bin/env node
// Site cache purge check: proves on a built Nuxt server with swr route rules
// that a page cached before a CMS change shows the change on the first
// request after a purge (the purge empties Nitro's route cache and the SEO
// layer's resolve cache), and that the purge route refuses unauthenticated,
// wrong-token and proxied requests.
//
// node purge-check.mjs --output <.output dir> --fixtures <dir> --path <page path>
//
// The CMS change is a new title in the fixture resolve answer for the path
// (<fixtures>/seo/resolve.json in a temporary copy). It also checks that a
// redirect the CMS adds for a route-cached page applies at once when the
// resolve cache is off (NUXT_SEO_RESOLVE_CACHE_TTL=0): the redirect decision
// runs before the route cache.
//
// It also checks that a cached page revalidates with its ETag (304), that
// a second purge after re-caching runs (the route is never cached itself)
// and that a server without a token answers 404. vue-journal-layer's copy
// also checks browser max-age=0 (its journal-revalidate plugin) and the
// page's _payload.json (which carries Journal data); neither applies here:
// the SEO answer lives in the page's state, not in useAsyncData.
// The fixtures are copied to a temporary directory, so the source fixtures
// never change. The last line is "purge-check: ok (<n> checks)" on success;
// every failure prints "purge-check: FAIL ..." and exits 1.
import { randomBytes } from 'node:crypto'
import { cpSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { startFixtureApi } from './fixture-api.mjs'
import { startNuxtServer } from './nuxt-server.mjs'
const PURGE_PATH = '/api/__site__/cache/purge'
/** @param {string[]} argv */
function parseArgs(argv) {
/** @type {Record<string, string>} */
const out = {}
const names = new Set(['--output', '--fixtures', '--path'])
for (let i = 0; i < argv.length; i++) {
const arg = argv[i]
if (!names.has(arg)) throw new Error(`unknown argument ${arg}`)
const v = argv[++i]
if (v === undefined) throw new Error(`${arg} needs a value`)
out[arg.slice(2)] = v
}
for (const name of names) {
if (!out[name.slice(2)]) throw new Error('--output, --fixtures and --path are required')
}
if (!out.path.startsWith('/')) throw new Error(`--path must start with "/", got ${out.path}`)
return /** @type {{ output: string, fixtures: string, path: string }} */ (out)
}
/**
* Changes the fixture resolve answer for `path`: appends `marker` to its
* title, or (with `redirectTo`) turns it into a 301 redirect.
* @param {string} dir @param {string} path @param {string} marker @param {string} [redirectTo]
*/
function changeAnswer(dir, path, marker, redirectTo) {
const file = join(dir, 'seo', 'resolve.json')
const answers = JSON.parse(readFileSync(file, 'utf8'))
const answer = answers[path]
if (!answer || answer.action !== 'ok') throw new Error(`no ok answer for ${path} in ${file}`)
if (redirectTo) {
answers[path] = { action: 'redirect', status: 301, location: redirectTo, url: answer.url, locale: '', staging: false, seo: null }
} else {
answer.seo.title = `${answer.seo.title} ${marker}`
}
writeFileSync(file, JSON.stringify(answers, null, 2))
}
/** @param {string} base @param {string} path @param {Record<string, string>} [headers] */
async function get(base, path, headers = {}) {
const res = await fetch(base + path, { redirect: 'manual', headers, signal: AbortSignal.timeout(20000) })
return { status: res.status, headers: res.headers, body: await res.text() }
}
/** @param {string} base @param {{ kind: string, slug: string }} event @param {Record<string, string>} headers */
async function purge(base, event, headers) {
const res = await fetch(base + PURGE_PATH, {
method: 'POST',
headers: { 'content-type': 'application/json', ...headers },
body: JSON.stringify(event),
signal: AbortSignal.timeout(20000),
})
const text = await res.text()
/** @type {any} */
let json = null
try {
json = JSON.parse(text)
} catch {
json = null
}
return { status: res.status, json, text }
}
async function main() {
const args = parseArgs(process.argv.slice(2))
const event = { kind: 'seo', slug: args.path }
const dir = mkdtempSync(join(tmpdir(), 'purge-check-'))
cpSync(resolve(args.fixtures), dir, { recursive: true })
const token = randomBytes(24).toString('hex')
const auth = { authorization: `Bearer ${token}` }
const marker = `purge-check-marker-${randomBytes(6).toString('hex')}`
const second = `purge-check-second-${randomBytes(6).toString('hex')}`
/** @type {string[]} */
const failures = []
let checks = 0
/** @param {boolean} ok @param {string} what */
const check = (ok, what) => {
checks++
if (!ok) failures.push(what)
return ok
}
const api = await startFixtureApi({ dir })
/** @type {{ url: string, close: () => Promise<void> } | null} */
let nuxt = null
// The steps stop at the first failure that makes the later ones
// meaningless; the failures are reported after the servers stop.
const steps = async () => {
nuxt = await startNuxtServer({
outputDir: args.output,
apiBase: `${api.url}/_seo/api/v1`,
env: { NUXT_CACHE_PURGE_TOKEN: token },
})
const base = nuxt.url
const page = args.path
// 1. The page renders and is cached.
const first = await get(base, page)
if (!check(first.status === 200, `GET ${page} before the change: status ${first.status}, want 200`)) return
// 2. Change the answer behind the API; the cached page must not show it.
changeAnswer(dir, page, marker)
api.reload()
const cached = await get(base, page)
check(cached.status === 200, `GET ${page} after the change: status ${cached.status}, want 200`)
if (!check(!cached.body.includes(marker), `GET ${page} shows the change before any purge: the page is not route-cached (missing swr route rules?)`)) return
// 3. An authorized purge removes the cached entries.
const purged = await purge(base, event, auth)
check(purged.status === 200, `authorized purge: status ${purged.status}, want 200 (${purged.text.slice(0, 200)})`)
check(typeof purged.json?.purged === 'number' && purged.json.purged >= 1, `authorized purge: answer ${purged.text.slice(0, 200)}, want purged >= 1`)
// 4. The next request shows the change (route cache and resolve cache
// both emptied) and revalidates with its ETag.
const fresh = await get(base, page)
check(fresh.status === 200 && fresh.body.includes(marker), `GET ${page} after the purge: status ${fresh.status}, change shown: ${fresh.body.includes(marker)}`)
const etag = fresh.headers.get('etag')
if (check(!!etag, `GET ${page} after the purge: no ETag`)) {
const revalidated = await get(base, page, { 'if-none-match': /** @type {string} */ (etag) })
check(revalidated.status === 304, `conditional GET ${page}: status ${revalidated.status}, want 304`)
}
// 5. A second purge after the page was cached again runs too: the purge
// route itself is never route-cached.
changeAnswer(dir, page, second)
api.reload()
const recached = await get(base, page)
check(!recached.body.includes(second), `GET ${page} after the second change: shown before the second purge (page not re-cached)`)
const again = await purge(base, event, auth)
check(again.status === 200 && typeof again.json?.purged === 'number' && again.json.purged >= 1, `second purge: status ${again.status}, answer ${again.text.slice(0, 200)}, want 200 with purged >= 1`)
const fresh2 = await get(base, page)
check(fresh2.body.includes(second), `GET ${page} after the second purge: the second change is not shown (the purge answer came from a cache)`)
// 6. Refused purges.
const none = await purge(base, event, {})
check(none.status === 401, `purge without a token: status ${none.status}, want 401`)
const wrong = await purge(base, event, { authorization: `Bearer ${randomBytes(24).toString('hex')}` })
check(wrong.status === 401, `purge with a wrong token: status ${wrong.status}, want 401`)
const proxied = await purge(base, event, { ...auth, 'x-forwarded-for': '203.0.113.7' })
check(proxied.status === 404, `proxied purge with the right token: status ${proxied.status}, want 404`)
const realIP = await purge(base, event, { ...auth, 'x-real-ip': '203.0.113.7' })
check(realIP.status === 404, `purge with X-Real-IP and the right token: status ${realIP.status}, want 404`)
// 7. A server without a token has no purge route.
await nuxt.close()
nuxt = await startNuxtServer({ outputDir: args.output, apiBase: `${api.url}/_seo/api/v1`, env: { NUXT_CACHE_PURGE_TOKEN: '' } })
const disabled = await purge(nuxt.url, event, auth)
check(disabled.status === 404, `purge on a server without a token: status ${disabled.status}, want 404`)
// 8. Without the resolve cache, a redirect the CMS adds for a
// route-cached page applies on the next request, without a purge.
await nuxt.close()
nuxt = await startNuxtServer({ outputDir: args.output, apiBase: `${api.url}/_seo/api/v1`, env: { NUXT_SEO_RESOLVE_CACHE_TTL: '0' } })
const before = await get(nuxt.url, page)
check(before.status === 200, `GET ${page} before the redirect: status ${before.status}, want 200`)
changeAnswer(dir, page, '', 'https://moved.test/elsewhere')
api.reload()
const moved = await get(nuxt.url, page)
check(
moved.status === 301 && moved.headers.get('location') === 'https://moved.test/elsewhere',
`GET ${page} after the CMS added a redirect: status ${moved.status}, Location ${moved.headers.get('location')}, want 301 to https://moved.test/elsewhere`,
)
}
try {
await steps()
} finally {
if (nuxt) await /** @type {{ close: () => Promise<void> }} */ (nuxt).close()
await api.close()
rmSync(dir, { recursive: true, force: true })
}
if (failures.length > 0) {
for (const f of failures) console.error(`purge-check: FAIL ${f}`)
process.exit(1)
}
console.log(`purge-check: ok (${checks} checks)`)
}
main().catch((err) => {
console.error(`purge-check: FAIL setup: ${err instanceof Error ? err.message : String(err)}`)
process.exit(1)
})

View File

@@ -0,0 +1,194 @@
#!/usr/bin/env node
// Purge token check: proves on a built Nuxt server that the site cache purge
// token never reaches a page, whichever environment variable carries it, and
// that the shared purge route reads the new and the deprecated names.
//
// node purge-token-check.mjs --output <.output dir> --fixtures <dir> --path <page path>
//
// nuxt-site-config (through @nuxtjs/seo) copies every NUXT_SITE_* variable
// into the public site config, which Nuxt serializes into each page's
// payload. Three scenarios, each on a fresh server with fresh random tokens:
//
// A NUXT_CACHE_PURGE_TOKEN only
// B the deprecated NUXT_SITE_CACHE_PURGE_TOKEN and
// NUXT_SITE_CACHE_ALLOW_PROXIED=true only
// C both token names, with different values (the new one wins)
//
// In each, `/`, the page and its _payload.json (when the build serves one)
// must contain no token value and neither cachePurgeToken nor
// cacheAllowProxied, and neither must the server's log. The purge route must
// answer 200 to the expected bearer, 401 to the other or a wrong one, and a
// proxied request with the bearer 200 in B (allowProxied) and 404 in A. B
// logs the deprecation warning exactly once, A and C never.
//
// Failure messages name a key or say "token value"; they never print a
// value. The last line is "purge-token-check: ok (<n> checks)" on success;
// every failure prints "purge-token-check: FAIL ..." and exits 1.
import { randomBytes } from 'node:crypto'
import { resolve } from 'node:path'
import { startFixtureApi } from './fixture-api.mjs'
import { startNuxtServer } from './nuxt-server.mjs'
const PURGE_PATH = '/api/__site__/cache/purge'
const PURGE_KEYS = ['cachePurgeToken', 'cacheAllowProxied']
const DEPRECATION_LINE = /^\[site-cache\].*NUXT_CACHE_PURGE_TOKEN/gm
/** @param {string[]} argv */
function parseArgs(argv) {
/** @type {Record<string, string>} */
const out = {}
const names = new Set(['--output', '--fixtures', '--path'])
for (let i = 0; i < argv.length; i++) {
const arg = argv[i]
if (!names.has(arg)) throw new Error(`unknown argument ${arg}`)
const v = argv[++i]
if (v === undefined) throw new Error(`${arg} needs a value`)
out[arg.slice(2)] = v
}
for (const name of names) {
if (!out[name.slice(2)]) throw new Error('--output, --fixtures and --path are required')
}
if (!out.path.startsWith('/')) throw new Error(`--path must start with "/", got ${out.path}`)
return /** @type {{ output: string, fixtures: string, path: string }} */ (out)
}
const token = () => randomBytes(32).toString('hex')
/** @param {string} base @param {string} path */
async function get(base, path) {
const res = await fetch(base + path, { redirect: 'manual', signal: AbortSignal.timeout(20000) })
return { status: res.status, body: await res.text() }
}
/** @param {string} base @param {Record<string, string>} headers */
async function purge(base, headers) {
const res = await fetch(base + PURGE_PATH, {
method: 'POST',
headers: { 'content-type': 'application/json', ...headers },
body: JSON.stringify({ kind: 'post', slug: 'purge-token-check' }),
signal: AbortSignal.timeout(20000),
})
await res.text()
return res.status
}
/** @param {string} value */
const bearer = (value) => ({ authorization: `Bearer ${value}` })
/** The payload path Nuxt serves for a page path. @param {string} path */
function payloadPath(path) {
return `${path.replace(/\/+$/, '')}/_payload.json`
}
async function main() {
const args = parseArgs(process.argv.slice(2))
/** @type {string[]} */
const failures = []
let checks = 0
/** @param {boolean} ok @param {string} what */
const check = (ok, what) => {
checks++
if (!ok) failures.push(what)
return ok
}
const a = token()
const b = token()
const cNew = token()
const cOld = token()
const scenarios = [
{
name: 'A (NUXT_CACHE_PURGE_TOKEN)',
env: { NUXT_CACHE_PURGE_TOKEN: a },
tokens: [a],
accepted: a,
refused: [token()],
proxied: 404,
warnings: 0,
},
{
name: 'B (deprecated NUXT_SITE_CACHE_PURGE_TOKEN, NUXT_SITE_CACHE_ALLOW_PROXIED=true)',
env: { NUXT_SITE_CACHE_PURGE_TOKEN: b, NUXT_SITE_CACHE_ALLOW_PROXIED: 'true' },
tokens: [b],
accepted: b,
refused: [token()],
proxied: 200,
warnings: 1,
},
{
name: 'C (both names, different values)',
env: { NUXT_CACHE_PURGE_TOKEN: cNew, NUXT_SITE_CACHE_PURGE_TOKEN: cOld },
tokens: [cNew, cOld],
accepted: cNew,
refused: [cOld, token()],
proxied: 404,
warnings: 0,
},
]
const api = await startFixtureApi({ dir: resolve(args.fixtures) })
/** @type {{ url: string, close: () => Promise<void>, output: () => string } | null} */
let nuxt = null
try {
for (const s of scenarios) {
nuxt = await startNuxtServer({ outputDir: args.output, apiBase: `${api.url}/_seo/api/v1`, env: s.env })
const base = nuxt.url
// 1. No token value and no purge key in any page or payload.
const paths = ['/', args.path]
const page = await get(base, args.path)
check(page.status === 200, `${s.name}: GET ${args.path}: status ${page.status}, want 200`)
const payload = payloadPath(args.path)
if ((await get(base, payload)).status === 200) paths.push(payload)
for (const path of paths) {
const { body } = await get(base, path)
for (const [i, value] of s.tokens.entries()) {
check(!body.includes(value), `${s.name}: GET ${path} contains token value #${i + 1}`)
}
for (const key of PURGE_KEYS) {
check(!body.includes(key), `${s.name}: GET ${path} contains the key ${key}`)
}
}
// 2. The purge route reads the expected token.
const ok = await purge(base, bearer(s.accepted))
check(ok === 200, `${s.name}: purge with the expected bearer: status ${ok}, want 200`)
for (const [i, value] of s.refused.entries()) {
const status = await purge(base, bearer(value))
check(status === 401, `${s.name}: purge with refused bearer #${i + 1}: status ${status}, want 401`)
}
const none = await purge(base, {})
check(none === 401, `${s.name}: purge without a bearer: status ${none}, want 401`)
const proxied = await purge(base, { ...bearer(s.accepted), 'x-forwarded-for': '203.0.113.7' })
check(proxied === s.proxied, `${s.name}: proxied purge with the expected bearer: status ${proxied}, want ${s.proxied}`)
// 3. The log: the deprecation warning count, no token value, no key.
await new Promise((r) => setTimeout(r, 200))
const log = nuxt.output()
const warnings = (log.match(DEPRECATION_LINE) ?? []).length
check(warnings === s.warnings, `${s.name}: ${warnings} deprecation warnings in the server log, want ${s.warnings}`)
for (const [i, value] of s.tokens.entries()) {
check(!log.includes(value), `${s.name}: the server log contains token value #${i + 1}`)
}
await nuxt.close()
nuxt = null
}
} finally {
if (nuxt) await /** @type {{ close: () => Promise<void> }} */ (nuxt).close()
await api.close()
}
if (failures.length > 0) {
for (const f of failures) console.error(`purge-token-check: FAIL ${f}`)
process.exit(1)
}
console.log(`purge-token-check: ok (${checks} checks)`)
}
main().catch((err) => {
console.error(`purge-token-check: FAIL setup: ${err instanceof Error ? err.message : String(err)}`)
process.exit(1)
})

View File

@@ -2,38 +2,60 @@
// SSR smoke check: boots the fixture SEO API and the built playground
// server, then checks each line of an expectations file.
//
// node testing/smoke-ssr.mjs --output <.output dir> --fixtures <dir> --expect <file> [--env KEY=VALUE]...
// node testing/smoke-ssr.mjs --output <.output dir> --fixtures <dir> --expect <file> [--no-site] [--site-file NAME] [--env KEY=VALUE]...
//
// Expectation grammar, one check per line ('#' starts a comment line):
// STATUS PATH [contains=TEXT] [notcontains=TEXT] [type=PREFIX] [header=NAME: TEXT]
// report PATH
// A value runs until the next ` contains=`, ` notcontains=`, ` type=`,
// ` header=` or the end of the line, so it may contain spaces. header=
// checks that the response header NAME contains TEXT. A `report PATH` line
// requires a POST /notfound for PATH to have reached the fixture API by the
// end of the run (the server is started with the fixture's 404 token). Requests are plain GETs that do
// not follow redirects and carry `X-Forwarded-For: 203.0.113.7` and
// `Accept: text/html` (as a browser does, so errors render as pages); afterwards
// at least one fixture API request must have carried that address (the SSR
// fetches forward the visitor's x-forwarded-for).
// STATUS PATH [contains=TEXT] [notcontains=TEXT] [count=TEXT:N] [type=PREFIX]
// [header=NAME: VALUE] [noheader=NAME] [reqheader=NAME: VALUE]
// report PATH a POST /notfound for PATH reached the API by the end of the run
// noreport PATH no POST /notfound for PATH reached the API by the end of the run
// resolves PATH N the API has seen exactly N resolve calls for PATH so far
// (PATH * counts every resolve call)
// apicalls N the API has seen exactly N requests so far
// routereport N [has=PATH]... [lacks=PATH]... [noindex=PATH]... [lacksnoindex=PATH]...
// exactly N route reports (POST /registry/routes) reached
// the API by the end of the run, each with the token;
// every has= path is in the last one's routes, no lacks=
// path is; every noindex= path is in its noindex list, no
// lacksnoindex= path is
// log TEXT:N the server's output holds exactly N occurrences of TEXT
// (waits up to 3 s for them; N after the last colon)
// A value runs until the next ` contains=`, ` notcontains=`, ` count=`,
// ` type=`, ` header=`, ` noheader=`, ` reqheader=` or the end of the line, so
// it may contain spaces. Every option may repeat on a line and every
// occurrence applies. count= wants exactly N occurrences of TEXT in the body
// (N is the number after the last colon). header= wants the response header
// NAME to equal VALUE (both trimmed); noheader= wants it absent; reqheader=
// adds a request header (a crafted Host or X-Forwarded-Host). The resolves
// and apicalls lines are checked in file order, between the requests; the
// report lines after the last request.
//
// Requests are plain GETs that do not follow redirects and carry
// `X-Forwarded-For: 203.0.113.7` and `Accept: text/html` (as a browser does,
// so errors render as pages); when the API saw any request, at least one
// must have carried that address (the SSR fetches forward the visitor's
// x-forwarded-for). The server is started with the fixture's 404 token and
// the fixture API as NUXT_SEO_API_BASE; --env overrides either. --no-site
// makes the fixture API answer /site (and the route report) with 404, as a
// backend before SEO v0.2.0 does; --site-file answers /site with another
// file of <fixtures>/seo.
//
// The same approach as vue-faq-layer's testing/smoke-ssr.mjs; plain .mjs
// with JSDoc types so it runs from node_modules unchanged.
import { spawn } from 'node:child_process'
import { existsSync, readFileSync } from 'node:fs'
import { createServer } from 'node:net'
import { join, resolve } from 'node:path'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { FIXTURE_NOTFOUND_TOKEN, startFixtureApi } from './fixture-api.mjs'
import { startNuxtServer } from './nuxt-server.mjs'
const VISITOR_IP = '203.0.113.7'
const API_PREFIX = '/_seo/api/v1'
const API_ENV = 'NUXT_SEO_API_BASE'
const OPTION_KEYS = ['contains', 'notcontains', 'count', 'type', 'header', 'noheader', 'reqheader']
/** @param {string[]} argv */
function parseArgs(argv) {
/** @type {{ output?: string, fixtures?: string, expect?: string, env: Record<string, string> }} */
const out = { env: {} }
/** @type {{ output?: string, fixtures?: string, expect?: string, site: boolean, siteFile?: string, env: Record<string, string> }} */
const out = { env: {}, site: true }
for (let i = 0; i < argv.length; i++) {
const arg = argv[i]
const next = () => {
@@ -44,6 +66,8 @@ function parseArgs(argv) {
if (arg === '--output') out.output = next()
else if (arg === '--fixtures') out.fixtures = next()
else if (arg === '--expect') out.expect = next()
else if (arg === '--no-site') out.site = false
else if (arg === '--site-file') out.siteFile = next()
else if (arg === '--env') {
const kv = next()
const eq = kv.indexOf('=')
@@ -52,20 +76,22 @@ function parseArgs(argv) {
} else throw new Error(`unknown argument ${arg}`)
}
if (!out.output || !out.fixtures || !out.expect) throw new Error('--output, --fixtures and --expect are required')
return /** @type {{ output: string, fixtures: string, expect: string, env: Record<string, string> }} */ (out)
return /** @type {{ output: string, fixtures: string, expect: string, site: boolean, siteFile?: string, env: Record<string, string> }} */ (out)
}
/**
* @typedef {{ status: number, path: string, options: Array<{ key: string, value: string }> }} ExpectCheck
*/
/**
* @param {string} line
* @returns {{ status: number, path: string, contains?: string, notcontains?: string, type?: string, header?: string }}
* @returns {ExpectCheck}
*/
export function parseExpectLine(line) {
const m = /^(\d{3})\s+(\S+)(.*)$/.exec(line.trim())
if (!m) throw new Error('expected "STATUS PATH [options]"')
/** @type {{ status: number, path: string, contains?: string, notcontains?: string, type?: string, header?: string }} */
const check = { status: Number(m[1]), path: m[2] }
const rest = m[3]
const re = /\s(contains|notcontains|type|header)=/g
const re = new RegExp(`\\s(${OPTION_KEYS.join('|')})=`, 'g')
/** @type {Array<{ key: string, start: number, valueStart: number }>} */
const marks = []
let hit
@@ -74,67 +100,61 @@ export function parseExpectLine(line) {
if (marks.length > 0 && rest.slice(0, marks[0].start).trim() !== '') {
throw new Error(`unknown option text "${rest.slice(0, marks[0].start).trim()}"`)
}
/** @type {Record<string, string>} */
const options = {}
marks.forEach((mark, i) => {
const options = marks.map((mark, i) => {
const end = i + 1 < marks.length ? marks[i + 1].start : rest.length
options[mark.key] = rest.slice(mark.valueStart, end).trim()
return { key: mark.key, value: rest.slice(mark.valueStart, end).trim() }
})
return { ...check, ...options }
return { status: Number(m[1]), path: m[2], options }
}
/** @returns {Promise<number>} */
function freePort() {
return new Promise((done, fail) => {
const srv = createServer()
srv.once('error', fail)
srv.listen(0, '127.0.0.1', () => {
const { port } = /** @type {import('node:net').AddressInfo} */ (srv.address())
srv.close(() => done(port))
})
})
/** NAME: VALUE split at the first colon. @param {string} value */
function nameValue(value) {
const colon = value.indexOf(':')
return colon > 0 ? { name: value.slice(0, colon).trim(), value: value.slice(colon + 1).trim() } : { name: value.trim(), value: '' }
}
/** @param {string} body @param {string} text */
function occurrences(body, text) {
if (text === '') return 0
let n = 0
for (let at = body.indexOf(text); at !== -1; at = body.indexOf(text, at + text.length)) n++
return n
}
/**
* Boots `<outputDir>/server/index.mjs` on 127.0.0.1 and waits until it answers.
* @param {{ outputDir: string, env: Record<string, string>, timeoutMs?: number }} opts
* @returns {Promise<{ url: string, close: () => Promise<void> }>}
* The failures of one response against its check.
* @param {ExpectCheck} check @param {{ status: number, headers: Headers, body: string }} res
* @returns {string[]}
*/
async function startNuxtServer({ outputDir, env, timeoutMs = 30000 }) {
const entry = join(resolve(outputDir), 'server', 'index.mjs')
if (!existsSync(entry)) throw new Error(`${entry} does not exist; run pnpm build:playground first`)
const port = await freePort()
const url = `http://127.0.0.1:${port}`
const child = spawn(process.execPath, [entry], {
env: { ...process.env, NODE_ENV: 'production', NITRO_HOST: '127.0.0.1', NITRO_PORT: String(port), ...env },
stdio: ['ignore', 'pipe', 'pipe'],
})
let log = ''
child.stdout.on('data', (b) => (log += b))
child.stderr.on('data', (b) => (log += b))
let exited = false
child.once('exit', () => (exited = true))
const close = () =>
new Promise((/** @type {(v?: void) => void} */ done) => {
if (exited) return done()
child.once('exit', () => done())
child.kill('SIGTERM')
setTimeout(() => {
if (!exited) child.kill('SIGKILL')
}, 5000).unref()
})
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
if (exited) throw new Error(`server exited before answering\n${log}`)
try {
await fetch(url + '/', { redirect: 'manual', signal: AbortSignal.timeout(2000) })
return { url, close }
} catch {
await new Promise((r) => setTimeout(r, 200))
export function checkResponse(check, res) {
if (res.status !== check.status) return [`status ${res.status}, want ${check.status}`]
/** @type {string[]} */
const out = []
const type = res.headers.get('content-type') ?? ''
for (const { key, value } of check.options) {
if (key === 'contains' && !res.body.includes(value)) out.push(`body lacks "${value}"`)
else if (key === 'notcontains' && res.body.includes(value)) out.push(`body contains "${value}"`)
else if (key === 'count') {
const colon = value.lastIndexOf(':')
const want = colon > 0 ? Number(value.slice(colon + 1)) : NaN
if (!Number.isInteger(want)) {
out.push(`count=${value}: want TEXT:N`)
continue
}
const text = value.slice(0, colon)
const got = occurrences(res.body, text)
if (got !== want) out.push(`body has ${got} of "${text}", want ${want}`)
} else if (key === 'type' && !type.startsWith(value)) out.push(`content-type "${type}", want prefix "${value}"`)
else if (key === 'header') {
const h = nameValue(value)
const got = res.headers.get(h.name)
if (got === null || got.trim() !== h.value) out.push(`header ${h.name} is ${JSON.stringify(got)}, want "${h.value}"`)
} else if (key === 'noheader') {
const got = res.headers.get(value)
if (got !== null) out.push(`header ${value} is ${JSON.stringify(got)}, want none`)
}
}
await close()
throw new Error(`no answer on ${url} within ${timeoutMs} ms\n${log}`)
return out
}
async function main() {
@@ -145,17 +165,40 @@ async function main() {
.filter((l) => l !== '' && !l.startsWith('#'))
const reports = lines.filter((l) => l.startsWith('report ')).map((l) => l.slice('report '.length).trim())
const checks = lines.filter((l) => !l.startsWith('report '))
const api = await startFixtureApi({ dir: resolve(args.fixtures) })
/** @type {{ url: string, close: () => Promise<void> } | null} */
const noreports = lines.filter((l) => l.startsWith('noreport ')).map((l) => l.slice('noreport '.length).trim())
const routeReports = lines.filter((l) => l.startsWith('routereport '))
const steps = lines.filter((l) => !l.startsWith('report ') && !l.startsWith('noreport ') && !l.startsWith('routereport '))
const api = await startFixtureApi({ dir: resolve(args.fixtures), site: args.site, ...(args.siteFile ? { siteFile: args.siteFile } : {}) })
/** @type {{ url: string, close: () => Promise<void>, output: () => string } | null} */
let nuxt = null
const failures = /** @type {string[]} */ ([])
try {
nuxt = await startNuxtServer({
outputDir: args.output,
env: { [API_ENV]: `${api.url}${API_PREFIX}`, NUXT_SEO_NOTFOUND_TOKEN: FIXTURE_NOTFOUND_TOKEN, ...args.env },
apiBase: `${api.url}${API_PREFIX}`,
env: { NUXT_SEO_NOTFOUND_TOKEN: FIXTURE_NOTFOUND_TOKEN, ...args.env },
})
for (const line of checks) {
for (const line of steps) {
const resolves = /^resolves\s+(\S+)\s+(\d+)$/.exec(line)
if (resolves) {
const got = api.resolveCount(resolves[1] === '*' ? undefined : resolves[1])
if (got !== Number(resolves[2])) failures.push(`${line}: the API saw ${got} resolve calls`)
continue
}
const log = /^log\s+(.+):(\d+)$/.exec(line)
if (log) {
const want = Number(log[2])
const seen = () => occurrences(nuxt?.output() ?? '', log[1])
for (let i = 0; i < 30 && seen() < want; i++) await new Promise((r) => setTimeout(r, 100))
if (want === 0) await new Promise((r) => setTimeout(r, 500))
if (seen() !== want) failures.push(`${line}: the server output has ${seen()}\n${nuxt?.output() ?? ''}`)
continue
}
const calls = /^apicalls\s+(\d+)$/.exec(line)
if (calls) {
if (api.requests.length !== Number(calls[1])) failures.push(`${line}: the API saw ${api.requests.length} requests (${api.requests.map((r) => `${r.method} ${r.url}`).join(', ')})`)
continue
}
let check
try {
check = parseExpectLine(line)
@@ -163,41 +206,63 @@ async function main() {
failures.push(`${line}: ${/** @type {Error} */ (err).message}`)
continue
}
/** @type {Record<string, string>} */
const headers = { 'X-Forwarded-For': VISITOR_IP, Accept: 'text/html' }
for (const o of check.options) {
if (o.key !== 'reqheader') continue
const h = nameValue(o.value)
headers[h.name] = h.value
}
let res
try {
res = await fetch(nuxt.url + check.path, {
redirect: 'manual',
headers: { 'X-Forwarded-For': VISITOR_IP, Accept: 'text/html' },
signal: AbortSignal.timeout(20000),
})
res = await fetch(nuxt.url + check.path, { redirect: 'manual', headers, signal: AbortSignal.timeout(20000) })
} catch (err) {
failures.push(`${line}: request failed: ${/** @type {Error} */ (err).message}`)
continue
}
const body = await res.text()
const type = res.headers.get('content-type') ?? ''
if (res.status !== check.status) failures.push(`${line}: status ${res.status}, want ${check.status}`)
else if (check.contains !== undefined && !body.includes(check.contains)) failures.push(`${line}: body lacks "${check.contains}"`)
else if (check.notcontains !== undefined && body.includes(check.notcontains)) failures.push(`${line}: body contains "${check.notcontains}"`)
else if (check.type !== undefined && !type.startsWith(check.type)) failures.push(`${line}: content-type "${type}", want prefix "${check.type}"`)
else if (check.header !== undefined) {
const colon = check.header.indexOf(':')
const name = colon > 0 ? check.header.slice(0, colon).trim() : check.header
const want = colon > 0 ? check.header.slice(colon + 1).trim() : ''
const got = res.headers.get(name)
if (got === null || !got.includes(want)) failures.push(`${line}: header ${name} is ${JSON.stringify(got)}, want it to contain "${want}"`)
}
for (const f of checkResponse(check, { status: res.status, headers: res.headers, body })) failures.push(`${line}: ${f}`)
}
// The 404 reports go out after the response; give them a moment.
const reported = () => api.requests.filter((r) => r.method === 'POST' && r.url.endsWith('/notfound')).map((r) => /** @type {any} */ (r.body)?.url)
for (let i = 0; i < 20 && reports.some((p) => !reported().includes(p)); i++) await new Promise((r) => setTimeout(r, 100))
if (noreports.length > 0) await new Promise((r) => setTimeout(r, 500))
for (const p of reports) {
if (!reported().includes(p)) failures.push(`report ${p}: no 404 report reached the API (got ${JSON.stringify(reported())})`)
const n = reported().filter((u) => u === p).length
if (n > 1) failures.push(`report ${p}: reported ${n} times, want once per request`)
}
for (const p of noreports) {
if (reported().includes(p)) failures.push(`noreport ${p}: a 404 report reached the API`)
}
const routePosts = () => api.requests.filter((r) => r.method === 'POST' && r.url.endsWith('/registry/routes'))
for (const line of routeReports) {
const m = /^routereport\s+(\d+)((?:\s+(?:has|lacks|noindex|lacksnoindex)=\S+)*)$/.exec(line)
if (!m) {
failures.push(`${line}: expected "routereport N [has=PATH]... [lacks=PATH]... [noindex=PATH]... [lacksnoindex=PATH]..."`)
continue
}
const want = Number(m[1])
for (let i = 0; i < 20 && routePosts().length < want; i++) await new Promise((r) => setTimeout(r, 100))
if (want === 0) await new Promise((r) => setTimeout(r, 500))
const posts = routePosts()
if (posts.length !== want) failures.push(`${line}: the API saw ${posts.length} route reports`)
if (posts.some((r) => r.authorization !== `Bearer ${FIXTURE_NOTFOUND_TOKEN}`)) failures.push(`${line}: a route report carried the wrong token`)
const lastBody = /** @type {any} */ (posts[posts.length - 1]?.body)
const last = lastBody?.routes ?? []
const lastNoindex = lastBody?.noindex ?? []
for (const opt of (m[2] ?? '').trim().split(/\s+/).filter(Boolean)) {
const [key, path] = [opt.slice(0, opt.indexOf('=')), opt.slice(opt.indexOf('=') + 1)]
if (key === 'has' && !last.includes(path)) failures.push(`${line}: ${path} is not in the report ${JSON.stringify(last)}`)
if (key === 'lacks' && last.includes(path)) failures.push(`${line}: ${path} is in the report ${JSON.stringify(last)}`)
if (key === 'noindex' && !lastNoindex.includes(path)) failures.push(`${line}: ${path} is not in the report's noindex list ${JSON.stringify(lastNoindex)}`)
if (key === 'lacksnoindex' && lastNoindex.includes(path)) failures.push(`${line}: ${path} is in the report's noindex list ${JSON.stringify(lastNoindex)}`)
}
}
if (api.requests.some((r) => r.url.endsWith('/notfound') && r.authorization !== `Bearer ${FIXTURE_NOTFOUND_TOKEN}`)) {
failures.push('a 404 report carried the wrong token')
}
if (!api.requests.some((r) => (r.xff ?? '').includes(VISITOR_IP))) {
if (api.requests.length > 0 && !api.requests.some((r) => (r.xff ?? '').includes(VISITOR_IP))) {
failures.push('x-forwarded-for: the SSR fetches did not forward the visitor address')
}
} finally {
@@ -209,7 +274,7 @@ async function main() {
for (const f of failures) console.error(`smoke-ssr: FAIL ${f}`)
process.exit(1)
}
console.log(`smoke-ssr: ok (${lines.length} checks)`)
console.log(`smoke-ssr: ok (${lines.length} checks, ${args.expect})`)
}
main().catch((err) => {

173
tests/bodyLinks.test.ts Normal file
View File

@@ -0,0 +1,173 @@
// Body link utilities (UI-SPEC L7 steps 2 to 4, D-82, D-83, D-71). The input
// is always sanitize-html output (normalized tags, escaped text and
// attribute values), as the journal layer's gate hands it over.
import assert from 'node:assert/strict'
import test from 'node:test'
import { applyOutbound, autolink, imageAttrs } from '../app/utils/bodyLinks.ts'
const LINKS = [
{ phrase: 'lwia grzywa', url: '/blog/lwia-grzywa' },
{ phrase: 'reishi', url: 'https://example.test/blog/reishi' },
]
test('autolink: first occurrence only, case-insensitive, a plain <a href>', () => {
const html = '<p>Lwia grzywa to grzyb. Lwia grzywa rośnie na drzewach.</p>'
assert.equal(autolink(html, LINKS, 5, '/blog/inne'), '<p><a href="/blog/lwia-grzywa">Lwia grzywa</a> to grzyb. Lwia grzywa rośnie na drzewach.</p>')
})
test('autolink: whole words only', () => {
assert.equal(autolink('<p>reishiowy napar</p>', LINKS, 5, '/x'), '<p>reishiowy napar</p>')
assert.equal(autolink('<p>superreishi</p>', LINKS, 5, '/x'), '<p>superreishi</p>')
assert.equal(autolink('<p>(reishi)</p>', LINKS, 5, '/x'), '<p>(<a href="https://example.test/blog/reishi">reishi</a>)</p>')
assert.equal(autolink('<p>Żółć reishi.</p>', LINKS, 5, '/x'), '<p>Żółć <a href="https://example.test/blog/reishi">reishi</a>.</p>')
})
test('autolink: at most max links in the whole text, each phrase once', () => {
const html = '<p>reishi i lwia grzywa</p><p>reishi znowu</p>'
assert.equal(autolink(html, LINKS, 1, '/x'), '<p><a href="https://example.test/blog/reishi">reishi</a> i lwia grzywa</p><p>reishi znowu</p>')
assert.equal(autolink(html, LINKS, 0, '/x'), html)
assert.equal(
autolink(html, LINKS, 5, '/x'),
'<p><a href="https://example.test/blog/reishi">reishi</a> i <a href="/blog/lwia-grzywa">lwia grzywa</a></p><p>reishi znowu</p>',
)
})
test('autolink: never inside a, headings, code, pre, figcaption or button', () => {
for (const tag of ['a href="/y"', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'code', 'pre', 'figcaption', 'button']) {
const name = tag.split(' ')[0]
const html = `<${tag}>reishi <strong>reishi</strong></${name}><p>reishi</p>`
assert.equal(autolink(html, LINKS, 5, '/x'), `<${tag}>reishi <strong>reishi</strong></${name}><p><a href="https://example.test/blog/reishi">reishi</a></p>`, name)
}
})
test('autolink: never links the page to itself', () => {
const html = '<p>lwia grzywa</p>'
assert.equal(autolink(html, LINKS, 5, '/blog/lwia-grzywa'), html)
assert.equal(autolink(html, LINKS, 5, '/blog/lwia-grzywa/'), html)
assert.equal(autolink(html, LINKS, 5, 'https://example.test/blog/lwia-grzywa'), html)
const self = [{ phrase: 'reishi', url: 'https://example.test/blog/reishi' }]
assert.equal(autolink('<p>reishi</p>', self, 5, '/blog/reishi'), '<p>reishi</p>')
assert.equal(autolink('<p>reishi</p>', self, 5, 'https://other.test/blog/reishi'), '<p><a href="https://example.test/blog/reishi">reishi</a></p>')
})
test('autolink: regex characters in a phrase match literally', () => {
const links = [{ phrase: 'C++ (beta).', url: '/c' }, { phrase: 'a.b', url: '/ab' }]
assert.equal(autolink('<p>We use C++ (beta). today, axb too</p>', links, 5, '/x'), '<p>We use <a href="/c">C++ (beta).</a> today, axb too</p>')
})
test('autolink: escaped text and entities are matched, never split', () => {
const links = [{ phrase: 'Tom & Jerry', url: '/tj' }, { phrase: 'amp', url: '/amp' }, { phrase: 'lt', url: '/lt' }]
assert.equal(autolink('<p>Tom &amp; Jerry &lt;3</p>', links, 5, '/x'), '<p><a href="/tj">Tom &amp; Jerry</a> &lt;3</p>')
assert.equal(autolink('<p>&amp;amp &lt;lt</p>', links, 5, '/x'), '<p>&amp;<a href="/amp">amp</a> &lt;<a href="/lt">lt</a></p>')
})
test('autolink: markup is only added between tags, never inside an attribute', () => {
const html = '<p title="reishi">x</p><img src="/reishi.png" alt="reishi"><p>reishi</p>'
assert.equal(autolink(html, LINKS, 5, '/x'), '<p title="reishi">x</p><img src="/reishi.png" alt="reishi"><p><a href="https://example.test/blog/reishi">reishi</a></p>')
const two = [{ phrase: 'reishi', url: '/r' }, { phrase: 'href', url: '/h' }]
assert.equal(autolink('<p>reishi href</p>', two, 5, '/x'), '<p><a href="/r">reishi</a> <a href="/h">href</a></p>')
})
test('autolink: URLs are validated and escaped (T-33-39)', () => {
const bad = [
{ phrase: 'one', url: 'javascript:alert(1)' },
{ phrase: 'two', url: '//evil.test/x' },
{ phrase: 'three', url: 'data:text/html,x' },
{ phrase: 'four', url: 'relative/path' },
{ phrase: '', url: '/empty' },
]
assert.equal(autolink('<p>one two three four</p>', bad, 5, '/x'), '<p>one two three four</p>')
const quoted = [{ phrase: 'q', url: '/a"><script>alert(1)</script>?b=1&c=2' }]
assert.equal(autolink('<p>q</p>', quoted, 5, '/x'), '<p><a href="/a&quot;&gt;&lt;script&gt;alert(1)&lt;/script&gt;?b=1&amp;c=2">q</a></p>')
})
test('autolink: no links or empty HTML returns the input', () => {
assert.equal(autolink('', LINKS, 5, '/x'), '')
assert.equal(autolink('<p>reishi</p>', [], 5, '/x'), '<p>reishi</p>')
assert.equal(autolink('<p>reishi</p>', null as never, 5, '/x'), '<p>reishi</p>')
})
const OUT = { siteHost: 'example.test', nofollowDomains: ['partner.test'], sponsoredDomains: ['ads.test'] }
const HINT = '(opens in a new tab)'
test('applyOutbound: new tab on adds target, noopener and the hidden hint', () => {
assert.equal(
applyOutbound('<p><a href="https://other.test/x">Other</a></p>', { ...OUT, newTab: true, hint: HINT }),
'<p><a href="https://other.test/x" target="_blank" rel="noopener">Other<span class="seo-visually-hidden"> (opens in a new tab)</span></a></p>',
)
})
test('applyOutbound: new tab on with the hint off adds target and noopener only', () => {
assert.equal(
applyOutbound('<a href="https://other.test/x">Other</a>', { ...OUT, newTab: true, hint: '' }),
'<a href="https://other.test/x" target="_blank" rel="noopener">Other</a>',
)
})
test('applyOutbound: new tab off keeps editor rel and domain-list rel, no target, noopener or hint', () => {
assert.equal(applyOutbound('<a rel="ugc" href="https://other.test/x">Other</a>', { ...OUT, newTab: false, hint: HINT }), '<a rel="ugc" href="https://other.test/x">Other</a>')
assert.equal(applyOutbound('<a href="https://partner.test/x">P</a>', { ...OUT, newTab: false, hint: HINT }), '<a href="https://partner.test/x" rel="nofollow">P</a>')
assert.equal(applyOutbound('<a href="https://shop.ads.test/x">A</a>', { ...OUT, newTab: false, hint: HINT }), '<a href="https://shop.ads.test/x" rel="sponsored">A</a>')
})
test('applyOutbound: editor rel kept and merged with the domain lists and noopener', () => {
assert.equal(
applyOutbound('<a rel="nofollow sponsored" href="https://partner.test/x">P</a>', { ...OUT, newTab: true, hint: '' }),
'<a rel="nofollow sponsored noopener" href="https://partner.test/x" target="_blank">P</a>',
)
assert.equal(
applyOutbound('<a href="https://www.partner.test/x" rel="ugc">P</a>', { ...OUT, newTab: true, hint: '' }),
'<a href="https://www.partner.test/x" rel="ugc nofollow noopener" target="_blank">P</a>',
)
})
test('applyOutbound: a domain list matches the domain and its subdomains, not look-alikes', () => {
assert.equal(applyOutbound('<a href="https://notpartner.test/">N</a>', { ...OUT, newTab: false }), '<a href="https://notpartner.test/">N</a>')
assert.equal(applyOutbound('<a href="https://PARTNER.test/">N</a>', { ...OUT, newTab: false }), '<a href="https://PARTNER.test/" rel="nofollow">N</a>')
})
test('applyOutbound: internal, relative and non-http links are unchanged', () => {
const opts = { ...OUT, newTab: true, hint: HINT }
for (const html of [
'<a href="/blog/x">In</a>',
'<a href="#notes">In</a>',
'<a href="https://example.test/blog">In</a>',
'<a href="http://EXAMPLE.test:443/blog" rel="nofollow">In</a>',
'<a href="mailto:a@example.test">Mail</a>',
'<a href="relative">R</a>',
'<a>No href</a>',
]) {
assert.equal(applyOutbound(html, opts), html, html)
}
})
test('applyOutbound: protocol-relative links to another host are outbound', () => {
assert.equal(applyOutbound('<a href="//other.test/x">O</a>', { ...OUT, newTab: true, hint: '' }), '<a href="//other.test/x" target="_blank" rel="noopener">O</a>')
})
test('applyOutbound: the hint text is escaped and lands before the anchor closes', () => {
assert.equal(
applyOutbound('<a href="https://o.test/"><strong>B</strong> t</a><p>after</p>', { ...OUT, newTab: true, hint: '<b>&' }),
'<a href="https://o.test/" target="_blank" rel="noopener"><strong>B</strong> t<span class="seo-visually-hidden"> &lt;b&gt;&amp;</span></a><p>after</p>',
)
})
test('applyOutbound: entity-encoded hrefs are read decoded and kept as written', () => {
assert.equal(
applyOutbound('<a href="https://partner.test/?a=1&amp;b=2">P</a>', { ...OUT, newTab: false }),
'<a href="https://partner.test/?a=1&amp;b=2" rel="nofollow">P</a>',
)
})
test('applyOutbound: the site host may carry a port and www is not stripped', () => {
assert.equal(applyOutbound('<a href="http://localhost:3000/x">L</a>', { siteHost: 'localhost:3000', newTab: true }), '<a href="http://localhost:3000/x">L</a>')
assert.equal(applyOutbound('<a href="https://www.example.test/x">W</a>', { ...OUT, newTab: true }), '<a href="https://www.example.test/x" target="_blank" rel="noopener">W</a>')
})
test('imageAttrs: lazy loading and async decoding, stored attributes and alt="" kept', () => {
assert.equal(imageAttrs('<p><img src="/a.png" alt="" width="640" height="480" /></p>'), '<p><img src="/a.png" alt="" width="640" height="480" loading="lazy" decoding="async" /></p>')
assert.equal(imageAttrs('<img src="/a.png" alt="A cat">'), '<img src="/a.png" alt="A cat" loading="lazy" decoding="async">')
assert.equal(imageAttrs('<img src="/a.png" loading="eager">'), '<img src="/a.png" loading="eager" decoding="async">')
assert.equal(imageAttrs('<p title="&lt;img&gt;">x</p>'), '<p title="&lt;img&gt;">x</p>')
assert.equal(imageAttrs(''), '')
})

View File

@@ -0,0 +1,33 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { seoClaimBreadcrumbs, seoReleaseBreadcrumbs } from '../app/utils/breadcrumbClaim.ts'
import { seoSitePath } from '../app/utils/sitePath.ts'
test('seoClaimBreadcrumbs: the first instance of a path owns the trail (layout plus page: one trail)', () => {
const layout = seoClaimBreadcrumbs(null, '/blog/x', 'layout')
assert.equal(layout.owns, true)
const page = seoClaimBreadcrumbs(layout.state, '/blog/x', 'page')
assert.equal(page.owns, false)
assert.deepEqual(page.state, { path: '/blog/x', id: 'layout' })
assert.equal(seoClaimBreadcrumbs(page.state, '/blog/x', 'layout').owns, true, 'a re-render keeps the claim')
})
test('seoClaimBreadcrumbs: a new path hands the claim out again', () => {
const first = seoClaimBreadcrumbs({ path: '/a', id: 'page-a' }, '/b', 'layout')
assert.equal(first.owns, true)
assert.deepEqual(first.state, { path: '/b', id: 'layout' })
})
test('seoReleaseBreadcrumbs: only the owner releases', () => {
assert.equal(seoReleaseBreadcrumbs({ path: '/a', id: 'x' }, 'x'), null)
assert.deepEqual(seoReleaseBreadcrumbs({ path: '/a', id: 'x' }, 'y'), { path: '/a', id: 'x' })
})
test('seoSitePath: same-origin URLs become paths', () => {
assert.equal(seoSitePath('https://example.test/blog/x?a=1', 'https://example.test'), '/blog/x?a=1')
assert.equal(seoSitePath('https://example.test/', 'https://example.test/'), '/')
assert.equal(seoSitePath('https://other.test/x', 'https://example.test'), 'https://other.test/x')
assert.equal(seoSitePath('/x', undefined), '/x')
assert.equal(seoSitePath('javascript:alert(1)', 'https://example.test'), '')
assert.equal(seoSitePath('not a url', 'https://example.test'), '')
})

Some files were not shown because too many files have changed in this diff Show More