@duffcloudservices/cms 0.10.0 → 0.11.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.
- package/README.md +59 -2
- package/dist/chunk-DAYLLSEE.js +3 -0
- package/dist/{chunk-KCWMS7P4.js.map → chunk-DAYLLSEE.js.map} +1 -1
- package/dist/{chunk-UPAMLKOQ.js → chunk-F3EIWEZD.js} +158 -11
- package/dist/chunk-F3EIWEZD.js.map +1 -0
- package/dist/index.d.ts +74 -6
- package/dist/index.js +88 -6
- package/dist/index.js.map +1 -1
- package/dist/plugins/index.d.ts +91 -3
- package/dist/plugins/index.js +350 -82
- package/dist/plugins/index.js.map +1 -1
- package/dist/seo/index.d.ts +132 -226
- package/dist/seo/index.js +2 -2
- package/dist/spliceHeadHtml-CsBEucGy.d.ts +254 -0
- package/dist/{vitepressTransform-DeEzgGWU.d.ts → vitepressTransform-DfmABXmK.d.ts} +53 -2
- package/package.json +14 -2
- package/src/components/DcsCallButton.test.ts +126 -0
- package/src/components/DcsCallButton.vue +185 -0
- package/src/components/DcsReviewShowcase.vue +13 -2
- package/src/components/LiteMediaEmbed.test.ts +229 -0
- package/src/components/LiteMediaEmbed.vue +399 -0
- package/src/composables/useMediaCarousel.ts +6 -1
- package/src/composables/useReviewContent.test.ts +86 -0
- package/src/composables/useReviewContent.ts +34 -2
- package/src/composables/useSiteVisitorSession.test.ts +120 -0
- package/src/composables/useSiteVisitorSession.ts +160 -0
- package/dist/chunk-KCWMS7P4.js +0 -3
- 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
|
|
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
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":[],"names":[],"mappings":"","file":"chunk-
|
|
1
|
+
{"version":3,"sources":[],"names":[],"mappings":"","file":"chunk-DAYLLSEE.js"}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import
|
|
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
|
|
413
|
-
canonical = `${global.siteUrl.replace(/\/$/, "")}${
|
|
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, {
|
|
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 (
|
|
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(
|
|
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
|
|
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-
|
|
1022
|
-
//# sourceMappingURL=chunk-
|
|
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
|