@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 fs2 from 'fs';
2
- import path from 'path';
1
+ import fs3 from 'fs';
2
+ import path3 from 'path';
3
3
  import yaml from 'js-yaml';
4
4
 
5
5
  // src/seo/schemaGraph.ts
@@ -438,8 +438,8 @@ function resolvePageSeo(pageSlug, pagePath, seoConfig, fallbackTitle) {
438
438
  const page = seoConfig?.pages?.[pageSlug] ?? {};
439
439
  let canonical = page.canonical || "";
440
440
  if (!canonical && global.siteUrl) {
441
- const path3 = pagePath ?? (pageSlug === "home" ? "/" : `/${pageSlug}`);
442
- canonical = `${global.siteUrl.replace(/\/$/, "")}${path3}`;
441
+ const path4 = pagePath ?? (pageSlug === "home" ? "/" : `/${pageSlug}`);
442
+ canonical = `${global.siteUrl.replace(/\/$/, "")}${path4}`;
443
443
  }
444
444
  const pageSpecificTitle = page.title || fallbackTitle;
445
445
  let title;
@@ -621,65 +621,6 @@ function spliceHeadHtml(html, tags) {
621
621
  ${indent}</head>`;
622
622
  });
623
623
  }
624
- function loadPagesManifest(projectRoot, relativePagesPath, debug = false) {
625
- const possiblePaths = [
626
- path.resolve(projectRoot, relativePagesPath),
627
- path.resolve(projectRoot, "..", relativePagesPath),
628
- path.resolve(process.cwd(), relativePagesPath)
629
- ];
630
- let foundPath;
631
- for (const testPath of possiblePaths) {
632
- if (fs2.existsSync(testPath)) {
633
- foundPath = testPath;
634
- break;
635
- }
636
- }
637
- if (!foundPath) {
638
- if (debug) {
639
- console.warn("[dcs-seo] No pages.yaml found at:");
640
- possiblePaths.forEach((p) => console.warn(` - ${p}`));
641
- }
642
- return null;
643
- }
644
- let raw;
645
- try {
646
- raw = yaml.load(fs2.readFileSync(foundPath, "utf8"));
647
- } catch (error) {
648
- console.warn(`[dcs-seo] Failed to parse ${foundPath}:`, error);
649
- return null;
650
- }
651
- const entries = parsePagesManifest(raw);
652
- if (!entries) {
653
- console.warn(`[dcs-seo] pages.yaml at ${foundPath} has no usable page entries`);
654
- return null;
655
- }
656
- if (debug) {
657
- console.log(`[dcs-seo] Loaded ${entries.length} routes from ${foundPath}`);
658
- }
659
- return entries;
660
- }
661
- function parsePagesManifest(raw) {
662
- if (!raw || typeof raw !== "object") return null;
663
- const pages = raw.pages;
664
- if (!Array.isArray(pages)) return null;
665
- const routes = [];
666
- for (const entry of pages) {
667
- if (!entry || typeof entry !== "object") continue;
668
- const p = entry.path;
669
- if (typeof p !== "string" || p.length === 0) continue;
670
- const slug = entry.slug;
671
- const title = entry.title;
672
- const lastmodRaw = entry.lastmod ?? entry.lastUpdated ?? entry.dateModified;
673
- const lastmod = typeof lastmodRaw === "string" && lastmodRaw.length > 0 ? lastmodRaw : void 0;
674
- routes.push({
675
- slug: typeof slug === "string" ? slug : "",
676
- path: p,
677
- ...typeof title === "string" && title.length > 0 ? { title } : {},
678
- ...lastmod ? { lastmod } : {}
679
- });
680
- }
681
- return routes.length > 0 ? routes : null;
682
- }
683
624
 
684
625
  // src/seo/sitemap.ts
685
626
  var XML_HEADER = '<?xml version="1.0" encoding="UTF-8"?>';
@@ -864,10 +805,430 @@ function buildLlmsTxt(params) {
864
805
  }
865
806
  return out.join("\n") + "\n";
866
807
  }
808
+ function loadPagesManifest(projectRoot, relativePagesPath, debug = false) {
809
+ const possiblePaths = [
810
+ path3.resolve(projectRoot, relativePagesPath),
811
+ path3.resolve(projectRoot, "..", relativePagesPath),
812
+ path3.resolve(process.cwd(), relativePagesPath)
813
+ ];
814
+ let foundPath;
815
+ for (const testPath of possiblePaths) {
816
+ if (fs3.existsSync(testPath)) {
817
+ foundPath = testPath;
818
+ break;
819
+ }
820
+ }
821
+ if (!foundPath) {
822
+ if (debug) {
823
+ console.warn("[dcs-seo] No pages.yaml found at:");
824
+ possiblePaths.forEach((p) => console.warn(` - ${p}`));
825
+ }
826
+ return null;
827
+ }
828
+ let raw;
829
+ try {
830
+ raw = yaml.load(fs3.readFileSync(foundPath, "utf8"));
831
+ } catch (error) {
832
+ console.warn(`[dcs-seo] Failed to parse ${foundPath}:`, error);
833
+ return null;
834
+ }
835
+ const entries = parsePagesManifest(raw);
836
+ if (!entries) {
837
+ console.warn(`[dcs-seo] pages.yaml at ${foundPath} has no usable page entries`);
838
+ return null;
839
+ }
840
+ if (debug) {
841
+ console.log(`[dcs-seo] Loaded ${entries.length} routes from ${foundPath}`);
842
+ }
843
+ return entries;
844
+ }
845
+ function parsePagesManifest(raw) {
846
+ if (!raw || typeof raw !== "object") return null;
847
+ const pages = raw.pages;
848
+ if (!Array.isArray(pages)) return null;
849
+ const routes = [];
850
+ for (const entry of pages) {
851
+ if (!entry || typeof entry !== "object") continue;
852
+ const p = entry.path;
853
+ if (typeof p !== "string" || p.length === 0) continue;
854
+ const slug = entry.slug;
855
+ const title = entry.title;
856
+ const lastmodRaw = entry.lastmod ?? entry.lastUpdated ?? entry.dateModified;
857
+ const lastmod = typeof lastmodRaw === "string" && lastmodRaw.length > 0 ? lastmodRaw : void 0;
858
+ routes.push({
859
+ slug: typeof slug === "string" ? slug : "",
860
+ path: p,
861
+ ...typeof title === "string" && title.length > 0 ? { title } : {},
862
+ ...lastmod ? { lastmod } : {}
863
+ });
864
+ }
865
+ return routes.length > 0 ? routes : null;
866
+ }
867
+ function routeExclusionReason(route, rules) {
868
+ const exclude = rules.exclude ?? [];
869
+ if (exclude.includes(route.path)) return "excluded";
870
+ if (route.slug && exclude.includes(route.slug)) return "excluded";
871
+ if (matchesExcludedGlob(route.path, [...rules.excludedGlobs ?? []])) return "excluded-glob";
872
+ return null;
873
+ }
874
+ function isRouteEmitted(route, rules) {
875
+ return routeExclusionReason(route, rules) === null;
876
+ }
877
+
878
+ // src/seo/installSeoHead.ts
879
+ var SEO_HEAD_ROUTE_HAS_NO_META = true;
880
+ function readInjectedSeo() {
881
+ try {
882
+ if (typeof __DCS_SEO__ !== "undefined" && __DCS_SEO__ !== null) return __DCS_SEO__;
883
+ } catch {
884
+ }
885
+ return void 0;
886
+ }
887
+ function readInjectedPages() {
888
+ try {
889
+ if (typeof __DCS_PAGES__ !== "undefined" && __DCS_PAGES__ !== null) return __DCS_PAGES__;
890
+ } catch {
891
+ }
892
+ return void 0;
893
+ }
894
+ function normalizeSeoHeadPath(input) {
895
+ if (typeof input !== "string") return "/";
896
+ let p = input;
897
+ const hash = p.indexOf("#");
898
+ if (hash !== -1) p = p.slice(0, hash);
899
+ const query = p.indexOf("?");
900
+ if (query !== -1) p = p.slice(0, query);
901
+ p = p.replace(/\/+$/, "");
902
+ return p === "" ? "/" : p;
903
+ }
904
+ function findDuplicateNormalizedPaths(routes) {
905
+ const groups = /* @__PURE__ */ new Map();
906
+ for (const route of routes) {
907
+ if (!route || typeof route.path !== "string" || route.path === "") continue;
908
+ const key = normalizeSeoHeadPath(route.path);
909
+ const bucket = groups.get(key);
910
+ if (bucket) bucket.push(route);
911
+ else groups.set(key, [route]);
912
+ }
913
+ const dupes = [];
914
+ for (const [normalizedPath, group] of groups) {
915
+ if (group.length > 1) dupes.push({ normalizedPath, routes: group });
916
+ }
917
+ return dupes;
918
+ }
919
+ function formatDuplicateNormalizedPaths(dupes) {
920
+ const lines = dupes.map(
921
+ (d) => ` ${d.normalizedPath} <= ` + d.routes.map((r) => `${JSON.stringify(r.path)} (slug ${JSON.stringify(r.slug)})`).join(", ")
922
+ );
923
+ return `pages.yaml declares ${dupes.length} route path(s) that collide after trailing-slash normalization:
924
+ ${lines.join("\n")}
925
+ The build-time emitter's LAST write wins the output file while the runtime lookup's FIRST entry wins the head \u2014 so a colliding manifest guarantees a baked/runtime divergence whichever side is preferred. There is no safe default: the entries carry different slugs and therefore different .dcs/seo.yaml identities. Delete or rename one.`;
926
+ }
927
+ function buildSeoHeadRouteMap(routes) {
928
+ const map = /* @__PURE__ */ new Map();
929
+ for (const route of routes) {
930
+ if (!route || typeof route.path !== "string" || route.path === "") continue;
931
+ const key = normalizeSeoHeadPath(route.path);
932
+ if (!map.has(key)) map.set(key, route);
933
+ }
934
+ return map;
935
+ }
936
+ function isStartLocation(from) {
937
+ if (!from || typeof from !== "object") return false;
938
+ if (from.name !== void 0 && from.name !== null) return false;
939
+ if (!Array.isArray(from.matched) || from.matched.length > 0) return false;
940
+ return normalizeSeoHeadPath(typeof from.path === "string" ? from.path : "") === "/";
941
+ }
942
+ function installSeoHead(router, options) {
943
+ const head = options.head;
944
+ const manifest = readInjectedPages();
945
+ const routes = options.pages ?? manifest?.routes;
946
+ const seo = options.seo ?? readInjectedSeo();
947
+ const noindex = options.noindex ?? manifest?.noindex ?? [];
948
+ const onResolve = options.onResolve;
949
+ const noindexSet = new Set(noindex);
950
+ const routeMap = routes && routes.length > 0 ? buildSeoHeadRouteMap(routes) : null;
951
+ if (routes && routes.length > 0) {
952
+ const dupes = findDuplicateNormalizedPaths(routes);
953
+ if (dupes.length > 0) {
954
+ console.error(`[dcs-seo] installSeoHead: ${formatDuplicateNormalizedPaths(dupes)}`);
955
+ }
956
+ }
957
+ let entry = null;
958
+ let initialNavigationPending = true;
959
+ return router.afterEach((to, from, failure) => {
960
+ const path4 = to && typeof to.path === "string" ? to.path : "";
961
+ const normalizedPath = normalizeSeoHeadPath(path4);
962
+ if (failure) {
963
+ onResolve?.({ path: path4, normalizedPath, matched: null, reason: "navigation-failed", title: null });
964
+ return;
965
+ }
966
+ if (initialNavigationPending) {
967
+ const initial = isStartLocation(from);
968
+ initialNavigationPending = false;
969
+ if (initial) {
970
+ onResolve?.({
971
+ path: path4,
972
+ normalizedPath,
973
+ matched: null,
974
+ reason: "initial-navigation",
975
+ title: null
976
+ });
977
+ return;
978
+ }
979
+ }
980
+ if (!routeMap) {
981
+ onResolve?.({ path: path4, normalizedPath, matched: null, reason: "no-manifest", title: null });
982
+ return;
983
+ }
984
+ const matched = routeMap.get(normalizedPath);
985
+ if (!matched) {
986
+ onResolve?.({ path: path4, normalizedPath, matched: null, reason: "no-match", title: null });
987
+ return;
988
+ }
989
+ const forceNoindex = noindexSet.has(matched.path) || !!matched.slug && noindexSet.has(matched.slug);
990
+ const tags = buildHeadTags(matched.slug, matched.path, seo, {
991
+ // MUST MATCH `dcsSeoPlugin`'s emitter call. `includeKeywords` defaults to
992
+ // false to keep the legacy `useSEO` runtime byte-identical to its history;
993
+ // the emitter opts in, so a re-assert that did not would leave the
994
+ // PREVIOUS post's baked `<meta name="keywords">` in the DOM while title,
995
+ // description and canonical all moved (C-361 F4, verified at
996
+ // dcsSeoPlugin.ts). This is a new API — the old composable's omission is
997
+ // not a compatibility constraint on it.
998
+ includeKeywords: true,
999
+ fallbackTitle: matched.title,
1000
+ robots: forceNoindex ? "noindex, nofollow" : void 0
1001
+ });
1002
+ const input = { title: tags.title, meta: tags.meta, link: tags.link };
1003
+ if (entry) entry.patch(input);
1004
+ else entry = head.push(input);
1005
+ onResolve?.({ path: path4, normalizedPath, matched, reason: "applied", title: tags.title });
1006
+ });
1007
+ }
1008
+
1009
+ // src/seo/headHonesty.ts
1010
+ var NAMED_ENTITIES = {
1011
+ amp: "&",
1012
+ lt: "<",
1013
+ gt: ">",
1014
+ quot: '"',
1015
+ apos: "'",
1016
+ nbsp: "\xA0",
1017
+ mdash: "\u2014",
1018
+ ndash: "\u2013",
1019
+ hellip: "\u2026",
1020
+ copy: "\xA9",
1021
+ reg: "\xAE",
1022
+ trade: "\u2122",
1023
+ rsquo: "\u2019",
1024
+ lsquo: "\u2018",
1025
+ ldquo: "\u201C",
1026
+ rdquo: "\u201D"
1027
+ };
1028
+ function decodeHtmlEntities(value) {
1029
+ return value.replace(/&(#x?[0-9a-fA-F]+|[a-zA-Z][a-zA-Z0-9]*);/g, (whole, body) => {
1030
+ if (body[0] === "#") {
1031
+ const isHex = body[1] === "x" || body[1] === "X";
1032
+ const code = Number.parseInt(isHex ? body.slice(2) : body.slice(1), isHex ? 16 : 10);
1033
+ if (!Number.isFinite(code) || code < 0 || code > 1114111) return whole;
1034
+ try {
1035
+ return String.fromCodePoint(code);
1036
+ } catch {
1037
+ return whole;
1038
+ }
1039
+ }
1040
+ const named = NAMED_ENTITIES[body.toLowerCase()];
1041
+ return named ?? whole;
1042
+ });
1043
+ }
1044
+ function normalizeHeadText(value) {
1045
+ if (value === null || value === void 0) return null;
1046
+ const decoded = decodeHtmlEntities(value);
1047
+ const collapsed = decoded.replace(/\s+/g, " ").trim();
1048
+ return collapsed.normalize("NFC");
1049
+ }
1050
+ function readTagAttr(tag, attr) {
1051
+ const re = new RegExp(`\\b${attr}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s"'=<>\`]+))`, "i");
1052
+ const m = re.exec(tag);
1053
+ if (!m) return null;
1054
+ return m[1] ?? m[2] ?? m[3] ?? "";
1055
+ }
1056
+ function extractBakedHead(html) {
1057
+ const titleMatch = /<title[^>]*>([\s\S]*?)<\/title>/i.exec(html);
1058
+ const title = titleMatch ? titleMatch[1] : null;
1059
+ let description = null;
1060
+ const metaRe = /<meta\b[^>]*>/gi;
1061
+ let m;
1062
+ while ((m = metaRe.exec(html)) !== null) {
1063
+ const tag = m[0];
1064
+ const name = readTagAttr(tag, "name");
1065
+ if (name?.toLowerCase() !== "description") continue;
1066
+ description = readTagAttr(tag, "content") ?? "";
1067
+ }
1068
+ return { title, description };
1069
+ }
1070
+ function findHeadHonestyViolations(observations, options = {}) {
1071
+ const { checkDescription = true, allow = [] } = options;
1072
+ const allowed = new Set(allow);
1073
+ const violations = [];
1074
+ for (const obs of observations) {
1075
+ if (allowed.has(obs.route)) continue;
1076
+ const bakedTitle = normalizeHeadText(obs.bakedTitle);
1077
+ const runtimeTitle = normalizeHeadText(obs.runtimeTitle);
1078
+ if (!(bakedTitle === null && runtimeTitle === null) && bakedTitle !== runtimeTitle) {
1079
+ violations.push({ route: obs.route, field: "title", baked: bakedTitle, runtime: runtimeTitle });
1080
+ }
1081
+ if (!checkDescription) continue;
1082
+ const bakedDescription = normalizeHeadText(obs.bakedDescription);
1083
+ const runtimeDescription = normalizeHeadText(obs.runtimeDescription);
1084
+ if (!(bakedDescription === null && runtimeDescription === null) && bakedDescription !== runtimeDescription) {
1085
+ violations.push({
1086
+ route: obs.route,
1087
+ field: "description",
1088
+ baked: bakedDescription,
1089
+ runtime: runtimeDescription
1090
+ });
1091
+ }
1092
+ }
1093
+ return violations;
1094
+ }
1095
+ function display(value) {
1096
+ return value === null ? "(absent)" : JSON.stringify(value);
1097
+ }
1098
+ function formatHeadHonestyReport(violations) {
1099
+ const routes = new Set(violations.map((v) => v.route));
1100
+ const lines = [
1101
+ `[dcs-seo] head honesty: ${violations.length} divergence(s) across ${routes.size} route(s) \u2014 the BAKED <head> (what a non-JS AI crawler receives) does not match the RENDERED <head> (what Google and every human sees).`
1102
+ ];
1103
+ for (const v of violations) {
1104
+ lines.push(` ${v.route} [${v.field}]`);
1105
+ lines.push(` baked : ${display(v.baked)}`);
1106
+ lines.push(` rendered: ${display(v.runtime)}`);
1107
+ }
1108
+ lines.push(
1109
+ ` Fix: delete the hardcoded title/description from the route's applyHead({\u2026}) call so .dcs/seo.yaml is the only writer. (applyHead's \`title\` override is used VERBATIM \u2014 it does NOT go through global.titleTemplate, which is why an override that "looks right" still diverges.)`
1110
+ );
1111
+ return lines.join("\n");
1112
+ }
1113
+ var HeadHonestyError = class extends Error {
1114
+ violations;
1115
+ constructor(violations) {
1116
+ super(formatHeadHonestyReport(violations));
1117
+ this.name = "HeadHonestyError";
1118
+ this.violations = violations;
1119
+ }
1120
+ };
1121
+ function coerceMode(value) {
1122
+ if (value === void 0 || value === null) return void 0;
1123
+ if (value === true) return "error";
1124
+ if (value === false) return "off";
1125
+ const normalized = String(value).trim().toLowerCase();
1126
+ if (normalized === "error" || normalized === "fail" || normalized === "strict") return "error";
1127
+ if (normalized === "warn" || normalized === "warning" || normalized === "report") return "warn";
1128
+ if (normalized === "off" || normalized === "false" || normalized === "none") return "off";
1129
+ return void 0;
1130
+ }
1131
+ function resolveHonestyMode(input) {
1132
+ return coerceMode(input.env) ?? coerceMode(input.seoYaml) ?? coerceMode(input.option) ?? input.fallback ?? "error";
1133
+ }
1134
+ function findHeadHonestyExecutionFaults(ex) {
1135
+ const faults = [];
1136
+ if (!ex.rendererAvailable) {
1137
+ faults.push(
1138
+ "no headless renderer was available (playwright missing, or Chromium failed to launch), so NOT ONE route's rendered <head> was observed"
1139
+ );
1140
+ }
1141
+ if (ex.eligibleRoutes.length === 0) {
1142
+ const byReason = /* @__PURE__ */ new Map();
1143
+ for (const f of ex.filteredRoutes) {
1144
+ byReason.set(f.reason, [...byReason.get(f.reason) ?? [], f.route]);
1145
+ }
1146
+ const detail = [...byReason.entries()].map(([reason, routes]) => `${reason}: ${routes.join(", ")}`).join("; ") || "the route manifest was empty";
1147
+ faults.push(
1148
+ `0 of ${ex.totalRoutes} route(s) were eligible for observation (${detail}), so the baked-vs-rendered comparison had nothing to compare`
1149
+ );
1150
+ } else {
1151
+ const observed = new Set(ex.observedRoutes);
1152
+ const unobserved = ex.eligibleRoutes.filter((r) => !observed.has(r));
1153
+ if (unobserved.length > 0) {
1154
+ faults.push(
1155
+ `${ex.observedRoutes.length} of ${ex.eligibleRoutes.length} eligible route(s) produced an observation; these were NOT checked: ${unobserved.join(", ")}`
1156
+ );
1157
+ }
1158
+ }
1159
+ return faults;
1160
+ }
1161
+ function formatHeadHonestyExecutionReport(ex, faults) {
1162
+ const lines = [
1163
+ "[dcs-seo] head honesty DID NOT RUN \u2014 the baked/rendered <head> divergence class was NOT checked for this build:"
1164
+ ];
1165
+ for (const f of faults) lines.push(` - ${f}`);
1166
+ lines.push(
1167
+ ` observed ${ex.observedRoutes.length} / eligible ${ex.eligibleRoutes.length} / manifest ${ex.totalRoutes} route(s); renderer ${ex.rendererAvailable ? "available" : "ABSENT"}`
1168
+ );
1169
+ lines.push(
1170
+ " Fix: install the renderer (`pnpm add -D playwright && npx playwright install chromium`) BEFORE the build step, or lower this rail deliberately and visibly (DCS_SEO_HEAD_HONESTY=warn / .dcs/seo.yaml headHonesty.mode). A rail that silently does not run is the exact class this work exists to close."
1171
+ );
1172
+ return lines.join("\n");
1173
+ }
1174
+ var HeadHonestyNotRunError = class extends Error {
1175
+ execution;
1176
+ faults;
1177
+ constructor(execution, faults) {
1178
+ super(formatHeadHonestyExecutionReport(execution, faults));
1179
+ this.name = "HeadHonestyNotRunError";
1180
+ this.execution = execution;
1181
+ this.faults = faults;
1182
+ }
1183
+ };
1184
+ function assertHeadHonestyExecuted(execution, options) {
1185
+ if (options.mode === "off") return false;
1186
+ const faults = findHeadHonestyExecutionFaults(execution);
1187
+ if (faults.length === 0) return true;
1188
+ const report = formatHeadHonestyExecutionReport(execution, faults);
1189
+ if (options.mode === "warn") {
1190
+ console.warn(report);
1191
+ console.warn(
1192
+ "[dcs-seo] head honesty is in WARN mode; this build is NOT gated on the above (in error mode it would be RED)."
1193
+ );
1194
+ return execution.observedRoutes.length > 0;
1195
+ }
1196
+ throw new HeadHonestyNotRunError(execution, faults);
1197
+ }
1198
+ function assertHeadHonesty(observations, options) {
1199
+ const { mode, allow = [], debug = false } = options;
1200
+ if (mode === "off") {
1201
+ console.warn(
1202
+ "[dcs-seo] head honesty is OFF for this build \u2014 the baked/rendered <title> divergence class is NOT being checked."
1203
+ );
1204
+ return [];
1205
+ }
1206
+ if (allow.length > 0) {
1207
+ console.warn(
1208
+ `[dcs-seo] head honesty: ${allow.length} route(s) EXEMPTED from the baked==rendered assert: ${allow.join(", ")}`
1209
+ );
1210
+ }
1211
+ const violations = findHeadHonestyViolations(observations, options);
1212
+ if (violations.length === 0) {
1213
+ console.log(
1214
+ `[dcs-seo] head honesty: ${observations.length} route(s) verified \u2014 baked <head> == rendered <head>`
1215
+ );
1216
+ return violations;
1217
+ }
1218
+ if (mode === "warn") {
1219
+ console.warn(formatHeadHonestyReport(violations));
1220
+ console.warn(
1221
+ "[dcs-seo] head honesty is in WARN mode; this build is NOT gated on the divergence above."
1222
+ );
1223
+ return violations;
1224
+ }
1225
+ if (debug) console.error(formatHeadHonestyReport(violations));
1226
+ throw new HeadHonestyError(violations);
1227
+ }
867
1228
  function routeToOutputFile(outDir, routePath) {
868
1229
  const trimmed = routePath.replace(/^\/+/, "").replace(/\/+$/, "");
869
- if (trimmed === "") return path.join(outDir, "index.html");
870
- return path.join(outDir, ...trimmed.split("/"), "index.html");
1230
+ if (trimmed === "") return path3.join(outDir, "index.html");
1231
+ return path3.join(outDir, ...trimmed.split("/"), "index.html");
871
1232
  }
872
1233
  var BodyPrerenderError = class extends Error {
873
1234
  route;
@@ -913,6 +1274,7 @@ async function prerenderBodies(params) {
913
1274
  exclude = [],
914
1275
  noindex = [],
915
1276
  excludedGlobs = [],
1277
+ spliceBody = true,
916
1278
  debug = false
917
1279
  } = params;
918
1280
  const excludeSet = new Set(exclude);
@@ -920,55 +1282,79 @@ async function prerenderBodies(params) {
920
1282
  let prerendered = 0;
921
1283
  let skipped = 0;
922
1284
  const done = [];
1285
+ const headObservations = [];
1286
+ const eligibleRoutes = [];
1287
+ const filteredRoutes = [];
923
1288
  for (const route of routes) {
924
1289
  if (excludeSet.has(route.path) || route.slug && excludeSet.has(route.slug)) {
925
1290
  skipped += 1;
1291
+ filteredRoutes.push({ route: route.path, reason: "excluded" });
926
1292
  if (debug) console.log(`[dcs-seo] body-prerender skip (excluded): ${route.path}`);
927
1293
  continue;
928
1294
  }
929
1295
  if (matchesExcludedGlob(route.path, excludedGlobs)) {
930
1296
  skipped += 1;
1297
+ filteredRoutes.push({ route: route.path, reason: "excluded-glob" });
931
1298
  if (debug) console.log(`[dcs-seo] body-prerender skip (excluded glob): ${route.path}`);
932
1299
  continue;
933
1300
  }
934
1301
  if (noindexSet.has(route.path) || route.slug && noindexSet.has(route.slug)) {
935
1302
  skipped += 1;
1303
+ filteredRoutes.push({ route: route.path, reason: "noindex" });
936
1304
  if (debug) console.log(`[dcs-seo] body-prerender skip (noindex/auth): ${route.path}`);
937
1305
  continue;
938
1306
  }
939
1307
  const outFile = routeToOutputFile(outDir, route.path);
940
- if (!fs2.existsSync(outFile)) {
1308
+ if (!fs3.existsSync(outFile)) {
941
1309
  skipped += 1;
1310
+ filteredRoutes.push({ route: route.path, reason: "no-head-file" });
942
1311
  if (debug) console.log(`[dcs-seo] body-prerender skip (no head file): ${route.path}`);
943
1312
  continue;
944
1313
  }
1314
+ eligibleRoutes.push(route.path);
945
1315
  const rendered = await renderer.renderRoute(route.path);
946
1316
  if (rendered.errors.length > 0) {
947
1317
  throw new BodyPrerenderError(route.path, rendered.errors);
948
1318
  }
1319
+ const html = fs3.readFileSync(outFile, "utf8");
1320
+ if (rendered.head) {
1321
+ const baked = extractBakedHead(html);
1322
+ headObservations.push({
1323
+ route: route.path,
1324
+ bakedTitle: baked.title,
1325
+ runtimeTitle: rendered.head.title,
1326
+ bakedDescription: baked.description,
1327
+ runtimeDescription: rendered.head.description,
1328
+ runtimeDescriptionCount: rendered.head.descriptionCount
1329
+ });
1330
+ }
949
1331
  const body = (rendered.bodyHtml ?? "").trim();
950
1332
  if (body === "") {
951
1333
  skipped += 1;
952
1334
  if (debug) console.log(`[dcs-seo] body-prerender skip (empty render): ${route.path}`);
953
1335
  continue;
954
1336
  }
955
- const html = fs2.readFileSync(outFile, "utf8");
1337
+ if (!spliceBody) {
1338
+ skipped += 1;
1339
+ if (debug) console.log(`[dcs-seo] body-prerender skip (observe-only): ${route.path}`);
1340
+ continue;
1341
+ }
956
1342
  const spliced = spliceBodyHtml(html, body);
957
1343
  if (spliced === html) {
958
1344
  skipped += 1;
959
1345
  if (debug) console.log(`[dcs-seo] body-prerender skip (no #app container): ${route.path}`);
960
1346
  continue;
961
1347
  }
962
- fs2.writeFileSync(outFile, spliced, "utf8");
1348
+ fs3.writeFileSync(outFile, spliced, "utf8");
963
1349
  prerendered += 1;
964
1350
  done.push(route.path);
965
1351
  if (debug) {
966
1352
  console.log(
967
- `[dcs-seo] body-prerender wrote ${path.relative(outDir, outFile)} (${body.length} bytes)`
1353
+ `[dcs-seo] body-prerender wrote ${path3.relative(outDir, outFile)} (${body.length} bytes)`
968
1354
  );
969
1355
  }
970
1356
  }
971
- return { prerendered, skipped, routes: done };
1357
+ return { prerendered, skipped, routes: done, headObservations, eligibleRoutes, filteredRoutes };
972
1358
  }
973
1359
  function isBodyPrerenderEnabled(input) {
974
1360
  if (!input.emitStaticHtml) return false;
@@ -977,6 +1363,518 @@ function isBodyPrerenderEnabled(input) {
977
1363
  if (input.seoPrerenderBody === false) return false;
978
1364
  return true;
979
1365
  }
1366
+ var NON_HTML_EXTENSIONS = /* @__PURE__ */ new Set([
1367
+ ".svg",
1368
+ ".png",
1369
+ ".jpg",
1370
+ ".jpeg",
1371
+ ".webp",
1372
+ ".avif",
1373
+ ".gif",
1374
+ ".ico",
1375
+ ".bmp",
1376
+ ".woff",
1377
+ ".woff2",
1378
+ ".ttf",
1379
+ ".otf",
1380
+ ".eot",
1381
+ ".txt",
1382
+ ".xml",
1383
+ ".json",
1384
+ ".pdf",
1385
+ ".mp4",
1386
+ ".webm",
1387
+ ".mp3",
1388
+ ".wav",
1389
+ ".css",
1390
+ ".js",
1391
+ ".mjs",
1392
+ ".map"
1393
+ ]);
1394
+ var JSONLD_ASSET_KEYS = /* @__PURE__ */ new Set([
1395
+ "logo",
1396
+ "image",
1397
+ "contentUrl",
1398
+ "thumbnailUrl",
1399
+ "photo",
1400
+ "primaryImageOfPage"
1401
+ ]);
1402
+ var JSONLD_PAGE_KEYS = /* @__PURE__ */ new Set(["url", "item", "mainEntityOfPage"]);
1403
+ var ASSET_LINK_RELS = /* @__PURE__ */ new Set([
1404
+ "icon",
1405
+ "shortcut icon",
1406
+ "apple-touch-icon",
1407
+ "apple-touch-icon-precomposed",
1408
+ "mask-icon",
1409
+ "manifest",
1410
+ "preload"
1411
+ ]);
1412
+ var PAGE_LINK_RELS = /* @__PURE__ */ new Set(["canonical", "alternate"]);
1413
+ function extensionOf(url) {
1414
+ try {
1415
+ const u = url.startsWith("http") ? new URL(url) : new URL(url, "https://placeholder.invalid");
1416
+ return path3.extname(u.pathname).toLowerCase();
1417
+ } catch {
1418
+ return "";
1419
+ }
1420
+ }
1421
+ function walkJsonLd(node, from, trail, out) {
1422
+ if (Array.isArray(node)) {
1423
+ for (const item of node) walkJsonLd(item, from, trail, out);
1424
+ return;
1425
+ }
1426
+ if (node === null || typeof node !== "object") return;
1427
+ const nodeType = node["@type"];
1428
+ const scope = typeof nodeType === "string" ? `${trail}${nodeType}.` : trail;
1429
+ for (const [key, value] of Object.entries(node)) {
1430
+ if (key === "@id" || key === "sameAs" || key === "@context" || key === "@type") continue;
1431
+ if (typeof value === "string" && /^(https?:)?\/\//.test(value)) {
1432
+ if (JSONLD_ASSET_KEYS.has(key)) {
1433
+ out.push({ url: value, source: `JSON-LD ${scope}${key}`, from, expectation: "asset" });
1434
+ continue;
1435
+ }
1436
+ if (JSONLD_PAGE_KEYS.has(key)) {
1437
+ out.push({ url: value, source: `JSON-LD ${scope}${key}`, from, expectation: "page" });
1438
+ continue;
1439
+ }
1440
+ continue;
1441
+ }
1442
+ if (Array.isArray(value) && JSONLD_ASSET_KEYS.has(key)) {
1443
+ value.forEach((v, i) => {
1444
+ if (typeof v === "string") {
1445
+ out.push({ url: v, source: `JSON-LD ${scope}${key}[${i}]`, from, expectation: "asset" });
1446
+ } else {
1447
+ walkJsonLd(v, from, `${scope}${key}[${i}].`, out);
1448
+ }
1449
+ });
1450
+ continue;
1451
+ }
1452
+ if (value !== null && typeof value === "object") {
1453
+ walkJsonLd(value, from, Array.isArray(value) ? scope : `${scope}${key}.`, out);
1454
+ }
1455
+ }
1456
+ }
1457
+ function unescapeAttr(value) {
1458
+ return value.replace(/&quot;/g, '"').replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&amp;/g, "&");
1459
+ }
1460
+ function collectUrlsFromHtml(html, from) {
1461
+ const out = [];
1462
+ const metaRe = /<meta\b[^>]*>/gi;
1463
+ let m;
1464
+ while ((m = metaRe.exec(html)) !== null) {
1465
+ const tag = m[0];
1466
+ const property = readTagAttr(tag, "property")?.toLowerCase();
1467
+ const name = readTagAttr(tag, "name")?.toLowerCase();
1468
+ const content = readTagAttr(tag, "content");
1469
+ if (!content) continue;
1470
+ const key = property ?? name;
1471
+ if (!key) continue;
1472
+ if (key === "og:image" || key === "twitter:image" || key === "og:image:secure_url") {
1473
+ out.push({ url: unescapeAttr(content), source: key, from, expectation: "asset" });
1474
+ } else if (key === "og:url") {
1475
+ out.push({ url: unescapeAttr(content), source: key, from, expectation: "page" });
1476
+ }
1477
+ }
1478
+ const linkRe = /<link\b[^>]*>/gi;
1479
+ while ((m = linkRe.exec(html)) !== null) {
1480
+ const tag = m[0];
1481
+ const rel = readTagAttr(tag, "rel")?.toLowerCase();
1482
+ const href = readTagAttr(tag, "href");
1483
+ if (!rel || !href) continue;
1484
+ if (ASSET_LINK_RELS.has(rel)) {
1485
+ out.push({ url: unescapeAttr(href), source: `link rel="${rel}"`, from, expectation: "asset" });
1486
+ } else if (PAGE_LINK_RELS.has(rel)) {
1487
+ out.push({ url: unescapeAttr(href), source: `link rel="${rel}"`, from, expectation: "page" });
1488
+ }
1489
+ }
1490
+ const scriptRe = /<script[^>]*type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi;
1491
+ while ((m = scriptRe.exec(html)) !== null) {
1492
+ try {
1493
+ walkJsonLd(JSON.parse(m[1]), from, "", out);
1494
+ } catch {
1495
+ }
1496
+ }
1497
+ return out;
1498
+ }
1499
+ function collectUrlsFromSitemap(xml, from = "sitemap.xml") {
1500
+ const out = [];
1501
+ const re = /<loc>\s*([\s\S]*?)\s*<\/loc>/gi;
1502
+ let m;
1503
+ while ((m = re.exec(xml)) !== null) {
1504
+ out.push({ url: unescapeAttr(m[1].trim()), source: "<loc>", from, expectation: "page" });
1505
+ }
1506
+ return out;
1507
+ }
1508
+ function collectUrlsFromLlmsTxt(txt, from = "llms.txt") {
1509
+ const out = [];
1510
+ const re = /\[[^\]]*\]\((https?:\/\/[^)\s]+)\)/g;
1511
+ let m;
1512
+ while ((m = re.exec(txt)) !== null) {
1513
+ const url = m[1];
1514
+ out.push({
1515
+ url,
1516
+ source: "llms.txt link",
1517
+ from,
1518
+ // An llms.txt entry may point at a page OR at a text artifact
1519
+ // (`llms-full.txt`); classify by extension so a `.txt` reference is held to
1520
+ // the non-HTML bar — that is exactly the shape an answer engine probes for.
1521
+ expectation: NON_HTML_EXTENSIONS.has(extensionOf(url)) ? "asset" : "page"
1522
+ });
1523
+ }
1524
+ return out;
1525
+ }
1526
+ function collectEmittedUrlsFromFiles(files) {
1527
+ const out = [];
1528
+ for (const entry of files) {
1529
+ let text;
1530
+ try {
1531
+ text = fs3.readFileSync(entry.path, "utf8");
1532
+ } catch {
1533
+ continue;
1534
+ }
1535
+ if (entry.kind === "html") out.push(...collectUrlsFromHtml(text, entry.from));
1536
+ else if (entry.kind === "sitemap") out.push(...collectUrlsFromSitemap(text, entry.from));
1537
+ else out.push(...collectUrlsFromLlmsTxt(text, entry.from));
1538
+ }
1539
+ return out;
1540
+ }
1541
+ function dedupeUrls(urls) {
1542
+ const seen = /* @__PURE__ */ new Map();
1543
+ for (const u of urls) {
1544
+ const key = `${u.expectation}\0${u.url}`;
1545
+ if (!seen.has(key)) seen.set(key, u);
1546
+ }
1547
+ return [...seen.values()];
1548
+ }
1549
+ function classifyUrl(entry, siteUrl) {
1550
+ const url = entry.url.trim();
1551
+ if (/^(data|mailto|tel|javascript|blob):/i.test(url)) {
1552
+ return { ...entry, locality: "unfetchable", pathname: "" };
1553
+ }
1554
+ let siteOrigin;
1555
+ try {
1556
+ if (siteUrl) siteOrigin = new URL(siteUrl).origin;
1557
+ } catch {
1558
+ siteOrigin = void 0;
1559
+ }
1560
+ if (url.startsWith("/") && !url.startsWith("//")) {
1561
+ return { ...entry, locality: "same-origin", pathname: url.split(/[?#]/)[0] };
1562
+ }
1563
+ try {
1564
+ const parsed = new URL(url.startsWith("//") ? `https:${url}` : url);
1565
+ if (siteOrigin && parsed.origin === siteOrigin) {
1566
+ return { ...entry, locality: "same-origin", pathname: parsed.pathname };
1567
+ }
1568
+ return { ...entry, locality: "remote", pathname: parsed.pathname };
1569
+ } catch {
1570
+ return { ...entry, locality: "unfetchable", pathname: "" };
1571
+ }
1572
+ }
1573
+ function resolveDistCandidates(outDir, pathname, base) {
1574
+ const clean = decodeURIComponent(pathname).replace(/^\/+/, "");
1575
+ const candidates = [path3.join(outDir, ...clean.split("/"))];
1576
+ const normalizedBase = base.replace(/^\/+|\/+$/g, "");
1577
+ if (normalizedBase && clean.startsWith(normalizedBase + "/")) {
1578
+ candidates.push(path3.join(outDir, ...clean.slice(normalizedBase.length + 1).split("/")));
1579
+ }
1580
+ return candidates;
1581
+ }
1582
+ function checkSameOriginAssets(params) {
1583
+ return auditSameOriginAssets(params).violations;
1584
+ }
1585
+ function auditSameOriginAssets(params) {
1586
+ const { urls, outDir, base = "/", allow = [] } = params;
1587
+ const allowed = new Set(allow);
1588
+ const violations = [];
1589
+ let checked = 0;
1590
+ let exempted = 0;
1591
+ for (const entry of dedupeUrls(urls)) {
1592
+ if (entry.locality !== "same-origin" || entry.expectation !== "asset") continue;
1593
+ if (allowed.has(entry.url) || allowed.has(entry.pathname)) {
1594
+ exempted += 1;
1595
+ continue;
1596
+ }
1597
+ checked += 1;
1598
+ const candidates = resolveDistCandidates(outDir, entry.pathname, base);
1599
+ const found = candidates.some((c) => {
1600
+ try {
1601
+ return fs3.statSync(c).isFile();
1602
+ } catch {
1603
+ return false;
1604
+ }
1605
+ });
1606
+ if (found) continue;
1607
+ violations.push({
1608
+ url: entry.url,
1609
+ source: entry.source,
1610
+ from: entry.from,
1611
+ reason: `no file at ${candidates.map((c) => path3.relative(outDir, c)).join(" or ")} in the build output \u2014 the host's SPA fallback will answer this with index.html (200 text/html), so every crawler resolving it gets a web page where a non-HTML asset was promised`
1612
+ });
1613
+ }
1614
+ return { violations, checked, exempted };
1615
+ }
1616
+ var CACHE_VERSION = 1;
1617
+ function readCache(cacheFile) {
1618
+ try {
1619
+ const parsed = JSON.parse(fs3.readFileSync(cacheFile, "utf8"));
1620
+ if (parsed?.version === CACHE_VERSION && parsed.entries) return parsed;
1621
+ } catch {
1622
+ }
1623
+ return { version: CACHE_VERSION, entries: {} };
1624
+ }
1625
+ function writeCache(cacheFile, cache) {
1626
+ try {
1627
+ fs3.mkdirSync(path3.dirname(cacheFile), { recursive: true });
1628
+ fs3.writeFileSync(cacheFile, JSON.stringify(cache), "utf8");
1629
+ } catch {
1630
+ }
1631
+ }
1632
+ function isHtmlContentType(contentType) {
1633
+ if (!contentType) return false;
1634
+ return /^\s*(text\/html|application\/xhtml\+xml)/i.test(contentType);
1635
+ }
1636
+ async function checkRemoteAssets(urls, options) {
1637
+ const {
1638
+ projectRoot,
1639
+ timeoutMs = 5e3,
1640
+ budgetMs = 2e4,
1641
+ concurrency = 6,
1642
+ cacheTtlMs = 24 * 60 * 60 * 1e3,
1643
+ allow = [],
1644
+ fetchImpl = globalThis.fetch,
1645
+ debug = false
1646
+ } = options;
1647
+ const cacheFile = options.cacheFile ?? path3.join(projectRoot, "node_modules", ".cache", "dcs-seo", "url-probes.json");
1648
+ const allowed = new Set(allow);
1649
+ const remoteAssets = dedupeUrls(
1650
+ urls.filter((u) => u.locality === "remote" && u.expectation === "asset")
1651
+ );
1652
+ const targets = remoteAssets.filter((u) => !allowed.has(u.url));
1653
+ const exempted = remoteAssets.length - targets.length;
1654
+ const violations = [];
1655
+ const warnings = [];
1656
+ let cachedCount = 0;
1657
+ let probedCount = 0;
1658
+ let answeredCount = 0;
1659
+ if (targets.length === 0) {
1660
+ return { violations, warnings, cached: 0, probed: 0, checked: 0, exempted };
1661
+ }
1662
+ if (typeof fetchImpl !== "function") {
1663
+ warnings.push({
1664
+ url: "(all remote)",
1665
+ source: "remote probe",
1666
+ from: "-",
1667
+ reason: "no global fetch available in this Node runtime; remote URL checks skipped"
1668
+ });
1669
+ return { violations, warnings, cached: 0, probed: 0, checked: 0, exempted };
1670
+ }
1671
+ const cache = readCache(cacheFile);
1672
+ const now = Date.now();
1673
+ const deadline = now + budgetMs;
1674
+ const record = (entry, probe) => {
1675
+ if (probe.verdict === "ok") return;
1676
+ if (probe.verdict === "html") {
1677
+ violations.push({
1678
+ url: entry.url,
1679
+ source: entry.source,
1680
+ from: entry.from,
1681
+ reason: `resolved 200 with content-type "${probe.contentType}" \u2014 an HTML page where a non-HTML asset was promised. This is the "worse than a 404" shape: nothing errors, nothing logs, and every crawler resolving it silently gets a web page`
1682
+ });
1683
+ return;
1684
+ }
1685
+ violations.push({
1686
+ url: entry.url,
1687
+ source: entry.source,
1688
+ from: entry.from,
1689
+ reason: `resolved HTTP ${probe.status} \u2014 the URL the factory published does not exist`
1690
+ });
1691
+ };
1692
+ const queue = [...targets];
1693
+ const worker = async () => {
1694
+ for (; ; ) {
1695
+ const entry = queue.shift();
1696
+ if (!entry) return;
1697
+ const cached = cache.entries[entry.url];
1698
+ if (cached && now - cached.at < cacheTtlMs) {
1699
+ cachedCount += 1;
1700
+ answeredCount += 1;
1701
+ record(entry, cached);
1702
+ continue;
1703
+ }
1704
+ if (Date.now() >= deadline) {
1705
+ warnings.push({
1706
+ url: entry.url,
1707
+ source: entry.source,
1708
+ from: entry.from,
1709
+ reason: `remote-probe time budget (${budgetMs}ms) exhausted before this URL was checked`
1710
+ });
1711
+ continue;
1712
+ }
1713
+ probedCount += 1;
1714
+ let res;
1715
+ try {
1716
+ res = await fetchImpl(entry.url, {
1717
+ method: "HEAD",
1718
+ redirect: "follow",
1719
+ signal: AbortSignal.timeout(timeoutMs)
1720
+ });
1721
+ if (res.status === 405 || res.status === 501) {
1722
+ res = await fetchImpl(entry.url, {
1723
+ method: "GET",
1724
+ redirect: "follow",
1725
+ headers: { Range: "bytes=0-2047" },
1726
+ signal: AbortSignal.timeout(timeoutMs)
1727
+ });
1728
+ }
1729
+ } catch (err) {
1730
+ warnings.push({
1731
+ url: entry.url,
1732
+ source: entry.source,
1733
+ from: entry.from,
1734
+ reason: `network probe did not complete (${err instanceof Error ? err.message : String(err)}) \u2014 treated as UNKNOWN, not as a failure`
1735
+ });
1736
+ continue;
1737
+ }
1738
+ const contentType = res.headers.get("content-type") ?? void 0;
1739
+ let probe;
1740
+ if (res.status >= 400) {
1741
+ probe = { verdict: "status", status: res.status, contentType, at: Date.now() };
1742
+ } else if (isHtmlContentType(contentType)) {
1743
+ probe = { verdict: "html", status: res.status, contentType, at: Date.now() };
1744
+ } else {
1745
+ probe = { verdict: "ok", status: res.status, contentType, at: Date.now() };
1746
+ }
1747
+ cache.entries[entry.url] = probe;
1748
+ answeredCount += 1;
1749
+ if (debug) {
1750
+ console.log(`[dcs-seo] url probe ${entry.url} -> ${probe.verdict} (${res.status} ${contentType ?? "?"})`);
1751
+ }
1752
+ record(entry, probe);
1753
+ }
1754
+ };
1755
+ await Promise.all(Array.from({ length: Math.min(concurrency, targets.length) }, worker));
1756
+ writeCache(cacheFile, cache);
1757
+ return {
1758
+ violations,
1759
+ warnings,
1760
+ cached: cachedCount,
1761
+ probed: probedCount,
1762
+ checked: answeredCount,
1763
+ exempted
1764
+ };
1765
+ }
1766
+ function formatEmittedUrlReport(violations) {
1767
+ const lines = [
1768
+ `[dcs-seo] emitted-URL honesty: ${violations.length} URL(s) the SEO factory publishes do not resolve to what they promise.`
1769
+ ];
1770
+ for (const v of violations) {
1771
+ lines.push(` ${v.url}`);
1772
+ lines.push(` emitted by: ${v.source} (in ${v.from})`);
1773
+ lines.push(` problem : ${v.reason}`);
1774
+ }
1775
+ lines.push(
1776
+ ` Fix: point the URL at something that exists (via .dcs/seo.yaml \u2014 brand marks usually live on the per-site CDN), or ship the file into the site's public/ directory.`
1777
+ );
1778
+ return lines.join("\n");
1779
+ }
1780
+ function auditCheckedTotal(audit) {
1781
+ return audit.sameOriginAssetsChecked + audit.remoteAssetsChecked;
1782
+ }
1783
+ function formatEmittedUrlAudit(audit, routeCount) {
1784
+ const checked = auditCheckedTotal(audit);
1785
+ const parts = [
1786
+ `[dcs-seo] emitted-URL honesty: ${checked} asset URL(s) VERIFIED (${audit.sameOriginAssetsChecked} proven against dist/, ${audit.remoteAssetsChecked} probed remotely${audit.remoteCached ? `, ${audit.remoteCached} from cache` : ""})`,
1787
+ `${audit.pagesUnverified} page URL(s) collected but NOT verified by this rail`
1788
+ ];
1789
+ if (audit.unknown > 0) parts.push(`${audit.unknown} UNKNOWN (no definitive answer)`);
1790
+ if (audit.exempted > 0) parts.push(`${audit.exempted} exempted`);
1791
+ if (audit.unfetchable > 0) parts.push(`${audit.unfetchable} unfetchable by construction`);
1792
+ return parts.join("; ") + `. ${audit.collected} URL(s) collected across ${routeCount} route(s) + sitemap + llms.txt.`;
1793
+ }
1794
+ var EmittedUrlNotRunError = class extends Error {
1795
+ audit;
1796
+ constructor(audit, detail) {
1797
+ super(
1798
+ `[dcs-seo] emitted-URL honesty DID NOT RUN \u2014 ${detail}. Nothing was verified, so this build proves nothing about the URLs it publishes. (Emitted artifacts unreadable or empty? outDir wrong?) A rail that silently does not run is the exact class this work exists to close.`
1799
+ );
1800
+ this.name = "EmittedUrlNotRunError";
1801
+ this.audit = audit;
1802
+ }
1803
+ };
1804
+ var EmittedUrlError = class extends Error {
1805
+ violations;
1806
+ constructor(violations) {
1807
+ super(formatEmittedUrlReport(violations));
1808
+ this.name = "EmittedUrlError";
1809
+ this.violations = violations;
1810
+ }
1811
+ };
1812
+
1813
+ // src/seo/charsetBudget.ts
1814
+ var CHARSET_BUDGET_BYTES = 1024;
1815
+ var CHARSET_HEADROOM_WARN_BYTES = 256;
1816
+ var META_CHARSET_RE = /<meta\s+[^>]*\bcharset\s*=\s*["']?[^"'>\s]+["']?[^>]*>/i;
1817
+ var META_HTTP_EQUIV_CHARSET_RE = /<meta\s+[^>]*http-equiv\s*=\s*["']?content-type["']?[^>]*content\s*=\s*["'][^"']*charset=[^"']*["'][^>]*>/i;
1818
+ var CANONICAL_CHARSET_META = '<meta charset="utf-8" />';
1819
+ function findCharsetByteOffset(html) {
1820
+ const match = META_CHARSET_RE.exec(html) ?? META_HTTP_EQUIV_CHARSET_RE.exec(html);
1821
+ if (!match) return -1;
1822
+ return Buffer.byteLength(html.slice(0, match.index), "utf8");
1823
+ }
1824
+ function checkCharsetBudget(html, options = {}) {
1825
+ const limitBytes = options.limitBytes ?? CHARSET_BUDGET_BYTES;
1826
+ const match = META_CHARSET_RE.exec(html) ?? META_HTTP_EQUIV_CHARSET_RE.exec(html);
1827
+ if (!match) {
1828
+ return { ok: false, offset: -1, endOffset: -1, headroom: -limitBytes, limitBytes, declaration: null };
1829
+ }
1830
+ const offset = Buffer.byteLength(html.slice(0, match.index), "utf8");
1831
+ const endOffset = offset + Buffer.byteLength(match[0], "utf8");
1832
+ return {
1833
+ ok: endOffset <= limitBytes,
1834
+ offset,
1835
+ endOffset,
1836
+ headroom: limitBytes - endOffset,
1837
+ limitBytes,
1838
+ declaration: match[0]
1839
+ };
1840
+ }
1841
+ function hoistCharsetMeta(html, options = {}) {
1842
+ const insertIfMissing = options.insertIfMissing ?? true;
1843
+ const headOpen = /<head(\s[^>]*)?>/i.exec(html);
1844
+ if (!headOpen) return html;
1845
+ const insertAt = headOpen.index + headOpen[0].length;
1846
+ const match = META_CHARSET_RE.exec(html) ?? META_HTTP_EQUIV_CHARSET_RE.exec(html);
1847
+ if (!match) {
1848
+ if (!insertIfMissing) return html;
1849
+ return html.slice(0, insertAt) + "\n " + CANONICAL_CHARSET_META + html.slice(insertAt);
1850
+ }
1851
+ const between = html.slice(insertAt, match.index);
1852
+ if (between.trim() === "") return html;
1853
+ const headClose = /<\/head\s*>/i.exec(html);
1854
+ if (headClose && match.index > headClose.index) return html;
1855
+ const declaration = match[0];
1856
+ const before = html.slice(0, match.index).replace(/[ \t]*$/, "");
1857
+ const after = html.slice(match.index + declaration.length).replace(/^[ \t]*\r?\n/, "");
1858
+ const withoutDeclaration = before + after;
1859
+ return withoutDeclaration.slice(0, insertAt) + "\n " + declaration + withoutDeclaration.slice(insertAt);
1860
+ }
1861
+ var CharsetBudgetError = class extends Error {
1862
+ file;
1863
+ result;
1864
+ constructor(file, result) {
1865
+ super(
1866
+ result.declaration === null ? `[dcs-seo] charset budget FAILED for ${file}: the document declares NO character encoding. Browsers fall back to a locale default and every non-ASCII byte can mojibake. Emit <meta charset="utf-8"> as the first child of <head>.` : `[dcs-seo] charset budget FAILED for ${file}: <meta charset> ends at byte ${result.endOffset} of the ${result.limitBytes}-byte encoding-sniffing window (${result.headroom} bytes of headroom). The HTML parser stops looking after byte ${result.limitBytes}, so this document's encoding is not guaranteed to be detected. Move the declaration to the top of <head>.`
1867
+ );
1868
+ this.name = "CharsetBudgetError";
1869
+ this.file = file;
1870
+ this.result = result;
1871
+ }
1872
+ };
1873
+ function formatCharsetHeadroomWarning(file, result, warnBelow = CHARSET_HEADROOM_WARN_BYTES) {
1874
+ if (!result.ok) return null;
1875
+ if (result.headroom >= warnBelow) return null;
1876
+ return `[dcs-seo] charset budget: ${file} declares its encoding at byte ${result.offset} \u2014 only ${result.headroom} bytes of the ${result.limitBytes}-byte window remain. One more <head> injection breaks encoding detection on this site.`;
1877
+ }
980
1878
 
981
1879
  // src/seo/vitepressTransform.ts
982
1880
  function defaultRelativePathToRoute(relativePath, params) {
@@ -1164,6 +2062,6 @@ function createSeoTransformPageData(options) {
1164
2062
  };
1165
2063
  }
1166
2064
 
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
2065
+ export { AI_BOTS, BodyPrerenderError, CANONICAL_CHARSET_META, CHARSET_BUDGET_BYTES, CHARSET_HEADROOM_WARN_BYTES, CharsetBudgetError, EmittedUrlError, EmittedUrlNotRunError, HeadHonestyError, HeadHonestyNotRunError, SEO_HEAD_ROUTE_HAS_NO_META, absolutizeUrl, assertHeadHonesty, assertHeadHonestyExecuted, auditCheckedTotal, auditSameOriginAssets, breadcrumbTrailFromRoute, buildBlogPosting, buildBreadcrumbList, buildFaqPage, buildGlobalGraph, buildHasCredential, buildHeadTags, buildLlmsTxt, buildReviewSchemaParts, buildRobotsTxt, buildSeoHeadRouteMap, buildSitemapXml, buildVitePressSeoHead, checkCharsetBudget, checkRemoteAssets, checkSameOriginAssets, classifyUrl, collectEmittedUrlsFromFiles, collectUrlsFromHtml, collectUrlsFromLlmsTxt, collectUrlsFromSitemap, createSeoTransformPageData, decodeHtmlEntities, dedupeUrls, defaultRelativePathToRoute, deriveSameAs, escapeJsonLd, extractBakedHead, filterRealFaq, filterRealReviews, findBusinessLicense, findCharsetByteOffset, findDuplicateNormalizedPaths, findHeadHonestyExecutionFaults, findHeadHonestyViolations, findLocalBusinessSchema, findReviewItemsForPage, formatCharsetHeadroomWarning, formatDuplicateNormalizedPaths, formatEmittedUrlAudit, formatEmittedUrlReport, formatHeadHonestyExecutionReport, formatHeadHonestyReport, generateJsonLd, generateOpenGraphMeta, generateTwitterMeta, graphAbsorbs, graphIds, hoistCharsetMeta, installSeoHead, isBodyPrerenderEnabled, isHandAuthoredRobotsAcceptable, isLocalBusinessType, isRouteEmitted, isRouteIndexable, loadPagesManifest, normalizeHeadText, normalizeSeoHeadPath, parsePagesManifest, prerenderBodies, renderHeadTags, resolveHonestyMode, resolvePageSeo, routeExclusionReason, routeToOutputFile, slugToTitle, spliceBodyHtml, spliceHeadHtml, stripManagedHeadTags };
2066
+ //# sourceMappingURL=chunk-HVSF23P7.js.map
2067
+ //# sourceMappingURL=chunk-HVSF23P7.js.map