@getvitops/astro 8.2.0 → 8.3.0-alpha.1
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/CHANGELOG.md +135 -0
- package/dist/index.d.mts +51 -1
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +300 -45
- package/dist/index.mjs.map +1 -1
- package/package.json +17 -6
- package/src/components/Entries.astro +58 -0
- package/src/components/Head.astro +6 -37
- package/src/components/ReviewCardBody.astro +11 -0
- package/src/components/SiteFooter.astro +79 -0
- package/src/components/SiteNav.astro +163 -0
- package/src/components/SiteNavList.astro +73 -0
- package/src/components/sitenav.ts +260 -0
- package/src/inline-scripts.ts +53 -0
- package/src/json-ld.ts +19 -0
- package/virtual.d.ts +17 -0
package/dist/index.mjs
CHANGED
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
import { t as resolveOrganizationName } from "./config-adapters-CbTywgEY.mjs";
|
|
2
2
|
import { n as TRACKING_ENDPOINT, r as resolveTracking, t as TRACKING_COOKIE } from "./tracking-Cb3A0BrC.mjs";
|
|
3
|
+
import { createRequire } from "node:module";
|
|
3
4
|
import { cpSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
4
5
|
import { dirname, join, relative, resolve } from "node:path";
|
|
5
6
|
import { fileURLToPath } from "node:url";
|
|
6
7
|
import { isConfig, renderRobotsTxt, resolveConfig } from "@getvitops/generator";
|
|
7
8
|
import { collectIconRefs, faviconLinks, generateFavicons, generateIconInclude, resolveIcon, scanFiles, writeFaviconManifest } from "@getvitops/utils";
|
|
8
9
|
import vitopsPlugin from "@getvitops/vite";
|
|
10
|
+
import { REVIEWS_CACHE_PATH, readCache } from "@getvitops/utils/reviews";
|
|
9
11
|
import { AD_PLATFORMS, AD_PROVIDER_KEYS, normalizeConversionLabels } from "@getvitops/utils/ads";
|
|
10
12
|
import { fontProviders } from "astro/config";
|
|
11
13
|
import { t } from "@getvitops/utils/content";
|
|
14
|
+
import { createHash } from "node:crypto";
|
|
12
15
|
import { execFile } from "node:child_process";
|
|
13
16
|
import { promisify } from "node:util";
|
|
14
17
|
|
|
@@ -383,6 +386,40 @@ function localeConsistencyWarnings(site, i18n) {
|
|
|
383
386
|
return warnings;
|
|
384
387
|
}
|
|
385
388
|
|
|
389
|
+
// src/tailwind-sources.ts
|
|
390
|
+
/**
|
|
391
|
+
* The `@source` paths a site's Tailwind needs so classes used only by installed packages'
|
|
392
|
+
* renderers are generated. Tailwind v4 does not scan `node_modules`, so `font-display` (used by
|
|
393
|
+
* `vitops.stats`) or a class in an `@getvitops/astro` component produced no rule and the page
|
|
394
|
+
* rendered at body size with no error anywhere.
|
|
395
|
+
*
|
|
396
|
+
* Resolution is injected so the choice is testable; an absent package is skipped, not an error —
|
|
397
|
+
* a site without `@getvitops/emdash` has nothing of its to scan.
|
|
398
|
+
*/
|
|
399
|
+
/** Package → directory (relative to the package root) holding the markup that uses classes. */
|
|
400
|
+
const RENDERER_DIRS = {
|
|
401
|
+
"@getvitops/astro": "src/components",
|
|
402
|
+
"@getvitops/emdash": "src/astro"
|
|
403
|
+
};
|
|
404
|
+
/**
|
|
405
|
+
* @param cssDir directory of the emitted `tailwind.css`; `@source` paths are relative to it.
|
|
406
|
+
* @param resolvePackageJson returns the absolute path of a package's `package.json`, or throws.
|
|
407
|
+
*/
|
|
408
|
+
function tailwindSources(cssDir, resolvePackageJson) {
|
|
409
|
+
const out = [];
|
|
410
|
+
for (const [pkg, dir] of Object.entries(RENDERER_DIRS)) {
|
|
411
|
+
let manifest;
|
|
412
|
+
try {
|
|
413
|
+
manifest = resolvePackageJson(pkg);
|
|
414
|
+
} catch {
|
|
415
|
+
continue;
|
|
416
|
+
}
|
|
417
|
+
const rel = relative(cssDir, join(dirname(manifest), dir)).split("\\").join("/");
|
|
418
|
+
out.push(rel.startsWith(".") ? rel : `./${rel}`);
|
|
419
|
+
}
|
|
420
|
+
return out;
|
|
421
|
+
}
|
|
422
|
+
|
|
386
423
|
// src/seo-defaults.ts
|
|
387
424
|
/** Drop `undefined`/empty-string keys so the result carries only what the config actually states. */
|
|
388
425
|
function compact(value) {
|
|
@@ -545,6 +582,14 @@ function readSiteAds(configPath, siteEnv) {
|
|
|
545
582
|
return null;
|
|
546
583
|
}
|
|
547
584
|
}
|
|
585
|
+
/** Read `site.security.turnstile.siteKey`. Best-effort, like `readSiteAds`. */
|
|
586
|
+
function readSiteTurnstileKey(configPath, siteEnv) {
|
|
587
|
+
try {
|
|
588
|
+
return resolveConfig(JSON.parse(readFileSync(configPath, "utf8")), siteEnv).site.security?.turnstile?.siteKey || null;
|
|
589
|
+
} catch {
|
|
590
|
+
return null;
|
|
591
|
+
}
|
|
592
|
+
}
|
|
548
593
|
/**
|
|
549
594
|
* Read `site.experiments` + `site.analytics.matomo.abVariantDimensions` out of
|
|
550
595
|
* the site config, resolving each experiment's cookie name (default
|
|
@@ -687,6 +732,40 @@ function virtualIconsPlugin(data) {
|
|
|
687
732
|
}
|
|
688
733
|
};
|
|
689
734
|
}
|
|
735
|
+
const REVIEWS_ID = "virtual:getvitops/reviews";
|
|
736
|
+
const RESOLVED_REVIEWS_ID = `\0${REVIEWS_ID}`;
|
|
737
|
+
/**
|
|
738
|
+
* The committed `.vitops/reviews.json` cache as a build-time import, for server-rendered
|
|
739
|
+
* components that cannot read the filesystem (the EmDash `vitops.reviews` block runs in a
|
|
740
|
+
* Worker — and, with pages prerendered, inside the Cloudflare adapter's local workerd).
|
|
741
|
+
*
|
|
742
|
+
* **An absent or unusable file serves an EMPTY cache, never an error.** A fresh scaffold has
|
|
743
|
+
* no `.vitops/reviews.json` (nothing has run `vitops reviews` yet) and a build must not fail
|
|
744
|
+
* for it; the block renders an editor placeholder instead. `readCache` already treats a
|
|
745
|
+
* corrupt or wrong-version file as absent, which is the same rule — the failure is silent
|
|
746
|
+
* here only because the consumer of this module reports it.
|
|
747
|
+
*
|
|
748
|
+
* Read in `load`, not at plugin construction, and the file is registered as a watch file, so
|
|
749
|
+
* a dev server picks up a re-run of `vitops reviews` without a restart.
|
|
750
|
+
*/
|
|
751
|
+
function virtualReviewsPlugin(root) {
|
|
752
|
+
const path = resolve(root, REVIEWS_CACHE_PATH);
|
|
753
|
+
return {
|
|
754
|
+
name: "@getvitops/astro:virtual-reviews",
|
|
755
|
+
resolveId(id) {
|
|
756
|
+
return id === REVIEWS_ID ? RESOLVED_REVIEWS_ID : null;
|
|
757
|
+
},
|
|
758
|
+
load(id) {
|
|
759
|
+
if (id !== RESOLVED_REVIEWS_ID) return null;
|
|
760
|
+
this?.addWatchFile?.(path);
|
|
761
|
+
const cache = readCache(path) ?? {
|
|
762
|
+
version: 1,
|
|
763
|
+
locations: {}
|
|
764
|
+
};
|
|
765
|
+
return `export default ${JSON.stringify(cache)};`;
|
|
766
|
+
}
|
|
767
|
+
};
|
|
768
|
+
}
|
|
690
769
|
/** Defaults live here so the component and the integration can't drift. */
|
|
691
770
|
const ICON_DEFAULTS = {
|
|
692
771
|
ui: "fa7-solid",
|
|
@@ -731,6 +810,46 @@ function readsAsConfig(path) {
|
|
|
731
810
|
return false;
|
|
732
811
|
}
|
|
733
812
|
}
|
|
813
|
+
/** Whether the site config sets `site.icons.sprite` — the fact that makes the generator emit icons.svg. */
|
|
814
|
+
function siteWantsSprite(root, siteInput, siteEnv) {
|
|
815
|
+
if (!siteInput) return false;
|
|
816
|
+
try {
|
|
817
|
+
const cfg = resolveConfig(JSON.parse(readFileSync(resolve(root, siteInput), "utf8")), siteEnv ?? "production");
|
|
818
|
+
return Boolean(cfg.site.icons?.sprite);
|
|
819
|
+
} catch {
|
|
820
|
+
return false;
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
/**
|
|
824
|
+
* A standalone page hosting `<wc-theme-editor>`, for a host that cannot load the
|
|
825
|
+
* framework CSS itself (the EmDash admin embeds this in an iframe, so the design
|
|
826
|
+
* system never touches the admin's own styles). `?save=<url>` supplies the save
|
|
827
|
+
* endpoint, which is the host's to name, not this integration's.
|
|
828
|
+
*/
|
|
829
|
+
function writeEditorHost(dest) {
|
|
830
|
+
mkdirSync(dest, { recursive: true });
|
|
831
|
+
writeFileSync(join(dest, "design-system-editor.html"), `<!doctype html>
|
|
832
|
+
<html lang="en">
|
|
833
|
+
<head>
|
|
834
|
+
<meta charset="utf-8">
|
|
835
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
836
|
+
<meta name="robots" content="noindex">
|
|
837
|
+
<title>Design system editor</title>
|
|
838
|
+
<link rel="stylesheet" href="/vitops/editor.css">
|
|
839
|
+
</head>
|
|
840
|
+
<body>
|
|
841
|
+
<script type="module">
|
|
842
|
+
await import('/vitops/editor.js');
|
|
843
|
+
const el = document.createElement('wc-theme-editor');
|
|
844
|
+
el.setAttribute('manifest', '/vitops/design-manifest.json');
|
|
845
|
+
const save = new URLSearchParams(location.search).get('save');
|
|
846
|
+
if (save && save.startsWith('/')) el.setAttribute('save-endpoint', save);
|
|
847
|
+
document.body.append(el);
|
|
848
|
+
<\/script>
|
|
849
|
+
</body>
|
|
850
|
+
</html>
|
|
851
|
+
`);
|
|
852
|
+
}
|
|
734
853
|
function vitops(opts = {}) {
|
|
735
854
|
return {
|
|
736
855
|
name: "@getvitops/astro",
|
|
@@ -739,7 +858,7 @@ function vitops(opts = {}) {
|
|
|
739
858
|
const publicDir = fileURLToPath(config.publicDir);
|
|
740
859
|
const webComponents = opts.webComponents !== false;
|
|
741
860
|
const editor = opts.editor === true;
|
|
742
|
-
if (editor && !opts.css) logger.warn("editor
|
|
861
|
+
if ((editor || opts.editorManifest) && !opts.css) logger.warn("editor/editorManifest needs a `css` config — it reads design-manifest.json.");
|
|
743
862
|
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`.");
|
|
744
863
|
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`.");
|
|
745
864
|
const cssInput = opts.css ? opts.css.input ?? "design-system.json" : void 0;
|
|
@@ -766,6 +885,7 @@ function vitops(opts = {}) {
|
|
|
766
885
|
const analytics = resolveAnalytics(opts.analytics, { consent: consentEnabled });
|
|
767
886
|
const trackingOpts = opts.tracking === true ? { enabled: true } : opts.tracking || (siteConsent?.tracking?.enabled ? siteConsent.tracking : void 0);
|
|
768
887
|
const adsConfig = siteInput ? readSiteAds(resolve(root, siteInput), opts.site?.siteEnv ?? "production") : null;
|
|
888
|
+
const turnstileSiteKey = siteInput ? readSiteTurnstileKey(resolve(root, siteInput), opts.site?.siteEnv ?? "production") : null;
|
|
769
889
|
const experimentsConfig = siteInput ? readSiteExperiments(resolve(root, siteInput), opts.site?.siteEnv ?? "production") : null;
|
|
770
890
|
const detectedCategories = consentCategories([...analytics.tags, ...resolveAds(adsConfig?.properties, {
|
|
771
891
|
consent: consentEnabled,
|
|
@@ -812,8 +932,15 @@ function vitops(opts = {}) {
|
|
|
812
932
|
logger.info("generated favicons + manifest → public/");
|
|
813
933
|
}
|
|
814
934
|
let headFonts = [];
|
|
815
|
-
|
|
816
|
-
|
|
935
|
+
let fontsOpt = opts.fonts;
|
|
936
|
+
if (fontsOpt === void 0 && siteInput) try {
|
|
937
|
+
if ((resolveConfig(JSON.parse(readFileSync(resolve(root, siteInput), "utf8")), opts.site?.siteEnv ?? "production").site.fonts ?? []).length > 0) {
|
|
938
|
+
fontsOpt = true;
|
|
939
|
+
logger.info(`fonts: ${siteInput} declares site.fonts, so webfonts are on (pass fonts: false to opt out)`);
|
|
940
|
+
}
|
|
941
|
+
} catch {}
|
|
942
|
+
if (fontsOpt) {
|
|
943
|
+
const given = Array.isArray(fontsOpt) ? { families: fontsOpt } : typeof fontsOpt === "string" ? { input: fontsOpt } : fontsOpt === true ? {} : fontsOpt;
|
|
817
944
|
const o = {
|
|
818
945
|
...given,
|
|
819
946
|
...given.input == null && given.families == null && siteInput ? {
|
|
@@ -851,11 +978,12 @@ function vitops(opts = {}) {
|
|
|
851
978
|
"polyfills"
|
|
852
979
|
] : [];
|
|
853
980
|
if (consentRuntime) bundles.push("consent.js");
|
|
854
|
-
if (editor) bundles.push("editor.js");
|
|
981
|
+
if (editor || opts.editorManifest) bundles.push("editor.js");
|
|
855
982
|
for (const f of bundles) {
|
|
856
983
|
const src = join(coreDist, f);
|
|
857
984
|
if (existsSync(src)) cpSync(src, join(dest, f), { recursive: true });
|
|
858
985
|
}
|
|
986
|
+
if (opts.editorManifest) writeEditorHost(dest);
|
|
859
987
|
logger.info("copied web-component bundles → public/vitops/");
|
|
860
988
|
} else logger.warn(`@getvitops/core dist not found (${coreDist}); run build:core. Skipping WC copy.`);
|
|
861
989
|
}
|
|
@@ -968,43 +1096,51 @@ function vitops(opts = {}) {
|
|
|
968
1096
|
sprite: engine === "sprite" || o.sprite ? spriteHref : null
|
|
969
1097
|
};
|
|
970
1098
|
}
|
|
971
|
-
updateConfig({ vite: {
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1099
|
+
updateConfig({ vite: {
|
|
1100
|
+
ssr: { optimizeDeps: { include: ["@getvitops/astro > @getvitops/generator/icons-sprite"] } },
|
|
1101
|
+
plugins: [
|
|
1102
|
+
virtualReviewsPlugin(root),
|
|
1103
|
+
virtualIconsPlugin(iconsData ?? {
|
|
1104
|
+
engine: "none",
|
|
1105
|
+
root,
|
|
1106
|
+
ui: ICON_DEFAULTS.ui,
|
|
1107
|
+
brand: ICON_DEFAULTS.brand,
|
|
1108
|
+
weight: null,
|
|
1109
|
+
overrides: {},
|
|
1110
|
+
sprite: null
|
|
1111
|
+
}),
|
|
1112
|
+
virtualHeadPlugin({
|
|
1113
|
+
favicons: !!opts.favicon,
|
|
1114
|
+
faviconLinks: opts.favicon ? faviconLinks({
|
|
1115
|
+
hasSvg,
|
|
1116
|
+
manifest: hasManifest
|
|
1117
|
+
}) : [],
|
|
1118
|
+
themeColor: opts.favicon?.themeColor ?? null,
|
|
1119
|
+
webComponents,
|
|
1120
|
+
wcBase: "/vitops",
|
|
1121
|
+
editor,
|
|
1122
|
+
sitemap: sitemapHref,
|
|
1123
|
+
fonts: headFonts,
|
|
1124
|
+
site: config.site ?? null,
|
|
1125
|
+
seo: seoDefaults,
|
|
1126
|
+
analytics: opts.analytics ?? {},
|
|
1127
|
+
consent: consentEnabled,
|
|
1128
|
+
consentCategories: offeredCategories,
|
|
1129
|
+
consentPolicyUrl: consentOpts.policyUrl ?? null,
|
|
1130
|
+
consentRuntime,
|
|
1131
|
+
tracking: tracking.enabled ? {
|
|
1132
|
+
enabled: true,
|
|
1133
|
+
category: tracking.category,
|
|
1134
|
+
cookies: tracking.cookies,
|
|
1135
|
+
abVariantCookie: experimentsConfig?.primaryCookie ?? null
|
|
1136
|
+
} : null,
|
|
1137
|
+
experiments: experimentsConfig?.experiments ?? {},
|
|
1138
|
+
matomoAbVariantDimensions: experimentsConfig?.matomoAbVariantDimensions ?? {},
|
|
1139
|
+
ads: adsConfig,
|
|
1140
|
+
turnstileSiteKey
|
|
1141
|
+
})
|
|
1142
|
+
]
|
|
1143
|
+
} });
|
|
1008
1144
|
if (opts.css) {
|
|
1009
1145
|
const format = opts.css.format ?? "tailwind";
|
|
1010
1146
|
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.");
|
|
@@ -1014,15 +1150,16 @@ function vitops(opts = {}) {
|
|
|
1014
1150
|
...opts.css.theme != null ? { theme: opts.css.theme } : {},
|
|
1015
1151
|
format,
|
|
1016
1152
|
out,
|
|
1017
|
-
...editor ? { editorManifestDir: publicDir } : {},
|
|
1018
|
-
...iconsData?.sprite ? { spriteDir: publicDir } : {},
|
|
1153
|
+
...editor || opts.editorManifest ? { editorManifestDir: publicDir } : {},
|
|
1154
|
+
...iconsData?.sprite || siteWantsSprite(root, siteInput, opts.site?.siteEnv) ? { spriteDir: publicDir } : {},
|
|
1019
1155
|
...opts.legal ? { legal: {
|
|
1020
1156
|
...opts.legal,
|
|
1021
1157
|
input: legalInput
|
|
1022
1158
|
} } : {},
|
|
1023
1159
|
...opts.media ? { media: opts.media } : {},
|
|
1024
1160
|
...opts.site ? { site: opts.site } : {},
|
|
1025
|
-
...opts.css.systemColorScheme != null ? { systemColorScheme: opts.css.systemColorScheme } : {}
|
|
1161
|
+
...opts.css.systemColorScheme != null ? { systemColorScheme: opts.css.systemColorScheme } : {},
|
|
1162
|
+
...format === "tailwind" ? { sources: tailwindSources(resolve(root, out), (pkg) => createRequire(resolve(root, "package.json")).resolve(`${pkg}/package.json`)) } : {}
|
|
1026
1163
|
})];
|
|
1027
1164
|
if (format === "tailwind") {
|
|
1028
1165
|
let tailwindcss;
|
|
@@ -1048,6 +1185,124 @@ function vitops(opts = {}) {
|
|
|
1048
1185
|
};
|
|
1049
1186
|
}
|
|
1050
1187
|
|
|
1188
|
+
// src/inline-scripts.ts
|
|
1189
|
+
/**
|
|
1190
|
+
* The inline scripts `<Head />` emits, as plain strings.
|
|
1191
|
+
*
|
|
1192
|
+
* They live here rather than in `Head.astro`'s frontmatter for one reason: a
|
|
1193
|
+
* Content-Security-Policy that doesn't allow `'unsafe-inline'` has to name each
|
|
1194
|
+
* inline script by SHA-256, and the hash is of the exact emitted bytes. One
|
|
1195
|
+
* constant imported by both the component (which emits it) and `csp.ts` (which
|
|
1196
|
+
* hashes it) makes "the hash matches what the page serves" true by construction
|
|
1197
|
+
* rather than by a test remembering to compare two copies. `csp.test.ts` and the
|
|
1198
|
+
* container render test still assert it against real rendered HTML.
|
|
1199
|
+
*
|
|
1200
|
+
* No imports and no Node builtins: `Head.astro` runs in a Worker for any page
|
|
1201
|
+
* that is rendered on demand.
|
|
1202
|
+
*/
|
|
1203
|
+
/**
|
|
1204
|
+
* Applies the remembered colour scheme before first paint.
|
|
1205
|
+
*
|
|
1206
|
+
* The key MUST match `WCColorSchemeToggle.STORAGE_KEY` in @getvitops/core;
|
|
1207
|
+
* `head.test.ts` asserts they stay in step. try/catch because localStorage
|
|
1208
|
+
* throws in private/partitioned contexts.
|
|
1209
|
+
*/
|
|
1210
|
+
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){}";
|
|
1211
|
+
/**
|
|
1212
|
+
* The consent gate's build-time signal: "something on this page may need
|
|
1213
|
+
* permission, and the real gate hasn't loaded yet."
|
|
1214
|
+
*
|
|
1215
|
+
* `consent.js` and `elements.js` are both deferred modules with no ordering
|
|
1216
|
+
* guarantee between them, so `<wc-color-scheme-toggle>` can upgrade and be clicked
|
|
1217
|
+
* before the gate exists. Without this, a caller probing for
|
|
1218
|
+
* `window.vitopsConsent` in that window would see nothing, correctly conclude
|
|
1219
|
+
* that sites without the gate should just store — and store. The stub closes the
|
|
1220
|
+
* window by making the gate's *existence* known synchronously in `<head>`, which
|
|
1221
|
+
* is the only place that beats every deferred bundle.
|
|
1222
|
+
*
|
|
1223
|
+
* It answers `false` to everything, because nothing is granted until the store
|
|
1224
|
+
* has been read, and queues what it was asked so `consent/runtime.ts` can replay
|
|
1225
|
+
* it on evaluation. `q` entries are `[category]` for `require()` and
|
|
1226
|
+
* `[category, resolve]` for `request()`. Same shape as the `dataLayer`/`gtag`
|
|
1227
|
+
* stub in `analytics.ts` and for the same reason.
|
|
1228
|
+
*
|
|
1229
|
+
* Emitted only when the gate is on: its absence is what tells a caller the site
|
|
1230
|
+
* has no gate at all.
|
|
1231
|
+
*/
|
|
1232
|
+
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}};";
|
|
1233
|
+
|
|
1234
|
+
// src/csp.ts
|
|
1235
|
+
/**
|
|
1236
|
+
* Content-Security-Policy helpers for the inline scripts `<Head />` emits.
|
|
1237
|
+
*
|
|
1238
|
+
* `<Head />` writes up to two inline `<script>`s into every page: the pre-paint
|
|
1239
|
+
* colour-scheme bootstrap and the consent-gate stub. A policy without
|
|
1240
|
+
* `'unsafe-inline'` blocks both unless each is named by hash, and both are
|
|
1241
|
+
* constants (`inline-scripts.ts`), so their hashes are fixed per package version
|
|
1242
|
+
* and can sit in a static `_headers` file — which is exactly what the scaffolded
|
|
1243
|
+
* templates do, in `Content-Security-Policy-Report-Only` mode.
|
|
1244
|
+
*
|
|
1245
|
+
* Why hashes and not nonces: a nonce is per response, so it needs the page to be
|
|
1246
|
+
* rendered by a Worker on every request. The templates' pages are prerendered
|
|
1247
|
+
* static assets, which have no request to attach a nonce to. Hashes also survive
|
|
1248
|
+
* being cached.
|
|
1249
|
+
*
|
|
1250
|
+
* What this does NOT cover, and the policy says so rather than hiding it: scripts
|
|
1251
|
+
* the page gets from elsewhere (EmDash's own head tags, a server-island loader,
|
|
1252
|
+
* an editor's HTML block, the consent gate activating a third-party tag). Those
|
|
1253
|
+
* are what Report-Only is for — see the template's AGENTS.md.
|
|
1254
|
+
*
|
|
1255
|
+
* Node-only (`node:crypto`), so it is exported from the package root — a build
|
|
1256
|
+
* time import — and never from a Worker entry.
|
|
1257
|
+
*/
|
|
1258
|
+
/** The CSP source expression for one inline script: `'sha256-<base64>'`. */
|
|
1259
|
+
function cspScriptHash(script) {
|
|
1260
|
+
return `'sha256-${createHash("sha256").update(script, "utf8").digest("base64")}'`;
|
|
1261
|
+
}
|
|
1262
|
+
/**
|
|
1263
|
+
* CSP hash sources for every inline script `<Head />` can emit.
|
|
1264
|
+
*
|
|
1265
|
+
* Defaults to ALL of them: listing a hash for a script the page doesn't emit is
|
|
1266
|
+
* harmless, while omitting one a page does emit is a violation.
|
|
1267
|
+
*/
|
|
1268
|
+
function inlineScriptHashes(opts = {}) {
|
|
1269
|
+
const out = [];
|
|
1270
|
+
if (opts.webComponents !== false) out.push(cspScriptHash(THEME_SCRIPT));
|
|
1271
|
+
if (opts.consent !== false) out.push(cspScriptHash(CONSENT_STUB));
|
|
1272
|
+
return out;
|
|
1273
|
+
}
|
|
1274
|
+
/**
|
|
1275
|
+
* A strict starting policy for a prerendered site.
|
|
1276
|
+
*
|
|
1277
|
+
* `script-src` is `'self'` plus the `<Head />` hashes — no `'unsafe-inline'`.
|
|
1278
|
+
* `style-src` keeps `'unsafe-inline'` because Astro inlines scoped `<style>` and
|
|
1279
|
+
* components set `style=""` attributes; a style injection is far less dangerous
|
|
1280
|
+
* than a script one, and hashing every stylesheet would break on any edit.
|
|
1281
|
+
* `img-src` allows `https:` because editors paste images from anywhere.
|
|
1282
|
+
*/
|
|
1283
|
+
function contentSecurityPolicy(opts = {}) {
|
|
1284
|
+
const list = (name, ...sources) => `${name} ${sources.flatMap((s) => s ?? []).join(" ")}`.trim();
|
|
1285
|
+
const directives = [
|
|
1286
|
+
"default-src 'self'",
|
|
1287
|
+
list("script-src", ["'self'"], inlineScriptHashes(opts.inline), opts.scriptSrc),
|
|
1288
|
+
"style-src 'self' 'unsafe-inline'",
|
|
1289
|
+
list("img-src", [
|
|
1290
|
+
"'self'",
|
|
1291
|
+
"data:",
|
|
1292
|
+
"https:"
|
|
1293
|
+
], opts.imgSrc),
|
|
1294
|
+
"font-src 'self'",
|
|
1295
|
+
list("connect-src", ["'self'"], opts.connectSrc),
|
|
1296
|
+
list("frame-src", opts.frameSrc?.length ? opts.frameSrc : ["'none'"]),
|
|
1297
|
+
"object-src 'none'",
|
|
1298
|
+
"base-uri 'self'",
|
|
1299
|
+
"form-action 'self'",
|
|
1300
|
+
"frame-ancestors 'self'"
|
|
1301
|
+
];
|
|
1302
|
+
if (opts.reportUri) directives.push(`report-uri ${opts.reportUri}`);
|
|
1303
|
+
return directives.join("; ");
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1051
1306
|
// src/lastmod.ts
|
|
1052
1307
|
/**
|
|
1053
1308
|
* Real per-page `<lastmod>` for the sitemap, derived from git.
|
|
@@ -1328,5 +1583,5 @@ function resolveSeo(defaults, props, ctx) {
|
|
|
1328
1583
|
};
|
|
1329
1584
|
}
|
|
1330
1585
|
|
|
1331
|
-
export { TRACKING_COOKIE, consentCategories, vitops as default, gitLastmod, mergeSeoDefaults, resolveAds, resolveAnalytics, resolveSeo, resolveSeoDefaults, resolveTracking, routeFromPage, slugFromContent };
|
|
1586
|
+
export { TRACKING_COOKIE, consentCategories, contentSecurityPolicy, cspScriptHash, vitops as default, gitLastmod, inlineScriptHashes, mergeSeoDefaults, resolveAds, resolveAnalytics, resolveSeo, resolveSeoDefaults, resolveTracking, routeFromPage, slugFromContent };
|
|
1332
1587
|
//# sourceMappingURL=index.mjs.map
|