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