# @duffcloudservices/cms

Vue 3 composables and Vite plugins for DCS (Duff Cloud Services) CMS integration.

## Installation

```bash
# Using pnpm
pnpm add @duffcloudservices/cms

# Using npm
npm install @duffcloudservices/cms

# Using yarn
yarn add @duffcloudservices/cms
```

### Peer Dependencies

This package requires:
- `vue` ^3.4.0
- `@unhead/vue` ^1.9.0

## Quick Start

### 1. Configure Vite Plugins

```typescript
// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { dcsContentPlugin, dcsSeoPlugin } from '@duffcloudservices/cms/plugins'

export default defineConfig({
  plugins: [
    vue(),
    dcsContentPlugin(),
    dcsSeoPlugin()
  ]
})
```

For VitePress:

```typescript
// .vitepress/config.ts
import { defineConfig } from 'vitepress'
import { dcsContentPlugin, dcsSeoPlugin } from '@duffcloudservices/cms/plugins'

export default defineConfig({
  vite: {
    plugins: [
      dcsContentPlugin(),
      dcsSeoPlugin()
    ]
  }
})
```

### 2. Set Environment Variables

```bash
# .env
# For runtime overrides (premium tier): the API base URL only.
VITE_API_BASE_URL=https://portal.duffcloudservices.com
VITE_TEXT_OVERRIDE_MODE=commit  # 'commit' (default) or 'runtime'

# Deprecated: the site is now resolved server-side from the request Host
# (or the dedicated Container App's DCS_SITE_SLUG). VITE_SITE_SLUG is no
# longer placed in request URLs; it is optional and used only as a local
# cache-key hint. Safe to omit.
# VITE_SITE_SLUG=your-site-slug
```

### 3. Use Composables

```vue
<script setup lang="ts">
import { useTextContent, useSEO } from '@duffcloudservices/cms'

// Text content with defaults
const { t } = useTextContent({
  pageSlug: 'home',
  defaults: {
    'hero.title': 'Welcome to Our Site',
    'hero.subtitle': 'Build amazing things with us',
    'cta.primary': 'Get Started'
  }
})

// SEO configuration
const { applyHead } = useSEO('home')
applyHead()
</script>

<template>
  <section class="hero">
    <h1>{{ t('hero.title') }}</h1>
    <p>{{ t('hero.subtitle') }}</p>
    <button>{{ t('cta.primary') }}</button>
  </section>
</template>
```

## Composables

### useTextContent

Provides text content management with build-time injection and optional runtime overrides.

```typescript
import { useTextContent } from '@duffcloudservices/cms'

const {
  t,                    // (key: string, fallback?: string) => string
  texts,                // ComputedRef<Record<string, string>>
  overrides,            // Ref<Record<string, string>>
  isLoading,            // Ref<boolean>
  error,                // Ref<string | null>
  refresh,              // () => Promise<void>
  hasOverride,          // (key: string) => boolean
  hasBuildTimeContent,  // boolean
  mode                  // 'commit' | 'runtime'
} = useTextContent({
  pageSlug: 'home',
  defaults: {
    'hero.title': 'Default Title'
  },
  fetchOnMount: true,  // default: true (only matters in runtime mode)
  cacheTtl: 60000      // default: 60000ms
})
```

**Content Resolution Order:**
1. Runtime API overrides (premium tier only)
2. Build-time content from `.dcs/content.yaml`
3. Hardcoded defaults passed to the composable

### useSEO

Provides SEO configuration with meta tags, Open Graph, Twitter Cards, and JSON-LD.

```typescript
import { useSEO } from '@duffcloudservices/cms'

const {
  config,         // ComputedRef<ResolvedPageSeo>
  applyHead,      // () => void   <- NO ARGUMENTS. See the head-authority contract.
  getSchema,      // () => object[]
  getCanonical,   // () => string
  hasBuildTimeSeo // boolean
} = useSEO('home', '/') // pageSlug, optional pagePath

// Re-assert the baked head for this route.
applyHead()
```

#### The head-authority contract (C-356)

`.dcs/seo.yaml` is the **only** writer of `<title>`, description, keywords,
robots, canonical, `og:*`, `twitter:*` and the JSON-LD graph. `applyHead()`
RE-ASSERTS that head at runtime; it never authors one, so it takes no arguments.
Need a different value? Change it in `.dcs/seo.yaml` (or the portal SEO editor,
which writes it).

A hardcoded value that currently *matches* the baked one is still a violation —
the contract grades authority, not values. Full text, the measured evidence and
the rejected alternatives: **`.docs/plans/dynamic-site-resolution/README.md`
§ "The head-authority contract (C-356)"**.

Enforced here, not per-site: the `() => void` type (fails `type-check`),
`auditHeadContract()` in `src/seo/headContract.ts` (fails the audit), and a
runtime refusal that drops the override and keeps `seo.yaml` authoritative.
Audit a real site with:

```bash
pnpm --filter @duffcloudservices/cms build
node cli/head-contract-audit.mjs --all --json out.json
```

**Exemptions carry live claims (C-420).** An entry in a site's
`.dcs/head-contract.json` whose reason NAMES a host must also declare what that
host does, and the audit fetches it (redirects read, never followed):

```jsonc
"site/src/views/HomeView.vue": {
  "reason": "…why the seo.yaml factory cannot own this head…",
  "hosts": { "example.com": { "status": 200, "robots": "noindex",
                              "canonicalHost": "other.example" } }
}
```

A named host with no claim, a claim with no observation, and a claim the live
host contradicts all FAIL — `--no-probe` silences none of them. The measured
case: an exemption asserted a host was a noindex cross-domain handoff while that
host served `index, follow` and a self-canonical from a different SWA, and the
audit was green *because of* the exemption.

### useReleaseNotes

Fetches release notes from the DCS Portal API.

```typescript
import { useReleaseNotes } from '@duffcloudservices/cms'

const {
  releaseNote,  // Ref<ReleaseNote | null>
  isLoading,    // Ref<boolean>
  error,        // Ref<string | null>
  refresh       // () => Promise<void>
} = useReleaseNotes('1.2.0')  // or 'latest'
```

### useSiteVersion

Gets the current site version for footer badges.

```typescript
import { useSiteVersion } from '@duffcloudservices/cms'

const {
  version,          // Ref<string | null>
  isLoading,        // Ref<boolean>
  releaseNotesUrl   // ComputedRef<string>
} = useSiteVersion()
```

### Conversion capture (automatic — no site code)

Measures the money-moment. **Importing this package installs it.** One delegated,
capture-phase listener on `document` classifies every link and form submit on the site and
emits a `site_interaction` event.

There is nothing to mount. That is the point: this shipped as an opt-in
(`useConversionTracking`) and was adopted by *zero* of eleven fleet sites, so on 2026-07-26
two independent reviews found the same hole — Bryan's Handyman's five `tel:` CTAs, the only
conversion action the site has, emitting nothing at all. A capability a site must remember
to switch on is a capability that is off.

**Event schema**

| Property | Value |
|---|---|
| `interaction_type` | `booking` · `phone` · `email` · `form_submit` · `social` · `external` · `internal` · `button` |
| `is_conversion` | `"true"` for the first four. One predicate for owner reports. |
| `label` | Visible/aria label of the clicked control, truncated to 120 chars |
| `href` | Destination, query + fragment stripped. **Contact schemes are redacted** to `tel:#<digest>` |
| `href_scheme` | `tel:` · `sms:` · `mailto:` · `https:` · … |
| `href_host` | Destination host (`''` for contact schemes and buttons) |
| `href_hash` | Digest of the contact target — lets a report rank CTAs without storing a number |
| `page_path`, `host` | Where the interaction happened |
| `capture_version` | `2` |

No raw phone number or email address is ever emitted. The digest is a normalising
convenience, **not** anonymisation — a site publishes two or three numbers, so treat it as
"the contact string never reaches the analytics store", nothing stronger.

**Where the events go.** Two independent transports, and it will use whichever exist:

1. **GA4** — picked up automatically from `window.gtag`, which the deploy workflow injects
   whenever `.dcs/site.yaml` has a `google_analytics_id`. A site with GA4 configured
   measures conversions with **no code change whatsoever**.
2. **App Insights** — attaches through a global, so no package depends on a telemetry SDK:

```typescript
// After your App Insights instance is live. One line, no import.
window.__dcsConversionAttach?.((e) => appInsights.trackEvent(e))
```

`@duffcloudservices/telemetry` already does this inside `initialize()`. Clicks captured
before a transport exists are buffered (bounded FIFO) and flushed on attach — deferring or
lazy-loading the App Insights SDK must never silently zero a customer's booking numbers.

**Per-site configuration** — call the installer yourself at app entry; the first call wins:

```typescript
import { installConversionCapture } from '@duffcloudservices/cms'

installConversionCapture({
  bookingHosts: ['stridethera.com', 'momence.com'], // on top of the fleet defaults
  bookingPaths: ['/book'],                          // self-hosted booking routes
})
```

**It deliberately does nothing** when there is no `document` (SSR/prerender), inside an
iframe (the visual editor and preview surfaces — an editor's clicks are not leads), under
Do Not Track, or when the page sets `window.__dcsConversionOptOut = true` /
`<html data-dcs-analytics="off">`.

**Double counting is guarded, not hoped for.** Only one delegated listener may bind per
document — a second `start()` refuses with a console warning rather than doubling every
conversion — and a click on a form's submit control is dropped so the `submit` event can be
the single source of truth (a click that fails validation is not a lead). This is the same
discipline as `trackPageView`'s auto-route refusal: C-288 measured 52% duplicate page views
on a live site from exactly this class of mistake, and the portal reported them to the owner.

> Do **not** add `"sideEffects": false` to this package. It would let a consumer's bundler
> drop the auto-install and silently restore the blind spot.

`useConversionTracking` / `createConversionTracker` remain exported for non-cms consumers
and for tests.

## Vite Plugins

### dcsContentPlugin

Injects `.dcs/content.yaml` at build time.

```typescript
import { dcsContentPlugin } from '@duffcloudservices/cms/plugins'

dcsContentPlugin({
  contentPath: '.dcs/content.yaml',  // default
  debug: false                        // default
})
```

### dcsSeoPlugin

Injects `.dcs/seo.yaml` at build time and, opt-in, emits per-route static SEO.

```typescript
import { dcsSeoPlugin } from '@duffcloudservices/cms/plugins'

dcsSeoPlugin({
  seoPath: '.dcs/seo.yaml',  // default
  debug: false,               // default

  // Vue-SPA per-route static SEO (VitePress sites leave this off):
  emitStaticHtml: true,           // emit dist/<route>/index.html with baked
                                  // <head> meta + JSON-LD, plus sitemap/robots/llms
  noindex: ['account', 'projects'], // auth-gated routes → robots noindex,nofollow
})
```

**`noindex` is a predicate over `pages.yaml`, not a standalone directive.**
Every consumer asks it route-first (`noindexSet.has(route.path) || …has(route.slug)`),
so an entry naming a page that is not in `pages.yaml` emits **no document at
all** — the directive silently does nothing and the URL stays `index, follow`.
Since **C-416** that is a hard build failure (`NoindexOrphanError`, thrown from
`config()` before a byte is emitted):

```
[dcs-seo] noindex entries matching NO page in the route manifest: "/homepage-clone-1", "account".
… Fix it by adding the page to pages.yaml …, or by deleting the stale entry.
Matchable keys are the manifest's paths and slugs: "/", "/services", "home", "services"
```

Notes: matching is raw, so `'/services/'` does **not** match a `'/services'`
route (the emitter would not match it either, so blessing it would certify a
no-op); matching runs against the **full** manifest, so an entry that is also in
`exclude` is fine. There is no severity knob — the fix is always "add the page"
or "delete the row".

#### Body prerender (crawler-visible body content)

When `emitStaticHtml` is on, the plugin also **prerenders each indexable
route's body** so non-JS AI crawlers see the real page prose — not just
`<div id="app"></div>`. After the per-route `<head>` is emitted, it drives the
just-built SPA in headless Chromium (via the site's `playwright`), captures each
route's rendered `#app` DOM, and splices it into the mount container. On the
client, `app.mount('#app')` replaces that DOM (no hydration), so users are
unaffected while crawlers get the body.

- **Default ON** whenever `emitStaticHtml` is on — a cms bump enables it with no
  per-site edit. `playwright` is already a fleet devDependency; if it is absent
  the pass is a graceful no-op (head + JSON-LD still emitted).
- **noindex / auth-gated routes are never body-prerendered** (they get a
  head-only file).
- A route that **crashes** at render time fails the build loud rather than
  shipping a broken/empty body. Because it renders the PRODUCTION bundle,
  dev-only fallback defaults (e.g. sample reviews) stay off — no fabricated
  content is baked.
- **Per-site escape hatch (no code change, no cms republish):** set
  `prerenderBody: false` at the root of `.dcs/seo.yaml` to disable body
  prerender for one site, or pass `dcsSeoPlugin({ prerenderBody: false })`.
  Preview builds skip it automatically.

#### SEO emission honesty rails (C-334)

Three build-time asserts against one failure class: **a platform capability that
exists, is silently undone downstream, and has nothing watching.** All three are
**ON by default at severity `error`** whenever `emitStaticHtml` is on, so a cms
bump arms them fleet-wide with no per-site edit.

| Rail | Asserts | Kill-tested against |
|------|---------|---------------------|
| **P1 head honesty** | For every emitted route, the **baked** `<title>`/description equals the **rendered** ones after the app mounts | Live Iron Oak, 2026-07-27: baked `Handyman, Carpentry & Home Repairs in SE Michigan \| Iron Oak` vs rendered `Our Services` — 18/18 routes divergent |
| **P2 emitted-URL honesty** | Every URL the factory publishes (JSON-LD `logo`/`image`, `og:image`, icon links, sitemap `<loc>`s, llms.txt links) resolves to what it promises | Live Iron Oak, 2026-07-27: `Organization.logo` → `200 text/html`, byte-identical to the homepage |
| **P12 charset budget** | `<meta charset>` lands inside the spec's first-1024-byte encoding-sniffing window — asserted straight out of `dcsContentPlugin` (C-414), with the `emitStaticHtml` hoist as a backstop | Live Iron Oak **and** Bryan's, 2026-07-27: charset at byte 910, 90 bytes of headroom past the declaration, no header-level `charset` backstop |

Why they behave the way they do:

- **P1 rides the prerender browser** — the page is already being loaded, so the
  marginal cost is one `page.evaluate()` per route. If a site has opted out of
  body prerender the render still happens in **observe-only** mode (nothing is
  written to `dist/`), because a gate that quietly does not exist on some sites
  is worse than no gate.
  A clean site passes *structurally*: `applyHead()` produces byte-identical tags
  to the build-time emit. **Since C-356 an override is impossible** — the
  parameter is gone from the signature and a runtime argument is dropped — which
  is what makes "structurally clean" the default rather than an achievement.
  (Historically `applyHead({ title })` used the override *verbatim*, skipping
  `global.titleTemplate`, so an override that "looked right" still diverged; that
  was the root cause of 93 divergences on KEPT and 18 more across three sites.)
  P1 remains the rail that judges the values; the source-side
  `auditHeadContract()` judges who is allowed to write them.
- **P2 splits by how certain the answer is.** Same-origin assets are proven
  against `dist/` with **no network at all**: an emitted `https://site/logos/x.svg`
  with no `dist/logos/x.svg` behind it means the host's SPA fallback *will*
  answer HTML, so it is a hard failure. Cross-origin assets are probed under a
  24h cache, a 5s per-request timeout and a 20s total budget, where a
  **definitive** wrong answer (HTML where an image was promised, or a 4xx/5xx)
  fails the build and a **non-answer** (DNS, timeout, reset, budget exhausted)
  is only a warning. A gate that reds on a network hiccup gets disabled within a
  week and then protects nothing.
- **P12 fixes as well as asserts.** The motion-token `<style>` *was* injected
  `head-prepend`, above the site's own `<meta charset>`; **C-414 fixed that at
  the source** — it now injects `head` (append), which keeps it above the
  bundled site CSS (so a per-site `:root` override still wins) while leaving the
  encoding declaration first in `<head>`. The `emitStaticHtml` path still hoists
  the declaration in the emitted shell (idempotent, moves nothing else, now a
  no-op on a stock shell) and then asserts the result — that hoist is the
  backstop for hand-rolled shells, and it never ran on VitePress sites, which is
  why the source fix was the one that reached the whole fleet. **Also fix the
  header** where the site's config lives — SWA currently answers
  `Content-Type: text/html` with no `charset` parameter, so there is no
  header-level backstop.

Escape hatches — deliberately impossible to use silently:

```yaml
# .dcs/seo.yaml (portal-owned, in git, reviewable)
headHonesty:
  mode: warn                 # error (default) | warn | off
  allow: ['/legacy-route']   # every exemption is printed on every build
  checkDescription: true
urlHonesty:
  mode: error
  allow: ['https://cdn.example.com/known-missing.svg']
  network: true              # false ⇒ same-origin still enforced
charsetBudget: error
```

```bash
# Per-build override — prints in the build log, so it is never invisible
DCS_SEO_HEAD_HONESTY=warn DCS_SEO_URL_HONESTY=warn pnpm build
DCS_SEO_URL_HONESTY_NETWORK=off pnpm build   # offline CI
```

Verification (needs a browser; not part of `pnpm test`):

```bash
pnpm --filter @duffcloudservices/cms run verify:honesty-rails
# and, to see what a LIVE site's build will red on before bumping cms:
pnpm --filter @duffcloudservices/cms run probe:live-urls https://example.com
```

#### The served-document pass (C-417)

All three rails above assert against the **candidate build** — the `dist/` on the
machine that ran the build. Nothing re-asserted them against the document a
browser receives, and that gap is how C-341's `charset hoisted 913 -> 48` claim
survived: true of a local build, false in production, where the served document
has `<meta charset>` at byte 910 (measured 2026-07-31, KEPT re-review F3). The
KEPT re-review estimated a post-deploy re-run would have caught it in 8 minutes.

Run the same rail primitives over the bytes the origin serves — **after every
deploy**, and always before citing a rail's number as evidence:

```bash
# P2 + P12 on the served document (default)
pnpm --filter @duffcloudservices/cms run verify:served-honesty https://example.com --routes all

# ...and hold the CANDIDATE BUILD's recorded numbers against what shipped.
# `dist-honesty-report.json` is what DCS_SEO_HONESTY_REPORT wrote during the build.
DCS_SEO_HONESTY_REPORT=dist-honesty-report.json pnpm build
pnpm --filter @duffcloudservices/cms run verify:served-honesty https://example.com \
  --routes all --rails p1,p2,p12 --rendered --compare dist-honesty-report.json
```

Exit codes: `0` pass · `1` a rail FAILED or the recorded evidence does not
describe the served document · `3` a requested rail DID NOT RUN. A rail that
could not run is never folded into a pass — the C-338 execution assertion,
applied to the served side.

## Configuration Files

### .dcs/content.yaml

```yaml
version: 1
lastUpdated: "2025-01-01T00:00:00Z"
updatedBy: "portal"

global:
  nav.home: Home
  nav.about: About
  footer.copyright: © 2025 My Company

pages:
  home:
    hero.title: Welcome to Our Site
    hero.subtitle: Build amazing things
    cta.primary: Get Started
  about:
    hero.title: About Us
    hero.subtitle: Learn more about our mission
```

### .dcs/seo.yaml

```yaml
version: 1
lastUpdated: "2025-01-01T00:00:00Z"

global:
  siteName: My Site
  siteUrl: https://example.com
  locale: en_US
  defaultTitle: My Site
  defaultDescription: Build amazing things with us
  titleTemplate: "%s | My Site"
  
  social:
    twitter: mycompany
    linkedin: my-company
  
  images:
    logo: https://example.com/logo.png
    ogDefault: https://example.com/og-image.jpg

pages:
  home:
    title: Welcome
    description: Build amazing things with our platform
    noTitleTemplate: true
    openGraph:
      type: website
    twitter:
      card: summary_large_image
  
  about:
    title: About Us
    description: Learn more about our company and mission
```

## TypeScript Support

All composables and plugins are fully typed. Import types as needed:

```typescript
import type {
  TextContentConfig,
  TextContentReturn,
  SeoConfiguration,
  PageSeoConfig,
  ReleaseNote
} from '@duffcloudservices/cms'
```

## Migration from Manual Setup

If you're migrating from manually copied composables:

```typescript
// Before:
import { useTextContent } from '@/lib/use-text-content'

// After:
import { useTextContent } from '@duffcloudservices/cms'
```

The API is the same, so no other code changes are needed.

## DCS Visual Editor Bridge

For DCS-managed sites, `src/editor/editorBridge.ts` is the shared
discovery/runtime layer that turns site DOM markers into portal editing
entry points.

### Managed background images

For customer-site hero, CTA, footer, card, staff, and gallery images that need editor image replacement, prefer the exported `ManagedImage` component over CSS-only `background-image` or hardcoded image arrays.

```vue
<script setup lang="ts">
import ManagedImage from '@duffcloudservices/cms/managed-image'
</script>

<template>
  <ManagedImage
    page-slug="home"
    image-key="hero.image.url"
    alt-key="hero.image.alt"
    fallback-src="/images/hero.jpg"
    fallback-alt="Clinic hero"
    context="hero"
    class="h-full w-full object-cover"
  />
</template>
```

Add the URL and alt keys to `.dcs/content.yaml` using CDN URLs for committed content:

```yaml
pages:
  home:
    hero.image.url: https://files.duffcloudservices.com/content/site-slug/assets/example.jpg
    hero.image.alt: Clinic hero
```

`ManagedImage` resolves build-time/runtime content with `useTextContent`, renders a real `<picture>/<img>` target, applies responsive CDN variants through `useResponsiveImage`, and emits `data-dcs-image-key`, `data-dcs-image-url`, and `data-dcs-image-alt` on the actual `<img>`. This lets the visual editor open image management for the asset and lets snapshot capture wait on an actual image before `full-page.png`.

For repeated galleries or cards, use indexed keys such as `gallery.item-0.image.url` and `gallery.item-0.image.alt`, then pass those keys through the rendered item. Do not make editable images CSS-only backgrounds; if a design needs background behavior, render a real managed image layer behind the content.

### Click-to-call (NAP phone)

`DcsCallButton` renders a `tel:` call affordance driven by the portal-managed `business.phone` NAP key (the same `global` content key used for LocalBusiness identity). Use it for the above-the-fold mobile call path service sites need.

```vue
<script setup lang="ts">
import DcsCallButton from '@duffcloudservices/cms/call-button'
</script>

<template>
  <!-- compact, above-the-fold mobile header affordance -->
  <DcsCallButton variant="icon" />

  <!-- icon + label + number for a nav/menu row or hero -->
  <DcsCallButton variant="inline" />
</template>
```

`business.phone` lives in `.dcs/content.yaml` under `global` (create-dcs-site scaffolds it empty):

```yaml
global:
  business.phone: "(248) 385-2926"
  business.name: Iron Oak Contractors
```

The number is read through `useTextContent`, so it is SSR-safe (no `window`/`document` at setup) and portal-editable. The component **renders nothing** until `business.phone` is set — it never fabricates a placeholder number into prod SSR. The `tel:` href strips formatting to digits (preserving a leading `+` for E.164), the visible number carries `data-dcs-text="business.phone"` for inline editing, and styling is themeable via `--dcs-call-*` custom properties (icon inherits `currentColor` and stays unobtrusive on desktop).

Current first-party surfaces:

- **Managed forms** — discovers `[data-form-key]`, reports
  `hasManagedForms`/`managedFormIds`, and emits
  `dcs:managed-form-click`
- **Reviews** — discovers `[data-dcs-reviews]`, reports
  `hasReviews`/`reviewKeys`, emits `dcs:reviews-click`, and accepts
  structured `dcs:update-reviews` data for live preview refresh

The bridge owns discovery and affordances; the portal owns the actual
editing workflow. Keep every entry point converged on one sheet per
component family instead of creating parallel editors.

See [`../FIRST-PARTY-COMPONENTS.md`](../FIRST-PARTY-COMPONENTS.md) for
the shared contract across runtime markers, bridge events, portal
workflows, publishing, rollout, and validation.

## License

MIT
