@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
@@ -1,19 +1,20 @@
1
- import { buildSitemapXml, isHandAuthoredRobotsAcceptable, buildRobotsTxt, buildLlmsTxt, matchesExcludedGlob, breadcrumbTrailFromRoute, findReviewItemsForPage, buildHeadTags, spliceHeadHtml, loadPagesManifest } from '../chunk-UPAMLKOQ.js';
2
- export { buildVitePressSeoHead, createSeoTransformPageData, defaultRelativePathToRoute } from '../chunk-UPAMLKOQ.js';
3
- import fs3 from 'fs';
4
- import path3 from 'path';
5
- import yaml3 from 'js-yaml';
1
+ import { buildSitemapXml, isHandAuthoredRobotsAcceptable, buildRobotsTxt, buildLlmsTxt, matchesExcludedGlob, breadcrumbTrailFromRoute, findReviewItemsForPage, findBusinessLicense, buildHeadTags, spliceHeadHtml, routeToOutputFile, loadPagesManifest, isBodyPrerenderEnabled, prerenderBodies } from '../chunk-F3EIWEZD.js';
2
+ export { buildVitePressSeoHead, createSeoTransformPageData, defaultRelativePathToRoute } from '../chunk-F3EIWEZD.js';
3
+ import fs5 from 'fs';
4
+ import path5 from 'path';
5
+ import yaml4 from 'js-yaml';
6
+ import http from 'http';
6
7
  import { defineComponent, h } from 'vue';
7
8
 
8
9
  function dcsSearchPaths(projectRoot, relPath) {
9
10
  return [
10
- path3.resolve(projectRoot, relPath),
11
- path3.resolve(projectRoot, "..", relPath),
12
- path3.resolve(process.cwd(), relPath)
11
+ path5.resolve(projectRoot, relPath),
12
+ path5.resolve(projectRoot, "..", relPath),
13
+ path5.resolve(process.cwd(), relPath)
13
14
  ];
14
15
  }
15
16
  function resolveDcsFile(projectRoot, relPath) {
16
- return dcsSearchPaths(projectRoot, relPath).find((p) => fs3.existsSync(p));
17
+ return dcsSearchPaths(projectRoot, relPath).find((p) => fs5.existsSync(p));
17
18
  }
18
19
  function readDcsYamlFresh(projectRoot, relPath, label, debug = false) {
19
20
  const found = resolveDcsFile(projectRoot, relPath);
@@ -22,7 +23,7 @@ function readDcsYamlFresh(projectRoot, relPath, label, debug = false) {
22
23
  return void 0;
23
24
  }
24
25
  try {
25
- return yaml3.load(fs3.readFileSync(found, "utf8"));
26
+ return yaml4.load(fs5.readFileSync(found, "utf8"));
26
27
  } catch (error) {
27
28
  console.warn(`[${label}] serve: failed to parse ${relPath}:`, error);
28
29
  return void 0;
@@ -67,8 +68,50 @@ function dcsMotionTokensStyleTag() {
67
68
  injectTo: "head-prepend"
68
69
  };
69
70
  }
71
+ var DEFAULT_AGENT_API_BASE_URL = "https://portal.duffcloudservices.com";
72
+ var DEFAULT_AGENT_WIDGET_SCRIPT_URL = "https://files.duffcloudservices.com/agent-widget/v1/dcs-agent-widget.js";
73
+ var trimTrailingSlash = (value) => value.replace(/\/+$/, "");
74
+ var encodeForInlineScript = (value) => JSON.stringify(value).replace(/</g, "\\u003c").replace(/>/g, "\\u003e").replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029");
75
+ function buildAgentWidgetLoaderScript(options) {
76
+ const slug = encodeForInlineScript(options.siteSlug);
77
+ const api = encodeForInlineScript(trimTrailingSlash(options.apiBaseUrl || DEFAULT_AGENT_API_BASE_URL));
78
+ const cdn = encodeForInlineScript(options.cdnScriptUrl || DEFAULT_AGENT_WIDGET_SCRIPT_URL);
79
+ return "(function(){try{if(window.__DCS_AGENT_LOADER__)return;window.__DCS_AGENT_LOADER__=1;try{if(window.self!==window.top)return;}catch(e){return;}if(/[?&]dcs-hide-widget(?:[=&]|$)/.test(location.search))return;var S=" + slug + ",A=" + api + ",C=" + cdn + ';var go=function(){try{var p=function(u){return fetch(u,{headers:{Accept:"application/json"}}).then(function(r){return r&&r.ok?r.json()["catch"](function(){return null;}):null;})["catch"](function(){return null;});};p("/api/v1/agent/config").then(function(c){return c||p(A+"/api/v1/sites/"+encodeURIComponent(S)+"/agent/config");}).then(function(c){if(!c||!c.enabled)return;window.__DCS_AGENT_BOOTSTRAP__={siteSlug:S,apiBaseUrl:A,config:c};var s=document.createElement("script");s.src=C;s.async=true;s.setAttribute("data-dcs-agent-slug",S);(document.head||document.documentElement).appendChild(s);}).catch(function(){});}catch(e){}};if("requestIdleCallback"in window){requestIdleCallback(go,{timeout:4000});}else{setTimeout(go,1200);}}catch(e){}})();';
80
+ }
81
+ var SITE_YAML_CANDIDATES = [".dcs/site.yaml", "../.dcs/site.yaml"];
82
+ function readSiteSlug(projectRoot) {
83
+ const candidates = [
84
+ ...SITE_YAML_CANDIDATES.map((rel) => path5.resolve(projectRoot, rel)),
85
+ path5.resolve(process.cwd(), ".dcs/site.yaml")
86
+ ];
87
+ for (const candidate of candidates) {
88
+ try {
89
+ if (!fs5.existsSync(candidate)) continue;
90
+ const parsed = yaml4.load(fs5.readFileSync(candidate, "utf8"));
91
+ const slug = parsed && typeof parsed.site_slug === "string" ? parsed.site_slug.trim() : "";
92
+ if (slug) return slug;
93
+ } catch {
94
+ }
95
+ }
96
+ return null;
97
+ }
98
+ function dcsAgentWidgetLoaderTag(options) {
99
+ const siteSlug = readSiteSlug(options.projectRoot);
100
+ if (!siteSlug) return null;
101
+ return {
102
+ tag: "script",
103
+ attrs: { "data-dcs-agent-loader": "" },
104
+ children: buildAgentWidgetLoaderScript({
105
+ siteSlug,
106
+ apiBaseUrl: options.apiBaseUrl,
107
+ cdnScriptUrl: options.cdnScriptUrl
108
+ }),
109
+ injectTo: "head"
110
+ };
111
+ }
70
112
 
71
113
  // src/plugins/dcsContentPlugin.ts
114
+ var DCS_SSR_NO_EXTERNAL = ["@duffcloudservices/cms"];
72
115
  function dcsContentPlugin(options = {}) {
73
116
  const { contentPath = ".dcs/content.yaml", debug = false } = options;
74
117
  let resolvedConfig;
@@ -84,18 +127,19 @@ function dcsContentPlugin(options = {}) {
84
127
  return {
85
128
  define: {
86
129
  __DCS_CONTENT__: "globalThis.__DCS_CONTENT__"
87
- }
130
+ },
131
+ ssr: { noExternal: DCS_SSR_NO_EXTERNAL }
88
132
  };
89
133
  }
90
134
  const projectRoot = config.root || process.cwd();
91
135
  const possiblePaths = [
92
- path3.resolve(projectRoot, contentPath),
93
- path3.resolve(projectRoot, "..", contentPath),
94
- path3.resolve(process.cwd(), contentPath)
136
+ path5.resolve(projectRoot, contentPath),
137
+ path5.resolve(projectRoot, "..", contentPath),
138
+ path5.resolve(process.cwd(), contentPath)
95
139
  ];
96
140
  let foundPath;
97
141
  for (const testPath of possiblePaths) {
98
- if (fs3.existsSync(testPath)) {
142
+ if (fs5.existsSync(testPath)) {
99
143
  foundPath = testPath;
100
144
  break;
101
145
  }
@@ -109,12 +153,13 @@ function dcsContentPlugin(options = {}) {
109
153
  return {
110
154
  define: {
111
155
  __DCS_CONTENT__: "undefined"
112
- }
156
+ },
157
+ ssr: { noExternal: DCS_SSR_NO_EXTERNAL }
113
158
  };
114
159
  }
115
160
  try {
116
- const fileContent = fs3.readFileSync(foundPath, "utf8");
117
- const content = yaml3.load(fileContent);
161
+ const fileContent = fs5.readFileSync(foundPath, "utf8");
162
+ const content = yaml4.load(fileContent);
118
163
  if (debug) {
119
164
  console.log(`[dcs-content] Loaded ${foundPath}`);
120
165
  console.log(`[dcs-content] Version: ${content.version}`);
@@ -124,14 +169,16 @@ function dcsContentPlugin(options = {}) {
124
169
  return {
125
170
  define: {
126
171
  __DCS_CONTENT__: JSON.stringify(content)
127
- }
172
+ },
173
+ ssr: { noExternal: DCS_SSR_NO_EXTERNAL }
128
174
  };
129
175
  } catch (error) {
130
176
  console.warn("[dcs-content] Failed to parse content.yaml:", error);
131
177
  return {
132
178
  define: {
133
179
  __DCS_CONTENT__: "undefined"
134
- }
180
+ },
181
+ ssr: { noExternal: DCS_SSR_NO_EXTERNAL }
135
182
  };
136
183
  }
137
184
  },
@@ -157,6 +204,14 @@ function dcsContentPlugin(options = {}) {
157
204
  order: "pre",
158
205
  handler(html) {
159
206
  const tags = [dcsMotionTokensStyleTag()];
207
+ if (!isDev) {
208
+ const projectRoot = resolvedConfig?.root || process.cwd();
209
+ const agentTag = dcsAgentWidgetLoaderTag({
210
+ projectRoot,
211
+ apiBaseUrl: process.env.VITE_API_BASE_URL
212
+ });
213
+ if (agentTag) tags.push(agentTag);
214
+ }
160
215
  if (isDev) {
161
216
  const projectRoot = resolvedConfig?.root || process.cwd();
162
217
  const content = readDcsYamlFresh(
@@ -190,17 +245,17 @@ function dcsContentPlugin(options = {}) {
190
245
  configureServer(server) {
191
246
  const projectRoot = resolvedConfig?.root || process.cwd();
192
247
  const watchPaths = [
193
- path3.resolve(projectRoot, contentPath),
194
- path3.resolve(projectRoot, "..", contentPath)
195
- ].map((p) => path3.normalize(p));
248
+ path5.resolve(projectRoot, contentPath),
249
+ path5.resolve(projectRoot, "..", contentPath)
250
+ ].map((p) => path5.normalize(p));
196
251
  watchPaths.forEach((watchPath) => {
197
- if (fs3.existsSync(watchPath)) {
252
+ if (fs5.existsSync(watchPath)) {
198
253
  server.watcher.add(watchPath);
199
254
  }
200
255
  });
201
256
  let reloadTimer;
202
257
  server.watcher.on("change", (changedPath) => {
203
- if (!watchPaths.includes(path3.normalize(changedPath))) return;
258
+ if (!watchPaths.includes(path5.normalize(changedPath))) return;
204
259
  if (reloadTimer) clearTimeout(reloadTimer);
205
260
  reloadTimer = setTimeout(() => {
206
261
  if (debug) {
@@ -212,19 +267,191 @@ function dcsContentPlugin(options = {}) {
212
267
  }
213
268
  };
214
269
  }
270
+ var CONTENT_TYPES = {
271
+ ".html": "text/html; charset=utf-8",
272
+ ".js": "text/javascript; charset=utf-8",
273
+ ".mjs": "text/javascript; charset=utf-8",
274
+ ".css": "text/css; charset=utf-8",
275
+ ".json": "application/json; charset=utf-8",
276
+ ".svg": "image/svg+xml",
277
+ ".png": "image/png",
278
+ ".jpg": "image/jpeg",
279
+ ".jpeg": "image/jpeg",
280
+ ".webp": "image/webp",
281
+ ".gif": "image/gif",
282
+ ".ico": "image/x-icon",
283
+ ".woff": "font/woff",
284
+ ".woff2": "font/woff2",
285
+ ".ttf": "font/ttf",
286
+ ".txt": "text/plain; charset=utf-8",
287
+ ".xml": "application/xml; charset=utf-8",
288
+ ".map": "application/json; charset=utf-8"
289
+ };
290
+ async function startStaticServer(outDir) {
291
+ const server = http.createServer((req, res) => {
292
+ let pathname = "/";
293
+ try {
294
+ pathname = decodeURIComponent(new URL(req.url ?? "/", "http://127.0.0.1").pathname);
295
+ } catch {
296
+ pathname = "/";
297
+ }
298
+ const resolved = path5.resolve(outDir, "." + pathname);
299
+ if (resolved !== outDir && !resolved.startsWith(outDir + path5.sep)) {
300
+ res.writeHead(403);
301
+ res.end();
302
+ return;
303
+ }
304
+ let filePath = resolved;
305
+ try {
306
+ const stat = fs5.statSync(filePath);
307
+ if (stat.isDirectory()) filePath = path5.join(filePath, "index.html");
308
+ } catch {
309
+ if (path5.extname(pathname)) {
310
+ res.writeHead(404);
311
+ res.end();
312
+ return;
313
+ }
314
+ filePath = path5.join(outDir, "index.html");
315
+ }
316
+ fs5.readFile(filePath, (err, buf) => {
317
+ if (err) {
318
+ res.writeHead(404);
319
+ res.end();
320
+ return;
321
+ }
322
+ res.writeHead(200, {
323
+ "Content-Type": CONTENT_TYPES[path5.extname(filePath).toLowerCase()] ?? "application/octet-stream"
324
+ });
325
+ res.end(buf);
326
+ });
327
+ });
328
+ await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
329
+ const addr = server.address();
330
+ const origin = `http://127.0.0.1:${addr.port}`;
331
+ return {
332
+ origin,
333
+ close: () => new Promise((resolve) => {
334
+ server.close(() => resolve());
335
+ })
336
+ };
337
+ }
338
+ async function createPlaywrightRenderer(options) {
339
+ const {
340
+ outDir,
341
+ navTimeoutMs = 3e4,
342
+ mountTimeoutMs = 15e3,
343
+ settleMs = 250,
344
+ debug = false
345
+ } = options;
346
+ let chromium;
347
+ try {
348
+ const specifier = "playwright";
349
+ const pw = await import(
350
+ /* @vite-ignore */
351
+ specifier
352
+ );
353
+ chromium = pw.chromium;
354
+ } catch {
355
+ console.warn(
356
+ "[dcs-seo] body-prerender: `playwright` is not installed; skipping body prerender (head + JSON-LD still emitted). `pnpm add -D playwright` to enable."
357
+ );
358
+ return null;
359
+ }
360
+ if (!chromium) {
361
+ console.warn("[dcs-seo] body-prerender: playwright has no chromium export; skipping.");
362
+ return null;
363
+ }
364
+ let browser;
365
+ try {
366
+ browser = await chromium.launch({ headless: true });
367
+ } catch (err) {
368
+ console.warn(
369
+ "[dcs-seo] body-prerender: failed to launch Chromium (run `npx playwright install chromium`); skipping body prerender:",
370
+ err instanceof Error ? err.message : err
371
+ );
372
+ return null;
373
+ }
374
+ const staticServer = await startStaticServer(outDir);
375
+ const context = await browser.newContext({ reducedMotion: "reduce" });
376
+ const renderer = {
377
+ async renderRoute(routePath) {
378
+ const page = await context.newPage();
379
+ const errors = [];
380
+ page.on("pageerror", (err) => errors.push(`pageerror: ${err.message}`));
381
+ if (debug) {
382
+ page.on("console", (msg) => {
383
+ if (msg.type() === "error") console.log(`[dcs-seo] (console) ${msg.text()}`);
384
+ });
385
+ }
386
+ await page.route("**/*", (route) => {
387
+ let sameOrigin = false;
388
+ try {
389
+ sameOrigin = new URL(route.request().url()).origin === staticServer.origin;
390
+ } catch {
391
+ sameOrigin = false;
392
+ }
393
+ if (sameOrigin) {
394
+ route.continue().catch(() => {
395
+ });
396
+ } else {
397
+ route.abort().catch(() => {
398
+ });
399
+ }
400
+ });
401
+ try {
402
+ await page.goto(staticServer.origin + routePath, {
403
+ waitUntil: "load",
404
+ timeout: navTimeoutMs
405
+ });
406
+ await page.waitForFunction(
407
+ () => {
408
+ const el = document.querySelector("#app");
409
+ return !!el && el.children.length > 0;
410
+ },
411
+ void 0,
412
+ { timeout: mountTimeoutMs }
413
+ );
414
+ if (settleMs > 0) await page.waitForTimeout(settleMs);
415
+ const bodyHtml = await page.$eval("#app", (el) => el.innerHTML);
416
+ return { bodyHtml: bodyHtml ?? "", errors };
417
+ } catch (err) {
418
+ errors.push(err instanceof Error ? err.message : String(err));
419
+ return { bodyHtml: "", errors };
420
+ } finally {
421
+ await page.close();
422
+ }
423
+ }
424
+ };
425
+ return {
426
+ renderer,
427
+ async close() {
428
+ try {
429
+ await context.close();
430
+ } catch {
431
+ }
432
+ try {
433
+ await browser.close();
434
+ } catch {
435
+ }
436
+ await staticServer.close();
437
+ }
438
+ };
439
+ }
440
+
441
+ // src/plugins/dcsSeoPlugin.ts
215
442
  function resolveOutDir(config) {
216
443
  const out = config.build?.outDir || "dist";
217
- return path3.isAbsolute(out) ? out : path3.resolve(config.root || process.cwd(), out);
444
+ return path5.isAbsolute(out) ? out : path5.resolve(config.root || process.cwd(), out);
218
445
  }
219
446
  function loadSeoConfig(projectRoot, seoPath, debug) {
220
447
  const possiblePaths = [
221
- path3.resolve(projectRoot, seoPath),
222
- path3.resolve(projectRoot, "..", seoPath),
223
- path3.resolve(process.cwd(), seoPath)
448
+ path5.resolve(projectRoot, seoPath),
449
+ path5.resolve(projectRoot, "..", seoPath),
450
+ path5.resolve(process.cwd(), seoPath)
224
451
  ];
225
452
  let foundPath;
226
453
  for (const testPath of possiblePaths) {
227
- if (fs3.existsSync(testPath)) {
454
+ if (fs5.existsSync(testPath)) {
228
455
  foundPath = testPath;
229
456
  break;
230
457
  }
@@ -238,8 +465,8 @@ function loadSeoConfig(projectRoot, seoPath, debug) {
238
465
  return null;
239
466
  }
240
467
  try {
241
- const fileContent = fs3.readFileSync(foundPath, "utf8");
242
- const config = yaml3.load(fileContent);
468
+ const fileContent = fs5.readFileSync(foundPath, "utf8");
469
+ const config = yaml4.load(fileContent);
243
470
  return { config, foundPath };
244
471
  } catch (error) {
245
472
  console.warn("[dcs-seo] Failed to parse seo.yaml:", error);
@@ -248,17 +475,17 @@ function loadSeoConfig(projectRoot, seoPath, debug) {
248
475
  }
249
476
  function loadContentConfig(projectRoot, contentRelPath, debug) {
250
477
  const possiblePaths = [
251
- path3.resolve(projectRoot, contentRelPath),
252
- path3.resolve(projectRoot, "..", contentRelPath),
253
- path3.resolve(process.cwd(), contentRelPath)
478
+ path5.resolve(projectRoot, contentRelPath),
479
+ path5.resolve(projectRoot, "..", contentRelPath),
480
+ path5.resolve(process.cwd(), contentRelPath)
254
481
  ];
255
- const foundPath = possiblePaths.find((p) => fs3.existsSync(p));
482
+ const foundPath = possiblePaths.find((p) => fs5.existsSync(p));
256
483
  if (!foundPath) {
257
484
  if (debug) console.log(`[dcs-seo] No content.yaml found (reviews disabled)`);
258
485
  return void 0;
259
486
  }
260
487
  try {
261
- const parsed = yaml3.load(fs3.readFileSync(foundPath, "utf8"));
488
+ const parsed = yaml4.load(fs5.readFileSync(foundPath, "utf8"));
262
489
  if (!parsed || typeof parsed !== "object") return void 0;
263
490
  return parsed;
264
491
  } catch (error) {
@@ -266,21 +493,16 @@ function loadContentConfig(projectRoot, contentRelPath, debug) {
266
493
  return void 0;
267
494
  }
268
495
  }
269
- function routeToOutputFile(outDir, routePath) {
270
- const trimmed = routePath.replace(/^\/+/, "").replace(/\/+$/, "");
271
- if (trimmed === "") return path3.join(outDir, "index.html");
272
- return path3.join(outDir, ...trimmed.split("/"), "index.html");
273
- }
274
496
  function loadExcludedGlobs(projectRoot, pagesPath) {
275
497
  const possiblePaths = [
276
- path3.resolve(projectRoot, pagesPath),
277
- path3.resolve(projectRoot, "..", pagesPath),
278
- path3.resolve(process.cwd(), pagesPath)
498
+ path5.resolve(projectRoot, pagesPath),
499
+ path5.resolve(projectRoot, "..", pagesPath),
500
+ path5.resolve(process.cwd(), pagesPath)
279
501
  ];
280
- const found = possiblePaths.find((p) => fs3.existsSync(p));
502
+ const found = possiblePaths.find((p) => fs5.existsSync(p));
281
503
  if (!found) return [];
282
504
  try {
283
- const raw = yaml3.load(fs3.readFileSync(found, "utf8"));
505
+ const raw = yaml4.load(fs5.readFileSync(found, "utf8"));
284
506
  const excluded = raw?.excluded;
285
507
  if (!Array.isArray(excluded)) return [];
286
508
  return excluded.filter((e) => typeof e === "string");
@@ -292,14 +514,14 @@ function deriveLastmod(seoConfig, projectRoot, pagesPath) {
292
514
  const fromSeo = seoConfig?.lastUpdated;
293
515
  if (typeof fromSeo === "string" && fromSeo.length > 0) return fromSeo;
294
516
  const possiblePaths = [
295
- path3.resolve(projectRoot, pagesPath),
296
- path3.resolve(projectRoot, "..", pagesPath),
297
- path3.resolve(process.cwd(), pagesPath)
517
+ path5.resolve(projectRoot, pagesPath),
518
+ path5.resolve(projectRoot, "..", pagesPath),
519
+ path5.resolve(process.cwd(), pagesPath)
298
520
  ];
299
- const found = possiblePaths.find((p) => fs3.existsSync(p));
521
+ const found = possiblePaths.find((p) => fs5.existsSync(p));
300
522
  if (!found) return void 0;
301
523
  try {
302
- const raw = yaml3.load(fs3.readFileSync(found, "utf8"));
524
+ const raw = yaml4.load(fs5.readFileSync(found, "utf8"));
303
525
  const fromPages = raw?.lastUpdated;
304
526
  if (typeof fromPages === "string" && fromPages.length > 0) return fromPages;
305
527
  } catch {
@@ -337,7 +559,7 @@ function emitSiteFiles(params) {
337
559
  lastmod
338
560
  });
339
561
  if (xml) {
340
- fs3.writeFileSync(path3.join(outDir, "sitemap.xml"), xml, "utf8");
562
+ fs5.writeFileSync(path5.join(outDir, "sitemap.xml"), xml, "utf8");
341
563
  wroteSitemap = true;
342
564
  written.push("sitemap.xml");
343
565
  if (debug) console.log("[dcs-seo] wrote sitemap.xml");
@@ -346,8 +568,8 @@ function emitSiteFiles(params) {
346
568
  }
347
569
  }
348
570
  if (robots.enabled ?? true) {
349
- const robotsPath = path3.join(outDir, "robots.txt");
350
- const existing = fs3.existsSync(robotsPath) && !robots.force ? fs3.readFileSync(robotsPath, "utf8") : null;
571
+ const robotsPath = path5.join(outDir, "robots.txt");
572
+ const existing = fs5.existsSync(robotsPath) && !robots.force ? fs5.readFileSync(robotsPath, "utf8") : null;
351
573
  const keepHandAuthored = existing !== null && isHandAuthoredRobotsAcceptable(existing);
352
574
  if (keepHandAuthored) {
353
575
  if (debug)
@@ -359,7 +581,7 @@ function emitSiteFiles(params) {
359
581
  robots,
360
582
  hasSitemap: wroteSitemap
361
583
  });
362
- fs3.writeFileSync(robotsPath, txt, "utf8");
584
+ fs5.writeFileSync(robotsPath, txt, "utf8");
363
585
  written.push("robots.txt");
364
586
  if (debug) {
365
587
  console.log(
@@ -378,7 +600,7 @@ function emitSiteFiles(params) {
378
600
  excludedGlobs
379
601
  });
380
602
  if (txt) {
381
- fs3.writeFileSync(path3.join(outDir, "llms.txt"), txt, "utf8");
603
+ fs5.writeFileSync(path5.join(outDir, "llms.txt"), txt, "utf8");
382
604
  written.push("llms.txt");
383
605
  if (debug) console.log("[dcs-seo] wrote llms.txt");
384
606
  } else if (debug) {
@@ -428,6 +650,7 @@ function emitStaticSeoHtml(params) {
428
650
  const forceNoindex = noindexSet.has(route.path) || route.slug && noindexSet.has(route.slug);
429
651
  const breadcrumbTrail = siteUrl ? breadcrumbTrailFromRoute(route.path, siteUrl, routeTitles) : void 0;
430
652
  const reviews = contentConfig ? findReviewItemsForPage(contentConfig, route.slug) : void 0;
653
+ const license = contentConfig ? findBusinessLicense(contentConfig, route.slug) : void 0;
431
654
  const isBlogPost = /^\/blog\/.+/.test(route.path) && !!route.title;
432
655
  const blogMeta = isBlogPost ? { headline: route.title, url: breadcrumbTrail?.[breadcrumbTrail.length - 1]?.item } : void 0;
433
656
  const faq = pickPageFaq(contentConfig, route.slug);
@@ -438,27 +661,50 @@ function emitStaticSeoHtml(params) {
438
661
  emitGraph: true,
439
662
  breadcrumbTrail,
440
663
  reviews,
664
+ license,
441
665
  blogMeta,
442
666
  faq
443
667
  });
444
668
  const html = spliceHeadHtml(shellHtml, tags);
445
669
  const outFile = routeToOutputFile(outDir, route.path);
446
- fs3.mkdirSync(path3.dirname(outFile), { recursive: true });
447
- fs3.writeFileSync(outFile, html, "utf8");
670
+ fs5.mkdirSync(path5.dirname(outFile), { recursive: true });
671
+ fs5.writeFileSync(outFile, html, "utf8");
448
672
  written++;
449
673
  if (debug) {
450
674
  console.log(
451
- `[dcs-seo] wrote ${path3.relative(outDir, outFile)} (title="${tags.title}"${forceNoindex ? ", noindex" : ""})`
675
+ `[dcs-seo] wrote ${path5.relative(outDir, outFile)} (title="${tags.title}"${forceNoindex ? ", noindex" : ""})`
452
676
  );
453
677
  }
454
678
  }
455
679
  return written;
456
680
  }
681
+ async function runBodyPrerender(ctx, opts) {
682
+ const handle = await createPlaywrightRenderer({ outDir: ctx.outDir, debug: opts.debug });
683
+ if (!handle) return;
684
+ console.log("[dcs-seo] body-prerender: rendering routes via headless Chromium\u2026");
685
+ try {
686
+ const result = await prerenderBodies({
687
+ outDir: ctx.outDir,
688
+ routes: ctx.routes,
689
+ renderer: handle.renderer,
690
+ exclude: opts.exclude,
691
+ noindex: opts.noindex,
692
+ excludedGlobs: ctx.excludedGlobs,
693
+ debug: opts.debug
694
+ });
695
+ console.log(
696
+ `[dcs-seo] body-prerender: ${result.prerendered} route(s) now have crawler-visible body content (${result.skipped} skipped)`
697
+ );
698
+ } finally {
699
+ await handle.close();
700
+ }
701
+ }
457
702
  function dcsSeoPlugin(options = {}) {
458
703
  const {
459
704
  seoPath = ".dcs/seo.yaml",
460
705
  debug = false,
461
706
  emitStaticHtml = false,
707
+ prerenderBody,
462
708
  pagesPath = ".dcs/pages.yaml",
463
709
  contentPath = ".dcs/content.yaml",
464
710
  exclude = [],
@@ -468,7 +714,9 @@ function dcsSeoPlugin(options = {}) {
468
714
  emitSiteFiles: emitSiteFilesOpt = emitStaticHtml,
469
715
  siteUrl,
470
716
  robots = {},
471
- preview = false,
717
+ // Self-detect preview builds from DCS_PREVIEW (mirrors dcsEditorPlugin) so a
718
+ // release/** SWA preview build never body-prerenders; an explicit option wins.
719
+ preview = process.env.DCS_PREVIEW === "true",
472
720
  llms = true
473
721
  } = options;
474
722
  let resolvedConfig;
@@ -520,7 +768,7 @@ function dcsSeoPlugin(options = {}) {
520
768
  * Fully defensive: any missing/unparseable input logs a warning and
521
769
  * no-ops. Never throws (so it can never break a production build).
522
770
  */
523
- writeBundle() {
771
+ async writeBundle() {
524
772
  if (!emitStaticHtml) {
525
773
  if (options.emitSiteFiles === true) {
526
774
  console.warn(
@@ -529,15 +777,16 @@ function dcsSeoPlugin(options = {}) {
529
777
  }
530
778
  return;
531
779
  }
780
+ let bodyPrerenderCtx = null;
532
781
  try {
533
782
  const projectRoot = resolvedConfig?.root || process.cwd();
534
783
  const outDir = resolveOutDir(resolvedConfig);
535
- if (!fs3.existsSync(outDir)) {
784
+ if (!fs5.existsSync(outDir)) {
536
785
  console.warn(`[dcs-seo] emitStaticHtml: outDir not found (${outDir}); skipping`);
537
786
  return;
538
787
  }
539
- const shellPath = path3.join(outDir, "index.html");
540
- if (!fs3.existsSync(shellPath)) {
788
+ const shellPath = path5.join(outDir, "index.html");
789
+ if (!fs5.existsSync(shellPath)) {
541
790
  console.warn(
542
791
  `[dcs-seo] emitStaticHtml: no index.html in ${outDir}; nothing to emit`
543
792
  );
@@ -556,7 +805,7 @@ function dcsSeoPlugin(options = {}) {
556
805
  `[dcs-seo] emitStaticHtml: ${seoPath} missing or unparseable; emitting with defaults only`
557
806
  );
558
807
  }
559
- const shellHtml = fs3.readFileSync(shellPath, "utf8");
808
+ const shellHtml = fs5.readFileSync(shellPath, "utf8");
560
809
  const excludedGlobs = loadExcludedGlobs(projectRoot, pagesPath);
561
810
  const contentConfig = loadContentConfig(projectRoot, contentPath, debug);
562
811
  const written = emitStaticSeoHtml({
@@ -594,9 +843,28 @@ function dcsSeoPlugin(options = {}) {
594
843
  );
595
844
  }
596
845
  }
846
+ const buildBase = resolvedConfig?.base ?? "/";
847
+ const baseIsRoot = buildBase === "/" || buildBase === "./" || buildBase === "";
848
+ if (isBodyPrerenderEnabled({
849
+ emitStaticHtml,
850
+ prerenderBodyOption: prerenderBody,
851
+ preview,
852
+ seoPrerenderBody: loaded?.config?.prerenderBody
853
+ })) {
854
+ if (baseIsRoot) {
855
+ bodyPrerenderCtx = { outDir, routes, excludedGlobs };
856
+ } else {
857
+ console.warn(
858
+ `[dcs-seo] body prerender skipped: non-root base "${buildBase}" (assets would not resolve through the prerender server; head + JSON-LD still emitted)`
859
+ );
860
+ }
861
+ }
597
862
  } catch (error) {
598
863
  console.warn("[dcs-seo] emitStaticHtml failed; build output left unchanged:", error);
599
864
  }
865
+ if (bodyPrerenderCtx) {
866
+ await runBodyPrerender(bodyPrerenderCtx, { exclude, noindex, debug });
867
+ }
600
868
  },
601
869
  /**
602
870
  * Dev-only: inject the FRESH on-disk `seo.yaml` as an inline
@@ -637,17 +905,17 @@ function dcsSeoPlugin(options = {}) {
637
905
  configureServer(server) {
638
906
  const projectRoot = resolvedConfig?.root || process.cwd();
639
907
  const watchPaths = [
640
- path3.resolve(projectRoot, seoPath),
641
- path3.resolve(projectRoot, "..", seoPath)
642
- ].map((p) => path3.normalize(p));
908
+ path5.resolve(projectRoot, seoPath),
909
+ path5.resolve(projectRoot, "..", seoPath)
910
+ ].map((p) => path5.normalize(p));
643
911
  watchPaths.forEach((watchPath) => {
644
- if (fs3.existsSync(watchPath)) {
912
+ if (fs5.existsSync(watchPath)) {
645
913
  server.watcher.add(watchPath);
646
914
  }
647
915
  });
648
916
  let reloadTimer;
649
917
  server.watcher.on("change", (changedPath) => {
650
- if (!watchPaths.includes(path3.normalize(changedPath))) return;
918
+ if (!watchPaths.includes(path5.normalize(changedPath))) return;
651
919
  if (reloadTimer) clearTimeout(reloadTimer);
652
920
  reloadTimer = setTimeout(() => {
653
921
  if (debug) {
@@ -735,7 +1003,7 @@ if (document.readyState === 'loading') {
735
1003
  *
736
1004
  * These are virtual modules resolved via absolute paths (e.g.
737
1005
  * `/__dcs-editor-bridge.js`). When base !== '/', the app is served at a
738
- * sub-path (e.g. /handyman-bryan/) and the absolute virtual paths are NOT
1006
+ * sub-path (e.g. /iron-oak-contractors/) and the absolute virtual paths are NOT
739
1007
  * routed to the Vite dev server by the reverse proxy — they hit the main
740
1008
  * DCS server instead and get redirected to portal.duffcloudservices.com
741
1009
  * (no CORS headers), causing browser console errors.
@@ -784,13 +1052,22 @@ function dcsPreviewPlugin(ribbonComponent, options = {}) {
784
1052
  function escapeHtml(str) {
785
1053
  return str.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
786
1054
  }
1055
+ function dimensionAttrs(entry) {
1056
+ const w = entry.width;
1057
+ const h2 = entry.height;
1058
+ if (typeof w === "number" && w > 0 && typeof h2 === "number" && h2 > 0) {
1059
+ return ` width="${w}" height="${h2}"`;
1060
+ }
1061
+ return "";
1062
+ }
787
1063
  function buildPictureElement(entry, alt, extraAttrs, sizes) {
788
1064
  const variants = entry.variants ?? [];
1065
+ const dims = dimensionAttrs(entry);
789
1066
  const displayVariants = variants.filter(
790
1067
  (v) => v.suffix === "-sm" || v.suffix === "-md" || v.suffix === "-lg"
791
1068
  );
792
1069
  if (displayVariants.length === 0) {
793
- return `<img src="${escapeHtml(entry.cdnUrl)}" alt="${escapeHtml(alt)}"${extraAttrs} loading="lazy" decoding="async" />`;
1070
+ return `<img src="${escapeHtml(entry.cdnUrl)}" alt="${escapeHtml(alt)}"${extraAttrs}${dims} loading="lazy" decoding="async" />`;
794
1071
  }
795
1072
  const srcset = displayVariants.map((v) => `${v.cdnUrl} ${v.width}w`).join(", ");
796
1073
  const mdVariant = variants.find((v) => v.suffix === "-md");
@@ -798,20 +1075,20 @@ function buildPictureElement(entry, alt, extraAttrs, sizes) {
798
1075
  return [
799
1076
  "<picture>",
800
1077
  ` <source srcset="${srcset}" type="image/webp" sizes="${escapeHtml(sizes)}" />`,
801
- ` <img src="${escapeHtml(fallbackSrc)}" alt="${escapeHtml(alt)}"${extraAttrs} loading="lazy" decoding="async" />`,
1078
+ ` <img src="${escapeHtml(fallbackSrc)}" alt="${escapeHtml(alt)}"${extraAttrs}${dims} loading="lazy" decoding="async" />`,
802
1079
  "</picture>"
803
1080
  ].join("\n");
804
1081
  }
805
1082
  function loadCdnImageMap(projectRoot, relativeMapPath, debug) {
806
1083
  const possiblePaths = [
807
- path3.resolve(projectRoot, relativeMapPath),
808
- path3.resolve(projectRoot, "..", relativeMapPath),
809
- path3.resolve(process.cwd(), relativeMapPath)
1084
+ path5.resolve(projectRoot, relativeMapPath),
1085
+ path5.resolve(projectRoot, "..", relativeMapPath),
1086
+ path5.resolve(process.cwd(), relativeMapPath)
810
1087
  ];
811
1088
  for (const testPath of possiblePaths) {
812
- if (fs3.existsSync(testPath)) {
1089
+ if (fs5.existsSync(testPath)) {
813
1090
  try {
814
- const raw = fs3.readFileSync(testPath, "utf8");
1091
+ const raw = fs5.readFileSync(testPath, "utf8");
815
1092
  const data = JSON.parse(raw);
816
1093
  const map = /* @__PURE__ */ new Map();
817
1094
  for (const entry of data.images) {
@@ -872,12 +1149,15 @@ function dcsCdnImagePlugin(options = {}) {
872
1149
  if (!entry) return _match;
873
1150
  const altMatch = (beforeSrc + afterSrc).match(/alt=["']([^"']*)["']/);
874
1151
  const alt = altMatch ? altMatch[1] : "";
875
- const otherAttrs = (beforeSrc + afterSrc).replace(/\balt=["'][^"']*["']/gi, "").replace(/\bloading=["'][^"']*["']/gi, "").replace(/\bdecoding=["'][^"']*["']/gi, "").trim();
1152
+ let otherAttrs = (beforeSrc + afterSrc).replace(/\balt=["'][^"']*["']/gi, "").replace(/\bloading=["'][^"']*["']/gi, "").replace(/\bdecoding=["'][^"']*["']/gi, "").trim();
1153
+ if (dimensionAttrs(entry)) {
1154
+ otherAttrs = otherAttrs.replace(/\b(?:width|height)=(?:"[^"]*"|'[^']*'|[^\s"'>/]+)/gi, "").replace(/\s{2,}/g, " ").trim();
1155
+ }
876
1156
  const extraAttrs = otherAttrs ? " " + otherAttrs : "";
877
1157
  const sizesMatch = (beforeSrc + afterSrc).match(/data-sizes=["']([^"']*)["']/);
878
1158
  const sizes = sizesMatch ? sizesMatch[1] : defaultSizes;
879
1159
  if (!entry.variants || entry.variants.length === 0) {
880
- return `<img src="${escapeHtml(entry.cdnUrl)}" alt="${escapeHtml(alt)}"${extraAttrs} loading="lazy" decoding="async" />`;
1160
+ return `<img src="${escapeHtml(entry.cdnUrl)}" alt="${escapeHtml(alt)}"${extraAttrs}${dimensionAttrs(entry)} loading="lazy" decoding="async" />`;
881
1161
  }
882
1162
  return buildPictureElement(entry, alt, extraAttrs, sizes);
883
1163
  });
@@ -972,13 +1252,13 @@ function dcsCdnBuildEnd(options = {}) {
972
1252
  return;
973
1253
  }
974
1254
  const outDir = siteConfig.outDir;
975
- if (!fs3.existsSync(outDir)) return;
1255
+ if (!fs5.existsSync(outDir)) return;
976
1256
  let filesProcessed = 0;
977
1257
  let refsReplaced = 0;
978
1258
  function walkDir(dir) {
979
- const entries = fs3.readdirSync(dir, { withFileTypes: true });
1259
+ const entries = fs5.readdirSync(dir, { withFileTypes: true });
980
1260
  for (const entry of entries) {
981
- const fullPath = path3.join(dir, entry.name);
1261
+ const fullPath = path5.join(dir, entry.name);
982
1262
  if (entry.isDirectory()) {
983
1263
  walkDir(fullPath);
984
1264
  } else if (extensions.some((ext) => entry.name.endsWith(ext))) {
@@ -987,7 +1267,7 @@ function dcsCdnBuildEnd(options = {}) {
987
1267
  }
988
1268
  }
989
1269
  function processFile(filePath) {
990
- let content = fs3.readFileSync(filePath, "utf8");
1270
+ let content = fs5.readFileSync(filePath, "utf8");
991
1271
  if (!pathPrefixes.some((p) => content.includes(p))) return;
992
1272
  let changed = false;
993
1273
  if (filePath.endsWith(".html")) {
@@ -1022,7 +1302,7 @@ function dcsCdnBuildEnd(options = {}) {
1022
1302
  }
1023
1303
  }
1024
1304
  if (changed) {
1025
- fs3.writeFileSync(filePath, content, "utf8");
1305
+ fs5.writeFileSync(filePath, content, "utf8");
1026
1306
  filesProcessed++;
1027
1307
  }
1028
1308
  }
@@ -1070,7 +1350,88 @@ function responsiveImagePlugin(md) {
1070
1350
  ].join("\n");
1071
1351
  };
1072
1352
  }
1353
+ var BASE_CDN_ASSET = /^https?:\/\/files\.[^/]+\/(?:content\/)?[^/]+\/assets\/(?:[^/]+\/)*[a-f0-9-]+\.\w+(?:[?#].*)?$/i;
1354
+ function isFullSizeCdnUrl(url) {
1355
+ return BASE_CDN_ASSET.test(url.trim());
1356
+ }
1357
+ function readAttr(tag, name) {
1358
+ const m = tag.match(new RegExp(`\\b${name}=("([^"]*)"|'([^']*)')`, "i"));
1359
+ if (!m) return null;
1360
+ return m[2] ?? m[3] ?? "";
1361
+ }
1362
+ function findFullSizeCdnImages(html) {
1363
+ const violations = [];
1364
+ for (const m of html.matchAll(/<img\b[^>]*>/gi)) {
1365
+ const src = readAttr(m[0], "src");
1366
+ if (src && isFullSizeCdnUrl(src)) {
1367
+ violations.push({ url: src.trim(), tag: "img" });
1368
+ }
1369
+ }
1370
+ for (const m of html.matchAll(/<source\b[^>]*>/gi)) {
1371
+ const srcset = readAttr(m[0], "srcset");
1372
+ if (!srcset) continue;
1373
+ for (const candidate of srcset.split(",")) {
1374
+ const url = candidate.trim().split(/\s+/)[0];
1375
+ if (url && isFullSizeCdnUrl(url)) {
1376
+ violations.push({ url, tag: "source" });
1377
+ }
1378
+ }
1379
+ }
1380
+ return violations;
1381
+ }
1382
+ function dcsResponsiveImageLint(options = {}) {
1383
+ const { failOnViolation = false, extensions = [".html"], debug = false } = options;
1384
+ return async (siteConfig) => {
1385
+ const report = { filesScanned: 0, violations: [] };
1386
+ const outDir = siteConfig.outDir;
1387
+ if (!outDir || !fs5.existsSync(outDir)) return report;
1388
+ const walk = (dir) => {
1389
+ for (const entry of fs5.readdirSync(dir, { withFileTypes: true })) {
1390
+ const full = path5.join(dir, entry.name);
1391
+ if (entry.isDirectory()) {
1392
+ walk(full);
1393
+ } else if (extensions.some((ext) => entry.name.endsWith(ext))) {
1394
+ report.filesScanned++;
1395
+ const html = fs5.readFileSync(full, "utf8");
1396
+ if (!html.includes("/assets/")) continue;
1397
+ const rel = path5.relative(outDir, full);
1398
+ for (const v of findFullSizeCdnImages(html)) {
1399
+ report.violations.push({ ...v, file: rel });
1400
+ if (debug) {
1401
+ console.log(`[dcs-responsive-lint] ${rel}: <${v.tag}> full-size ${v.url}`);
1402
+ }
1403
+ }
1404
+ }
1405
+ }
1406
+ };
1407
+ walk(outDir);
1408
+ if (report.violations.length > 0) {
1409
+ const lines = report.violations.map((v) => ` ${v.file}: <${v.tag}> ${v.url}`).join("\n");
1410
+ const summary = `[dcs-responsive-lint] ${report.violations.length} full-size CDN image(s) shipped without a -sm/-md/-lg responsive variant across ${report.filesScanned} file(s):
1411
+ ${lines}`;
1412
+ if (failOnViolation) {
1413
+ throw new Error(summary);
1414
+ }
1415
+ console.warn(summary);
1416
+ } else if (debug) {
1417
+ console.log(
1418
+ `[dcs-responsive-lint] clean \u2014 scanned ${report.filesScanned} file(s), no full-size images.`
1419
+ );
1420
+ }
1421
+ return report;
1422
+ };
1423
+ }
1424
+ function chainBuildEnd(...hooks) {
1425
+ const active = hooks.filter(
1426
+ (h2) => typeof h2 === "function"
1427
+ );
1428
+ return async (siteConfig) => {
1429
+ for (const hook of active) {
1430
+ await hook(siteConfig);
1431
+ }
1432
+ };
1433
+ }
1073
1434
 
1074
- export { DCS_MOTION_TOKENS_CSS, dcsCdnBuildEnd, dcsCdnImagePlugin, dcsContentPlugin, dcsEditorPlugin, dcsMotionTokensStyleTag, dcsPreviewPlugin, dcsSeoPlugin, emitSiteFiles, emitStaticSeoHtml, responsiveImagePlugin };
1435
+ export { DCS_MOTION_TOKENS_CSS, DEFAULT_AGENT_API_BASE_URL, DEFAULT_AGENT_WIDGET_SCRIPT_URL, buildAgentWidgetLoaderScript, chainBuildEnd, dcsAgentWidgetLoaderTag, dcsCdnBuildEnd, dcsCdnImagePlugin, dcsContentPlugin, dcsEditorPlugin, dcsMotionTokensStyleTag, dcsPreviewPlugin, dcsResponsiveImageLint, dcsSeoPlugin, emitSiteFiles, emitStaticSeoHtml, findFullSizeCdnImages, isFullSizeCdnUrl, readSiteSlug, responsiveImagePlugin };
1075
1436
  //# sourceMappingURL=index.js.map
1076
1437
  //# sourceMappingURL=index.js.map