@duffcloudservices/cms 0.12.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/README.md +244 -8
  2. package/dist/chunk-A5F4C72F.js +500 -0
  3. package/dist/chunk-A5F4C72F.js.map +1 -0
  4. package/dist/{chunk-F3EIWEZD.js → chunk-HVSF23P7.js} +971 -73
  5. package/dist/chunk-HVSF23P7.js.map +1 -0
  6. package/dist/editor/editorBridge.d.ts +13 -1
  7. package/dist/editor/editorBridge.js +75 -5
  8. package/dist/editor/editorBridge.js.map +1 -1
  9. package/dist/headHonesty-OzxvLuwd.d.ts +222 -0
  10. package/dist/index.d.ts +365 -22
  11. package/dist/index.js +421 -21
  12. package/dist/index.js.map +1 -1
  13. package/dist/installSeoHead-kWQwObez.d.ts +627 -0
  14. package/dist/plugins/index.d.ts +90 -6
  15. package/dist/plugins/index.js +530 -49
  16. package/dist/plugins/index.js.map +1 -1
  17. package/dist/seo/index.d.ts +763 -4
  18. package/dist/seo/index.js +2 -2
  19. package/dist/{vitepressTransform-DfmABXmK.d.ts → vitepressTransform-JG_zlaux.d.ts} +99 -6
  20. package/package.json +17 -6
  21. package/src/components/DcsCallButton.test.ts +58 -0
  22. package/src/components/DcsCallButton.vue +19 -4
  23. package/src/components/LiteMediaEmbed.vue +3 -3
  24. package/src/components/ManagedImage.test.ts +34 -0
  25. package/src/components/ManagedImage.vue +5 -0
  26. package/src/components/PreviewRibbon.vue +4 -1
  27. package/src/composables/useConversionTracking.test.ts +492 -0
  28. package/src/composables/useConversionTracking.ts +770 -0
  29. package/src/composables/useReleaseNotes.ts +7 -1
  30. package/src/composables/useSEO.applyHead.test.ts +150 -0
  31. package/src/composables/useSEO.ts +63 -17
  32. package/src/composables/useSiteVersion.ts +4 -1
  33. package/src/composables/useSiteVisitorSession.test.ts +56 -0
  34. package/src/composables/useSiteVisitorSession.ts +39 -3
  35. package/src/composables/useTextContent.ts +9 -1
  36. package/dist/chunk-DAYLLSEE.js +0 -3
  37. package/dist/chunk-DAYLLSEE.js.map +0 -1
  38. package/dist/chunk-F3EIWEZD.js.map +0 -1
  39. package/dist/spliceHeadHtml-CsBEucGy.d.ts +0 -254
@@ -1,5 +1,5 @@
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';
1
+ import { buildSitemapXml, isHandAuthoredRobotsAcceptable, buildRobotsTxt, buildLlmsTxt, routeExclusionReason, breadcrumbTrailFromRoute, findReviewItemsForPage, findBusinessLicense, buildHeadTags, spliceHeadHtml, routeToOutputFile, loadPagesManifest, hoistCharsetMeta, checkCharsetBudget, isBodyPrerenderEnabled, resolveHonestyMode, assertHeadHonestyExecuted, findHeadHonestyViolations, assertHeadHonesty, isRouteEmitted, findDuplicateNormalizedPaths, formatDuplicateNormalizedPaths, prerenderBodies, CharsetBudgetError, formatCharsetHeadroomWarning, dedupeUrls, collectEmittedUrlsFromFiles, classifyUrl, auditSameOriginAssets, checkRemoteAssets, auditCheckedTotal, EmittedUrlNotRunError, formatEmittedUrlAudit, formatEmittedUrlReport, EmittedUrlError } from '../chunk-HVSF23P7.js';
2
+ export { buildVitePressSeoHead, createSeoTransformPageData, defaultRelativePathToRoute } from '../chunk-HVSF23P7.js';
3
3
  import fs5 from 'fs';
4
4
  import path5 from 'path';
5
5
  import yaml4 from 'js-yaml';
@@ -65,7 +65,7 @@ function dcsMotionTokensStyleTag() {
65
65
  tag: "style",
66
66
  attrs: { "data-dcs-motion-tokens": "" },
67
67
  children: DCS_MOTION_TOKENS_CSS,
68
- injectTo: "head-prepend"
68
+ injectTo: "head"
69
69
  };
70
70
  }
71
71
  var DEFAULT_AGENT_API_BASE_URL = "https://portal.duffcloudservices.com";
@@ -76,7 +76,7 @@ function buildAgentWidgetLoaderScript(options) {
76
76
  const slug = encodeForInlineScript(options.siteSlug);
77
77
  const api = encodeForInlineScript(trimTrailingSlash(options.apiBaseUrl || DEFAULT_AGENT_API_BASE_URL));
78
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){}})();';
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){if(!r||(r.headers.get("content-type")||"").indexOf("json")===-1)return null;return r.ok?r.json()["catch"](function(){return null;}):0;})["catch"](function(){return null;});};p("/api/v1/agent/config").then(function(c){return c===null?p(A+"/api/v1/sites/"+encodeURIComponent(S)+"/agent/config"):c;}).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
80
  }
81
81
  var SITE_YAML_CANDIDATES = [".dcs/site.yaml", "../.dcs/site.yaml"];
82
82
  function readSiteSlug(projectRoot) {
@@ -189,8 +189,12 @@ function dcsContentPlugin(options = {}) {
189
189
  * — the fleet-wide seam that gives every DCS site the shared `--motion-*`
190
190
  * vocabulary. It carries no per-site data, so (unlike the content inject)
191
191
  * it is NOT gated on `isDev`: it runs in both `serve` and `build`.
192
- * `head-prepend` keeps it ABOVE the bundled site CSS, so a site's own
193
- * `:root` (theme `tailwind.css` / `style.css`) can still override a token.
192
+ * It is injected `head` (append), NOT `head-prepend` C-414. Appending
193
+ * still lands it ABOVE the bundled site CSS (Vite injects the build's
194
+ * `<link rel=stylesheet>` AFTER this `order: 'pre'` hook's tags), so a
195
+ * site's own `:root` (theme `tailwind.css` / `style.css`) can still
196
+ * override a token — while leaving the site's `<meta charset>` first in
197
+ * `<head>` instead of 858 bytes deep. See `motionTokens.ts`.
194
198
  *
195
199
  * 2. Dev-only: the FRESH on-disk `content.yaml` as an inline
196
200
  * `globalThis.__DCS_CONTENT__ = {…}` classic script. Runs on every dev
@@ -371,8 +375,19 @@ async function createPlaywrightRenderer(options) {
371
375
  );
372
376
  return null;
373
377
  }
374
- const staticServer = await startStaticServer(outDir);
375
- const context = await browser.newContext({ reducedMotion: "reduce" });
378
+ let staticServer;
379
+ let context;
380
+ try {
381
+ staticServer = await startStaticServer(outDir);
382
+ context = await browser.newContext({ reducedMotion: "reduce" });
383
+ } catch (err) {
384
+ await staticServer?.close().catch(() => {
385
+ });
386
+ await browser.close().catch(() => {
387
+ });
388
+ throw err;
389
+ }
390
+ const server = staticServer;
376
391
  const renderer = {
377
392
  async renderRoute(routePath) {
378
393
  const page = await context.newPage();
@@ -386,7 +401,7 @@ async function createPlaywrightRenderer(options) {
386
401
  await page.route("**/*", (route) => {
387
402
  let sameOrigin = false;
388
403
  try {
389
- sameOrigin = new URL(route.request().url()).origin === staticServer.origin;
404
+ sameOrigin = new URL(route.request().url()).origin === server.origin;
390
405
  } catch {
391
406
  sameOrigin = false;
392
407
  }
@@ -399,7 +414,7 @@ async function createPlaywrightRenderer(options) {
399
414
  }
400
415
  });
401
416
  try {
402
- await page.goto(staticServer.origin + routePath, {
417
+ await page.goto(server.origin + routePath, {
403
418
  waitUntil: "load",
404
419
  timeout: navTimeoutMs
405
420
  });
@@ -413,7 +428,17 @@ async function createPlaywrightRenderer(options) {
413
428
  );
414
429
  if (settleMs > 0) await page.waitForTimeout(settleMs);
415
430
  const bodyHtml = await page.$eval("#app", (el) => el.innerHTML);
416
- return { bodyHtml: bodyHtml ?? "", errors };
431
+ const head = await page.evaluate(() => {
432
+ const metas = Array.from(
433
+ document.querySelectorAll('meta[name="description" i]')
434
+ );
435
+ return {
436
+ title: document.title ?? null,
437
+ description: metas.length ? metas[metas.length - 1].getAttribute("content") : null,
438
+ descriptionCount: metas.length
439
+ };
440
+ });
441
+ return { bodyHtml: bodyHtml ?? "", errors, head };
417
442
  } catch (err) {
418
443
  errors.push(err instanceof Error ? err.message : String(err));
419
444
  return { bodyHtml: "", errors };
@@ -422,23 +447,40 @@ async function createPlaywrightRenderer(options) {
422
447
  }
423
448
  }
424
449
  };
450
+ let teardown = null;
425
451
  return {
426
452
  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();
453
+ close() {
454
+ teardown ??= (async () => {
455
+ try {
456
+ await context.close();
457
+ } catch {
458
+ }
459
+ try {
460
+ await browser.close();
461
+ } catch {
462
+ }
463
+ try {
464
+ await server.close();
465
+ } catch {
466
+ }
467
+ })();
468
+ return teardown;
437
469
  }
438
470
  };
439
471
  }
440
472
 
441
473
  // src/plugins/dcsSeoPlugin.ts
474
+ function readHonestyBlock(value) {
475
+ if (value === void 0) return { allow: [] };
476
+ if (typeof value === "boolean" || typeof value === "string") return { mode: value, allow: [] };
477
+ return {
478
+ mode: value.mode,
479
+ allow: Array.isArray(value.allow) ? value.allow : [],
480
+ checkDescription: value.checkDescription,
481
+ network: value.network
482
+ };
483
+ }
442
484
  function resolveOutDir(config) {
443
485
  const out = config.build?.outDir || "dist";
444
486
  return path5.isAbsolute(out) ? out : path5.resolve(config.root || process.cwd(), out);
@@ -510,6 +552,48 @@ function loadExcludedGlobs(projectRoot, pagesPath) {
510
552
  return [];
511
553
  }
512
554
  }
555
+ function assertNoDuplicateRoutePaths(routes) {
556
+ const dupes = findDuplicateNormalizedPaths(routes);
557
+ if (dupes.length === 0) return;
558
+ throw new Error(`[dcs-seo] ${formatDuplicateNormalizedPaths(dupes)}`);
559
+ }
560
+ function collectNoindexMatchKeys(routes) {
561
+ const keys = /* @__PURE__ */ new Set();
562
+ for (const route of routes) {
563
+ if (route.path) keys.add(route.path);
564
+ if (route.slug) keys.add(route.slug);
565
+ }
566
+ return keys;
567
+ }
568
+ function findOrphanNoindexEntries(noindex, routes) {
569
+ const known = collectNoindexMatchKeys(routes);
570
+ const seen = /* @__PURE__ */ new Set();
571
+ const orphans = [];
572
+ for (const entry of noindex) {
573
+ if (known.has(entry) || seen.has(entry)) continue;
574
+ seen.add(entry);
575
+ orphans.push(entry);
576
+ }
577
+ return orphans;
578
+ }
579
+ var NoindexOrphanError = class extends Error {
580
+ orphans;
581
+ knownKeys;
582
+ constructor(orphans, knownKeys) {
583
+ super(
584
+ `[dcs-seo] noindex entr${orphans.length === 1 ? "y" : "ies"} matching NO page in the route manifest: ${orphans.map((o) => JSON.stringify(o)).join(", ")}. A noindex entry is a predicate over pages.yaml, not a standalone directive: with no matching page NOTHING is emitted for that route, so the entry is a silent no-op and the URL stays "index, follow" to crawlers \u2014 which is exactly how six homepage-clone URLs on KEPT stayed indexable. Fix it by adding the page to pages.yaml (it then gets a real robots: noindex, nofollow document, sitemap/llms omission, and a runtime re-assert), or by deleting the stale entry. Matchable keys are the manifest's paths and slugs: ${knownKeys.map((k) => JSON.stringify(k)).join(", ") || "(manifest is empty)"}`
585
+ );
586
+ this.name = "NoindexOrphanError";
587
+ this.orphans = orphans;
588
+ this.knownKeys = knownKeys;
589
+ }
590
+ };
591
+ function assertNoOrphanNoindexEntries(noindex, routes) {
592
+ if (noindex.length === 0) return;
593
+ const orphans = findOrphanNoindexEntries(noindex, routes);
594
+ if (orphans.length === 0) return;
595
+ throw new NoindexOrphanError(orphans, [...collectNoindexMatchKeys(routes)].sort());
596
+ }
513
597
  function deriveLastmod(seoConfig, projectRoot, pagesPath) {
514
598
  const fromSeo = seoConfig?.lastUpdated;
515
599
  if (typeof fromSeo === "string" && fromSeo.length > 0) return fromSeo;
@@ -630,7 +714,6 @@ function emitStaticSeoHtml(params) {
630
714
  excludedGlobs = [],
631
715
  debug = false
632
716
  } = params;
633
- const excludeSet = new Set(exclude);
634
717
  const noindexSet = new Set(noindex);
635
718
  const siteUrl = seoConfig?.global?.siteUrl ?? "";
636
719
  const routeTitles = { "/": "Home" };
@@ -639,12 +722,13 @@ function emitStaticSeoHtml(params) {
639
722
  }
640
723
  let written = 0;
641
724
  for (const route of routes) {
642
- if (excludeSet.has(route.path) || route.slug && excludeSet.has(route.slug)) {
643
- if (debug) console.log(`[dcs-seo] skip (excluded): ${route.path}`);
644
- continue;
645
- }
646
- if (matchesExcludedGlob(route.path, excludedGlobs)) {
647
- if (debug) console.log(`[dcs-seo] skip (excluded glob): ${route.path}`);
725
+ const exclusion = routeExclusionReason(route, { exclude, excludedGlobs });
726
+ if (exclusion) {
727
+ if (debug) {
728
+ console.log(
729
+ `[dcs-seo] skip (${exclusion === "excluded-glob" ? "excluded glob" : "excluded"}): ${route.path}`
730
+ );
731
+ }
648
732
  continue;
649
733
  }
650
734
  const forceNoindex = noindexSet.has(route.path) || route.slug && noindexSet.has(route.slug);
@@ -679,9 +763,27 @@ function emitStaticSeoHtml(params) {
679
763
  return written;
680
764
  }
681
765
  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");
766
+ const forcedAbsent = process.env.DCS_SEO_FORCE_NO_RENDERER === "1";
767
+ if (forcedAbsent) {
768
+ console.warn(
769
+ "[dcs-seo] DCS_SEO_FORCE_NO_RENDERER=1 \u2014 pretending the headless renderer is absent (kill-test hook; in error mode this MUST fail the build)."
770
+ );
771
+ }
772
+ const handle = forcedAbsent ? null : await createPlaywrightRenderer({ outDir: ctx.outDir, debug: opts.debug });
773
+ if (!handle) {
774
+ console.warn(
775
+ "[dcs-seo] no headless renderer available: body prerender AND the P1 baked-vs-rendered <head> assert were both SKIPPED for this build."
776
+ );
777
+ return {
778
+ rendererAvailable: false,
779
+ headObservations: [],
780
+ eligibleRoutes: [],
781
+ filteredRoutes: []
782
+ };
783
+ }
784
+ console.log(
785
+ opts.spliceBody ? "[dcs-seo] body-prerender: rendering routes via headless Chromium\u2026" : "[dcs-seo] head-honesty: rendering routes via headless Chromium (observe-only)\u2026"
786
+ );
685
787
  try {
686
788
  const result = await prerenderBodies({
687
789
  outDir: ctx.outDir,
@@ -690,15 +792,151 @@ async function runBodyPrerender(ctx, opts) {
690
792
  exclude: opts.exclude,
691
793
  noindex: opts.noindex,
692
794
  excludedGlobs: ctx.excludedGlobs,
795
+ spliceBody: opts.spliceBody,
693
796
  debug: opts.debug
694
797
  });
695
- console.log(
696
- `[dcs-seo] body-prerender: ${result.prerendered} route(s) now have crawler-visible body content (${result.skipped} skipped)`
697
- );
798
+ if (opts.spliceBody) {
799
+ console.log(
800
+ `[dcs-seo] body-prerender: ${result.prerendered} route(s) now have crawler-visible body content (${result.skipped} skipped)`
801
+ );
802
+ }
803
+ return {
804
+ rendererAvailable: true,
805
+ headObservations: result.headObservations,
806
+ eligibleRoutes: result.eligibleRoutes,
807
+ filteredRoutes: result.filteredRoutes
808
+ };
698
809
  } finally {
699
810
  await handle.close();
700
811
  }
701
812
  }
813
+ async function runEmittedUrlHonesty(params) {
814
+ const { outDir, projectRoot, routes, siteUrl, base, mode, allow, network, debug, sink } = params;
815
+ if (mode === "off") {
816
+ console.warn(
817
+ "[dcs-seo] emitted-URL honesty is OFF for this build \u2014 nothing is checking that the logo/og:image/sitemap URLs this site publishes actually resolve."
818
+ );
819
+ return null;
820
+ }
821
+ if (allow.length > 0) {
822
+ console.warn(
823
+ `[dcs-seo] emitted-URL honesty: ${allow.length} URL(s) EXEMPTED: ${allow.join(", ")}`
824
+ );
825
+ }
826
+ const files = [];
827
+ for (const route of routes) {
828
+ files.push({ path: routeToOutputFile(outDir, route.path), from: route.path, kind: "html" });
829
+ }
830
+ files.push({ path: path5.join(outDir, "sitemap.xml"), from: "sitemap.xml", kind: "sitemap" });
831
+ files.push({ path: path5.join(outDir, "llms.txt"), from: "llms.txt", kind: "llms" });
832
+ const collected = dedupeUrls(collectEmittedUrlsFromFiles(files));
833
+ const classified = collected.map((u) => classifyUrl(u, siteUrl));
834
+ const sameOrigin = auditSameOriginAssets({ urls: classified, outDir, base, allow });
835
+ const violations = [...sameOrigin.violations];
836
+ const audit = {
837
+ collected: classified.length,
838
+ sameOriginAssetsChecked: sameOrigin.checked,
839
+ remoteAssetsChecked: 0,
840
+ remoteProbed: 0,
841
+ remoteCached: 0,
842
+ unknown: 0,
843
+ pagesUnverified: classified.filter((u) => u.expectation === "page").length,
844
+ unfetchable: classified.filter((u) => u.locality === "unfetchable").length,
845
+ exempted: sameOrigin.exempted,
846
+ failed: 0
847
+ };
848
+ if (network) {
849
+ const remote = await checkRemoteAssets(classified, { projectRoot, allow, debug });
850
+ violations.push(...remote.violations);
851
+ for (const w of remote.warnings) {
852
+ console.warn(`[dcs-seo] emitted-URL honesty (UNKNOWN, not a failure): ${w.url} \u2014 ${w.reason}`);
853
+ }
854
+ audit.remoteAssetsChecked = remote.checked ?? 0;
855
+ audit.remoteProbed = remote.probed;
856
+ audit.remoteCached = remote.cached;
857
+ audit.unknown = remote.warnings.length;
858
+ audit.exempted += remote.exempted ?? 0;
859
+ if (debug) {
860
+ console.log(
861
+ `[dcs-seo] emitted-URL honesty: remote probes \u2014 ${remote.probed} network, ${remote.cached} cached`
862
+ );
863
+ }
864
+ } else {
865
+ audit.unknown = classified.filter(
866
+ (u) => u.locality === "remote" && u.expectation === "asset"
867
+ ).length;
868
+ console.log(
869
+ `[dcs-seo] emitted-URL honesty: cross-origin probes disabled \u2014 ${audit.unknown} remote asset URL(s) are UNCHECKED this build; same-origin still enforced`
870
+ );
871
+ }
872
+ audit.failed = violations.length;
873
+ sink?.(audit, violations);
874
+ const checkedTotal = auditCheckedTotal(audit);
875
+ if (audit.collected === 0) {
876
+ const err = new EmittedUrlNotRunError(
877
+ audit,
878
+ `0 URLs were collected from ${files.length} emitted artifact(s)`
879
+ );
880
+ if (mode === "error") throw err;
881
+ console.warn(err.message);
882
+ console.warn("[dcs-seo] emitted-URL honesty is in WARN mode; this build is NOT gated on the above.");
883
+ return audit;
884
+ }
885
+ if (checkedTotal === 0) {
886
+ console.warn(
887
+ `[dcs-seo] emitted-URL honesty VERIFIED NOTHING this build: ${audit.collected} URL(s) were collected but none was a checkable asset (${audit.pagesUnverified} page(s), ${audit.unknown} unknown, ${audit.exempted} exempted, ${audit.unfetchable} unfetchable).`
888
+ );
889
+ }
890
+ if (violations.length === 0) {
891
+ console.log(formatEmittedUrlAudit(audit, routes.length));
892
+ return audit;
893
+ }
894
+ if (mode === "warn") {
895
+ console.warn(formatEmittedUrlReport(violations));
896
+ console.warn(formatEmittedUrlAudit(audit, routes.length));
897
+ console.warn("[dcs-seo] emitted-URL honesty is in WARN mode; this build is NOT gated on the above.");
898
+ return audit;
899
+ }
900
+ throw new EmittedUrlError(violations);
901
+ }
902
+ function runCharsetBudget(params) {
903
+ const { outDir, routes, mode, debug, sink } = params;
904
+ if (mode === "off") return;
905
+ const checked = [];
906
+ let failed = 0;
907
+ let worstOffset = 0;
908
+ const emit = () => sink?.({ checked: checked.length, failed, worstOffset });
909
+ for (const route of routes) {
910
+ const file = routeToOutputFile(outDir, route.path);
911
+ let html;
912
+ try {
913
+ html = fs5.readFileSync(file, "utf8");
914
+ } catch {
915
+ continue;
916
+ }
917
+ const rel = path5.relative(outDir, file);
918
+ const result = checkCharsetBudget(html);
919
+ worstOffset = Math.max(worstOffset, result.offset);
920
+ if (!result.ok) {
921
+ failed += 1;
922
+ if (mode === "warn") {
923
+ console.warn(new CharsetBudgetError(rel, result).message);
924
+ continue;
925
+ }
926
+ emit();
927
+ throw new CharsetBudgetError(rel, result);
928
+ }
929
+ const warning = formatCharsetHeadroomWarning(rel, result);
930
+ if (warning) console.warn(warning);
931
+ checked.push(rel);
932
+ }
933
+ emit();
934
+ if (debug || checked.length > 0) {
935
+ console.log(
936
+ `[dcs-seo] charset budget: ${checked.length} emitted document(s) declare their encoding inside the first 1024 bytes`
937
+ );
938
+ }
939
+ }
702
940
  function dcsSeoPlugin(options = {}) {
703
941
  const {
704
942
  seoPath = ".dcs/seo.yaml",
@@ -713,6 +951,9 @@ function dcsSeoPlugin(options = {}) {
713
951
  // sites already using emitStaticHtml gain sitemap/robots/llms with no edit.
714
952
  emitSiteFiles: emitSiteFilesOpt = emitStaticHtml,
715
953
  siteUrl,
954
+ headHonesty: headHonestyOpt,
955
+ urlHonesty: urlHonestyOpt,
956
+ charsetBudget: charsetBudgetOpt,
716
957
  robots = {},
717
958
  // Self-detect preview builds from DCS_PREVIEW (mirrors dcsEditorPlugin) so a
718
959
  // release/** SWA preview build never body-prerenders; an explicit option wins.
@@ -731,16 +972,43 @@ function dcsSeoPlugin(options = {}) {
731
972
  if (env.command === "serve") {
732
973
  return {
733
974
  define: {
734
- __DCS_SEO__: "globalThis.__DCS_SEO__"
975
+ __DCS_SEO__: "globalThis.__DCS_SEO__",
976
+ __DCS_PAGES__: "globalThis.__DCS_PAGES__"
735
977
  }
736
978
  };
737
979
  }
738
980
  const projectRoot = config.root || process.cwd();
981
+ const pagesDefine = (() => {
982
+ const routes = loadPagesManifest(projectRoot, pagesPath, debug);
983
+ if (!routes) {
984
+ if (noindex.length > 0) {
985
+ console.warn(
986
+ `[dcs-seo] noindex has ${noindex.length} entr${noindex.length === 1 ? "y" : "ies"} but ${pagesPath} is missing or empty \u2014 the list matches nothing and is inert.`
987
+ );
988
+ }
989
+ return "undefined";
990
+ }
991
+ assertNoOrphanNoindexEntries(noindex, routes);
992
+ const excludedGlobs = loadExcludedGlobs(projectRoot, pagesPath);
993
+ const emitted = routes.filter(
994
+ (r) => isRouteEmitted(r, { exclude, excludedGlobs })
995
+ );
996
+ assertNoDuplicateRoutePaths(emitted);
997
+ return JSON.stringify({
998
+ routes: emitted.map((r) => ({
999
+ slug: r.slug,
1000
+ path: r.path,
1001
+ ...r.title ? { title: r.title } : {}
1002
+ })),
1003
+ noindex
1004
+ });
1005
+ })();
739
1006
  const loaded = loadSeoConfig(projectRoot, seoPath, debug);
740
1007
  if (!loaded) {
741
1008
  return {
742
1009
  define: {
743
- __DCS_SEO__: "undefined"
1010
+ __DCS_SEO__: "undefined",
1011
+ __DCS_PAGES__: pagesDefine
744
1012
  }
745
1013
  };
746
1014
  }
@@ -754,7 +1022,8 @@ function dcsSeoPlugin(options = {}) {
754
1022
  }
755
1023
  return {
756
1024
  define: {
757
- __DCS_SEO__: JSON.stringify(loaded.config)
1025
+ __DCS_SEO__: JSON.stringify(loaded.config),
1026
+ __DCS_PAGES__: pagesDefine
758
1027
  }
759
1028
  };
760
1029
  },
@@ -778,6 +1047,8 @@ function dcsSeoPlugin(options = {}) {
778
1047
  return;
779
1048
  }
780
1049
  let bodyPrerenderCtx = null;
1050
+ let charsetShell = null;
1051
+ let honestyCtx = null;
781
1052
  try {
782
1053
  const projectRoot = resolvedConfig?.root || process.cwd();
783
1054
  const outDir = resolveOutDir(resolvedConfig);
@@ -805,7 +1076,29 @@ function dcsSeoPlugin(options = {}) {
805
1076
  `[dcs-seo] emitStaticHtml: ${seoPath} missing or unparseable; emitting with defaults only`
806
1077
  );
807
1078
  }
808
- const shellHtml = fs5.readFileSync(shellPath, "utf8");
1079
+ const rawShellHtml = fs5.readFileSync(shellPath, "utf8");
1080
+ const shellHtml = hoistCharsetMeta(rawShellHtml);
1081
+ {
1082
+ const b = checkCharsetBudget(rawShellHtml);
1083
+ const a = checkCharsetBudget(shellHtml);
1084
+ charsetShell = {
1085
+ hoisted: shellHtml !== rawShellHtml,
1086
+ beforeOffset: b.offset,
1087
+ beforeHeadroom: b.headroom,
1088
+ beforeOk: b.ok,
1089
+ afterOffset: a.offset,
1090
+ afterHeadroom: a.headroom,
1091
+ afterOk: a.ok
1092
+ };
1093
+ }
1094
+ if (shellHtml !== rawShellHtml) {
1095
+ fs5.writeFileSync(shellPath, shellHtml, "utf8");
1096
+ const before = checkCharsetBudget(rawShellHtml);
1097
+ const after = checkCharsetBudget(shellHtml);
1098
+ console.log(
1099
+ `[dcs-seo] charset budget: hoisted <meta charset> from byte ${before.offset} to byte ${after.offset} (${after.headroom} bytes of the 1024-byte encoding-sniffing window now free)`
1100
+ );
1101
+ }
809
1102
  const excludedGlobs = loadExcludedGlobs(projectRoot, pagesPath);
810
1103
  const contentConfig = loadContentConfig(projectRoot, contentPath, debug);
811
1104
  const written = emitStaticSeoHtml({
@@ -845,12 +1138,13 @@ function dcsSeoPlugin(options = {}) {
845
1138
  }
846
1139
  const buildBase = resolvedConfig?.base ?? "/";
847
1140
  const baseIsRoot = buildBase === "/" || buildBase === "./" || buildBase === "";
848
- if (isBodyPrerenderEnabled({
1141
+ const bodyPrerenderOn = isBodyPrerenderEnabled({
849
1142
  emitStaticHtml,
850
1143
  prerenderBodyOption: prerenderBody,
851
1144
  preview,
852
1145
  seoPrerenderBody: loaded?.config?.prerenderBody
853
- })) {
1146
+ });
1147
+ if (bodyPrerenderOn) {
854
1148
  if (baseIsRoot) {
855
1149
  bodyPrerenderCtx = { outDir, routes, excludedGlobs };
856
1150
  } else {
@@ -859,11 +1153,161 @@ function dcsSeoPlugin(options = {}) {
859
1153
  );
860
1154
  }
861
1155
  }
1156
+ const headOptBlock = readHonestyBlock(headHonestyOpt);
1157
+ const urlOptBlock = readHonestyBlock(urlHonestyOpt);
1158
+ const charsetOptBlock = readHonestyBlock(charsetBudgetOpt);
1159
+ const seoHeadBlock = readHonestyBlock(loaded?.config?.headHonesty ?? void 0);
1160
+ const seoUrlBlock = readHonestyBlock(loaded?.config?.urlHonesty ?? void 0);
1161
+ const seoCharsetBlock = readHonestyBlock(loaded?.config?.charsetBudget ?? void 0);
1162
+ const headMode = resolveHonestyMode({
1163
+ env: process.env.DCS_SEO_HEAD_HONESTY,
1164
+ seoYaml: seoHeadBlock.mode,
1165
+ option: headOptBlock.mode,
1166
+ fallback: "error"
1167
+ });
1168
+ const urlMode = resolveHonestyMode({
1169
+ env: process.env.DCS_SEO_URL_HONESTY,
1170
+ seoYaml: seoUrlBlock.mode,
1171
+ option: urlOptBlock.mode,
1172
+ fallback: "error"
1173
+ });
1174
+ const charsetMode = resolveHonestyMode({
1175
+ env: process.env.DCS_SEO_CHARSET_BUDGET,
1176
+ seoYaml: seoCharsetBlock.mode,
1177
+ option: charsetOptBlock.mode,
1178
+ fallback: "error"
1179
+ });
1180
+ const renderForHonestyOnly = !bodyPrerenderOn && headMode !== "off" && baseIsRoot;
1181
+ if (renderForHonestyOnly) {
1182
+ bodyPrerenderCtx = { outDir, routes, excludedGlobs };
1183
+ }
1184
+ if (headMode !== "off" && !baseIsRoot) {
1185
+ console.warn(
1186
+ `[dcs-seo] head honesty SKIPPED: non-root base "${buildBase}" (the prerender server cannot serve this build, so the rendered <head> cannot be observed)`
1187
+ );
1188
+ }
1189
+ honestyCtx = {
1190
+ outDir,
1191
+ projectRoot,
1192
+ routes,
1193
+ siteUrl: siteUrl || loaded?.config?.global?.siteUrl,
1194
+ base: buildBase,
1195
+ headMode: baseIsRoot ? headMode : "off",
1196
+ headAllow: [...headOptBlock.allow, ...seoHeadBlock.allow],
1197
+ headCheckDescription: seoHeadBlock.checkDescription ?? headOptBlock.checkDescription ?? true,
1198
+ urlMode,
1199
+ urlAllow: [...urlOptBlock.allow, ...seoUrlBlock.allow],
1200
+ urlNetwork: process.env.DCS_SEO_URL_HONESTY_NETWORK?.toLowerCase() === "off" ? false : seoUrlBlock.network ?? urlOptBlock.network ?? true,
1201
+ charsetMode,
1202
+ renderForHonestyOnly
1203
+ };
862
1204
  } catch (error) {
863
1205
  console.warn("[dcs-seo] emitStaticHtml failed; build output left unchanged:", error);
864
1206
  }
1207
+ let headObservations = [];
1208
+ let rendererAvailable = false;
1209
+ let eligibleRoutes = [];
1210
+ let filteredRoutes = [];
865
1211
  if (bodyPrerenderCtx) {
866
- await runBodyPrerender(bodyPrerenderCtx, { exclude, noindex, debug });
1212
+ const outcome = await runBodyPrerender(bodyPrerenderCtx, {
1213
+ exclude,
1214
+ noindex,
1215
+ debug,
1216
+ spliceBody: !(honestyCtx?.renderForHonestyOnly ?? false)
1217
+ });
1218
+ headObservations = outcome.headObservations;
1219
+ rendererAvailable = outcome.rendererAvailable;
1220
+ eligibleRoutes = outcome.eligibleRoutes;
1221
+ filteredRoutes = outcome.filteredRoutes;
1222
+ }
1223
+ if (honestyCtx) {
1224
+ const ctx = honestyCtx;
1225
+ const reportPath = process.env.DCS_SEO_HONESTY_REPORT;
1226
+ const report = {
1227
+ schema: 1,
1228
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
1229
+ projectRoot: ctx.projectRoot,
1230
+ outDir: ctx.outDir,
1231
+ siteUrl: ctx.siteUrl ?? null,
1232
+ base: ctx.base,
1233
+ routes: ctx.routes.length,
1234
+ p1: {
1235
+ mode: ctx.headMode,
1236
+ rendererAvailable,
1237
+ eligible: eligibleRoutes.length,
1238
+ observed: headObservations.length,
1239
+ unobserved: eligibleRoutes.filter(
1240
+ (r) => !headObservations.some((o) => o.route === r)
1241
+ ),
1242
+ filtered: filteredRoutes,
1243
+ exempted: ctx.headAllow.length,
1244
+ divergent: null,
1245
+ executed: false
1246
+ },
1247
+ p12: { mode: ctx.charsetMode, shell: charsetShell, checked: 0, failed: 0, worstOffset: 0 },
1248
+ p2: { mode: ctx.urlMode, network: ctx.urlNetwork, audit: null }
1249
+ };
1250
+ const p1 = report.p1;
1251
+ try {
1252
+ const shouldCompare = assertHeadHonestyExecuted(
1253
+ {
1254
+ rendererAvailable,
1255
+ eligibleRoutes,
1256
+ observedRoutes: headObservations.map((o) => o.route),
1257
+ filteredRoutes,
1258
+ totalRoutes: ctx.routes.length
1259
+ },
1260
+ { mode: ctx.headMode }
1261
+ );
1262
+ p1.executed = shouldCompare;
1263
+ if (shouldCompare || ctx.headMode === "off") {
1264
+ const found = findHeadHonestyViolations(headObservations, {
1265
+ allow: ctx.headAllow,
1266
+ checkDescription: ctx.headCheckDescription
1267
+ });
1268
+ p1.divergent = found.length;
1269
+ p1.violations = found;
1270
+ assertHeadHonesty(headObservations, {
1271
+ mode: ctx.headMode,
1272
+ allow: ctx.headAllow,
1273
+ checkDescription: ctx.headCheckDescription,
1274
+ debug
1275
+ });
1276
+ }
1277
+ runCharsetBudget({
1278
+ outDir: ctx.outDir,
1279
+ routes: ctx.routes,
1280
+ mode: ctx.charsetMode,
1281
+ debug,
1282
+ sink: (s) => Object.assign(report.p12, s)
1283
+ });
1284
+ await runEmittedUrlHonesty({
1285
+ outDir: ctx.outDir,
1286
+ projectRoot: ctx.projectRoot,
1287
+ routes: ctx.routes,
1288
+ siteUrl: ctx.siteUrl,
1289
+ base: ctx.base,
1290
+ mode: ctx.urlMode,
1291
+ allow: ctx.urlAllow,
1292
+ network: ctx.urlNetwork,
1293
+ debug,
1294
+ sink: (a, v) => {
1295
+ const p2 = report.p2;
1296
+ p2.audit = a;
1297
+ p2.violations = v;
1298
+ }
1299
+ });
1300
+ } finally {
1301
+ if (reportPath) {
1302
+ try {
1303
+ fs5.mkdirSync(path5.dirname(path5.resolve(reportPath)), { recursive: true });
1304
+ fs5.writeFileSync(path5.resolve(reportPath), JSON.stringify(report, null, 2), "utf8");
1305
+ console.log(`[dcs-seo] honesty report written: ${path5.resolve(reportPath)}`);
1306
+ } catch (err) {
1307
+ console.warn("[dcs-seo] could not write honesty report:", err);
1308
+ }
1309
+ }
1310
+ }
867
1311
  }
868
1312
  },
869
1313
  /**
@@ -873,24 +1317,61 @@ function dcsSeoPlugin(options = {}) {
873
1317
  * on-disk SEO edits on the portal's `?_hc` hard reload with no restart.
874
1318
  * Gated on `isDev` so it never runs in `vite`/`vitepress build` (the build
875
1319
  * path keeps the baked `define` literal + the `writeBundle` emitter).
1320
+ *
1321
+ * `__DCS_PAGES__` (the `installSeoHead` route manifest) rides the same
1322
+ * refresh. It is injected INDEPENDENTLY of seo.yaml: a site can have a
1323
+ * pages.yaml and no seo.yaml, and returning early on a missing seo.yaml
1324
+ * would leave the router hook manifest-less (and therefore inert) in dev
1325
+ * only — a dev/prod behaviour split is exactly the kind of divergence this
1326
+ * plan exists to stop.
876
1327
  */
877
1328
  transformIndexHtml: {
878
1329
  order: "pre",
879
1330
  handler(html) {
880
1331
  if (!isDev) return html;
881
1332
  const projectRoot = resolvedConfig?.root || process.cwd();
1333
+ const tags = [];
882
1334
  const config = readDcsYamlFresh(projectRoot, seoPath, "dcs-seo", debug);
883
- if (config === void 0) return html;
884
- if (debug) {
885
- console.log("[dcs-seo] serve: injecting fresh __DCS_SEO__ into <head>");
886
- }
887
- const tags = [
888
- {
1335
+ if (config !== void 0) {
1336
+ if (debug) {
1337
+ console.log("[dcs-seo] serve: injecting fresh __DCS_SEO__ into <head>");
1338
+ }
1339
+ tags.push({
889
1340
  tag: "script",
890
1341
  children: buildGlobalAssignScript("__DCS_SEO__", config),
891
1342
  injectTo: "head-prepend"
1343
+ });
1344
+ }
1345
+ const routes = loadPagesManifest(projectRoot, pagesPath, debug);
1346
+ if (routes) {
1347
+ const excludedGlobs = loadExcludedGlobs(projectRoot, pagesPath);
1348
+ const emitted = routes.filter((r) => isRouteEmitted(r, { exclude, excludedGlobs }));
1349
+ const dupes = findDuplicateNormalizedPaths(emitted);
1350
+ if (dupes.length > 0) {
1351
+ console.error(
1352
+ `[dcs-seo] serve: ${formatDuplicateNormalizedPaths(dupes)}
1353
+ [dcs-seo] serve: this is reported here and FAILS the production build.`
1354
+ );
1355
+ }
1356
+ if (debug) {
1357
+ console.log(
1358
+ `[dcs-seo] serve: injecting fresh __DCS_PAGES__ (${emitted.length} of ${routes.length} route(s), excluded ones omitted) into <head>`
1359
+ );
892
1360
  }
893
- ];
1361
+ tags.push({
1362
+ tag: "script",
1363
+ children: buildGlobalAssignScript("__DCS_PAGES__", {
1364
+ routes: emitted.map((r) => ({
1365
+ slug: r.slug,
1366
+ path: r.path,
1367
+ ...r.title ? { title: r.title } : {}
1368
+ })),
1369
+ noindex
1370
+ }),
1371
+ injectTo: "head-prepend"
1372
+ });
1373
+ }
1374
+ if (tags.length === 0) return html;
894
1375
  return { html, tags };
895
1376
  }
896
1377
  },
@@ -1432,6 +1913,6 @@ function chainBuildEnd(...hooks) {
1432
1913
  };
1433
1914
  }
1434
1915
 
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 };
1916
+ export { DCS_MOTION_TOKENS_CSS, DEFAULT_AGENT_API_BASE_URL, DEFAULT_AGENT_WIDGET_SCRIPT_URL, NoindexOrphanError, buildAgentWidgetLoaderScript, chainBuildEnd, collectNoindexMatchKeys, dcsAgentWidgetLoaderTag, dcsCdnBuildEnd, dcsCdnImagePlugin, dcsContentPlugin, dcsEditorPlugin, dcsMotionTokensStyleTag, dcsPreviewPlugin, dcsResponsiveImageLint, dcsSeoPlugin, emitSiteFiles, emitStaticSeoHtml, findFullSizeCdnImages, findOrphanNoindexEntries, isFullSizeCdnUrl, readSiteSlug, responsiveImagePlugin };
1436
1917
  //# sourceMappingURL=index.js.map
1437
1918
  //# sourceMappingURL=index.js.map