feat: vue-seo-layer, the Nuxt half of the SummerCMS SEO plugin
This commit is contained in:
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
node_modules/
|
||||
.nuxt/
|
||||
.output/
|
||||
.data/
|
||||
.playground/.nuxt/
|
||||
.playground/.output/
|
||||
.env
|
||||
3
.playground/app/app.vue
Normal file
3
.playground/app/app.vue
Normal file
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<NuxtPage />
|
||||
</template>
|
||||
8
.playground/app/pages/about.vue
Normal file
8
.playground/app/pages/about.vue
Normal file
@@ -0,0 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
useSeoMeta({ title: 'Own about', description: 'Own about description', robots: 'noindex' })
|
||||
useHead({ link: [{ rel: 'canonical', href: 'https://own-canonical.test/about' }] })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main><h1>About</h1></main>
|
||||
</template>
|
||||
7
.playground/app/pages/index.vue
Normal file
7
.playground/app/pages/index.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
useSeoMeta({ title: 'Own home', description: 'Own home description', ogTitle: 'Own home og' })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main><h1>Home</h1></main>
|
||||
</template>
|
||||
7
.playground/app/pages/partial.vue
Normal file
7
.playground/app/pages/partial.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
useSeoMeta({ title: 'Partial own', description: 'Partial own description' })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main><h1>Partial</h1></main>
|
||||
</template>
|
||||
7
.playground/app/pages/plain.vue
Normal file
7
.playground/app/pages/plain.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
useSeoMeta({ title: 'Plain page', description: 'Plain description' })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main><h1>Plain</h1></main>
|
||||
</template>
|
||||
10
.playground/app/pages/posts/[slug].vue
Normal file
10
.playground/app/pages/posts/[slug].vue
Normal file
@@ -0,0 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
// A record page that 404s for an unknown slug, as a content page would.
|
||||
const route = useRoute()
|
||||
if (route.params.slug !== 'known') throw createError({ statusCode: 404, statusMessage: 'Post not found', fatal: true })
|
||||
useSeoMeta({ title: 'Known post' })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main><h1>Known post</h1></main>
|
||||
</template>
|
||||
7
.playground/app/pages/staging.vue
Normal file
7
.playground/app/pages/staging.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
useSeoMeta({ title: 'Staging page', robots: 'index, follow' })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main><h1>Staging</h1></main>
|
||||
</template>
|
||||
9
.playground/nuxt.config.ts
Normal file
9
.playground/nuxt.config.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
// 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.
|
||||
export default defineNuxtConfig({
|
||||
extends: ['..'],
|
||||
compatibilityDate: '2025-07-15',
|
||||
app: { head: { titleTemplate: '%s | Playground' } },
|
||||
})
|
||||
56
README.md
Normal file
56
README.md
Normal file
@@ -0,0 +1,56 @@
|
||||
# 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.
|
||||
|
||||
```ts
|
||||
// nuxt.config.ts of the app
|
||||
export default defineNuxtConfig({
|
||||
extends: ['vue-seo-layer'],
|
||||
})
|
||||
```
|
||||
|
||||
```json
|
||||
"vue-seo-layer": "git+https://git.golem15.com/golem15/vue-seo-layer.git#v0.1.0"
|
||||
```
|
||||
|
||||
The layer declares no Nuxt modules and works next to `@nuxtjs/seo`, the Journal layer or an app's own `useSeoMeta`.
|
||||
|
||||
## 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.
|
||||
- **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.
|
||||
- **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`.
|
||||
|
||||
## 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.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.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. |
|
||||
|
||||
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`).
|
||||
|
||||
## 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.
|
||||
|
||||
## Development
|
||||
|
||||
```sh
|
||||
pnpm install
|
||||
pnpm test # node --test on the helpers
|
||||
pnpm build:playground
|
||||
pnpm smoke:ssr # the playground against testing/fixture-api.mjs and testing/expect.txt
|
||||
```
|
||||
|
||||
`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`.
|
||||
33
app/composables/useSeoResolve.ts
Normal file
33
app/composables/useSeoResolve.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
// 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
|
||||
// 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.
|
||||
// Any failure (timeout, 4xx, 5xx, an unexpected body) answers null: the page
|
||||
// then renders with its own head.
|
||||
|
||||
import { seoAnswer } from '../utils/seo.ts'
|
||||
import type { SeoResolveAnswer } from '../utils/types.ts'
|
||||
|
||||
/** The resolve answer of the page being rendered, null when there is none. */
|
||||
export function useSeoAnswer() {
|
||||
return useState<SeoResolveAnswer | null>('seo-layer:answer', () => null)
|
||||
}
|
||||
|
||||
export async function resolveSeo(url: string): Promise<SeoResolveAnswer | null> {
|
||||
const config = useRuntimeConfig()
|
||||
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 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 }))
|
||||
}
|
||||
const base = (config.public.seo as { apiBase: string }).apiBase
|
||||
return seoAnswer(await $fetch('/resolve', { baseURL: base, query: { url }, timeout }))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
39
app/middleware/seo.global.ts
Normal file
39
app/middleware/seo.global.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
// Resolves every navigation before its page runs, so a redirect or a 410
|
||||
// wins over the page (also for an old address no page matches). The
|
||||
// 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'
|
||||
|
||||
export default defineNuxtRouteMiddleware(async (to) => {
|
||||
const nuxtApp = useNuxtApp()
|
||||
const config = useRuntimeConfig()
|
||||
if ((config.public.seo as { enabled?: boolean } | undefined)?.enabled === false) return
|
||||
if (import.meta.client && nuxtApp.isHydrating && nuxtApp.payload.serverRendered) return
|
||||
|
||||
const state = useSeoAnswer()
|
||||
const url = seoResolvableUrl(to.fullPath)
|
||||
if (!url) {
|
||||
state.value = null
|
||||
return
|
||||
}
|
||||
const answer = await resolveSeo(url)
|
||||
state.value = answer
|
||||
if (!answer) return
|
||||
|
||||
if (import.meta.server) {
|
||||
const header = seoXRobotsTag(answer)
|
||||
if (header) useResponseHeader('X-Robots-Tag').value = header
|
||||
}
|
||||
|
||||
// On the server a redirect only sends Location (as external), so the
|
||||
// target page is not rendered and its headers stay off the redirect.
|
||||
const target = seoRedirectTarget(answer)
|
||||
if (target) {
|
||||
if (import.meta.server) useResponseHeader('X-Robots-Tag').value = undefined
|
||||
return navigateTo(target.to, { redirectCode: target.code, external: target.external || import.meta.server })
|
||||
}
|
||||
if (answer.action === 'gone') {
|
||||
return abortNavigation(createError({ statusCode: 410, statusMessage: 'Gone', fatal: true }))
|
||||
}
|
||||
})
|
||||
14
app/plugins/seo-head.ts
Normal file
14
app/plugins/seo-head.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
// 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.
|
||||
|
||||
import { seoHeadInput } 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' })
|
||||
})
|
||||
136
app/utils/seo.ts
Normal file
136
app/utils/seo.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
// Pure helpers of the SEO layer: which URLs are resolved, how an answer
|
||||
// becomes head tags, a redirect target and a robots value. No Nuxt imports,
|
||||
// so `node --test` drives them directly. Names start with `seo` because
|
||||
// app/utils is auto-imported into the consuming app.
|
||||
|
||||
import type { SeoResolveAnswer } from './types.ts'
|
||||
|
||||
const ACTIONS = new Set(['ok', 'not_found', 'redirect', 'gone'])
|
||||
|
||||
/** The robots value of every page outside the backend's production environment. */
|
||||
export const SEO_STAGING_ROBOTS = 'noindex, nofollow'
|
||||
|
||||
/**
|
||||
* The URL to resolve for a route's full path: the path and query without
|
||||
* the hash, or null for internal paths (/_nuxt, /__nuxt_error, /_journal …,
|
||||
* /api/…) that are never pages.
|
||||
*/
|
||||
export function seoResolvableUrl(fullPath: string): string | null {
|
||||
const url = fullPath.split('#')[0] ?? ''
|
||||
if (!url.startsWith('/') || url.startsWith('//')) return null
|
||||
if (url.startsWith('/_') || url === '/api' || url.startsWith('/api/') || url.startsWith('/api?')) return null
|
||||
return url
|
||||
}
|
||||
|
||||
/** The answer when `value` has the shape of a resolve answer, else null. */
|
||||
export function seoAnswer(value: unknown): SeoResolveAnswer | null {
|
||||
if (!value || typeof value !== 'object') return null
|
||||
const v = value as Partial<SeoResolveAnswer>
|
||||
if (typeof v.action !== 'string' || !ACTIONS.has(v.action) || typeof v.status !== 'number') return null
|
||||
if (v.seo !== null && v.seo !== undefined && typeof v.seo !== 'object') return null
|
||||
return { ...v, url: typeof v.url === 'string' ? v.url : '', staging: v.staging === true, seo: v.seo ?? null } as SeoResolveAnswer
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a redirect answer sends the visitor. A target on the site's own
|
||||
* origin (the origin of answer.url) becomes a path, so a staging or preview
|
||||
* host keeps the visitor even when the backend's site URL names production;
|
||||
* any other target stays absolute and external. Null when the answer is not
|
||||
* a redirect or has no usable location.
|
||||
*/
|
||||
export function seoRedirectTarget(answer: SeoResolveAnswer): { to: string; external: boolean; code: number } | null {
|
||||
if (answer.action !== 'redirect' || !answer.location) return null
|
||||
const code = answer.status === 302 ? 302 : 301
|
||||
let target: URL
|
||||
try {
|
||||
target = new URL(answer.location, answer.url || 'http://site.invalid')
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
if (target.protocol !== 'http:' && target.protocol !== 'https:') return null
|
||||
let site: URL | null = null
|
||||
try {
|
||||
site = answer.url ? new URL(answer.url) : null
|
||||
} catch {
|
||||
site = null
|
||||
}
|
||||
if (!site || target.origin === site.origin || target.origin === 'http://site.invalid') {
|
||||
return { to: `${target.pathname}${target.search}${target.hash}`, external: false, code }
|
||||
}
|
||||
return { to: target.href, external: true, code }
|
||||
}
|
||||
|
||||
/** The robots value for the page: noindex everywhere on staging, else the answer's robots on ok, else "". */
|
||||
export function seoRobots(answer: SeoResolveAnswer | null): string {
|
||||
if (!answer) return ''
|
||||
if (answer.staging) return SEO_STAGING_ROBOTS
|
||||
if (answer.action === 'ok' && answer.seo?.robots) return answer.seo.robots
|
||||
return ''
|
||||
}
|
||||
|
||||
/** The X-Robots-Tag header value, "" for none. */
|
||||
export function seoXRobotsTag(answer: SeoResolveAnswer | null): string {
|
||||
if (!answer) return ''
|
||||
if (answer.staging) return SEO_STAGING_ROBOTS
|
||||
if (answer.action === 'ok' && answer.seo?.x_robots_tag) return answer.seo.x_robots_tag
|
||||
return ''
|
||||
}
|
||||
|
||||
interface SeoHeadInput {
|
||||
title?: string
|
||||
titleTemplate?: string
|
||||
meta: Array<{ name?: string; property?: string; content: string }>
|
||||
link: Array<{ rel: string; href: string }>
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export function seoHeadInput(answer: SeoResolveAnswer | null): SeoHeadInput {
|
||||
const head: SeoHeadInput = { meta: [], link: [] }
|
||||
const robots = seoRobots(answer)
|
||||
if (robots) head.meta.push({ name: 'robots', content: robots })
|
||||
const seo = answer?.action === 'ok' ? answer.seo : null
|
||||
if (!seo) return head
|
||||
const name = (n: string, content: string | undefined) => {
|
||||
if (content) head.meta.push({ name: n, content })
|
||||
}
|
||||
const property = (p: string, content: string | undefined) => {
|
||||
if (content) head.meta.push({ property: p, content })
|
||||
}
|
||||
if (seo.title) {
|
||||
head.title = seo.title
|
||||
head.titleTemplate = '%s'
|
||||
}
|
||||
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)
|
||||
name('twitter:card', seo.twitter?.card)
|
||||
name('twitter:site', seo.twitter?.site)
|
||||
for (const tag of Array.isArray(seo.verification) ? seo.verification : []) {
|
||||
if (tag && typeof tag.name === 'string' && tag.name && typeof tag.content === 'string' && tag.content) {
|
||||
head.meta.push({ name: tag.name, content: tag.content })
|
||||
}
|
||||
}
|
||||
if (seo.canonical) head.link.push({ rel: 'canonical', href: seo.canonical })
|
||||
return head
|
||||
}
|
||||
|
||||
/** A verification file name the plugin serves: [A-Za-z0-9._-]+ ending .html, .txt or .xml. */
|
||||
export const SEO_VERIFICATION_FILE = /^\/([A-Za-z0-9][A-Za-z0-9._-]*\.(?:html|txt|xml))$/
|
||||
|
||||
/**
|
||||
* The verification file name of a request path, or null. Names the site
|
||||
* serves itself (robots.txt, sitemap.xml, rss.xml and the like) are never
|
||||
* looked up.
|
||||
*/
|
||||
export function seoVerificationName(path: string, reserved: readonly string[]): string | null {
|
||||
const m = SEO_VERIFICATION_FILE.exec(path.split('?')[0] ?? '')
|
||||
if (!m || !m[1]) return null
|
||||
return reserved.includes(m[1].toLowerCase()) ? null : m[1]
|
||||
}
|
||||
38
app/utils/types.ts
Normal file
38
app/utils/types.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
// Wire types of GET /_seo/api/v1/resolve. Field names mirror the Go
|
||||
// serializer (classes.Result in sm-seo-plugin) exactly. Types only, so
|
||||
// `node --test` can import any util.
|
||||
|
||||
/** ok, not_found, redirect or gone. */
|
||||
export type SeoAction = 'ok' | 'not_found' | 'redirect' | 'gone'
|
||||
|
||||
export interface SeoMetaTag {
|
||||
name: string
|
||||
content: string
|
||||
}
|
||||
|
||||
/** The head metadata of a resolved page. Empty strings mean "no value". */
|
||||
export interface SeoHead {
|
||||
title: string
|
||||
description: string
|
||||
canonical: string
|
||||
robots: string
|
||||
x_robots_tag: string
|
||||
og: { title: string; description: string; image: string; url: string }
|
||||
twitter: { card: string; site: string }
|
||||
verification: SeoMetaTag[]
|
||||
}
|
||||
|
||||
export interface SeoResolveAnswer {
|
||||
action: SeoAction
|
||||
/** The HTTP status the frontend should answer with (200, 301, 302, 404, 410). */
|
||||
status: number
|
||||
/** The absolute target of a redirect answer; absent otherwise. */
|
||||
location?: string
|
||||
/** The absolute URL of the page on the site URL. */
|
||||
url: string
|
||||
locale: string
|
||||
/** True outside the backend's production environment: noindex everywhere. */
|
||||
staging: boolean
|
||||
/** Set on ok answers only. */
|
||||
seo: SeoHead | null
|
||||
}
|
||||
43
nuxt.config.ts
Normal file
43
nuxt.config.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
// 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']`.
|
||||
//
|
||||
// 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.
|
||||
|
||||
export default defineNuxtConfig({
|
||||
compatibilityDate: '2025-07-15',
|
||||
devtools: { enabled: false },
|
||||
|
||||
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 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: '',
|
||||
// How long a resolve call may take before the page renders without it
|
||||
// (NUXT_SEO_TIMEOUT_MS).
|
||||
timeoutMs: 1500,
|
||||
},
|
||||
public: {
|
||||
seo: {
|
||||
// Same-origin base for browser navigations (NUXT_PUBLIC_SEO_API_BASE).
|
||||
// The site's proxy routes GET <base>/resolve to the backend.
|
||||
apiBase: '/_seo/api/v1',
|
||||
// NUXT_PUBLIC_SEO_ENABLED=false turns the layer off: no resolve
|
||||
// calls, no head, no redirects, no 404 reports.
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
25
package.json
Normal file
25
package.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "vue-seo-layer",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./nuxt.config.ts",
|
||||
"scripts": {
|
||||
"dev": "nuxi dev .playground",
|
||||
"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"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"nuxt": "4.4.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nuxt": "4.4.8",
|
||||
"typescript": "5.9.3",
|
||||
"vue": "3.5.35"
|
||||
}
|
||||
}
|
||||
6854
pnpm-lock.yaml
generated
Normal file
6854
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
4
pnpm-workspace.yaml
Normal file
4
pnpm-workspace.yaml
Normal file
@@ -0,0 +1,4 @@
|
||||
allowBuilds:
|
||||
'@parcel/watcher': true
|
||||
esbuild: true
|
||||
vue-demi: true
|
||||
35
server/middleware/seo-verification.ts
Normal file
35
server/middleware/seo-verification.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
// Serves the verification files stored in the SEO settings at the site root
|
||||
// (/google0123.html, /BingSiteAuth.xml …). The plugin answers them under
|
||||
// GET /verification/{name} and registers no root route, so the frontend maps
|
||||
// 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.
|
||||
|
||||
import { seoVerificationName } from '../../app/utils/seo.ts'
|
||||
|
||||
const RESERVED = ['robots.txt', 'sitemap.xml', 'sitemap.xsl', 'rss.xml', 'feed.xml', 'atom.xml', 'llms.txt']
|
||||
|
||||
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 res = await $fetch
|
||||
.raw(`/verification/${encodeURIComponent(name)}`, {
|
||||
baseURL: seo.apiBase,
|
||||
responseType: 'text',
|
||||
ignoreResponseError: true,
|
||||
timeout: Number(seo.timeoutMs) || 1500,
|
||||
})
|
||||
.catch(() => null)
|
||||
if (!res || res.status !== 200) return
|
||||
setResponseHeaders(event, {
|
||||
'content-type': res.headers.get('content-type') ?? 'text/plain; charset=utf-8',
|
||||
'x-content-type-options': 'nosniff',
|
||||
'content-security-policy': res.headers.get('content-security-policy') ?? "default-src 'none'; sandbox",
|
||||
'x-robots-tag': 'noindex',
|
||||
})
|
||||
return event.method === 'HEAD' ? '' : (res._data as string)
|
||||
})
|
||||
17
server/plugins/seo-client-address.ts
Normal file
17
server/plugins/seo-client-address.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
// Keeps the visitor's address available to route-cached SSR renders.
|
||||
//
|
||||
// An app rule such as `'/**': { swr }` renders cached pages from a copy of
|
||||
// the request without headers. The SEO API throttles per client address,
|
||||
// so forwarding only the Nuxt peer would put every cache miss in one bucket.
|
||||
// The event context is shared with that copy, so the incoming
|
||||
// x-forwarded-for plus the peer address is recorded here, before the cached
|
||||
// handler runs, and resolveSeo and the 404 report forward it.
|
||||
|
||||
export default defineNitroPlugin((nitroApp) => {
|
||||
nitroApp.hooks.hook('request', (event) => {
|
||||
const forwarded = [getRequestHeader(event, 'x-forwarded-for'), event.node.req.socket?.remoteAddress]
|
||||
.filter((v): v is string => typeof v === 'string' && v !== '')
|
||||
.join(', ')
|
||||
event.context.seoForwardedFor = forwarded
|
||||
})
|
||||
})
|
||||
53
server/plugins/seo-notfound.ts
Normal file
53
server/plugins/seo-notfound.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
// Reports every page answered 404 to the SEO plugin's 404 log
|
||||
// (POST /notfound with the bearer token). A page that throws a 404 (an
|
||||
// unknown route, a missing record) goes through Nitro's error path, which
|
||||
// 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.
|
||||
|
||||
import type { H3Event } from 'h3'
|
||||
import { seoResolvableUrl } from '../../app/utils/seo.ts'
|
||||
|
||||
let warned = false
|
||||
|
||||
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()
|
||||
if (!token) return
|
||||
const url = seoResolvableUrl(event.path)
|
||||
if (!url) return
|
||||
event.context.seoNotFoundReported = true
|
||||
|
||||
const referer = getRequestHeader(event, 'referer') ?? ''
|
||||
const forwarded = event.context.seoForwardedFor as string | undefined
|
||||
const sent = $fetch('/notfound', {
|
||||
baseURL: seo.apiBase,
|
||||
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,
|
||||
}).catch((err: unknown) => {
|
||||
if (!warned) {
|
||||
warned = true
|
||||
console.warn('[vue-seo-layer] 404 report failed:', (err as Error)?.message ?? err)
|
||||
}
|
||||
})
|
||||
event.waitUntil?.(sent)
|
||||
}
|
||||
|
||||
export default defineNitroPlugin((nitroApp) => {
|
||||
nitroApp.hooks.hook('error', (error, { event }) => {
|
||||
const status = (error as { statusCode?: number } | null)?.statusCode
|
||||
if (event && status === 404) report(event)
|
||||
})
|
||||
nitroApp.hooks.hook('afterResponse', (event) => {
|
||||
if (event.node.res.statusCode === 404) report(event)
|
||||
})
|
||||
})
|
||||
51
testing/expect.txt
Normal file
51
testing/expect.txt
Normal file
@@ -0,0 +1,51 @@
|
||||
# SSR expectations against the fixture SEO API (testing/fixtures).
|
||||
# Grammar: STATUS PATH [contains=TEXT] [notcontains=TEXT] [type=PREFIX] [header=NAME: TEXT]
|
||||
# report PATH
|
||||
# The playground pages set their own head; the answer's values win, an
|
||||
# empty value leaves the page's own tag, and a page the API does not know
|
||||
# keeps its own head.
|
||||
|
||||
# An ok answer: every value from the answer, none of the page's.
|
||||
200 / contains=<title>Home | Acme</title> notcontains=Own home
|
||||
200 / contains=<meta name="description" content="Acme home"> notcontains=Own home description
|
||||
200 / contains=<meta property="og:title" content="Home | Acme"> notcontains=Own home og
|
||||
200 / contains=<link rel="canonical" href="https://acme.test/">
|
||||
200 / contains=<meta name="twitter:site" content="@acme">
|
||||
200 / header=X-Robots-Tag: index, follow
|
||||
200 /about contains=<title>About us | Acme</title> notcontains=Own about
|
||||
200 /about contains=<link rel="canonical" href="https://acme.test/about"> notcontains=own-canonical.test
|
||||
200 /about contains=<meta name="robots" content="index, follow, max-image-preview:large"> notcontains=content="noindex"
|
||||
200 /about contains=<meta name="google-site-verification" content="fixture-google-code">
|
||||
|
||||
# Empty values keep the page's own tags; the template still applies.
|
||||
200 /partial contains=<title>Partial own | Playground</title>
|
||||
200 /partial contains=<meta name="description" content="Partial own description">
|
||||
200 /partial contains=<link rel="canonical" href="https://acme.test/partial">
|
||||
|
||||
# Not known to the API: the page's own head, no robots added.
|
||||
200 /plain contains=<title>Plain page | Playground</title> notcontains=name="robots"
|
||||
|
||||
# Staging: noindex wins over the page's index, also on a page the API does not know.
|
||||
200 /staging contains=<meta name="robots" content="noindex, nofollow"> notcontains=index, follow
|
||||
200 /staging header=X-Robots-Tag: noindex, nofollow
|
||||
|
||||
# Redirects: the site's own origin becomes a path, another host stays absolute.
|
||||
301 /old-path header=Location: /about
|
||||
302 /old-temp?ref=1 header=Location: /about?x=1
|
||||
301 /moved header=Location: https://other.test/x
|
||||
|
||||
# 410 wins over the route, also over a page that would render.
|
||||
410 /gone-path
|
||||
410 /posts/gone-post
|
||||
200 /posts/known contains=<title>Known post | Playground</title>
|
||||
|
||||
# 404s, from an unknown route and from a page, are reported to the log.
|
||||
404 /missing-page
|
||||
404 /posts/nope
|
||||
report /missing-page
|
||||
report /posts/nope
|
||||
|
||||
# Verification files at the root; anything else falls through.
|
||||
200 /google0123.html contains=google-site-verification: google0123.html header=X-Content-Type-Options: nosniff
|
||||
404 /unknown0123.html
|
||||
404 /robots.txt
|
||||
114
testing/fixture-api.mjs
Normal file
114
testing/fixture-api.mjs
Normal file
@@ -0,0 +1,114 @@
|
||||
// A small node:http stand-in for the SEO plugin's public API
|
||||
// (/_seo/api/v1):
|
||||
//
|
||||
// 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
|
||||
//
|
||||
// It records every request (with the parsed JSON body of a POST) so a smoke
|
||||
// run can assert header forwarding and the 404 reports.
|
||||
//
|
||||
// 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 { join } from 'node:path'
|
||||
|
||||
const PREFIX = '/_seo/api/v1'
|
||||
|
||||
/** The bearer token the fixture accepts on POST /notfound. */
|
||||
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
|
||||
*/
|
||||
|
||||
/**
|
||||
* Starts the fixture API on 127.0.0.1.
|
||||
* @param {{ dir: string, port?: number, token?: string }} opts
|
||||
* @returns {Promise<FixtureApi>}
|
||||
*/
|
||||
export function startFixtureApi({ dir, port = 0, token = FIXTURE_NOTFOUND_TOKEN }) {
|
||||
/** @type {FixtureRequest[]} */
|
||||
const requests = []
|
||||
/** @type {Record<string, unknown>} */
|
||||
const answers = JSON.parse(readFileSync(join(dir, 'resolve.json'), 'utf8'))
|
||||
|
||||
/**
|
||||
* @param {import('node:http').IncomingMessage} req
|
||||
* @returns {{ status: number, body?: unknown, text?: string, type?: string }}
|
||||
*/
|
||||
const route = (req) => {
|
||||
const url = new URL(req.url ?? '/', 'http://fixture.local')
|
||||
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') {
|
||||
const target = url.searchParams.get('url') ?? ''
|
||||
if (!target.startsWith('/')) return { status: 422, body: { error: 'bad_url' } }
|
||||
const hit = answers[target]
|
||||
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 === '/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' } }
|
||||
}
|
||||
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' } }
|
||||
}
|
||||
}
|
||||
return { status: 404, body: { error: 'not_found' } }
|
||||
}
|
||||
|
||||
const server = createServer((req, res) => {
|
||||
const chunks = /** @type {Buffer[]} */ ([])
|
||||
req.on('data', (c) => chunks.push(c))
|
||||
req.on('end', () => {
|
||||
const raw = Buffer.concat(chunks).toString('utf8')
|
||||
let body = null
|
||||
try {
|
||||
body = raw ? JSON.parse(raw) : null
|
||||
} catch {
|
||||
body = raw
|
||||
}
|
||||
const header = (/** @type {string} */ name) => {
|
||||
const v = req.headers[name]
|
||||
return Array.isArray(v) ? v.join(', ') : (v ?? null)
|
||||
}
|
||||
requests.push({ method: req.method ?? 'GET', url: req.url ?? '/', xff: header('x-forwarded-for'), authorization: header('authorization'), body })
|
||||
let out
|
||||
try {
|
||||
out = route(req)
|
||||
} catch (err) {
|
||||
out = { status: 500, body: { error: String(err) } }
|
||||
}
|
||||
if (out.text !== undefined) {
|
||||
res.writeHead(out.status, { 'content-type': out.type ?? 'text/plain' })
|
||||
res.end(out.text)
|
||||
return
|
||||
}
|
||||
res.writeHead(out.status, out.body === undefined ? {} : { 'content-type': 'application/json' })
|
||||
res.end(out.body === undefined ? undefined : JSON.stringify(out.body))
|
||||
})
|
||||
})
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
server.once('error', reject)
|
||||
server.listen(port, '127.0.0.1', () => {
|
||||
const addr = /** @type {import('node:net').AddressInfo} */ (server.address())
|
||||
resolve({
|
||||
url: `http://127.0.0.1:${addr.port}`,
|
||||
requests,
|
||||
close: () => new Promise((done) => server.close(() => done())),
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
38
testing/fixtures/resolve.json
Normal file
38
testing/fixtures/resolve.json
Normal file
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"/": {
|
||||
"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" },
|
||||
"verification": []
|
||||
}
|
||||
},
|
||||
"/about": {
|
||||
"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" }]
|
||||
}
|
||||
},
|
||||
"/partial": {
|
||||
"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": "" },
|
||||
"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 }
|
||||
}
|
||||
2
testing/fixtures/verification/google0123.html
Normal file
2
testing/fixtures/verification/google0123.html
Normal file
@@ -0,0 +1,2 @@
|
||||
<!-- fixture -->
|
||||
google-site-verification: google0123.html
|
||||
218
testing/smoke-ssr.mjs
Normal file
218
testing/smoke-ssr.mjs
Normal file
@@ -0,0 +1,218 @@
|
||||
#!/usr/bin/env node
|
||||
// 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]...
|
||||
//
|
||||
// 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).
|
||||
//
|
||||
// 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 { FIXTURE_NOTFOUND_TOKEN, startFixtureApi } from './fixture-api.mjs'
|
||||
|
||||
const VISITOR_IP = '203.0.113.7'
|
||||
const API_PREFIX = '/_seo/api/v1'
|
||||
const API_ENV = 'NUXT_SEO_API_BASE'
|
||||
|
||||
/** @param {string[]} argv */
|
||||
function parseArgs(argv) {
|
||||
/** @type {{ output?: string, fixtures?: string, expect?: string, env: Record<string, string> }} */
|
||||
const out = { env: {} }
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const arg = argv[i]
|
||||
const next = () => {
|
||||
const v = argv[++i]
|
||||
if (v === undefined) throw new Error(`${arg} needs a value`)
|
||||
return v
|
||||
}
|
||||
if (arg === '--output') out.output = next()
|
||||
else if (arg === '--fixtures') out.fixtures = next()
|
||||
else if (arg === '--expect') out.expect = next()
|
||||
else if (arg === '--env') {
|
||||
const kv = next()
|
||||
const eq = kv.indexOf('=')
|
||||
if (eq < 1) throw new Error(`--env expects KEY=VALUE, got ${kv}`)
|
||||
out.env[kv.slice(0, eq)] = kv.slice(eq + 1)
|
||||
} 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)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} line
|
||||
* @returns {{ status: number, path: string, contains?: string, notcontains?: string, type?: string, header?: string }}
|
||||
*/
|
||||
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
|
||||
/** @type {Array<{ key: string, start: number, valueStart: number }>} */
|
||||
const marks = []
|
||||
let hit
|
||||
while ((hit = re.exec(rest)) !== null) marks.push({ key: hit[1], start: hit.index, valueStart: re.lastIndex })
|
||||
if (marks.length === 0 && rest.trim() !== '') throw new Error(`unknown option text "${rest.trim()}"`)
|
||||
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 end = i + 1 < marks.length ? marks[i + 1].start : rest.length
|
||||
options[mark.key] = rest.slice(mark.valueStart, end).trim()
|
||||
})
|
||||
return { ...check, ...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))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 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> }>}
|
||||
*/
|
||||
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))
|
||||
}
|
||||
}
|
||||
await close()
|
||||
throw new Error(`no answer on ${url} within ${timeoutMs} ms\n${log}`)
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv.slice(2))
|
||||
const lines = readFileSync(resolve(args.expect), 'utf8')
|
||||
.split('\n')
|
||||
.map((l) => l.trim())
|
||||
.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} */
|
||||
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 },
|
||||
})
|
||||
for (const line of checks) {
|
||||
let check
|
||||
try {
|
||||
check = parseExpectLine(line)
|
||||
} catch (err) {
|
||||
failures.push(`${line}: ${/** @type {Error} */ (err).message}`)
|
||||
continue
|
||||
}
|
||||
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),
|
||||
})
|
||||
} 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}"`)
|
||||
}
|
||||
}
|
||||
// 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))
|
||||
for (const p of reports) {
|
||||
if (!reported().includes(p)) failures.push(`report ${p}: no 404 report reached the API (got ${JSON.stringify(reported())})`)
|
||||
}
|
||||
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))) {
|
||||
failures.push('x-forwarded-for: the SSR fetches did not forward the visitor address')
|
||||
}
|
||||
} finally {
|
||||
if (nuxt) await nuxt.close()
|
||||
await api.close()
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
for (const f of failures) console.error(`smoke-ssr: FAIL ${f}`)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log(`smoke-ssr: ok (${lines.length} checks)`)
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(`smoke-ssr: FAIL setup: ${err instanceof Error ? err.message : String(err)}`)
|
||||
process.exit(1)
|
||||
})
|
||||
110
tests/seo.test.ts
Normal file
110
tests/seo.test.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import {
|
||||
SEO_STAGING_ROBOTS,
|
||||
seoAnswer,
|
||||
seoHeadInput,
|
||||
seoRedirectTarget,
|
||||
seoResolvableUrl,
|
||||
seoRobots,
|
||||
seoVerificationName,
|
||||
seoXRobotsTag,
|
||||
} from '../app/utils/seo.ts'
|
||||
import type { SeoResolveAnswer } from '../app/utils/types.ts'
|
||||
|
||||
const okAnswer = (over: Partial<SeoResolveAnswer['seo']> = {}, staging = false): SeoResolveAnswer => ({
|
||||
action: 'ok',
|
||||
status: 200,
|
||||
url: 'https://acme.test/about',
|
||||
locale: 'en',
|
||||
staging,
|
||||
seo: {
|
||||
title: 'About | Acme',
|
||||
description: 'Who we are',
|
||||
canonical: 'https://acme.test/about',
|
||||
robots: 'index, follow',
|
||||
x_robots_tag: 'index, follow',
|
||||
og: { title: 'About | Acme', description: 'Who we are', image: 'https://acme.test/a.png', url: 'https://acme.test/about' },
|
||||
twitter: { card: 'summary_large_image', site: '@acme' },
|
||||
verification: [],
|
||||
...over,
|
||||
},
|
||||
})
|
||||
|
||||
test('resolvable URLs drop the hash and skip internal paths', () => {
|
||||
assert.equal(seoResolvableUrl('/blog?page=2#top'), '/blog?page=2')
|
||||
assert.equal(seoResolvableUrl('/'), '/')
|
||||
for (const p of ['/_nuxt/a.js', '/__nuxt_error', '/_journal/api/v1/posts', '/api/x', '/api', '//evil.test/x', 'x']) {
|
||||
assert.equal(seoResolvableUrl(p), null, p)
|
||||
}
|
||||
})
|
||||
|
||||
test('seoAnswer accepts the wire shape and rejects anything else', () => {
|
||||
assert.equal(seoAnswer(null), null)
|
||||
assert.equal(seoAnswer({ action: 'weird', status: 200 }), null)
|
||||
assert.equal(seoAnswer({ action: 'ok' }), null)
|
||||
assert.equal(seoAnswer({ action: 'ok', status: 200, seo: 'x' }), null)
|
||||
const a = seoAnswer({ action: 'not_found', status: 404, url: '/x', staging: true })
|
||||
assert.deepEqual(a && { action: a.action, staging: a.staging, seo: a.seo }, { action: 'not_found', staging: true, seo: null })
|
||||
})
|
||||
|
||||
test('a redirect on the site origin becomes a path, another host stays external', () => {
|
||||
const base = { action: 'redirect' as const, url: 'https://acme.test/old?x=1', locale: '', staging: false, seo: null }
|
||||
assert.deepEqual(seoRedirectTarget({ ...base, status: 301, location: 'https://acme.test/new?x=1' }), { to: '/new?x=1', external: false, code: 301 })
|
||||
assert.deepEqual(seoRedirectTarget({ ...base, status: 302, location: '/tmp' }), { to: '/tmp', external: false, code: 302 })
|
||||
assert.deepEqual(seoRedirectTarget({ ...base, status: 301, location: 'https://other.test/p' }), { to: 'https://other.test/p', external: true, code: 301 })
|
||||
assert.equal(seoRedirectTarget({ ...base, status: 301, location: 'javascript:alert(1)' }), null)
|
||||
assert.equal(seoRedirectTarget({ ...base, status: 301 }), null)
|
||||
assert.equal(seoRedirectTarget(okAnswer()), null)
|
||||
})
|
||||
|
||||
test('robots: staging wins, ok answers carry their own, other answers none', () => {
|
||||
assert.equal(seoRobots(okAnswer({}, true)), SEO_STAGING_ROBOTS)
|
||||
assert.equal(seoRobots(okAnswer()), 'index, follow')
|
||||
assert.equal(seoRobots({ action: 'not_found', status: 404, url: '', locale: '', staging: false, seo: null }), '')
|
||||
assert.equal(seoRobots({ action: 'not_found', status: 404, url: '', locale: '', staging: true, seo: null }), SEO_STAGING_ROBOTS)
|
||||
assert.equal(seoRobots(null), '')
|
||||
assert.equal(seoXRobotsTag(okAnswer({}, true)), SEO_STAGING_ROBOTS)
|
||||
assert.equal(seoXRobotsTag(okAnswer()), 'index, follow')
|
||||
})
|
||||
|
||||
test('the head of an ok answer has every value and a final title', () => {
|
||||
const head = seoHeadInput(okAnswer({ verification: [{ name: 'google-site-verification', content: 'abc' }] }))
|
||||
assert.equal(head.title, 'About | Acme')
|
||||
assert.equal(head.titleTemplate, '%s')
|
||||
assert.deepEqual(head.link, [{ rel: 'canonical', href: 'https://acme.test/about' }])
|
||||
const meta = Object.fromEntries(head.meta.map((m) => [m.name ?? m.property, m.content]))
|
||||
assert.deepEqual(meta, {
|
||||
robots: 'index, follow',
|
||||
description: 'Who we are',
|
||||
'og:title': 'About | Acme',
|
||||
'og:description': 'Who we are',
|
||||
'og:image': 'https://acme.test/a.png',
|
||||
'og:url': 'https://acme.test/about',
|
||||
'twitter:card': 'summary_large_image',
|
||||
'twitter:site': '@acme',
|
||||
'google-site-verification': 'abc',
|
||||
})
|
||||
})
|
||||
|
||||
test('empty values leave the page its own tags', () => {
|
||||
const head = seoHeadInput(okAnswer({ title: '', description: '', canonical: '', og: { title: '', description: '', image: '', url: '' }, twitter: { card: '', site: '' } }))
|
||||
assert.equal(head.title, undefined)
|
||||
assert.equal(head.titleTemplate, undefined)
|
||||
assert.deepEqual(head.link, [])
|
||||
assert.deepEqual(head.meta, [{ name: 'robots', content: 'index, follow' }])
|
||||
assert.deepEqual(seoHeadInput(null), { meta: [], link: [] })
|
||||
assert.deepEqual(seoHeadInput({ action: 'not_found', status: 404, url: '', locale: '', staging: true, seo: null }), {
|
||||
meta: [{ name: 'robots', content: SEO_STAGING_ROBOTS }],
|
||||
link: [],
|
||||
})
|
||||
})
|
||||
|
||||
test('verification names: one segment, three extensions, never a reserved name', () => {
|
||||
const reserved = ['robots.txt', 'sitemap.xml', 'rss.xml']
|
||||
assert.equal(seoVerificationName('/google0123abc.html', reserved), 'google0123abc.html')
|
||||
assert.equal(seoVerificationName('/BingSiteAuth.xml?x=1', reserved), 'BingSiteAuth.xml')
|
||||
for (const p of ['/robots.txt', '/Sitemap.xml', '/rss.xml', '/a/b.html', '/x.php', '/.env.txt', '/', '/x..']) {
|
||||
assert.equal(seoVerificationName(p, reserved), null, p)
|
||||
}
|
||||
})
|
||||
16
tsconfig.json
Normal file
16
tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
// Self-contained on purpose: Vite reads this file when it compiles the
|
||||
// layer's .vue and .ts files, also from a consumer's node_modules, where no
|
||||
// generated .nuxt/tsconfig.json exists. For editor types of Nuxt
|
||||
// auto-imports, run `pnpm dev:prepare` and open .playground.
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"noEmit": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"lib": ["ESNext", "DOM", "DOM.Iterable"]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user