/**
* 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)
}
}