@duffcloudservices/cms 0.11.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +244 -8
  2. package/dist/chunk-A5F4C72F.js +500 -0
  3. package/dist/chunk-A5F4C72F.js.map +1 -0
  4. package/dist/{chunk-F3EIWEZD.js → chunk-HVSF23P7.js} +971 -73
  5. package/dist/chunk-HVSF23P7.js.map +1 -0
  6. package/dist/editor/editorBridge.d.ts +53 -1
  7. package/dist/editor/editorBridge.js +141 -5
  8. package/dist/editor/editorBridge.js.map +1 -1
  9. package/dist/headHonesty-OzxvLuwd.d.ts +222 -0
  10. package/dist/index.d.ts +369 -22
  11. package/dist/index.js +424 -22
  12. package/dist/index.js.map +1 -1
  13. package/dist/installSeoHead-kWQwObez.d.ts +627 -0
  14. package/dist/plugins/index.d.ts +173 -6
  15. package/dist/plugins/index.js +628 -54
  16. package/dist/plugins/index.js.map +1 -1
  17. package/dist/seo/index.d.ts +763 -4
  18. package/dist/seo/index.js +2 -2
  19. package/dist/{vitepressTransform-DfmABXmK.d.ts → vitepressTransform-JG_zlaux.d.ts} +99 -6
  20. package/package.json +26 -16
  21. package/src/components/DcsCallButton.test.ts +58 -0
  22. package/src/components/DcsCallButton.vue +19 -4
  23. package/src/components/DcsReviewShowcase.vue +5 -1
  24. package/src/components/LiteMediaEmbed.vue +3 -3
  25. package/src/components/ManagedImage.test.ts +94 -0
  26. package/src/components/ManagedImage.vue +58 -6
  27. package/src/components/PreviewRibbon.vue +4 -1
  28. package/src/composables/useConversionTracking.test.ts +492 -0
  29. package/src/composables/useConversionTracking.ts +770 -0
  30. package/src/composables/useReleaseNotes.ts +7 -1
  31. package/src/composables/useResponsiveImage.ts +6 -0
  32. package/src/composables/useSEO.applyHead.test.ts +150 -0
  33. package/src/composables/useSEO.ts +63 -17
  34. package/src/composables/useSiteVersion.ts +4 -1
  35. package/src/composables/useSiteVisitorSession.test.ts +56 -0
  36. package/src/composables/useSiteVisitorSession.ts +39 -3
  37. package/src/composables/useTextContent.ts +9 -1
  38. package/dist/chunk-DAYLLSEE.js +0 -3
  39. package/dist/chunk-DAYLLSEE.js.map +0 -1
  40. package/dist/chunk-F3EIWEZD.js.map +0 -1
  41. package/dist/spliceHeadHtml-CsBEucGy.d.ts +0 -254
package/dist/seo/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import '../chunk-DAYLLSEE.js';
2
- export { AI_BOTS, BodyPrerenderError, absolutizeUrl, breadcrumbTrailFromRoute, buildBlogPosting, buildBreadcrumbList, buildFaqPage, buildGlobalGraph, buildHasCredential, buildHeadTags, buildLlmsTxt, buildReviewSchemaParts, buildRobotsTxt, buildSitemapXml, buildVitePressSeoHead, createSeoTransformPageData, defaultRelativePathToRoute, deriveSameAs, escapeJsonLd, filterRealFaq, filterRealReviews, findBusinessLicense, findLocalBusinessSchema, findReviewItemsForPage, generateJsonLd, generateOpenGraphMeta, generateTwitterMeta, graphAbsorbs, graphIds, isBodyPrerenderEnabled, isLocalBusinessType, isRouteIndexable, loadPagesManifest, parsePagesManifest, prerenderBodies, renderHeadTags, resolvePageSeo, routeToOutputFile, slugToTitle, spliceBodyHtml, spliceHeadHtml, stripManagedHeadTags } from '../chunk-F3EIWEZD.js';
1
+ export { HeadContractError, MIN_EXEMPTION_REASON_LENGTH, assertHeadContract, auditExemptionHosts, auditHeadContract, blankComments, formatExemptionAudit, formatHeadContractReport, hostsNamedIn, normalizeExemption, parseHostProbeHtml } from '../chunk-A5F4C72F.js';
2
+ export { AI_BOTS, BodyPrerenderError, CANONICAL_CHARSET_META, CHARSET_BUDGET_BYTES, CHARSET_HEADROOM_WARN_BYTES, CharsetBudgetError, EmittedUrlError, EmittedUrlNotRunError, HeadHonestyError, HeadHonestyNotRunError, SEO_HEAD_ROUTE_HAS_NO_META, absolutizeUrl, assertHeadHonesty, assertHeadHonestyExecuted, auditCheckedTotal, auditSameOriginAssets, breadcrumbTrailFromRoute, buildBlogPosting, buildBreadcrumbList, buildFaqPage, buildGlobalGraph, buildHasCredential, buildHeadTags, buildLlmsTxt, buildReviewSchemaParts, buildRobotsTxt, buildSeoHeadRouteMap, buildSitemapXml, buildVitePressSeoHead, checkCharsetBudget, checkRemoteAssets, checkSameOriginAssets, classifyUrl, collectUrlsFromHtml, collectUrlsFromLlmsTxt, collectUrlsFromSitemap, createSeoTransformPageData, decodeHtmlEntities, dedupeUrls, defaultRelativePathToRoute, deriveSameAs, escapeJsonLd, extractBakedHead, filterRealFaq, filterRealReviews, findBusinessLicense, findCharsetByteOffset, findDuplicateNormalizedPaths, findHeadHonestyExecutionFaults, findHeadHonestyViolations, findLocalBusinessSchema, findReviewItemsForPage, formatCharsetHeadroomWarning, formatDuplicateNormalizedPaths, formatEmittedUrlAudit, formatEmittedUrlReport, formatHeadHonestyExecutionReport, formatHeadHonestyReport, generateJsonLd, generateOpenGraphMeta, generateTwitterMeta, graphAbsorbs, graphIds, hoistCharsetMeta, installSeoHead, isBodyPrerenderEnabled, isLocalBusinessType, isRouteEmitted, isRouteIndexable, loadPagesManifest, normalizeHeadText, normalizeSeoHeadPath, parsePagesManifest, prerenderBodies, renderHeadTags, resolveHonestyMode, resolvePageSeo, routeExclusionReason, routeToOutputFile, slugToTitle, spliceBodyHtml, spliceHeadHtml, stripManagedHeadTags } from '../chunk-HVSF23P7.js';
3
3
  //# sourceMappingURL=index.js.map
4
4
  //# sourceMappingURL=index.js.map
@@ -26,6 +26,42 @@ interface SeoConfiguration {
26
26
  * JSON-LD emission is unaffected either way.
27
27
  */
28
28
  prerenderBody?: boolean;
29
+ /**
30
+ * Per-site escape hatch for the P1 baked-vs-rendered `<head>` assert (C-334).
31
+ * `false`/`'off'` disables it, `'warn'` reports without gating, `'error'`
32
+ * (default) fails the build. Lives here so the decision is portal-owned, in
33
+ * git, and reviewable — there is no silent way to switch the gate off.
34
+ */
35
+ headHonesty?: boolean | 'error' | 'warn' | 'off' | SeoHonestyBlock;
36
+ /**
37
+ * Per-site escape hatch for the P2 emitted-URL resolution assert (C-334).
38
+ * Same shape as {@link headHonesty}; `allow` lists URLs that are knowingly
39
+ * unresolvable and are still logged on every build.
40
+ */
41
+ urlHonesty?: boolean | 'error' | 'warn' | 'off' | SeoHonestyBlock;
42
+ /**
43
+ * Per-site escape hatch for the P12 `<meta charset>` head-budget assert
44
+ * (C-334). Set `'warn'` to report a document whose encoding declaration falls
45
+ * outside the 1024-byte sniffing window without failing the build.
46
+ */
47
+ charsetBudget?: boolean | 'error' | 'warn' | 'off' | SeoHonestyBlock;
48
+ }
49
+ /**
50
+ * The expanded form of a `.dcs/seo.yaml` honesty-rail escape hatch — a severity
51
+ * plus an explicit, logged allow-list.
52
+ */
53
+ interface SeoHonestyBlock {
54
+ /** `error` (default) | `warn` | `off`. */
55
+ mode?: 'error' | 'warn' | 'off';
56
+ /**
57
+ * Routes (P1) or URLs/paths (P2) knowingly exempted. Every entry is printed on
58
+ * every build, so an exemption can never quietly become permanent.
59
+ */
60
+ allow?: string[];
61
+ /** P1 only: also compare the meta description (default `true`). */
62
+ checkDescription?: boolean;
63
+ /** P2 only: set `false` to skip the cross-origin network probes entirely. */
64
+ network?: boolean;
29
65
  }
30
66
  /**
31
67
  * Global/site-wide SEO configuration
@@ -243,8 +279,16 @@ interface UseSeoConfig {
243
279
  interface UseSeoReturn {
244
280
  /** Computed page SEO configuration */
245
281
  config: vue.ComputedRef<ResolvedPageSeo>;
246
- /** Apply all meta tags via useHead */
247
- applyHead: (overrides?: HeadOverrides) => void;
282
+ /**
283
+ * RE-ASSERT the baked head for this route via useHead. Takes NO ARGUMENTS.
284
+ *
285
+ * `.dcs/seo.yaml` is the only writer of the managed head fields
286
+ * (head-authority contract, C-356 — `.docs/plans/dynamic-site-resolution/README.md`).
287
+ * The parameter was removed on purpose: passing one is a compile error, and a
288
+ * caller that reaches the runtime with one anyway has it DROPPED plus a
289
+ * console error. Change the value in `.dcs/seo.yaml`, not here.
290
+ */
291
+ applyHead: () => void;
248
292
  /** Get JSON-LD schema objects for the page */
249
293
  getSchema: () => object[];
250
294
  /** Get canonical URL for the page */
@@ -253,7 +297,14 @@ interface UseSeoReturn {
253
297
  hasBuildTimeSeo: boolean;
254
298
  }
255
299
  /**
256
- * Overrides that can be passed to applyHead
300
+ * @deprecated RETIRED BY THE HEAD-AUTHORITY CONTRACT (C-356). `applyHead()`
301
+ * accepts no overrides: `.dcs/seo.yaml` is the only writer of the managed head
302
+ * fields. The interface is retained only so an external `import type` does not
303
+ * break on the bump; nothing in the package consumes it. It will be deleted in
304
+ * the next cms MAJOR alongside the cms-core phantom-caller deletion (Q-297=A).
305
+ *
306
+ * If you were reaching for this, the answer is: put the value in
307
+ * `.dcs/seo.yaml` (or the portal SEO editor, which writes it).
257
308
  */
258
309
  interface HeadOverrides {
259
310
  /** Override title */
@@ -614,6 +665,39 @@ declare function loadPagesManifest(projectRoot: string, relativePagesPath: strin
614
665
  * empty string (so the route still gets baked, using global SEO defaults).
615
666
  */
616
667
  declare function parsePagesManifest(raw: unknown): PageRouteEntry[] | null;
668
+ /**
669
+ * The two exclusion inputs the per-route HTML emitter honours.
670
+ *
671
+ * `exclude` is a plugin OPTION (paths or slugs); `excludedGlobs` is the
672
+ * `pages.yaml` top-level `excluded:` list. Neither is a file the runtime can
673
+ * read, which is exactly why the RUNTIME MANIFEST has to be filtered before it
674
+ * is injected rather than filtered again at lookup time.
675
+ */
676
+ interface RouteEmissionRules {
677
+ /** `dcsSeoPlugin({ exclude })` — exact route paths OR slugs. */
678
+ exclude?: readonly string[];
679
+ /** `pages.yaml` top-level `excluded:` globs, e.g. `/dev-*`, `/_*`. */
680
+ excludedGlobs?: readonly string[];
681
+ }
682
+ /** Why a manifest route is NOT emitted, or `null` when it is. */
683
+ type RouteExclusionReason = 'excluded' | 'excluded-glob';
684
+ /**
685
+ * THE SINGLE EMISSION PREDICATE. Both the per-route HTML emitter and the
686
+ * `__DCS_PAGES__` runtime manifest resolve "is this route emitted?" through
687
+ * THIS function.
688
+ *
689
+ * WHY IT EXISTS (C-361 F5, verified). The emitter skipped `exclude` entries and
690
+ * `excluded:` globs, while `__DCS_PAGES__` was built from the raw manifest and
691
+ * carried EVERY loaded route. A private `/dev-*` route was therefore never baked
692
+ * — correct — but `installSeoHead` would still runtime-assert a canonical and
693
+ * `index, follow` over it on the first in-app navigation. The manifest has to
694
+ * model what the emitter WRITES, not what `pages.yaml` DECLARES, and the only
695
+ * way that survives a future edit is for there to be one predicate rather than
696
+ * two implementations that agree today.
697
+ */
698
+ declare function routeExclusionReason(route: Pick<PageRouteEntry, 'slug' | 'path'>, rules: RouteEmissionRules): RouteExclusionReason | null;
699
+ /** `true` when the build emits a per-route `<head>` for this route. */
700
+ declare function isRouteEmitted(route: Pick<PageRouteEntry, 'slug' | 'path'>, rules: RouteEmissionRules): boolean;
617
701
 
618
702
  /**
619
703
  * Shared, PURE (fs-free) cores for the DCS site-file emitters:
@@ -824,7 +908,12 @@ interface VitePressPageData {
824
908
  relativePath: string;
825
909
  /** Dynamic-route params (e.g. `{ topic: 'home-buying' }`). */
826
910
  params?: Record<string, unknown>;
827
- /** Page frontmatter; `head` is appended to here. */
911
+ /**
912
+ * Page frontmatter; `head` is appended to here.
913
+ *
914
+ * Deliberately `any`: site `seo.config.ts` rules dot-walk arbitrary frontmatter,
915
+ * so `unknown` would be a breaking API change for every consuming site.
916
+ */
828
917
  frontmatter: Record<string, any>;
829
918
  /** VitePress page title (drives `<title>` via `titleTemplate`). */
830
919
  title?: string;
@@ -846,7 +935,11 @@ interface SeoPageContext {
846
935
  canonical: string;
847
936
  /** Normalised site base URL (no trailing slash), e.g. `https://example.com`. */
848
937
  siteUrl: string;
849
- /** The page's frontmatter (read-only convenience; same object as pageData). */
938
+ /**
939
+ * The page's frontmatter (read-only convenience; same object as pageData).
940
+ *
941
+ * Deliberately `any` — mirrors `VitePressPageData.frontmatter` above.
942
+ */
850
943
  frontmatter: Record<string, any>;
851
944
  /** The resolved global SEO config block. */
852
945
  global: GlobalSeoConfig;
@@ -1027,4 +1120,4 @@ declare function buildVitePressSeoHead(pageData: VitePressPageData, options: Cre
1027
1120
  */
1028
1121
  declare function createSeoTransformPageData(options: CreateSeoTransformPageDataOptions): (pageData: VitePressPageData) => void;
1029
1122
 
1030
- export { buildHasCredential as $, AI_BOTS as A, type BuildSitemapParams as B, type CreateSeoTransformPageDataOptions as C, type DcsRobotsOptions as D, type ContentConfig as E, type FaqSource as F, type GlobalSeoConfig as G, type SeoConfiguration as H, type SeoAuthorConfig as I, type SeoSocialConfig as J, type SeoImagesConfig as K, type SeoOpenGraphConfig as L, type SeoTwitterConfig as M, type SeoSchemaConfig as N, type SeoAlternateConfig as O, type PageSeoConfig as P, type SeoVerificationConfig as Q, type ResolvedPageOverrides as R, type SeoPageContext as S, type ResolvedPageSeo as T, type UseSeoReturn as U, type VitePressPageData as V, type UseSeoConfig as W, type HeadOverrides as X, type PageRouteEntry as Y, loadPagesManifest as Z, parsePagesManifest as _, buildSitemapXml as a, findLocalBusinessSchema as a0, graphAbsorbs as a1, findBusinessLicense as a2, deriveSameAs as a3, graphIds as a4, isLocalBusinessType as a5, type NormalisedReview as a6, type NormalisedFaq as a7, type ReviewSchemaParts as a8, buildVitePressSeoHead as b, createSeoTransformPageData as c, defaultRelativePathToRoute as d, buildRobotsTxt as e, buildLlmsTxt as f, buildGlobalGraph as g, buildBreadcrumbList as h, isRouteIndexable as i, breadcrumbTrailFromRoute as j, buildBlogPosting as k, buildFaqPage as l, buildReviewSchemaParts as m, filterRealReviews as n, filterRealFaq as o, findReviewItemsForPage as p, absolutizeUrl as q, type SeoPageTypeRule as r, slugToTitle as s, type VitePressHeadConfig as t, type BuildRobotsParams as u, type BuildLlmsParams as v, type SchemaObject as w, type BreadcrumbCrumb as x, type BlogMeta as y, type ReviewSource as z };
1123
+ export { parsePagesManifest as $, AI_BOTS as A, type BuildSitemapParams as B, type CreateSeoTransformPageDataOptions as C, type DcsRobotsOptions as D, type ContentConfig as E, type FaqSource as F, type GlobalSeoConfig as G, type SeoConfiguration as H, type SeoAuthorConfig as I, type SeoSocialConfig as J, type SeoImagesConfig as K, type SeoOpenGraphConfig as L, type SeoTwitterConfig as M, type SeoSchemaConfig as N, type SeoAlternateConfig as O, type PageSeoConfig as P, type SeoVerificationConfig as Q, type ResolvedPageOverrides as R, type SeoPageContext as S, type ResolvedPageSeo as T, type UseSeoReturn as U, type VitePressPageData as V, type UseSeoConfig as W, type HeadOverrides as X, type SeoHonestyBlock as Y, type PageRouteEntry as Z, loadPagesManifest as _, buildSitemapXml as a, isRouteEmitted as a0, routeExclusionReason as a1, type RouteEmissionRules as a2, type RouteExclusionReason as a3, buildHasCredential as a4, findLocalBusinessSchema as a5, graphAbsorbs as a6, findBusinessLicense as a7, deriveSameAs as a8, graphIds as a9, isLocalBusinessType as aa, type NormalisedReview as ab, type NormalisedFaq as ac, type ReviewSchemaParts as ad, buildVitePressSeoHead as b, createSeoTransformPageData as c, defaultRelativePathToRoute as d, buildRobotsTxt as e, buildLlmsTxt as f, buildGlobalGraph as g, buildBreadcrumbList as h, isRouteIndexable as i, breadcrumbTrailFromRoute as j, buildBlogPosting as k, buildFaqPage as l, buildReviewSchemaParts as m, filterRealReviews as n, filterRealFaq as o, findReviewItemsForPage as p, absolutizeUrl as q, type SeoPageTypeRule as r, slugToTitle as s, type VitePressHeadConfig as t, type BuildRobotsParams as u, type BuildLlmsParams as v, type SchemaObject as w, type BreadcrumbCrumb as x, type BlogMeta as y, type ReviewSource as z };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@duffcloudservices/cms",
3
- "version": "0.11.0",
3
+ "version": "0.13.0",
4
4
  "description": "Vue 3 composables and Vite plugins for DCS CMS integration",
5
5
  "type": "module",
6
6
  "exports": {
@@ -46,19 +46,10 @@
46
46
  "src/components",
47
47
  "src/composables"
48
48
  ],
49
- "scripts": {
50
- "build": "tsup",
51
- "dev": "tsup --watch",
52
- "test": "vitest run",
53
- "test:watch": "vitest",
54
- "type-check": "tsc --noEmit",
55
- "lint": "eslint src --ext .ts",
56
- "prepublishOnly": "pnpm run build"
57
- },
58
49
  "peerDependencies": {
59
- "vue": "^3.4.0",
60
50
  "@unhead/vue": "^1.9.0",
61
- "playwright": "^1.40.0"
51
+ "playwright": "^1.40.0",
52
+ "vue": "^3.4.0"
62
53
  },
63
54
  "peerDependenciesMeta": {
64
55
  "playwright": {
@@ -66,22 +57,29 @@
66
57
  }
67
58
  },
68
59
  "dependencies": {
69
- "@duffcloudservices/cms-core": "0.4.3",
70
- "js-yaml": "^4.1.0"
60
+ "js-yaml": "^4.1.0",
61
+ "@duffcloudservices/cms-core": "0.8.0"
71
62
  },
72
63
  "devDependencies": {
73
64
  "@types/js-yaml": "^4.0.9",
74
65
  "@types/markdown-it": "^14.1.0",
75
66
  "@types/node": "^20.11.0",
67
+ "@unhead/vue": "^2.0.5",
68
+ "@unhead/vue-v1": "npm:@unhead/vue@1.11.20",
76
69
  "@vitejs/plugin-vue": "^6.0.1",
70
+ "@vitest/eslint-plugin": "^1.3.4",
71
+ "@vue/eslint-config-typescript": "^14.6.0",
77
72
  "@vue/test-utils": "^2.4.0",
73
+ "eslint": "^9.31.0",
74
+ "eslint-plugin-vue": "~10.3.0",
75
+ "jiti": "^2.4.2",
78
76
  "markdown-it": "^14.0.0",
79
77
  "tsup": "^8.0.0",
80
78
  "typescript": "~5.6.3",
81
79
  "vite": "^6.3.5",
82
80
  "vitest": "^3.2.3",
83
81
  "vue": "^3.5.16",
84
- "@unhead/vue": "^2.0.5"
82
+ "vue-router": "^4.5.1"
85
83
  },
86
84
  "keywords": [
87
85
  "vue",
@@ -106,5 +104,17 @@
106
104
  },
107
105
  "publishConfig": {
108
106
  "access": "public"
107
+ },
108
+ "scripts": {
109
+ "build": "tsup",
110
+ "dev": "tsup --watch",
111
+ "test": "vitest run",
112
+ "test:watch": "vitest",
113
+ "verify:honesty-rails": "node scripts/verify-honesty-rails.mjs",
114
+ "probe:live-urls": "node scripts/probe-live-emitted-urls.mjs",
115
+ "verify:served-honesty": "node scripts/probe-live-emitted-urls.mjs",
116
+ "type-check": "tsc --noEmit",
117
+ "lint": "eslint .",
118
+ "lint:fix": "eslint . --fix"
109
119
  }
110
- }
120
+ }
@@ -1,7 +1,25 @@
1
1
  import { describe, it, expect, afterEach, vi } from 'vitest'
2
2
  import { mount } from '@vue/test-utils'
3
+ import { readFileSync } from 'node:fs'
4
+ import { resolve } from 'node:path'
3
5
  import DcsCallButton from './DcsCallButton.vue'
4
6
 
7
+ /**
8
+ * Raw SFC source, used by the touch-target tests below.
9
+ *
10
+ * SCOPE LIMIT (stated deliberately): `mount()` does not apply an SFC's scoped
11
+ * `<style>` — jsdom never lays the component out — so these assertions prove
12
+ * the 44 px rule is DECLARED, not that a browser renders 44 px. They exist to
13
+ * stop the floor being silently deleted again; the rendered-height proof is a
14
+ * live `getBoundingClientRect()` measurement against a deployed site.
15
+ */
16
+ const sfcSource = readFileSync(
17
+ // vitest runs with cwd = the package root; `import.meta.url` is not a file:
18
+ // URL under the jsdom environment, so resolve from cwd instead.
19
+ resolve(process.cwd(), 'src/components/DcsCallButton.vue'),
20
+ 'utf8'
21
+ )
22
+
5
23
  /**
6
24
  * `useTextContent` reads the build-time `__DCS_CONTENT__` global synchronously
7
25
  * at setup. Stub it to simulate `.dcs/content.yaml` `global` overrides; pass
@@ -123,4 +141,44 @@ describe('DcsCallButton', () => {
123
141
  'Call Explicit Name at (248) 385-2926'
124
142
  )
125
143
  })
144
+
145
+ // -------------------------------------------------------------------------
146
+ // C-321 — WCAG 2.5.5 / 2.5.8 minimum touch target on BOTH variants.
147
+ //
148
+ // Regression guard: the floor used to live only on `--icon`, which left the
149
+ // DEFAULT `inline` variant (hero / nav tap-to-call — the primary mobile
150
+ // conversion on a service-business site) at its line-box height.
151
+ // -------------------------------------------------------------------------
152
+ describe('touch target', () => {
153
+ it('declares the 44px floor on the BASE class, so it applies to every variant', () => {
154
+ const base = sfcSource.match(/\.dcs-call-button\s*\{[^}]*\}/)
155
+ expect(base, 'base .dcs-call-button rule must exist').not.toBeNull()
156
+ expect(base![0]).toMatch(/min-height:\s*var\(--dcs-call-min-target,\s*2\.75rem\)/)
157
+ })
158
+
159
+ it('keeps the icon variant square by pinning min-width to the same token', () => {
160
+ const icon = sfcSource.match(/\.dcs-call-button--icon\s*\{[^}]*\}/)
161
+ expect(icon, '.dcs-call-button--icon rule must exist').not.toBeNull()
162
+ expect(icon![0]).toMatch(/min-width:\s*var\(--dcs-call-min-target,\s*2\.75rem\)/)
163
+ })
164
+
165
+ it('gives the inline variant real hit area on its long axis too', () => {
166
+ const inline = sfcSource.match(/\.dcs-call-button--inline\s*\{[^}]*\}/)
167
+ expect(inline, '.dcs-call-button--inline rule must exist').not.toBeNull()
168
+ // Padding must not be the old flat `0` — that is what produced a 24px tall
169
+ // target on the flagship real-estate site.
170
+ expect(inline![0]).not.toMatch(/padding:\s*var\(--dcs-call-inline-padding,\s*0\)/)
171
+ expect(inline![0]).toMatch(/padding:/)
172
+ })
173
+
174
+ it('never lets a variant override the floor below 44px', () => {
175
+ // Any `min-height` declaration in the stylesheet must resolve to >= 2.75rem.
176
+ const declared = [...sfcSource.matchAll(/min-height:\s*([^;]+);/g)].map((m) => m[1]!.trim())
177
+ expect(declared.length).toBeGreaterThan(0)
178
+ for (const value of declared) {
179
+ const rem = Number(value.match(/(\d+(?:\.\d+)?)rem/)?.[1] ?? '0')
180
+ expect(rem, `min-height "${value}" must be >= 2.75rem`).toBeGreaterThanOrEqual(2.75)
181
+ }
182
+ })
183
+ })
126
184
  })
@@ -134,6 +134,16 @@ const ariaLabel = computed(() =>
134
134
  .dcs-call-button {
135
135
  display: inline-flex;
136
136
  align-items: center;
137
+ /*
138
+ * WCAG 2.5.5 / 2.5.8 minimum touch target, on the BASE class so it applies to
139
+ * EVERY variant. C-321: the floor used to live only on `--icon`, which left
140
+ * the DEFAULT `inline` variant — the hero / nav tap-to-call, i.e. the primary
141
+ * mobile conversion action on a service-business site — at its line-box
142
+ * height. Measured live on the real-estate flagship, the equivalent
143
+ * hand-rolled markup came out 147x24 and 173x28 against the 44px floor.
144
+ * `min-height` (not `height`) keeps the label free to wrap.
145
+ */
146
+ min-height: var(--dcs-call-min-target, 2.75rem);
137
147
  gap: var(--dcs-call-gap, 0.5rem);
138
148
  color: var(--dcs-call-color, currentColor);
139
149
  text-decoration: none;
@@ -154,16 +164,21 @@ const ariaLabel = computed(() =>
154
164
  outline-offset: 2px;
155
165
  }
156
166
 
157
- /* Icon-only: a WCAG 2.5.5 minimum 44×44 touch target for reliable tapping. */
167
+ /* Icon-only: square it off at the same floor so the target is 44x44, not 44xN. */
158
168
  .dcs-call-button--icon {
159
169
  justify-content: center;
160
- min-width: 2.75rem;
161
- min-height: 2.75rem;
170
+ min-width: var(--dcs-call-min-target, 2.75rem);
162
171
  padding: var(--dcs-call-icon-padding, 0.5rem);
163
172
  }
164
173
 
174
+ /*
175
+ * Horizontal padding gives the inline variant real hit area on its long axis
176
+ * too; the negative inline margin keeps the button optically flush with
177
+ * adjacent text so adding the target does not visibly indent existing layouts.
178
+ */
165
179
  .dcs-call-button--inline {
166
- padding: var(--dcs-call-inline-padding, 0);
180
+ padding: var(--dcs-call-inline-padding, 0.25rem 0.5rem);
181
+ margin-inline: var(--dcs-call-inline-margin, -0.5rem);
167
182
  }
168
183
 
169
184
  .dcs-call-button__icon {
@@ -148,7 +148,11 @@ function isEditorContext(): boolean {
148
148
  </div>
149
149
  </div>
150
150
 
151
- <div class="dcs-review-card__rating" :aria-label="`${review.rating} out of 5 stars`">
151
+ <div
152
+ class="dcs-review-card__rating"
153
+ role="img"
154
+ :aria-label="`${review.rating} out of 5 stars`"
155
+ >
152
156
  <svg
153
157
  v-for="star in 5"
154
158
  :key="star"
@@ -77,13 +77,13 @@ const isEmbed = computed(() => isEmbedType.value && embedSrc.value !== '')
77
77
  */
78
78
  function extractYouTubeId(url: string): string {
79
79
  const embedMatch = url.match(/\/embed\/([\w-]{11})/)
80
- if (embedMatch) return embedMatch[1]
80
+ if (embedMatch) return embedMatch[1] ?? ''
81
81
 
82
82
  const watchMatch = url.match(/[?&]v=([\w-]{11})/)
83
- if (watchMatch) return watchMatch[1]
83
+ if (watchMatch) return watchMatch[1] ?? ''
84
84
 
85
85
  const shortMatch = url.match(/youtu\.be\/([\w-]{11})/)
86
- if (shortMatch) return shortMatch[1]
86
+ if (shortMatch) return shortMatch[1] ?? ''
87
87
 
88
88
  if (/^[\w-]{11}$/.test(url)) return url
89
89
  return ''
@@ -73,3 +73,97 @@ describe('ManagedImage self-healing fallback', () => {
73
73
  expect(wrapper.find('img').attributes('src')).toBe(ORIGINAL)
74
74
  })
75
75
  })
76
+
77
+ describe('ManagedImage layout-shift (CLS) dimension hints', () => {
78
+ it('emits width/height on the <img> when explicit dimensions are provided', () => {
79
+ const wrapper = mount(ManagedImage, {
80
+ props: {
81
+ pageSlug: 'home',
82
+ imageKey: 'hero.image',
83
+ fallbackSrc: ORIGINAL,
84
+ fallbackAlt: 'Hero',
85
+ context: 'hero',
86
+ width: 1920,
87
+ height: 1080,
88
+ },
89
+ })
90
+
91
+ const img = wrapper.find('img')
92
+ expect(img.attributes('width')).toBe('1920')
93
+ expect(img.attributes('height')).toBe('1080')
94
+ // Hero context also gets the LCP priority hint.
95
+ expect(img.attributes('fetchpriority')).toBe('high')
96
+ })
97
+
98
+ it('emits NO width/height when dimensions are unknown (never fabricated)', () => {
99
+ const wrapper = mount(ManagedImage, {
100
+ props: {
101
+ pageSlug: 'home',
102
+ imageKey: 'hero.image',
103
+ fallbackSrc: ORIGINAL,
104
+ fallbackAlt: 'Hero',
105
+ context: 'hero',
106
+ },
107
+ })
108
+
109
+ const img = wrapper.find('img')
110
+ expect(img.attributes('width')).toBeUndefined()
111
+ expect(img.attributes('height')).toBeUndefined()
112
+ })
113
+
114
+ it('reads .src/.width/.height from a D1-A dot-suffix imageBase and targets the .src key for the VE', () => {
115
+ const wrapper = mount(ManagedImage, {
116
+ props: {
117
+ pageSlug: 'home',
118
+ imageBase: 'hero.image',
119
+ fallbackSrc: ORIGINAL,
120
+ fallbackAlt: 'Hero',
121
+ context: 'hero',
122
+ width: 1600,
123
+ height: 900,
124
+ },
125
+ })
126
+
127
+ const img = wrapper.find('img')
128
+ // Dimensions flow through to the CLS hint.
129
+ expect(img.attributes('width')).toBe('1600')
130
+ expect(img.attributes('height')).toBe('900')
131
+ // The VE stages replacements against the `<base>.src` key.
132
+ expect(img.attributes('data-dcs-image-key')).toBe('hero.image.src')
133
+ expect(img.attributes('data-dcs-image-url')).toBe(ORIGINAL)
134
+ // … and alt changes against the `<base>.alt` key (C-207 item 7).
135
+ expect(img.attributes('data-dcs-image-alt-key')).toBe('hero.image.alt')
136
+ })
137
+ })
138
+
139
+ describe('ManagedImage managed alt key exposure (data-dcs-image-alt-key)', () => {
140
+ it('exposes the legacy altKey so the VE can stage a persistable alt change', () => {
141
+ const wrapper = mount(ManagedImage, {
142
+ props: {
143
+ pageSlug: 'home',
144
+ imageKey: 'hero.image',
145
+ altKey: 'hero.imageAlt',
146
+ fallbackSrc: ORIGINAL,
147
+ fallbackAlt: 'Hero',
148
+ context: 'hero',
149
+ },
150
+ })
151
+
152
+ expect(wrapper.find('img').attributes('data-dcs-image-alt-key')).toBe('hero.imageAlt')
153
+ })
154
+
155
+ it('omits the attribute when the image has no managed alt key', () => {
156
+ const wrapper = mount(ManagedImage, {
157
+ props: {
158
+ pageSlug: 'home',
159
+ imageKey: 'hero.image',
160
+ fallbackSrc: ORIGINAL,
161
+ fallbackAlt: 'Hero',
162
+ context: 'hero',
163
+ },
164
+ })
165
+
166
+ // No alt key ⇒ the VE must not preview an alt change it cannot persist.
167
+ expect(wrapper.find('img').attributes('data-dcs-image-alt-key')).toBeUndefined()
168
+ })
169
+ })
@@ -8,38 +8,82 @@ defineOptions({ inheritAttrs: false })
8
8
 
9
9
  const props = withDefaults(defineProps<{
10
10
  pageSlug: string
11
- imageKey: string
11
+ imageKey?: string
12
12
  fallbackSrc: string
13
13
  altKey?: string
14
14
  fallbackAlt?: string
15
15
  context?: ImageContext
16
16
  sizes?: string
17
17
  original?: boolean
18
+ /**
19
+ * D1-A structured image base (Q-168=A): a single dot-suffix key group, e.g.
20
+ * `hero.image`, from which `.src` / `.alt` / `.width` / `.height` are read.
21
+ * When set it supersedes `imageKey`/`altKey`. The legacy two-key form keeps
22
+ * working forever for backward compatibility. The `.width`/`.height` values
23
+ * feed the CLS layout-shift hint; when they are absent no hint is emitted
24
+ * (never fabricated).
25
+ */
26
+ imageBase?: string
27
+ /**
28
+ * Explicit intrinsic dimensions — a fallback when no `.width`/`.height`
29
+ * content key exists (e.g. a static hero whose size is known at build time).
30
+ * Both must be positive to emit a hint.
31
+ */
32
+ width?: number
33
+ height?: number
18
34
  }>(), {
35
+ imageKey: '',
19
36
  altKey: '',
20
37
  fallbackAlt: '',
21
38
  context: 'content',
22
39
  sizes: undefined,
23
40
  original: false,
41
+ imageBase: '',
42
+ width: undefined,
43
+ height: undefined,
24
44
  })
25
45
 
46
+ // Resolve the effective content keys. `imageBase` (D1-A) derives the four
47
+ // dot-suffix keys; otherwise fall back to the legacy imageKey/altKey pair.
48
+ const srcKey = computed(() => (props.imageBase ? `${props.imageBase}.src` : props.imageKey))
49
+ const effectiveAltKey = computed(() => (props.imageBase ? `${props.imageBase}.alt` : props.altKey))
50
+ const widthKey = computed(() => (props.imageBase ? `${props.imageBase}.width` : ''))
51
+ const heightKey = computed(() => (props.imageBase ? `${props.imageBase}.height` : ''))
52
+
26
53
  const attrs = useAttrs()
27
54
  const { t } = useTextContent({
28
55
  pageSlug: props.pageSlug,
29
56
  defaults: {
30
- [props.imageKey]: props.fallbackSrc,
31
- ...(props.altKey ? { [props.altKey]: props.fallbackAlt } : {}),
57
+ ...(srcKey.value ? { [srcKey.value]: props.fallbackSrc } : {}),
58
+ ...(effectiveAltKey.value ? { [effectiveAltKey.value]: props.fallbackAlt } : {}),
59
+ ...(widthKey.value && props.width ? { [widthKey.value]: String(props.width) } : {}),
60
+ ...(heightKey.value && props.height ? { [heightKey.value]: String(props.height) } : {}),
32
61
  },
33
62
  })
34
63
 
35
- const imageSrc = computed(() => t(props.imageKey, props.fallbackSrc))
36
- const imageAlt = computed(() => (props.altKey ? t(props.altKey, props.fallbackAlt) : props.fallbackAlt))
64
+ /** Parse a content value (always a string) into a positive integer, or undefined. */
65
+ function toPositiveInt(value: string | undefined): number | undefined {
66
+ if (!value) return undefined
67
+ const n = Number.parseInt(value, 10)
68
+ return Number.isFinite(n) && n > 0 ? n : undefined
69
+ }
70
+
71
+ const imageSrc = computed(() => t(srcKey.value, props.fallbackSrc))
72
+ const imageAlt = computed(() => (effectiveAltKey.value ? t(effectiveAltKey.value, props.fallbackAlt) : props.fallbackAlt))
73
+ const imageWidth = computed(() =>
74
+ toPositiveInt(widthKey.value ? t(widthKey.value, '') : undefined) ?? (props.width && props.width > 0 ? props.width : undefined),
75
+ )
76
+ const imageHeight = computed(() =>
77
+ toPositiveInt(heightKey.value ? t(heightKey.value, '') : undefined) ?? (props.height && props.height > 0 ? props.height : undefined),
78
+ )
37
79
  const image = useResponsiveImage({
38
80
  src: imageSrc,
39
81
  alt: imageAlt,
40
82
  context: () => props.context,
41
83
  sizes: () => props.sizes,
42
84
  original: () => props.original,
85
+ width: imageWidth,
86
+ height: imageHeight,
43
87
  })
44
88
 
45
89
  // Self-heal: a fit-variant (`-md`/`-lg`) may legitimately be absent when the
@@ -61,9 +105,17 @@ const imgAttrs = computed(() => ({
61
105
  // When a variant has failed, render the original (also exposed as
62
106
  // data-dcs-image-url for the visual editor) instead of the 404'd variant.
63
107
  ...(failed.value ? { src: imageSrc.value } : {}),
64
- 'data-dcs-image-key': props.imageKey,
108
+ // The VE stages a replacement against the SRC key (the key that holds the
109
+ // image URL), which is the dot-suffix `<base>.src` when imageBase is used,
110
+ // or the legacy imageKey otherwise.
111
+ 'data-dcs-image-key': srcKey.value,
65
112
  'data-dcs-image-url': imageSrc.value,
66
113
  'data-dcs-image-alt': imageAlt.value,
114
+ // The ALT key (when this image has one) so the VE can stage an alt change
115
+ // that Save actually persists — `<base>.alt` (D1-A) or the legacy altKey.
116
+ // Omitted when no managed alt key exists, so the VE knows NOT to preview
117
+ // an alt change it cannot persist.
118
+ ...(effectiveAltKey.value ? { 'data-dcs-image-alt-key': effectiveAltKey.value } : {}),
67
119
  }))
68
120
  </script>
69
121
 
@@ -84,6 +84,7 @@
84
84
  </template>
85
85
 
86
86
  <script setup lang="ts">
87
+ import { platformFetch } from '@duffcloudservices/cms-core'
87
88
  import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
88
89
 
89
90
  /* ------------------------------------------------------------------ */
@@ -234,7 +235,9 @@ onMounted(async () => {
234
235
  const base =
235
236
  (import.meta.env as Record<string, string | undefined>)
236
237
  .VITE_API_BASE_URL ?? 'https://portal.duffcloudservices.com'
237
- const res = await fetch(
238
+ // C-298 layer 2: the `catch { /* non-critical */ }` below is a swallow site; the
239
+ // wrapper's console error is what makes an HTML body visible at all here.
240
+ const res = await platformFetch(
238
241
  `${base}/api/v1/release-notes/latest`,
239
242
  { headers: { Accept: 'application/json' } },
240
243
  )