// 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 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 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. 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, origin?: string): 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 && 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 : []) { 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: 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 } /** 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] } /** * 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 or calls usePagination() sets it. */ export function seoFillPages }>(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)), } }