@duffcloudservices/cms 0.10.0 → 0.12.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 (34) hide show
  1. package/README.md +59 -2
  2. package/dist/chunk-DAYLLSEE.js +3 -0
  3. package/dist/{chunk-KCWMS7P4.js.map → chunk-DAYLLSEE.js.map} +1 -1
  4. package/dist/{chunk-UPAMLKOQ.js → chunk-F3EIWEZD.js} +158 -11
  5. package/dist/chunk-F3EIWEZD.js.map +1 -0
  6. package/dist/editor/editorBridge.d.ts +41 -1
  7. package/dist/editor/editorBridge.js +68 -2
  8. package/dist/editor/editorBridge.js.map +1 -1
  9. package/dist/index.d.ts +78 -6
  10. package/dist/index.js +91 -7
  11. package/dist/index.js.map +1 -1
  12. package/dist/plugins/index.d.ts +174 -3
  13. package/dist/plugins/index.js +448 -87
  14. package/dist/plugins/index.js.map +1 -1
  15. package/dist/seo/index.d.ts +132 -226
  16. package/dist/seo/index.js +2 -2
  17. package/dist/spliceHeadHtml-CsBEucGy.d.ts +254 -0
  18. package/dist/{vitepressTransform-DeEzgGWU.d.ts → vitepressTransform-DfmABXmK.d.ts} +53 -2
  19. package/package.json +25 -14
  20. package/src/components/DcsCallButton.test.ts +126 -0
  21. package/src/components/DcsCallButton.vue +185 -0
  22. package/src/components/DcsReviewShowcase.vue +18 -3
  23. package/src/components/LiteMediaEmbed.test.ts +229 -0
  24. package/src/components/LiteMediaEmbed.vue +399 -0
  25. package/src/components/ManagedImage.test.ts +60 -0
  26. package/src/components/ManagedImage.vue +53 -6
  27. package/src/composables/useMediaCarousel.ts +6 -1
  28. package/src/composables/useResponsiveImage.ts +6 -0
  29. package/src/composables/useReviewContent.test.ts +86 -0
  30. package/src/composables/useReviewContent.ts +34 -2
  31. package/src/composables/useSiteVisitorSession.test.ts +120 -0
  32. package/src/composables/useSiteVisitorSession.ts +160 -0
  33. package/dist/chunk-KCWMS7P4.js +0 -3
  34. package/dist/chunk-UPAMLKOQ.js.map +0 -1
package/README.md CHANGED
@@ -208,17 +208,46 @@ dcsContentPlugin({
208
208
 
209
209
  ### dcsSeoPlugin
210
210
 
211
- Injects `.dcs/seo.yaml` at build time.
211
+ Injects `.dcs/seo.yaml` at build time and, opt-in, emits per-route static SEO.
212
212
 
213
213
  ```typescript
214
214
  import { dcsSeoPlugin } from '@duffcloudservices/cms/plugins'
215
215
 
216
216
  dcsSeoPlugin({
217
217
  seoPath: '.dcs/seo.yaml', // default
218
- debug: false // default
218
+ debug: false, // default
219
+
220
+ // Vue-SPA per-route static SEO (VitePress sites leave this off):
221
+ emitStaticHtml: true, // emit dist/<route>/index.html with baked
222
+ // <head> meta + JSON-LD, plus sitemap/robots/llms
223
+ noindex: ['account', 'projects'], // auth-gated routes → robots noindex,nofollow
219
224
  })
220
225
  ```
221
226
 
227
+ #### Body prerender (crawler-visible body content)
228
+
229
+ When `emitStaticHtml` is on, the plugin also **prerenders each indexable
230
+ route's body** so non-JS AI crawlers see the real page prose — not just
231
+ `<div id="app"></div>`. After the per-route `<head>` is emitted, it drives the
232
+ just-built SPA in headless Chromium (via the site's `playwright`), captures each
233
+ route's rendered `#app` DOM, and splices it into the mount container. On the
234
+ client, `app.mount('#app')` replaces that DOM (no hydration), so users are
235
+ unaffected while crawlers get the body.
236
+
237
+ - **Default ON** whenever `emitStaticHtml` is on — a cms bump enables it with no
238
+ per-site edit. `playwright` is already a fleet devDependency; if it is absent
239
+ the pass is a graceful no-op (head + JSON-LD still emitted).
240
+ - **noindex / auth-gated routes are never body-prerendered** (they get a
241
+ head-only file).
242
+ - A route that **crashes** at render time fails the build loud rather than
243
+ shipping a broken/empty body. Because it renders the PRODUCTION bundle,
244
+ dev-only fallback defaults (e.g. sample reviews) stay off — no fabricated
245
+ content is baked.
246
+ - **Per-site escape hatch (no code change, no cms republish):** set
247
+ `prerenderBody: false` at the root of `.dcs/seo.yaml` to disable body
248
+ prerender for one site, or pass `dcsSeoPlugin({ prerenderBody: false })`.
249
+ Preview builds skip it automatically.
250
+
222
251
  ## Configuration Files
223
252
 
224
253
  ### .dcs/content.yaml
@@ -349,6 +378,34 @@ pages:
349
378
 
350
379
  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.
351
380
 
381
+ ### Click-to-call (NAP phone)
382
+
383
+ `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.
384
+
385
+ ```vue
386
+ <script setup lang="ts">
387
+ import DcsCallButton from '@duffcloudservices/cms/call-button'
388
+ </script>
389
+
390
+ <template>
391
+ <!-- compact, above-the-fold mobile header affordance -->
392
+ <DcsCallButton variant="icon" />
393
+
394
+ <!-- icon + label + number for a nav/menu row or hero -->
395
+ <DcsCallButton variant="inline" />
396
+ </template>
397
+ ```
398
+
399
+ `business.phone` lives in `.dcs/content.yaml` under `global` (create-dcs-site scaffolds it empty):
400
+
401
+ ```yaml
402
+ global:
403
+ business.phone: "(248) 385-2926"
404
+ business.name: Iron Oak Contractors
405
+ ```
406
+
407
+ 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).
408
+
352
409
  Current first-party surfaces:
353
410
 
354
411
  - **Managed forms** — discovers `[data-form-key]`, reports
@@ -0,0 +1,3 @@
1
+
2
+ //# sourceMappingURL=chunk-DAYLLSEE.js.map
3
+ //# sourceMappingURL=chunk-DAYLLSEE.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":[],"names":[],"mappings":"","file":"chunk-KCWMS7P4.js"}
1
+ {"version":3,"sources":[],"names":[],"mappings":"","file":"chunk-DAYLLSEE.js"}
@@ -1,4 +1,4 @@
1
- import fs from 'fs';
1
+ import fs2 from 'fs';
2
2
  import path from 'path';
3
3
  import yaml from 'js-yaml';
4
4
 
@@ -141,6 +141,16 @@ function buildReviewSchemaParts(items) {
141
141
  };
142
142
  return { review, aggregateRating };
143
143
  }
144
+ var LICENSE_CREDENTIAL_CATEGORY = "license";
145
+ function buildHasCredential(license) {
146
+ const value = typeof license === "string" ? license.trim() : "";
147
+ if (!value) return void 0;
148
+ return {
149
+ "@type": "EducationalOccupationalCredential",
150
+ credentialCategory: LICENSE_CREDENTIAL_CATEGORY,
151
+ name: value
152
+ };
153
+ }
144
154
  function buildGlobalGraph(global, opts = {}) {
145
155
  const siteUrl = global.siteUrl ? trimSlash(global.siteUrl) : "";
146
156
  if (!siteUrl) return [];
@@ -183,6 +193,10 @@ function buildGlobalGraph(global, opts = {}) {
183
193
  localBusiness.aggregateRating = reviewParts.aggregateRating;
184
194
  }
185
195
  }
196
+ const credential = buildHasCredential(opts.license);
197
+ if (credential) {
198
+ localBusiness.hasCredential = credential;
199
+ }
186
200
  graph.push(localBusiness);
187
201
  }
188
202
  return [
@@ -306,6 +320,21 @@ function findReviewItemsForPage(content, pageSlug) {
306
320
  const page = content.pages?.[pageSlug];
307
321
  return fromBlock(page) ?? fromBlock(content.global) ?? [];
308
322
  }
323
+ var BUSINESS_LICENSE_KEY = "business.license";
324
+ function findBusinessLicense(content, pageSlug) {
325
+ if (!content) return void 0;
326
+ const fromBlock = (block) => {
327
+ if (!block) return void 0;
328
+ const raw = block[BUSINESS_LICENSE_KEY];
329
+ if (typeof raw === "string") {
330
+ const trimmed = raw.trim();
331
+ return trimmed.length > 0 ? trimmed : void 0;
332
+ }
333
+ if (typeof raw === "number" && Number.isFinite(raw)) return String(raw);
334
+ return void 0;
335
+ };
336
+ return fromBlock(content.pages?.[pageSlug]) ?? fromBlock(content.global);
337
+ }
309
338
  function absolutizeUrl(value, siteUrl, fallback = "") {
310
339
  const v = (value ?? "").trim();
311
340
  if (!v) return fallback;
@@ -409,8 +438,8 @@ function resolvePageSeo(pageSlug, pagePath, seoConfig, fallbackTitle) {
409
438
  const page = seoConfig?.pages?.[pageSlug] ?? {};
410
439
  let canonical = page.canonical || "";
411
440
  if (!canonical && global.siteUrl) {
412
- const path2 = pagePath ?? (pageSlug === "home" ? "/" : `/${pageSlug}`);
413
- canonical = `${global.siteUrl.replace(/\/$/, "")}${path2}`;
441
+ const path3 = pagePath ?? (pageSlug === "home" ? "/" : `/${pageSlug}`);
442
+ canonical = `${global.siteUrl.replace(/\/$/, "")}${path3}`;
414
443
  }
415
444
  const pageSpecificTitle = page.title || fallbackTitle;
416
445
  let title;
@@ -492,7 +521,10 @@ function buildJsonLd(resolved, global, overrides) {
492
521
  const emitGraph = overrides?.emitGraph === true;
493
522
  let perSchema = resolved.schemas;
494
523
  if (emitGraph) {
495
- const graph = buildGlobalGraph(global, { reviews: overrides?.reviews });
524
+ const graph = buildGlobalGraph(global, {
525
+ reviews: overrides?.reviews,
526
+ license: overrides?.license
527
+ });
496
528
  if (graph.length > 0) {
497
529
  out.push(...graph);
498
530
  perSchema = resolved.schemas.filter((s) => !graphAbsorbs(s, global));
@@ -597,7 +629,7 @@ function loadPagesManifest(projectRoot, relativePagesPath, debug = false) {
597
629
  ];
598
630
  let foundPath;
599
631
  for (const testPath of possiblePaths) {
600
- if (fs.existsSync(testPath)) {
632
+ if (fs2.existsSync(testPath)) {
601
633
  foundPath = testPath;
602
634
  break;
603
635
  }
@@ -611,7 +643,7 @@ function loadPagesManifest(projectRoot, relativePagesPath, debug = false) {
611
643
  }
612
644
  let raw;
613
645
  try {
614
- raw = yaml.load(fs.readFileSync(foundPath, "utf8"));
646
+ raw = yaml.load(fs2.readFileSync(foundPath, "utf8"));
615
647
  } catch (error) {
616
648
  console.warn(`[dcs-seo] Failed to parse ${foundPath}:`, error);
617
649
  return null;
@@ -832,6 +864,119 @@ function buildLlmsTxt(params) {
832
864
  }
833
865
  return out.join("\n") + "\n";
834
866
  }
867
+ function routeToOutputFile(outDir, routePath) {
868
+ const trimmed = routePath.replace(/^\/+/, "").replace(/\/+$/, "");
869
+ if (trimmed === "") return path.join(outDir, "index.html");
870
+ return path.join(outDir, ...trimmed.split("/"), "index.html");
871
+ }
872
+ var BodyPrerenderError = class extends Error {
873
+ route;
874
+ errors;
875
+ constructor(route, errors) {
876
+ super(
877
+ `[dcs-seo] body prerender FAILED for route ${route}: ${errors.join(" | ")}. Refusing to ship a broken/empty body \u2014 fix the route or disable body prerender for this site (seo.yaml prerenderBody: false).`
878
+ );
879
+ this.name = "BodyPrerenderError";
880
+ this.route = route;
881
+ this.errors = errors;
882
+ }
883
+ };
884
+ function spliceBodyHtml(html, bodyInnerHtml) {
885
+ const openRe = /<div\s+[^>]*\bid=["']app["'][^>]*>/i;
886
+ const openMatch = openRe.exec(html);
887
+ if (!openMatch) return html;
888
+ const openEnd = openMatch.index + openMatch[0].length;
889
+ const tagRe = /<(\/?)div\b[^>]*>/gi;
890
+ tagRe.lastIndex = openEnd;
891
+ let depth = 1;
892
+ let closeStart = -1;
893
+ let m;
894
+ while ((m = tagRe.exec(html)) !== null) {
895
+ if (m[1] === "/") {
896
+ depth -= 1;
897
+ if (depth === 0) {
898
+ closeStart = m.index;
899
+ break;
900
+ }
901
+ } else {
902
+ depth += 1;
903
+ }
904
+ }
905
+ if (closeStart === -1) return html;
906
+ return html.slice(0, openEnd) + bodyInnerHtml + html.slice(closeStart);
907
+ }
908
+ async function prerenderBodies(params) {
909
+ const {
910
+ outDir,
911
+ routes,
912
+ renderer,
913
+ exclude = [],
914
+ noindex = [],
915
+ excludedGlobs = [],
916
+ debug = false
917
+ } = params;
918
+ const excludeSet = new Set(exclude);
919
+ const noindexSet = new Set(noindex);
920
+ let prerendered = 0;
921
+ let skipped = 0;
922
+ const done = [];
923
+ for (const route of routes) {
924
+ if (excludeSet.has(route.path) || route.slug && excludeSet.has(route.slug)) {
925
+ skipped += 1;
926
+ if (debug) console.log(`[dcs-seo] body-prerender skip (excluded): ${route.path}`);
927
+ continue;
928
+ }
929
+ if (matchesExcludedGlob(route.path, excludedGlobs)) {
930
+ skipped += 1;
931
+ if (debug) console.log(`[dcs-seo] body-prerender skip (excluded glob): ${route.path}`);
932
+ continue;
933
+ }
934
+ if (noindexSet.has(route.path) || route.slug && noindexSet.has(route.slug)) {
935
+ skipped += 1;
936
+ if (debug) console.log(`[dcs-seo] body-prerender skip (noindex/auth): ${route.path}`);
937
+ continue;
938
+ }
939
+ const outFile = routeToOutputFile(outDir, route.path);
940
+ if (!fs2.existsSync(outFile)) {
941
+ skipped += 1;
942
+ if (debug) console.log(`[dcs-seo] body-prerender skip (no head file): ${route.path}`);
943
+ continue;
944
+ }
945
+ const rendered = await renderer.renderRoute(route.path);
946
+ if (rendered.errors.length > 0) {
947
+ throw new BodyPrerenderError(route.path, rendered.errors);
948
+ }
949
+ const body = (rendered.bodyHtml ?? "").trim();
950
+ if (body === "") {
951
+ skipped += 1;
952
+ if (debug) console.log(`[dcs-seo] body-prerender skip (empty render): ${route.path}`);
953
+ continue;
954
+ }
955
+ const html = fs2.readFileSync(outFile, "utf8");
956
+ const spliced = spliceBodyHtml(html, body);
957
+ if (spliced === html) {
958
+ skipped += 1;
959
+ if (debug) console.log(`[dcs-seo] body-prerender skip (no #app container): ${route.path}`);
960
+ continue;
961
+ }
962
+ fs2.writeFileSync(outFile, spliced, "utf8");
963
+ prerendered += 1;
964
+ done.push(route.path);
965
+ if (debug) {
966
+ console.log(
967
+ `[dcs-seo] body-prerender wrote ${path.relative(outDir, outFile)} (${body.length} bytes)`
968
+ );
969
+ }
970
+ }
971
+ return { prerendered, skipped, routes: done };
972
+ }
973
+ function isBodyPrerenderEnabled(input) {
974
+ if (!input.emitStaticHtml) return false;
975
+ if (input.preview) return false;
976
+ if (input.prerenderBodyOption === false) return false;
977
+ if (input.seoPrerenderBody === false) return false;
978
+ return true;
979
+ }
835
980
 
836
981
  // src/seo/vitepressTransform.ts
837
982
  function defaultRelativePathToRoute(relativePath, params) {
@@ -865,7 +1010,8 @@ function buildVitePressSeoHead(pageData, options) {
865
1010
  emitBlogPosting = false,
866
1011
  blogMatch,
867
1012
  emitFaq = false,
868
- resolveReviews
1013
+ resolveReviews,
1014
+ resolveLicense
869
1015
  } = options;
870
1016
  const global = seoConfig?.global ?? {};
871
1017
  const siteUrl = normaliseSiteUrl(global.siteUrl);
@@ -924,7 +1070,8 @@ function buildVitePressSeoHead(pageData, options) {
924
1070
  let globalSchemas = resolved.schemas;
925
1071
  if (emitGraph) {
926
1072
  const reviews = resolveReviews?.(ctx);
927
- const graph = buildGlobalGraph(global, { reviews });
1073
+ const license = resolveLicense?.(ctx);
1074
+ const graph = buildGlobalGraph(global, { reviews, license });
928
1075
  if (graph.length > 0) {
929
1076
  for (const obj of graph) head.push(ldScript(obj));
930
1077
  globalSchemas = resolved.schemas.filter((s) => !graphAbsorbs(s, global));
@@ -1017,6 +1164,6 @@ function createSeoTransformPageData(options) {
1017
1164
  };
1018
1165
  }
1019
1166
 
1020
- export { AI_BOTS, absolutizeUrl, breadcrumbTrailFromRoute, buildBlogPosting, buildBreadcrumbList, buildFaqPage, buildGlobalGraph, buildHeadTags, buildLlmsTxt, buildReviewSchemaParts, buildRobotsTxt, buildSitemapXml, buildVitePressSeoHead, createSeoTransformPageData, defaultRelativePathToRoute, deriveSameAs, escapeJsonLd, filterRealFaq, filterRealReviews, findLocalBusinessSchema, findReviewItemsForPage, generateJsonLd, generateOpenGraphMeta, generateTwitterMeta, graphAbsorbs, graphIds, isHandAuthoredRobotsAcceptable, isLocalBusinessType, isRouteIndexable, loadPagesManifest, matchesExcludedGlob, parsePagesManifest, renderHeadTags, resolvePageSeo, slugToTitle, spliceHeadHtml, stripManagedHeadTags };
1021
- //# sourceMappingURL=chunk-UPAMLKOQ.js.map
1022
- //# sourceMappingURL=chunk-UPAMLKOQ.js.map
1167
+ 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, isHandAuthoredRobotsAcceptable, isLocalBusinessType, isRouteIndexable, loadPagesManifest, matchesExcludedGlob, parsePagesManifest, prerenderBodies, renderHeadTags, resolvePageSeo, routeToOutputFile, slugToTitle, spliceBodyHtml, spliceHeadHtml, stripManagedHeadTags };
1168
+ //# sourceMappingURL=chunk-F3EIWEZD.js.map
1169
+ //# sourceMappingURL=chunk-F3EIWEZD.js.map