@duffcloudservices/cms 0.11.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.
- package/README.md +244 -8
- package/dist/chunk-A5F4C72F.js +500 -0
- package/dist/chunk-A5F4C72F.js.map +1 -0
- package/dist/{chunk-F3EIWEZD.js → chunk-HVSF23P7.js} +971 -73
- package/dist/chunk-HVSF23P7.js.map +1 -0
- package/dist/editor/editorBridge.d.ts +53 -1
- package/dist/editor/editorBridge.js +141 -5
- package/dist/editor/editorBridge.js.map +1 -1
- package/dist/headHonesty-OzxvLuwd.d.ts +222 -0
- package/dist/index.d.ts +369 -22
- package/dist/index.js +424 -22
- package/dist/index.js.map +1 -1
- package/dist/installSeoHead-kWQwObez.d.ts +627 -0
- package/dist/plugins/index.d.ts +173 -6
- package/dist/plugins/index.js +628 -54
- package/dist/plugins/index.js.map +1 -1
- package/dist/seo/index.d.ts +763 -4
- package/dist/seo/index.js +2 -2
- package/dist/{vitepressTransform-DfmABXmK.d.ts → vitepressTransform-JG_zlaux.d.ts} +99 -6
- package/package.json +26 -16
- package/src/components/DcsCallButton.test.ts +58 -0
- package/src/components/DcsCallButton.vue +19 -4
- package/src/components/DcsReviewShowcase.vue +5 -1
- package/src/components/LiteMediaEmbed.vue +3 -3
- package/src/components/ManagedImage.test.ts +94 -0
- package/src/components/ManagedImage.vue +58 -6
- package/src/components/PreviewRibbon.vue +4 -1
- package/src/composables/useConversionTracking.test.ts +492 -0
- package/src/composables/useConversionTracking.ts +770 -0
- package/src/composables/useReleaseNotes.ts +7 -1
- package/src/composables/useResponsiveImage.ts +6 -0
- package/src/composables/useSEO.applyHead.test.ts +150 -0
- package/src/composables/useSEO.ts +63 -17
- package/src/composables/useSiteVersion.ts +4 -1
- package/src/composables/useSiteVisitorSession.test.ts +56 -0
- package/src/composables/useSiteVisitorSession.ts +39 -3
- package/src/composables/useTextContent.ts +9 -1
- package/dist/chunk-DAYLLSEE.js +0 -3
- package/dist/chunk-DAYLLSEE.js.map +0 -1
- package/dist/chunk-F3EIWEZD.js.map +0 -1
- package/dist/spliceHeadHtml-CsBEucGy.d.ts +0 -254
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import
|
|
2
|
-
import
|
|
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
|
|
442
|
-
canonical = `${global.siteUrl.replace(/\/$/, "")}${
|
|
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
|
|
870
|
-
return
|
|
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 (!
|
|
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
|
-
|
|
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
|
-
|
|
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 ${
|
|
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(/"/g, '"').replace(/</g, "<").replace(/>/g, ">").replace(/&/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,
|
|
1168
|
-
//# sourceMappingURL=chunk-
|
|
1169
|
-
//# sourceMappingURL=chunk-
|
|
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
|