@getvitops/astro 8.2.0-alpha.0 → 8.3.0-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/CHANGELOG.md +584 -26
  2. package/dist/config-adapters-CbTywgEY.mjs +71 -0
  3. package/dist/config-adapters-CbTywgEY.mjs.map +1 -0
  4. package/dist/exports/tracking.d.mts +1 -1
  5. package/dist/exports/tracking.mjs +1 -1
  6. package/dist/index.d.mts +83 -43
  7. package/dist/index.d.mts.map +1 -1
  8. package/dist/index.mjs +351 -60
  9. package/dist/index.mjs.map +1 -1
  10. package/dist/middleware.d.mts +0 -2
  11. package/dist/middleware.d.mts.map +1 -1
  12. package/dist/middleware.mjs +1 -2
  13. package/dist/routes/conversion.d.mts +71 -15
  14. package/dist/routes/conversion.d.mts.map +1 -1
  15. package/dist/routes/conversion.mjs +79 -68
  16. package/dist/routes/conversion.mjs.map +1 -1
  17. package/dist/{tracking-M2PqlMpw.mjs → tracking-Cb3A0BrC.mjs} +2 -3
  18. package/dist/tracking-Cb3A0BrC.mjs.map +1 -0
  19. package/dist/{tracking-BNKKBqOe.d.mts → tracking-D2xOzjFD.d.mts} +5 -25
  20. package/dist/tracking-D2xOzjFD.d.mts.map +1 -0
  21. package/package.json +18 -6
  22. package/src/ads.ts +15 -22
  23. package/src/components/CookieConsent.astro +2 -1
  24. package/src/components/Entries.astro +58 -0
  25. package/src/components/Head.astro +6 -37
  26. package/src/components/ReviewCardBody.astro +16 -2
  27. package/src/components/SiteFooter.astro +79 -0
  28. package/src/components/SiteNav.astro +163 -0
  29. package/src/components/SiteNavList.astro +73 -0
  30. package/src/components/sitenav.ts +260 -0
  31. package/src/inline-scripts.ts +53 -0
  32. package/src/json-ld.ts +19 -0
  33. package/src/schemas/Article.astro +2 -1
  34. package/src/schemas/BookActions.astro +2 -1
  35. package/src/schemas/Breadcrumb.astro +2 -1
  36. package/src/schemas/Carousel.astro +2 -1
  37. package/src/schemas/CourseList.astro +2 -1
  38. package/src/schemas/Dataset.astro +2 -1
  39. package/src/schemas/DiscussionForum.astro +2 -1
  40. package/src/schemas/EducationQA.astro +2 -1
  41. package/src/schemas/EmployerAggregateRating.astro +2 -1
  42. package/src/schemas/Event.astro +2 -1
  43. package/src/schemas/FAQ.astro +2 -1
  44. package/src/schemas/FactCheck.astro +2 -1
  45. package/src/schemas/ImageMetadata.astro +2 -1
  46. package/src/schemas/JobPosting.astro +2 -1
  47. package/src/schemas/LocalBusiness.astro +2 -1
  48. package/src/schemas/MathSolver.astro +2 -1
  49. package/src/schemas/MovieCarousel.astro +2 -1
  50. package/src/schemas/Organization.astro +2 -1
  51. package/src/schemas/Product.astro +2 -1
  52. package/src/schemas/ProfilePage.astro +2 -1
  53. package/src/schemas/QAPage.astro +2 -1
  54. package/src/schemas/Recipe.astro +2 -1
  55. package/src/schemas/Review.astro +2 -1
  56. package/src/schemas/SoftwareApp.astro +2 -1
  57. package/src/seo.ts +6 -5
  58. package/src/tracking.ts +5 -9
  59. package/virtual.d.ts +17 -0
  60. package/dist/tracking-BNKKBqOe.d.mts.map +0 -1
  61. package/dist/tracking-M2PqlMpw.mjs.map +0 -1
package/dist/index.mjs CHANGED
@@ -1,18 +1,22 @@
1
- import { n as TRACKING_ENDPOINT, r as resolveTracking, t as TRACKING_COOKIE } from "./tracking-M2PqlMpw.mjs";
1
+ import { t as resolveOrganizationName } from "./config-adapters-CbTywgEY.mjs";
2
+ import { n as TRACKING_ENDPOINT, r as resolveTracking, t as TRACKING_COOKIE } from "./tracking-Cb3A0BrC.mjs";
2
3
  import { cpSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
4
  import { dirname, join, relative, resolve } from "node:path";
4
5
  import { fileURLToPath } from "node:url";
5
6
  import { isConfig, renderRobotsTxt, resolveConfig } from "@getvitops/generator";
6
7
  import { collectIconRefs, faviconLinks, generateFavicons, generateIconInclude, resolveIcon, scanFiles, writeFaviconManifest } from "@getvitops/utils";
7
8
  import vitopsPlugin from "@getvitops/vite";
9
+ import { REVIEWS_CACHE_PATH, readCache } from "@getvitops/utils/reviews";
8
10
  import { AD_PLATFORMS, AD_PROVIDER_KEYS, normalizeConversionLabels } from "@getvitops/utils/ads";
9
11
  import { fontProviders } from "astro/config";
12
+ import { t } from "@getvitops/utils/content";
13
+ import { createHash } from "node:crypto";
10
14
  import { execFile } from "node:child_process";
11
15
  import { promisify } from "node:util";
12
16
 
13
17
  export * from "@getvitops/utils"
14
18
 
15
- //#region src/analytics.ts
19
+ // src/analytics.ts
16
20
  /**
17
21
  * A small inline snippet reading each experiment's cookie into a plain
18
22
  * object, skipping any experiment whose cookie isn't set (visitor not yet
@@ -195,8 +199,7 @@ function consentCategories(tags) {
195
199
  ].filter((c) => used.has(c));
196
200
  }
197
201
 
198
- //#endregion
199
- //#region src/ads.ts
202
+ // src/ads.ts
200
203
  /**
201
204
  * The `<Ads />` resolution layer — the site's ad properties in, the exact tags to
202
205
  * emit out.
@@ -306,8 +309,7 @@ function resolveAds(ads, ctx = {}) {
306
309
  };
307
310
  }
308
311
 
309
- //#endregion
310
- //#region src/fonts.ts
312
+ // src/fonts.ts
311
313
  /**
312
314
  * Provider names, matching `fontProviders`' keys 1:1 (verified against astro@7.1.2).
313
315
  * The generator's `SiteFontSchema` enumerates the same set, so a mismatch is a
@@ -359,8 +361,7 @@ function resolveFonts(decls) {
359
361
  };
360
362
  }
361
363
 
362
- //#endregion
363
- //#region src/i18n.ts
364
+ // src/i18n.ts
364
365
  function codesOf(entry) {
365
366
  return typeof entry === "string" ? [entry] : entry.codes;
366
367
  }
@@ -384,8 +385,69 @@ function localeConsistencyWarnings(site, i18n) {
384
385
  return warnings;
385
386
  }
386
387
 
387
- //#endregion
388
- //#region src/integration.ts
388
+ // src/seo-defaults.ts
389
+ /** Drop `undefined`/empty-string keys so the result carries only what the config actually states. */
390
+ function compact(value) {
391
+ return Object.fromEntries(Object.entries(value).filter(([, v]) => v !== void 0 && v !== ""));
392
+ }
393
+ function resolveSeoDefaults(config, options = {}) {
394
+ const seo = config.site.seo;
395
+ const defaultLocale = config.site.defaultLocale;
396
+ const locale = options.locale ?? defaultLocale;
397
+ const text = (value) => t(value, locale, defaultLocale) || void 0;
398
+ const robots = config.site.environments?.[options.env ?? "production"]?.robots ?? seo?.robots;
399
+ const image = seo?.openGraph?.image;
400
+ const openGraph = compact({
401
+ type: seo?.openGraph?.type,
402
+ locale: seo?.openGraph?.locale,
403
+ localeAlternates: seo?.openGraph?.localeAlternates,
404
+ image: image === void 0 ? void 0 : typeof image === "string" ? { url: image } : compact({
405
+ url: image.src,
406
+ alt: image.alt,
407
+ width: image.width,
408
+ height: image.height,
409
+ type: image.type
410
+ })
411
+ });
412
+ const twitter = compact({
413
+ card: seo?.twitter?.card,
414
+ site: seo?.twitter?.site,
415
+ creator: seo?.twitter?.creator
416
+ });
417
+ return compact({
418
+ siteName: text(seo?.siteName) ?? resolveOrganizationName(config, locale),
419
+ titleTemplate: text(seo?.titleTemplate),
420
+ defaultTitle: text(seo?.defaultTitle),
421
+ defaultDescription: text(seo?.defaultDescription),
422
+ robots,
423
+ googleSiteVerification: seo?.googleSiteVerification,
424
+ bingSiteVerification: seo?.bingSiteVerification,
425
+ ...Object.keys(openGraph).length ? { openGraph } : {},
426
+ ...Object.keys(twitter).length ? { twitter } : {}
427
+ });
428
+ }
429
+ /**
430
+ * Merge `vitops({ seo })` over config-derived defaults. The two nested objects merge key by key, so
431
+ * setting `openGraph.locale` in code doesn't discard the config's fallback image.
432
+ */
433
+ function mergeSeoDefaults(fromConfig, override) {
434
+ if (!override) return fromConfig;
435
+ const merged = {
436
+ ...fromConfig,
437
+ ...override
438
+ };
439
+ if (fromConfig.openGraph || override.openGraph) merged.openGraph = {
440
+ ...fromConfig.openGraph,
441
+ ...override.openGraph
442
+ };
443
+ if (fromConfig.twitter || override.twitter) merged.twitter = {
444
+ ...fromConfig.twitter,
445
+ ...override.twitter
446
+ };
447
+ return merged;
448
+ }
449
+
450
+ // src/integration.ts
389
451
  /**
390
452
  * @getvitops/astro — the Astro integration. One entry wires the design system's
391
453
  * `<head>` contributions into a consumer site:
@@ -444,6 +506,20 @@ function readSiteLocales(configPath, siteEnv) {
444
506
  }
445
507
  }
446
508
  /**
509
+ * Resolve `site.seo` (plus `organization.name` as the site-name fallback) out of the site config.
510
+ *
511
+ * Best-effort, like `readSiteLocales`: a malformed or missing config is silence here, since every
512
+ * other consumer of the file validates it loudly in the same hook. An empty object means "the
513
+ * config states no SEO defaults", which is also what no site config at all means.
514
+ */
515
+ function readSiteSeo(configPath, siteEnv) {
516
+ try {
517
+ return resolveSeoDefaults(resolveConfig(JSON.parse(readFileSync(configPath, "utf8")), siteEnv), { env: siteEnv });
518
+ } catch {
519
+ return {};
520
+ }
521
+ }
522
+ /**
447
523
  * Read `site.ads` and this environment's ads switch out of the site config.
448
524
  *
449
525
  * Best-effort, like `undisclosedProviders`: every other consumer of this file
@@ -471,6 +547,14 @@ function readSiteAds(configPath, siteEnv) {
471
547
  return null;
472
548
  }
473
549
  }
550
+ /** Read `site.security.turnstile.siteKey`. Best-effort, like `readSiteAds`. */
551
+ function readSiteTurnstileKey(configPath, siteEnv) {
552
+ try {
553
+ return resolveConfig(JSON.parse(readFileSync(configPath, "utf8")), siteEnv).site.security?.turnstile?.siteKey || null;
554
+ } catch {
555
+ return null;
556
+ }
557
+ }
474
558
  /**
475
559
  * Read `site.experiments` + `site.analytics.matomo.abVariantDimensions` out of
476
560
  * the site config, resolving each experiment's cookie name (default
@@ -613,6 +697,40 @@ function virtualIconsPlugin(data) {
613
697
  }
614
698
  };
615
699
  }
700
+ const REVIEWS_ID = "virtual:getvitops/reviews";
701
+ const RESOLVED_REVIEWS_ID = `\0${REVIEWS_ID}`;
702
+ /**
703
+ * The committed `.vitops/reviews.json` cache as a build-time import, for server-rendered
704
+ * components that cannot read the filesystem (the EmDash `vitops.reviews` block runs in a
705
+ * Worker — and, with pages prerendered, inside the Cloudflare adapter's local workerd).
706
+ *
707
+ * **An absent or unusable file serves an EMPTY cache, never an error.** A fresh scaffold has
708
+ * no `.vitops/reviews.json` (nothing has run `vitops reviews` yet) and a build must not fail
709
+ * for it; the block renders an editor placeholder instead. `readCache` already treats a
710
+ * corrupt or wrong-version file as absent, which is the same rule — the failure is silent
711
+ * here only because the consumer of this module reports it.
712
+ *
713
+ * Read in `load`, not at plugin construction, and the file is registered as a watch file, so
714
+ * a dev server picks up a re-run of `vitops reviews` without a restart.
715
+ */
716
+ function virtualReviewsPlugin(root) {
717
+ const path = resolve(root, REVIEWS_CACHE_PATH);
718
+ return {
719
+ name: "@getvitops/astro:virtual-reviews",
720
+ resolveId(id) {
721
+ return id === REVIEWS_ID ? RESOLVED_REVIEWS_ID : null;
722
+ },
723
+ load(id) {
724
+ if (id !== RESOLVED_REVIEWS_ID) return null;
725
+ this?.addWatchFile?.(path);
726
+ const cache = readCache(path) ?? {
727
+ version: 1,
728
+ locations: {}
729
+ };
730
+ return `export default ${JSON.stringify(cache)};`;
731
+ }
732
+ };
733
+ }
616
734
  /** Defaults live here so the component and the integration can't drift. */
617
735
  const ICON_DEFAULTS = {
618
736
  ui: "fa7-solid",
@@ -657,6 +775,46 @@ function readsAsConfig(path) {
657
775
  return false;
658
776
  }
659
777
  }
778
+ /** Whether the site config sets `site.icons.sprite` — the fact that makes the generator emit icons.svg. */
779
+ function siteWantsSprite(root, siteInput, siteEnv) {
780
+ if (!siteInput) return false;
781
+ try {
782
+ const cfg = resolveConfig(JSON.parse(readFileSync(resolve(root, siteInput), "utf8")), siteEnv ?? "production");
783
+ return Boolean(cfg.site.icons?.sprite);
784
+ } catch {
785
+ return false;
786
+ }
787
+ }
788
+ /**
789
+ * A standalone page hosting `<wc-theme-editor>`, for a host that cannot load the
790
+ * framework CSS itself (the EmDash admin embeds this in an iframe, so the design
791
+ * system never touches the admin's own styles). `?save=<url>` supplies the save
792
+ * endpoint, which is the host's to name, not this integration's.
793
+ */
794
+ function writeEditorHost(dest) {
795
+ mkdirSync(dest, { recursive: true });
796
+ writeFileSync(join(dest, "design-system-editor.html"), `<!doctype html>
797
+ <html lang="en">
798
+ <head>
799
+ <meta charset="utf-8">
800
+ <meta name="viewport" content="width=device-width, initial-scale=1">
801
+ <meta name="robots" content="noindex">
802
+ <title>Design system editor</title>
803
+ <link rel="stylesheet" href="/vitops/editor.css">
804
+ </head>
805
+ <body>
806
+ <script type="module">
807
+ await import('/vitops/editor.js');
808
+ const el = document.createElement('wc-theme-editor');
809
+ el.setAttribute('manifest', '/vitops/design-manifest.json');
810
+ const save = new URLSearchParams(location.search).get('save');
811
+ if (save && save.startsWith('/')) el.setAttribute('save-endpoint', save);
812
+ document.body.append(el);
813
+ <\/script>
814
+ </body>
815
+ </html>
816
+ `);
817
+ }
660
818
  function vitops(opts = {}) {
661
819
  return {
662
820
  name: "@getvitops/astro",
@@ -665,7 +823,7 @@ function vitops(opts = {}) {
665
823
  const publicDir = fileURLToPath(config.publicDir);
666
824
  const webComponents = opts.webComponents !== false;
667
825
  const editor = opts.editor === true;
668
- if (editor && !opts.css) logger.warn("editor: true needs a `css` config — it reads design-manifest.json.");
826
+ if ((editor || opts.editorManifest) && !opts.css) logger.warn("editor/editorManifest needs a `css` config — it reads design-manifest.json.");
669
827
  if (opts.legal && !opts.css) logger.warn("legal: needs a `css` config — the generation runs in the Vite plugin that `css` registers. Run `npx vitops legal` instead if you do not use `css`.");
670
828
  if (opts.media && !opts.css) logger.warn("media: needs a `css` config — the encoding runs in the Vite plugin that `css` registers. Run `npx vitops media` instead if you do not use `css`.");
671
829
  const cssInput = opts.css ? opts.css.input ?? "design-system.json" : void 0;
@@ -677,7 +835,8 @@ function vitops(opts = {}) {
677
835
  const siteLocales = readSiteLocales(resolve(root, siteInput), opts.site?.siteEnv ?? "production");
678
836
  for (const warning of localeConsistencyWarnings(siteLocales, config.i18n)) logger.warn(warning);
679
837
  }
680
- if (opts.seo) {
838
+ const seoDefaults = mergeSeoDefaults(siteInput ? readSiteSeo(resolve(root, siteInput), opts.site?.siteEnv ?? "production") : {}, opts.seo);
839
+ if (opts.seo || Object.keys(seoDefaults).some((k) => k !== "siteName")) {
681
840
  if (!config.site) logger.warn("seo: needs the `site` astro.config option (your deployed URL) — without it <Seo /> omits canonical, og:url and any relative og:image rather than guessing an origin.");
682
841
  if (config.integrations.some((i) => i.name === "emdash")) logger.warn("seo: emdash() is registered, and its <EmDashHead> emits the same title/description/canonical/og tags from the CMS. Use one or the other — rendering both duplicates every tag.");
683
842
  }
@@ -691,6 +850,7 @@ function vitops(opts = {}) {
691
850
  const analytics = resolveAnalytics(opts.analytics, { consent: consentEnabled });
692
851
  const trackingOpts = opts.tracking === true ? { enabled: true } : opts.tracking || (siteConsent?.tracking?.enabled ? siteConsent.tracking : void 0);
693
852
  const adsConfig = siteInput ? readSiteAds(resolve(root, siteInput), opts.site?.siteEnv ?? "production") : null;
853
+ const turnstileSiteKey = siteInput ? readSiteTurnstileKey(resolve(root, siteInput), opts.site?.siteEnv ?? "production") : null;
694
854
  const experimentsConfig = siteInput ? readSiteExperiments(resolve(root, siteInput), opts.site?.siteEnv ?? "production") : null;
695
855
  const detectedCategories = consentCategories([...analytics.tags, ...resolveAds(adsConfig?.properties, {
696
856
  consent: consentEnabled,
@@ -737,8 +897,15 @@ function vitops(opts = {}) {
737
897
  logger.info("generated favicons + manifest → public/");
738
898
  }
739
899
  let headFonts = [];
740
- if (opts.fonts) {
741
- const given = Array.isArray(opts.fonts) ? { families: opts.fonts } : typeof opts.fonts === "string" ? { input: opts.fonts } : opts.fonts === true ? {} : opts.fonts;
900
+ let fontsOpt = opts.fonts;
901
+ if (fontsOpt === void 0 && siteInput) try {
902
+ if ((resolveConfig(JSON.parse(readFileSync(resolve(root, siteInput), "utf8")), opts.site?.siteEnv ?? "production").site.fonts ?? []).length > 0) {
903
+ fontsOpt = true;
904
+ logger.info(`fonts: ${siteInput} declares site.fonts, so webfonts are on (pass fonts: false to opt out)`);
905
+ }
906
+ } catch {}
907
+ if (fontsOpt) {
908
+ const given = Array.isArray(fontsOpt) ? { families: fontsOpt } : typeof fontsOpt === "string" ? { input: fontsOpt } : fontsOpt === true ? {} : fontsOpt;
742
909
  const o = {
743
910
  ...given,
744
911
  ...given.input == null && given.families == null && siteInput ? {
@@ -776,11 +943,12 @@ function vitops(opts = {}) {
776
943
  "polyfills"
777
944
  ] : [];
778
945
  if (consentRuntime) bundles.push("consent.js");
779
- if (editor) bundles.push("editor.js");
946
+ if (editor || opts.editorManifest) bundles.push("editor.js");
780
947
  for (const f of bundles) {
781
948
  const src = join(coreDist, f);
782
949
  if (existsSync(src)) cpSync(src, join(dest, f), { recursive: true });
783
950
  }
951
+ if (opts.editorManifest) writeEditorHost(dest);
784
952
  logger.info("copied web-component bundles → public/vitops/");
785
953
  } else logger.warn(`@getvitops/core dist not found (${coreDist}); run build:core. Skipping WC copy.`);
786
954
  }
@@ -893,43 +1061,51 @@ function vitops(opts = {}) {
893
1061
  sprite: engine === "sprite" || o.sprite ? spriteHref : null
894
1062
  };
895
1063
  }
896
- updateConfig({ vite: { plugins: [virtualIconsPlugin(iconsData ?? {
897
- engine: "none",
898
- root,
899
- ui: ICON_DEFAULTS.ui,
900
- brand: ICON_DEFAULTS.brand,
901
- weight: null,
902
- overrides: {},
903
- sprite: null
904
- }), virtualHeadPlugin({
905
- favicons: !!opts.favicon,
906
- faviconLinks: opts.favicon ? faviconLinks({
907
- hasSvg,
908
- manifest: hasManifest
909
- }) : [],
910
- themeColor: opts.favicon?.themeColor ?? null,
911
- webComponents,
912
- wcBase: "/vitops",
913
- editor,
914
- sitemap: sitemapHref,
915
- fonts: headFonts,
916
- site: config.site ?? null,
917
- seo: opts.seo ?? {},
918
- analytics: opts.analytics ?? {},
919
- consent: consentEnabled,
920
- consentCategories: offeredCategories,
921
- consentPolicyUrl: consentOpts.policyUrl ?? null,
922
- consentRuntime,
923
- tracking: tracking.enabled ? {
924
- enabled: true,
925
- category: tracking.category,
926
- cookies: tracking.cookies,
927
- abVariantCookie: experimentsConfig?.primaryCookie ?? null
928
- } : null,
929
- experiments: experimentsConfig?.experiments ?? {},
930
- matomoAbVariantDimensions: experimentsConfig?.matomoAbVariantDimensions ?? {},
931
- ads: adsConfig
932
- })] } });
1064
+ updateConfig({ vite: {
1065
+ ssr: { optimizeDeps: { include: ["@getvitops/astro > @getvitops/generator/icons-sprite"] } },
1066
+ plugins: [
1067
+ virtualReviewsPlugin(root),
1068
+ virtualIconsPlugin(iconsData ?? {
1069
+ engine: "none",
1070
+ root,
1071
+ ui: ICON_DEFAULTS.ui,
1072
+ brand: ICON_DEFAULTS.brand,
1073
+ weight: null,
1074
+ overrides: {},
1075
+ sprite: null
1076
+ }),
1077
+ virtualHeadPlugin({
1078
+ favicons: !!opts.favicon,
1079
+ faviconLinks: opts.favicon ? faviconLinks({
1080
+ hasSvg,
1081
+ manifest: hasManifest
1082
+ }) : [],
1083
+ themeColor: opts.favicon?.themeColor ?? null,
1084
+ webComponents,
1085
+ wcBase: "/vitops",
1086
+ editor,
1087
+ sitemap: sitemapHref,
1088
+ fonts: headFonts,
1089
+ site: config.site ?? null,
1090
+ seo: seoDefaults,
1091
+ analytics: opts.analytics ?? {},
1092
+ consent: consentEnabled,
1093
+ consentCategories: offeredCategories,
1094
+ consentPolicyUrl: consentOpts.policyUrl ?? null,
1095
+ consentRuntime,
1096
+ tracking: tracking.enabled ? {
1097
+ enabled: true,
1098
+ category: tracking.category,
1099
+ cookies: tracking.cookies,
1100
+ abVariantCookie: experimentsConfig?.primaryCookie ?? null
1101
+ } : null,
1102
+ experiments: experimentsConfig?.experiments ?? {},
1103
+ matomoAbVariantDimensions: experimentsConfig?.matomoAbVariantDimensions ?? {},
1104
+ ads: adsConfig,
1105
+ turnstileSiteKey
1106
+ })
1107
+ ]
1108
+ } });
933
1109
  if (opts.css) {
934
1110
  const format = opts.css.format ?? "tailwind";
935
1111
  if (format === "design") throw new Error("[vitops] css.format: 'design' emits only DESIGN.md, not a stylesheet. Run `npx vitops generate --format design --out .` for that file, and set css.format to 'tailwind' | 'css' | 'bricks' here.");
@@ -939,8 +1115,8 @@ function vitops(opts = {}) {
939
1115
  ...opts.css.theme != null ? { theme: opts.css.theme } : {},
940
1116
  format,
941
1117
  out,
942
- ...editor ? { editorManifestDir: publicDir } : {},
943
- ...iconsData?.sprite ? { spriteDir: publicDir } : {},
1118
+ ...editor || opts.editorManifest ? { editorManifestDir: publicDir } : {},
1119
+ ...iconsData?.sprite || siteWantsSprite(root, siteInput, opts.site?.siteEnv) ? { spriteDir: publicDir } : {},
944
1120
  ...opts.legal ? { legal: {
945
1121
  ...opts.legal,
946
1122
  input: legalInput
@@ -973,8 +1149,125 @@ function vitops(opts = {}) {
973
1149
  };
974
1150
  }
975
1151
 
976
- //#endregion
977
- //#region src/lastmod.ts
1152
+ // src/inline-scripts.ts
1153
+ /**
1154
+ * The inline scripts `<Head />` emits, as plain strings.
1155
+ *
1156
+ * They live here rather than in `Head.astro`'s frontmatter for one reason: a
1157
+ * Content-Security-Policy that doesn't allow `'unsafe-inline'` has to name each
1158
+ * inline script by SHA-256, and the hash is of the exact emitted bytes. One
1159
+ * constant imported by both the component (which emits it) and `csp.ts` (which
1160
+ * hashes it) makes "the hash matches what the page serves" true by construction
1161
+ * rather than by a test remembering to compare two copies. `csp.test.ts` and the
1162
+ * container render test still assert it against real rendered HTML.
1163
+ *
1164
+ * No imports and no Node builtins: `Head.astro` runs in a Worker for any page
1165
+ * that is rendered on demand.
1166
+ */
1167
+ /**
1168
+ * Applies the remembered colour scheme before first paint.
1169
+ *
1170
+ * The key MUST match `WCColorSchemeToggle.STORAGE_KEY` in @getvitops/core;
1171
+ * `head.test.ts` asserts they stay in step. try/catch because localStorage
1172
+ * throws in private/partitioned contexts.
1173
+ */
1174
+ const THEME_SCRIPT = "try{var s=localStorage.getItem('vitops-color-scheme');if(s==='light'||s==='dark'){document.documentElement.dataset.theme=s;document.documentElement.style.colorScheme=s+' only'}}catch(e){}";
1175
+ /**
1176
+ * The consent gate's build-time signal: "something on this page may need
1177
+ * permission, and the real gate hasn't loaded yet."
1178
+ *
1179
+ * `consent.js` and `elements.js` are both deferred modules with no ordering
1180
+ * guarantee between them, so `<wc-color-scheme-toggle>` can upgrade and be clicked
1181
+ * before the gate exists. Without this, a caller probing for
1182
+ * `window.vitopsConsent` in that window would see nothing, correctly conclude
1183
+ * that sites without the gate should just store — and store. The stub closes the
1184
+ * window by making the gate's *existence* known synchronously in `<head>`, which
1185
+ * is the only place that beats every deferred bundle.
1186
+ *
1187
+ * It answers `false` to everything, because nothing is granted until the store
1188
+ * has been read, and queues what it was asked so `consent/runtime.ts` can replay
1189
+ * it on evaluation. `q` entries are `[category]` for `require()` and
1190
+ * `[category, resolve]` for `request()`. Same shape as the `dataLayer`/`gtag`
1191
+ * stub in `analytics.ts` and for the same reason.
1192
+ *
1193
+ * Emitted only when the gate is on: its absence is what tells a caller the site
1194
+ * has no gate at all.
1195
+ */
1196
+ const CONSENT_STUB = "window.vitopsConsent=window.vitopsConsent||{q:[],require:function(c){this.q.push([c]);return false},request:function(c){var s=this;return new Promise(function(r){s.q.push([c,r])})},granted:function(){return false},needed:function(){return false}};";
1197
+
1198
+ // src/csp.ts
1199
+ /**
1200
+ * Content-Security-Policy helpers for the inline scripts `<Head />` emits.
1201
+ *
1202
+ * `<Head />` writes up to two inline `<script>`s into every page: the pre-paint
1203
+ * colour-scheme bootstrap and the consent-gate stub. A policy without
1204
+ * `'unsafe-inline'` blocks both unless each is named by hash, and both are
1205
+ * constants (`inline-scripts.ts`), so their hashes are fixed per package version
1206
+ * and can sit in a static `_headers` file — which is exactly what the scaffolded
1207
+ * templates do, in `Content-Security-Policy-Report-Only` mode.
1208
+ *
1209
+ * Why hashes and not nonces: a nonce is per response, so it needs the page to be
1210
+ * rendered by a Worker on every request. The templates' pages are prerendered
1211
+ * static assets, which have no request to attach a nonce to. Hashes also survive
1212
+ * being cached.
1213
+ *
1214
+ * What this does NOT cover, and the policy says so rather than hiding it: scripts
1215
+ * the page gets from elsewhere (EmDash's own head tags, a server-island loader,
1216
+ * an editor's HTML block, the consent gate activating a third-party tag). Those
1217
+ * are what Report-Only is for — see the template's AGENTS.md.
1218
+ *
1219
+ * Node-only (`node:crypto`), so it is exported from the package root — a build
1220
+ * time import — and never from a Worker entry.
1221
+ */
1222
+ /** The CSP source expression for one inline script: `'sha256-<base64>'`. */
1223
+ function cspScriptHash(script) {
1224
+ return `'sha256-${createHash("sha256").update(script, "utf8").digest("base64")}'`;
1225
+ }
1226
+ /**
1227
+ * CSP hash sources for every inline script `<Head />` can emit.
1228
+ *
1229
+ * Defaults to ALL of them: listing a hash for a script the page doesn't emit is
1230
+ * harmless, while omitting one a page does emit is a violation.
1231
+ */
1232
+ function inlineScriptHashes(opts = {}) {
1233
+ const out = [];
1234
+ if (opts.webComponents !== false) out.push(cspScriptHash(THEME_SCRIPT));
1235
+ if (opts.consent !== false) out.push(cspScriptHash(CONSENT_STUB));
1236
+ return out;
1237
+ }
1238
+ /**
1239
+ * A strict starting policy for a prerendered site.
1240
+ *
1241
+ * `script-src` is `'self'` plus the `<Head />` hashes — no `'unsafe-inline'`.
1242
+ * `style-src` keeps `'unsafe-inline'` because Astro inlines scoped `<style>` and
1243
+ * components set `style=""` attributes; a style injection is far less dangerous
1244
+ * than a script one, and hashing every stylesheet would break on any edit.
1245
+ * `img-src` allows `https:` because editors paste images from anywhere.
1246
+ */
1247
+ function contentSecurityPolicy(opts = {}) {
1248
+ const list = (name, ...sources) => `${name} ${sources.flatMap((s) => s ?? []).join(" ")}`.trim();
1249
+ const directives = [
1250
+ "default-src 'self'",
1251
+ list("script-src", ["'self'"], inlineScriptHashes(opts.inline), opts.scriptSrc),
1252
+ "style-src 'self' 'unsafe-inline'",
1253
+ list("img-src", [
1254
+ "'self'",
1255
+ "data:",
1256
+ "https:"
1257
+ ], opts.imgSrc),
1258
+ "font-src 'self'",
1259
+ list("connect-src", ["'self'"], opts.connectSrc),
1260
+ list("frame-src", opts.frameSrc?.length ? opts.frameSrc : ["'none'"]),
1261
+ "object-src 'none'",
1262
+ "base-uri 'self'",
1263
+ "form-action 'self'",
1264
+ "frame-ancestors 'self'"
1265
+ ];
1266
+ if (opts.reportUri) directives.push(`report-uri ${opts.reportUri}`);
1267
+ return directives.join("; ");
1268
+ }
1269
+
1270
+ // src/lastmod.ts
978
1271
  /**
979
1272
  * Real per-page `<lastmod>` for the sitemap, derived from git.
980
1273
  *
@@ -1133,8 +1426,7 @@ async function gitLastmod(options = {}) {
1133
1426
  };
1134
1427
  }
1135
1428
 
1136
- //#endregion
1137
- //#region src/seo.ts
1429
+ // src/seo.ts
1138
1430
  /** `en-CA` → `en_CA`; Open Graph wants the underscore form. */
1139
1431
  const ogLocale = (locale) => locale.replace("-", "_");
1140
1432
  /**
@@ -1255,6 +1547,5 @@ function resolveSeo(defaults, props, ctx) {
1255
1547
  };
1256
1548
  }
1257
1549
 
1258
- //#endregion
1259
- export { TRACKING_COOKIE, consentCategories, vitops as default, gitLastmod, resolveAds, resolveAnalytics, resolveSeo, resolveTracking, routeFromPage, slugFromContent };
1550
+ export { TRACKING_COOKIE, consentCategories, contentSecurityPolicy, cspScriptHash, vitops as default, gitLastmod, inlineScriptHashes, mergeSeoDefaults, resolveAds, resolveAnalytics, resolveSeo, resolveSeoDefaults, resolveTracking, routeFromPage, slugFromContent };
1260
1551
  //# sourceMappingURL=index.mjs.map