@duffcloudservices/cms
v0.13.8
Published
Vue 3 composables and Vite plugins for DCS CMS integration
Downloads
800
Maintainers
Readme
@duffcloudservices/cms
Vue 3 composables and Vite plugins for DCS (Duff Cloud Services) CMS integration.
Installation
# Using pnpm
pnpm add @duffcloudservices/cms
# Using npm
npm install @duffcloudservices/cms
# Using yarn
yarn add @duffcloudservices/cmsPeer Dependencies
This package requires:
vue^3.4.0@unhead/vue^1.9.0
Quick Start
1. Configure Vite Plugins
// 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:
// .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
# .env
# The API base URL. REQUIRED for the release-notes / site-version reads and the
# preview ribbon: as of the cms release after 0.13.4 there is no built-in production default -
# an unset value logs one warning and the reads go relative to the site's own
# origin (C-1062). The canonical site-deploy workflow sets it from
# `.dcs/site.yaml` `api_base_url`; local and preview builds set it here.
VITE_API_BASE_URL=https://api.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-slug3. Use Composables
<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.
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:
- Runtime API overrides (premium tier only)
- Build-time content from
.dcs/content.yaml - Hardcoded defaults passed to the composable
useSEO
Provides SEO configuration with meta tags, Open Graph, Twitter Cards, and JSON-LD.
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:
pnpm --filter @duffcloudservices/cms build
node cli/head-contract-audit.mjs --all --json out.jsonExemptions 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):
"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.
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.
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:
- GA4 — picked up automatically from
window.gtag, which the deploy workflow injects whenever.dcs/site.yamlhas agoogle_analytics_id. A site with GA4 configured measures conversions with no code change whatsoever. - App Insights — attaches through a global, so no package depends on a telemetry SDK:
// 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:
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": falseto 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.
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.
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
emitStaticHtmlis on — a cms bump enables it with no per-site edit.playwrightis 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: falseat the root of.dcs/seo.yamlto disable body prerender for one site, or passdcsSeoPlugin({ 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 todist/), 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. (HistoricallyapplyHead({ title })used the override verbatim, skippingglobal.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-sideauditHeadContract()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 emittedhttps://site/logos/x.svgwith nodist/logos/x.svgbehind 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 injectedhead-prepend, above the site's own<meta charset>; C-414 fixed that at the source — it now injectshead(append), which keeps it above the bundled site CSS (so a per-site:rootoverride still wins) while leaving the encoding declaration first in<head>. TheemitStaticHtmlpath 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 answersContent-Type: text/htmlwith nocharsetparameter, so there is no header-level backstop.
Escape hatches — deliberately impossible to use silently:
# .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# 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 CIVerification (needs a browser; not part of pnpm test):
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.comThe 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:
# 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.jsonExit 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
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
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 missionTypeScript Support
All composables and plugins are fully typed. Import types as needed:
import type {
TextContentConfig,
TextContentReturn,
SeoConfiguration,
PageSeoConfig,
ReleaseNote
} from '@duffcloudservices/cms'Migration from Manual Setup
If you're migrating from manually copied composables:
// 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.
<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:
pages:
home:
hero.image.url: https://files.duffcloudservices.com/content/site-slug/assets/example.jpg
hero.image.alt: Clinic heroManagedImage 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.
<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):
global:
business.phone: "(248) 385-2926"
business.name: Iron Oak ContractorsThe 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], reportshasManagedForms/managedFormIds, and emitsdcs:managed-form-click - Reviews — discovers
[data-dcs-reviews], reportshasReviews/reviewKeys, emitsdcs:reviews-click, and accepts structureddcs:update-reviewsdata 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 for
the shared contract across runtime markers, bridge events, portal
workflows, publishing, rollout, and validation.
License
MIT
