@duffcloudservices/cms 0.12.0 → 0.13.1
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.
- package/README.md +244 -8
- package/dist/chunk-A5F4C72F.js +500 -0
- package/dist/chunk-A5F4C72F.js.map +1 -0
- package/dist/{chunk-F3EIWEZD.js → chunk-HVSF23P7.js} +971 -73
- package/dist/chunk-HVSF23P7.js.map +1 -0
- package/dist/editor/editorBridge.d.ts +13 -1
- package/dist/editor/editorBridge.js +75 -5
- package/dist/editor/editorBridge.js.map +1 -1
- package/dist/headHonesty-OzxvLuwd.d.ts +222 -0
- package/dist/index.d.ts +365 -22
- package/dist/index.js +421 -21
- package/dist/index.js.map +1 -1
- package/dist/installSeoHead-kWQwObez.d.ts +627 -0
- package/dist/plugins/index.d.ts +90 -6
- package/dist/plugins/index.js +530 -49
- package/dist/plugins/index.js.map +1 -1
- package/dist/seo/index.d.ts +763 -4
- package/dist/seo/index.js +2 -2
- package/dist/{vitepressTransform-DfmABXmK.d.ts → vitepressTransform-JG_zlaux.d.ts} +99 -6
- package/package.json +17 -6
- package/src/components/DcsCallButton.test.ts +58 -0
- package/src/components/DcsCallButton.vue +19 -4
- package/src/components/LiteMediaEmbed.vue +3 -3
- package/src/components/ManagedImage.test.ts +34 -0
- package/src/components/ManagedImage.vue +5 -0
- package/src/components/PreviewRibbon.vue +25 -6
- package/src/components/raw-source-imports.test.ts +44 -0
- package/src/composables/useConversionTracking.test.ts +492 -0
- package/src/composables/useConversionTracking.ts +770 -0
- package/src/composables/useReleaseNotes.ts +7 -1
- package/src/composables/useSEO.applyHead.test.ts +150 -0
- package/src/composables/useSEO.ts +63 -17
- package/src/composables/useSiteVersion.ts +4 -1
- package/src/composables/useSiteVisitorSession.test.ts +56 -0
- package/src/composables/useSiteVisitorSession.ts +39 -3
- package/src/composables/useTextContent.ts +9 -1
- package/dist/chunk-DAYLLSEE.js +0 -3
- package/dist/chunk-DAYLLSEE.js.map +0 -1
- package/dist/chunk-F3EIWEZD.js.map +0 -1
- package/dist/spliceHeadHtml-CsBEucGy.d.ts +0 -254
package/dist/seo/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
|
|
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-
|
|
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
|
-
/**
|
|
247
|
-
|
|
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
|
-
*
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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 {
|
|
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.
|
|
3
|
+
"version": "0.13.1",
|
|
4
4
|
"description": "Vue 3 composables and Vite plugins for DCS CMS integration",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -47,9 +47,9 @@
|
|
|
47
47
|
"src/composables"
|
|
48
48
|
],
|
|
49
49
|
"peerDependencies": {
|
|
50
|
-
"vue": "^3.4.0",
|
|
51
50
|
"@unhead/vue": "^1.9.0",
|
|
52
|
-
"playwright": "^1.40.0"
|
|
51
|
+
"playwright": "^1.40.0",
|
|
52
|
+
"vue": "^3.4.0"
|
|
53
53
|
},
|
|
54
54
|
"peerDependenciesMeta": {
|
|
55
55
|
"playwright": {
|
|
@@ -58,21 +58,28 @@
|
|
|
58
58
|
},
|
|
59
59
|
"dependencies": {
|
|
60
60
|
"js-yaml": "^4.1.0",
|
|
61
|
-
"@duffcloudservices/cms-core": "0.
|
|
61
|
+
"@duffcloudservices/cms-core": "0.8.1"
|
|
62
62
|
},
|
|
63
63
|
"devDependencies": {
|
|
64
64
|
"@types/js-yaml": "^4.0.9",
|
|
65
65
|
"@types/markdown-it": "^14.1.0",
|
|
66
66
|
"@types/node": "^20.11.0",
|
|
67
|
+
"@unhead/vue": "^2.0.5",
|
|
68
|
+
"@unhead/vue-v1": "npm:@unhead/vue@1.11.20",
|
|
67
69
|
"@vitejs/plugin-vue": "^6.0.1",
|
|
70
|
+
"@vitest/eslint-plugin": "^1.3.4",
|
|
71
|
+
"@vue/eslint-config-typescript": "^14.6.0",
|
|
68
72
|
"@vue/test-utils": "^2.4.0",
|
|
73
|
+
"eslint": "^9.31.0",
|
|
74
|
+
"eslint-plugin-vue": "~10.3.0",
|
|
75
|
+
"jiti": "^2.4.2",
|
|
69
76
|
"markdown-it": "^14.0.0",
|
|
70
77
|
"tsup": "^8.0.0",
|
|
71
78
|
"typescript": "~5.6.3",
|
|
72
79
|
"vite": "^6.3.5",
|
|
73
80
|
"vitest": "^3.2.3",
|
|
74
81
|
"vue": "^3.5.16",
|
|
75
|
-
"
|
|
82
|
+
"vue-router": "^4.5.1"
|
|
76
83
|
},
|
|
77
84
|
"keywords": [
|
|
78
85
|
"vue",
|
|
@@ -103,7 +110,11 @@
|
|
|
103
110
|
"dev": "tsup --watch",
|
|
104
111
|
"test": "vitest run",
|
|
105
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",
|
|
106
116
|
"type-check": "tsc --noEmit",
|
|
107
|
-
"lint": "eslint
|
|
117
|
+
"lint": "eslint .",
|
|
118
|
+
"lint:fix": "eslint . --fix"
|
|
108
119
|
}
|
|
109
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:
|
|
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 {
|
|
@@ -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 ''
|
|
@@ -131,5 +131,39 @@ describe('ManagedImage layout-shift (CLS) dimension hints', () => {
|
|
|
131
131
|
// The VE stages replacements against the `<base>.src` key.
|
|
132
132
|
expect(img.attributes('data-dcs-image-key')).toBe('hero.image.src')
|
|
133
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()
|
|
134
168
|
})
|
|
135
169
|
})
|
|
@@ -111,6 +111,11 @@ const imgAttrs = computed(() => ({
|
|
|
111
111
|
'data-dcs-image-key': srcKey.value,
|
|
112
112
|
'data-dcs-image-url': imageSrc.value,
|
|
113
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 } : {}),
|
|
114
119
|
}))
|
|
115
120
|
</script>
|
|
116
121
|
|
|
@@ -86,6 +86,16 @@
|
|
|
86
86
|
<script setup lang="ts">
|
|
87
87
|
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
|
|
88
88
|
|
|
89
|
+
/*
|
|
90
|
+
* NO cms-core VALUE imports in this file. The `./components` export ships RAW SFC
|
|
91
|
+
* source (tsup builds no .vue), so consumer sites type-check this file directly —
|
|
92
|
+
* and cms-core is a transitive dep pnpm's strict layout never exposes to them.
|
|
93
|
+
* A value import here is TS2307 in every fleet site's type-check gate (measured on
|
|
94
|
+
* the 0.13.0 canary, 2026-08-10; kept/boogie-babies/bryans/mi-handyman all red).
|
|
95
|
+
* `import type` is erased and safe. The C-298 layer-2 guard this file needs from
|
|
96
|
+
* platformFetch is inlined below at its single call site instead.
|
|
97
|
+
*/
|
|
98
|
+
|
|
89
99
|
/* ------------------------------------------------------------------ */
|
|
90
100
|
/* Props */
|
|
91
101
|
/* ------------------------------------------------------------------ */
|
|
@@ -234,13 +244,22 @@ onMounted(async () => {
|
|
|
234
244
|
const base =
|
|
235
245
|
(import.meta.env as Record<string, string | undefined>)
|
|
236
246
|
.VITE_API_BASE_URL ?? 'https://portal.duffcloudservices.com'
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
247
|
+
// C-298 layer 2, INLINED (see the no-cms-core-value-imports note at the top of
|
|
248
|
+
// this <script>): the `catch { /* non-critical */ }` below is a swallow site, so
|
|
249
|
+
// a non-JSON (HTML-shell) body must go LOUD here before it can be swallowed.
|
|
250
|
+
const url = `${base}/api/v1/release-notes/latest`
|
|
251
|
+
const res = await fetch(url, { headers: { Accept: 'application/json' } })
|
|
241
252
|
if (res.ok) {
|
|
242
|
-
const
|
|
243
|
-
if (
|
|
253
|
+
const ct = res.headers?.get?.('content-type') ?? ''
|
|
254
|
+
if (!/\bjson\b/i.test(ct)) {
|
|
255
|
+
console.error(
|
|
256
|
+
`[dcs-cms] PreviewRibbon: ${url} returned content-type "${ct}" — ` +
|
|
257
|
+
'an HTML body here means a platform misroute (C-298); refusing to parse it.',
|
|
258
|
+
)
|
|
259
|
+
} else {
|
|
260
|
+
const data = await res.json()
|
|
261
|
+
if (data.version) resolvedVersion.value = data.version
|
|
262
|
+
}
|
|
244
263
|
}
|
|
245
264
|
} catch {
|
|
246
265
|
/* non-critical */
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { readFileSync, readdirSync } from 'node:fs'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The `./components` export ships RAW SFC source (tsup builds no .vue), so every
|
|
7
|
+
* consumer site type-checks these files directly — against a dependency tree where
|
|
8
|
+
* cms-core is TRANSITIVE and pnpm's strict layout never exposes it. A VALUE import
|
|
9
|
+
* of cms-core in any raw-shipped .vue is therefore TS2307 in every fleet site's
|
|
10
|
+
* type-check gate, invisible to this package's own checks (cms-core resolves fine
|
|
11
|
+
* HERE — that is exactly why only a consumer-shaped gate or this test can see it).
|
|
12
|
+
*
|
|
13
|
+
* Measured on the 0.13.0 canary (2026-08-10): one value import in PreviewRibbon.vue
|
|
14
|
+
* would have redded CI on kept (the payer), boogie-babies, bryans and mi-handyman.
|
|
15
|
+
* `import type` is erased at compile time and stays allowed.
|
|
16
|
+
*/
|
|
17
|
+
describe('raw-source .vue exports must not value-import cms-core', () => {
|
|
18
|
+
const dir = __dirname
|
|
19
|
+
const vueFiles = readdirSync(dir).filter((f) => f.endsWith('.vue'))
|
|
20
|
+
|
|
21
|
+
it('finds the raw-shipped component set (non-vacuity)', () => {
|
|
22
|
+
expect(vueFiles.length).toBeGreaterThan(0)
|
|
23
|
+
expect(vueFiles).toContain('PreviewRibbon.vue')
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
for (const file of vueFiles) {
|
|
27
|
+
it(`${file} has no cms-core value import`, () => {
|
|
28
|
+
const src = readFileSync(join(dir, file), 'utf8')
|
|
29
|
+
const valueImports = Array.from(src.matchAll(/^\s*import\s+(?!type\b)[^;'"]*['"]@duffcloudservices\/cms-core['"]/gm))
|
|
30
|
+
expect(
|
|
31
|
+
valueImports.map((m) => m[0].trim()),
|
|
32
|
+
`${file} value-imports cms-core — this ships as raw source and breaks every consumer site's type-check (see header comment)`,
|
|
33
|
+
).toEqual([])
|
|
34
|
+
})
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
it('kill-test: the guard regex catches the 0.13.0 defect shape', () => {
|
|
38
|
+
const bad = "import { platformFetch } from '@duffcloudservices/cms-core'"
|
|
39
|
+
const good = "import type { PlatformFetchOptions } from '@duffcloudservices/cms-core'"
|
|
40
|
+
const re = /^\s*import\s+(?!type\b)[^;'"]*['"]@duffcloudservices\/cms-core['"]/gm
|
|
41
|
+
expect(Array.from(bad.matchAll(re)).length).toBe(1)
|
|
42
|
+
expect(Array.from(good.matchAll(re)).length).toBe(0)
|
|
43
|
+
})
|
|
44
|
+
})
|