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