/** * useSEO Composable * * Provides SEO configuration with build-time injection support from .dcs/seo.yaml. * Generates meta tags, Open Graph, Twitter Cards, and JSON-LD structured data. * * The actual tag resolution lives in the framework-agnostic `../seo/headTags` * module so that the build-time static-HTML emitter (`dcsSeoPlugin`) produces * byte-identical output. This composable is a thin Vue/unhead wrapper over it. * * THE HEAD-AUTHORITY CONTRACT (C-356). `.dcs/seo.yaml` is the ONLY writer of * the managed head fields. `applyHead()` RE-ASSERTS the baked head at runtime; * it never AUTHORS one, so it takes no arguments. Full text + reasoning: * `.docs/plans/dynamic-site-resolution/README.md` § "The head-authority * contract (C-356)". * * @example * ```vue * * ``` * * Need a different title? Change it in `.dcs/seo.yaml` (or in the portal SEO * editor, which writes it). A value hardcoded here is a SECOND writer, and a * second writer is a divergence by construction — whether or not today's two * values happen to agree. */ import { computed, type ComputedRef } from 'vue' import { useHead } from '@unhead/vue' import type { SeoConfiguration, GlobalSeoConfig, ResolvedPageSeo, UseSeoReturn, } from '../types/seo' import { buildHeadTags, resolvePageSeo, generateJsonLd } from '../seo/headTags' // Declare the global injected by dcsSeoPlugin declare const __DCS_SEO__: SeoConfiguration | undefined /** * Safely get build-time SEO configuration. * Returns undefined if not available (no seo.yaml or plugin not configured). */ function getBuildTimeSeo(): SeoConfiguration | undefined { try { if (typeof __DCS_SEO__ !== 'undefined' && __DCS_SEO__ !== null) { return __DCS_SEO__ } } catch { // __DCS_SEO__ not defined - that's fine, use defaults } return undefined } /** * The C-356 refusal. Exported so tests can assert the exact message and so a * host app can spot it in a log. * * IT DOES NOT THROW, ANYWHERE. The build-time gates are the ones that must be * unmissable — the `() => void` type (caught by every site's `type-check`, made * pre-deploy-mandatory by C-303/C-316) and `assertHeadContract` in the source * audit, which fails the build outright. By the time control reaches this * function the page is already rendering for a real visitor, and throwing there * would take a paying customer's page down over a metadata mistake. The * asymmetry is the point: the worst outcome of a refused override is that the * page serves the OWNER-APPROVED value with a console error next to it. */ export const HEAD_OVERRIDE_REFUSED_MESSAGE = '[dcs-seo] applyHead() was called WITH AN ARGUMENT and the argument was IGNORED. ' + '.dcs/seo.yaml is the only writer of title/description/keywords/canonical/OG/Twitter/' + 'JSON-LD (head-authority contract, C-356). A hardcoded value here overwrites the ' + 'owner-approved baked head for Google and every human while non-JS crawlers keep ' + 'receiving the approved one. Move the value into .dcs/seo.yaml and call applyHead() ' + 'with no arguments.' function reportHeadOverrideRefused(pageSlug: string, refused: unknown): void { const keys = refused && typeof refused === 'object' ? Object.keys(refused as object).join(', ') : String(refused) console.error( `${HEAD_OVERRIDE_REFUSED_MESSAGE}\n page slug: ${pageSlug}\n ignored keys: ${keys}` ) } /** * useSEO composable for DCS-managed SEO configuration. * * @param pageSlug - Page slug matching entry in seo.yaml * @param pagePath - Optional page path for canonical URL generation * @returns SEO helpers and state */ export function useSEO(pageSlug: string, pagePath?: string): UseSeoReturn { const seoConfig = getBuildTimeSeo() const hasBuildTimeSeo = seoConfig !== undefined // Computed resolved config const config: ComputedRef = computed(() => resolvePageSeo(pageSlug, pagePath, seoConfig) ) /** * Get JSON-LD schema objects for the page */ function getSchema(): object[] { return generateJsonLd(config.value.schemas, seoConfig?.global ?? {}) } /** * Get canonical URL for the page */ function getCanonical(): string { return config.value.canonical } /** * RE-ASSERT the baked head for this route. Takes no arguments. * * Delegates to the shared `buildHeadTags` resolver so the emitted tags match * the build-time static-HTML emitter exactly. Keywords are intentionally not * emitted at runtime (historical behaviour), so `includeKeywords` is omitted. * * ENFORCEMENT (C-356). The parameter is gone from the signature, so TypeScript * consumers fail `type-check`. But a `.vue` file compiled without type * checking, an `as any`, or a JS site can still reach this function with an * argument, and THAT is the shape that shipped 93 divergences on KEPT. So the * override is also refused at runtime: it is dropped, `.dcs/seo.yaml` still * wins, and the violation is reported. A violating site therefore degrades to * CORRECT SEO plus a loud message — never to a silently destroyed head. */ // Declared with a rest parameter so a runtime argument is CAPTURED, then // exposed through `UseSeoReturn` as `() => void` so a compile-time argument is // REJECTED. Both halves are needed: the type stops the honest caller, the // runtime stops the one who is not type-checked. function applyHeadImpl(...refused: unknown[]): void { if (refused.length > 0 && refused[0] !== undefined && refused[0] !== null) { reportHeadOverrideRefused(pageSlug, refused[0]) } const { title, meta, link, script } = buildHeadTags(pageSlug, pagePath, seoConfig) // Apply via useHead. The shared resolver returns framework-agnostic tag // shapes (HeadMetaTag/HeadLinkTag/HeadScriptTag); unhead's input types are // structurally compatible but add an open-ended `data-*` index signature, // so we hand them over via useHead's Head input type. Runtime is identical. useHead({ title, meta, link, script, } as unknown as Parameters[0]) } // The narrowing that makes the contract compile-checkable for consumers. const applyHead: () => void = applyHeadImpl return { config, applyHead, getSchema, getCanonical, hasBuildTimeSeo, } } /** * Create a typed useSEO function with site-specific defaults. * Useful for creating a site-wide wrapper. * * @example * ```ts * // composables/useSiteSeo.ts * import { createSiteSEO } from '@duffcloudservices/cms' * * export const useSiteSeo = createSiteSEO({ * siteName: 'My Site', * siteUrl: 'https://example.com' * }) * ``` */ export function createSiteSEO( _siteDefaults: Partial ): (pageSlug: string, pagePath?: string) => UseSeoReturn { return function siteUseSEO(pageSlug: string, pagePath?: string): UseSeoReturn { // Note: siteDefaults would be used if we needed to override at runtime // but build-time injection handles this via dcsSeoPlugin return useSEO(pageSlug, pagePath) } }