@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.
- package/CHANGELOG.md +584 -26
- package/dist/config-adapters-CbTywgEY.mjs +71 -0
- package/dist/config-adapters-CbTywgEY.mjs.map +1 -0
- package/dist/exports/tracking.d.mts +1 -1
- package/dist/exports/tracking.mjs +1 -1
- package/dist/index.d.mts +83 -43
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +351 -60
- package/dist/index.mjs.map +1 -1
- package/dist/middleware.d.mts +0 -2
- package/dist/middleware.d.mts.map +1 -1
- package/dist/middleware.mjs +1 -2
- package/dist/routes/conversion.d.mts +71 -15
- package/dist/routes/conversion.d.mts.map +1 -1
- package/dist/routes/conversion.mjs +79 -68
- package/dist/routes/conversion.mjs.map +1 -1
- package/dist/{tracking-M2PqlMpw.mjs → tracking-Cb3A0BrC.mjs} +2 -3
- package/dist/tracking-Cb3A0BrC.mjs.map +1 -0
- package/dist/{tracking-BNKKBqOe.d.mts → tracking-D2xOzjFD.d.mts} +5 -25
- package/dist/tracking-D2xOzjFD.d.mts.map +1 -0
- package/package.json +18 -6
- package/src/ads.ts +15 -22
- package/src/components/CookieConsent.astro +2 -1
- package/src/components/Entries.astro +58 -0
- package/src/components/Head.astro +6 -37
- package/src/components/ReviewCardBody.astro +16 -2
- 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/src/schemas/Article.astro +2 -1
- package/src/schemas/BookActions.astro +2 -1
- package/src/schemas/Breadcrumb.astro +2 -1
- package/src/schemas/Carousel.astro +2 -1
- package/src/schemas/CourseList.astro +2 -1
- package/src/schemas/Dataset.astro +2 -1
- package/src/schemas/DiscussionForum.astro +2 -1
- package/src/schemas/EducationQA.astro +2 -1
- package/src/schemas/EmployerAggregateRating.astro +2 -1
- package/src/schemas/Event.astro +2 -1
- package/src/schemas/FAQ.astro +2 -1
- package/src/schemas/FactCheck.astro +2 -1
- package/src/schemas/ImageMetadata.astro +2 -1
- package/src/schemas/JobPosting.astro +2 -1
- package/src/schemas/LocalBusiness.astro +2 -1
- package/src/schemas/MathSolver.astro +2 -1
- package/src/schemas/MovieCarousel.astro +2 -1
- package/src/schemas/Organization.astro +2 -1
- package/src/schemas/Product.astro +2 -1
- package/src/schemas/ProfilePage.astro +2 -1
- package/src/schemas/QAPage.astro +2 -1
- package/src/schemas/Recipe.astro +2 -1
- package/src/schemas/Review.astro +2 -1
- package/src/schemas/SoftwareApp.astro +2 -1
- package/src/seo.ts +6 -5
- package/src/tracking.ts +5 -9
- package/virtual.d.ts +17 -0
- package/dist/tracking-BNKKBqOe.d.mts.map +0 -1
- package/dist/tracking-M2PqlMpw.mjs.map +0 -1
package/dist/index.mjs
CHANGED
|
@@ -1,18 +1,22 @@
|
|
|
1
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
388
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
741
|
-
|
|
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: {
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
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
|
-
|
|
977
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|