feat: vue-seo-layer, the Nuxt half of the SummerCMS SEO plugin
This commit is contained in:
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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user