@getvitops/astro 8.1.0 → 8.2.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 (59) hide show
  1. package/CHANGELOG.md +824 -33
  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 +65 -48
  7. package/dist/index.d.mts.map +1 -1
  8. package/dist/index.mjs +240 -32
  9. package/dist/index.mjs.map +1 -1
  10. package/dist/middleware.d.mts +23 -0
  11. package/dist/middleware.d.mts.map +1 -0
  12. package/dist/middleware.mjs +34 -0
  13. package/dist/middleware.mjs.map +1 -0
  14. package/dist/routes/conversion.d.mts +131 -16
  15. package/dist/routes/conversion.d.mts.map +1 -1
  16. package/dist/routes/conversion.mjs +133 -28
  17. package/dist/routes/conversion.mjs.map +1 -1
  18. package/dist/{tracking-CmSfxWC3.mjs → tracking-Cb3A0BrC.mjs} +3 -4
  19. package/dist/tracking-Cb3A0BrC.mjs.map +1 -0
  20. package/dist/{tracking-DdEKExlE.d.mts → tracking-D2xOzjFD.d.mts} +6 -26
  21. package/dist/tracking-D2xOzjFD.d.mts.map +1 -0
  22. package/package.json +12 -8
  23. package/src/ads.ts +52 -16
  24. package/src/analytics.ts +98 -11
  25. package/src/components/Analytics.astro +5 -1
  26. package/src/components/CookieConsent.astro +2 -1
  27. package/src/components/Icon.astro +8 -1
  28. package/src/components/ReviewCardBody.astro +5 -2
  29. package/src/components/Tracking.astro +2 -1
  30. package/src/schemas/Article.astro +2 -1
  31. package/src/schemas/BookActions.astro +2 -1
  32. package/src/schemas/Breadcrumb.astro +2 -1
  33. package/src/schemas/Carousel.astro +2 -1
  34. package/src/schemas/CourseList.astro +2 -1
  35. package/src/schemas/Dataset.astro +2 -1
  36. package/src/schemas/DiscussionForum.astro +2 -1
  37. package/src/schemas/EducationQA.astro +2 -1
  38. package/src/schemas/EmployerAggregateRating.astro +2 -1
  39. package/src/schemas/Event.astro +2 -1
  40. package/src/schemas/FAQ.astro +2 -1
  41. package/src/schemas/FactCheck.astro +2 -1
  42. package/src/schemas/ImageMetadata.astro +2 -1
  43. package/src/schemas/JobPosting.astro +2 -1
  44. package/src/schemas/LocalBusiness.astro +2 -1
  45. package/src/schemas/MathSolver.astro +2 -1
  46. package/src/schemas/MovieCarousel.astro +2 -1
  47. package/src/schemas/Organization.astro +2 -1
  48. package/src/schemas/Product.astro +2 -1
  49. package/src/schemas/ProfilePage.astro +2 -1
  50. package/src/schemas/QAPage.astro +2 -1
  51. package/src/schemas/Recipe.astro +2 -1
  52. package/src/schemas/Review.astro +2 -1
  53. package/src/schemas/SoftwareApp.astro +2 -1
  54. package/src/scripts/tracking.ts +114 -21
  55. package/src/seo.ts +6 -5
  56. package/src/tracking.ts +6 -10
  57. package/virtual.d.ts +17 -1
  58. package/dist/tracking-CmSfxWC3.mjs.map +0 -1
  59. package/dist/tracking-DdEKExlE.d.mts.map +0 -1
package/dist/index.mjs CHANGED
@@ -1,18 +1,34 @@
1
- import { n as TRACKING_ENDPOINT, r as resolveTracking, t as TRACKING_COOKIE } from "./tracking-CmSfxWC3.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";
8
- import { AD_PLATFORMS, AD_PROVIDER_KEYS } from "@getvitops/utils/ads";
9
+ import { AD_PLATFORMS, AD_PROVIDER_KEYS, normalizeConversionLabels } from "@getvitops/utils/ads";
9
10
  import { fontProviders } from "astro/config";
11
+ import { t } from "@getvitops/utils/content";
10
12
  import { execFile } from "node:child_process";
11
13
  import { promisify } from "node:util";
12
14
 
13
15
  export * from "@getvitops/utils"
14
16
 
15
- //#region src/analytics.ts
17
+ // src/analytics.ts
18
+ /**
19
+ * A small inline snippet reading each experiment's cookie into a plain
20
+ * object, skipping any experiment whose cookie isn't set (visitor not yet
21
+ * assigned, or the experiment disabled after this page was cached). Shared
22
+ * across providers so the cookie-parsing logic — and its `SAFE_ID` guard on
23
+ * both the experiment key and cookie name, the same discipline `bad()`
24
+ * already applies to provider IDs landing in an inline `<script>` — exists
25
+ * exactly once.
26
+ */
27
+ function experimentCookieReads(experiments) {
28
+ const entries = Object.entries(experiments).filter(([key, e]) => !bad(key) && !bad(e.cookieName));
29
+ if (!entries.length) return "";
30
+ return "function _abrc(n){var m=document.cookie.match(new RegExp('(?:^|; )'+n+'=([^;]*)'));return m?decodeURIComponent(m[1]):null;}var _ab={};" + entries.map(([key, e]) => `_ab[${JSON.stringify(key)}]=_abrc(${JSON.stringify(e.cookieName)});`).join("") + `for(var _k in _ab){if(_ab[_k]==null)delete _ab[_k];}`;
31
+ }
16
32
  /**
17
33
  * IDs land inside an inline `<script>`, so they are checked rather than trusted.
18
34
  * A `<\/script>` in a measurement ID is not a plausible accident, but the cost of
@@ -27,10 +43,12 @@ function bad(value) {
27
43
  function literal(value) {
28
44
  return JSON.stringify(value).replaceAll("<", "\\u003c");
29
45
  }
30
- function googleAnalytics(input) {
46
+ function googleAnalytics(input, experiments = {}) {
31
47
  const opts = typeof input === "string" ? { id: input } : input;
32
48
  if (bad(opts.id)) return `googleAnalytics: "${opts.id}" is not a valid measurement ID. Skipped.`;
33
- const config = opts.config ? `,${literal(opts.config)}` : "";
49
+ const staticConfig = opts.config ?? {};
50
+ const reads = experimentCookieReads(experiments);
51
+ const gtagConfig = reads ? `${reads}gtag('config','${opts.id}',Object.assign(${literal(staticConfig)},_ab));` : `gtag('config','${opts.id}'${opts.config ? `,${literal(staticConfig)}` : ""});`;
34
52
  return {
35
53
  key: "googleAnalytics",
36
54
  provider: "Google Analytics",
@@ -45,7 +63,7 @@ function googleAnalytics(input) {
45
63
  ],
46
64
  strategy: "idle",
47
65
  gated: true,
48
- inline: `window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments)}gtag('js',new Date());gtag('config','${opts.id}'${config});`,
66
+ inline: "window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments)}gtag('js',new Date());" + gtagConfig,
49
67
  src: `https://www.googletagmanager.com/gtag/js?id=${encodeURIComponent(opts.id)}`,
50
68
  attrs: {}
51
69
  };
@@ -74,7 +92,7 @@ function clarity(input) {
74
92
  attrs: {}
75
93
  };
76
94
  }
77
- function matomo(opts) {
95
+ function matomo(opts, experiments = {}, dimensions = {}) {
78
96
  const siteId = String(opts.siteId);
79
97
  if (bad(siteId)) return `matomo: "${siteId}" is not a valid site ID. Skipped.`;
80
98
  let base;
@@ -85,6 +103,9 @@ function matomo(opts) {
85
103
  }
86
104
  const url = `${base.origin}${base.pathname.replace(/\/*$/, "")}/`;
87
105
  const cookies = opts.cookies === true;
106
+ const dimensionEntries = Object.entries(experiments).filter(([key]) => dimensions[key] != null);
107
+ const reads = experimentCookieReads(Object.fromEntries(dimensionEntries));
108
+ const dimensionPushes = reads ? reads + dimensionEntries.map(([key]) => `if(_ab[${JSON.stringify(key)}]!=null)_paq.push(['setCustomDimension',${dimensions[key]},_ab[${JSON.stringify(key)}]]);`).join("") : "";
88
109
  return {
89
110
  key: "matomo",
90
111
  provider: "Matomo",
@@ -99,14 +120,15 @@ function matomo(opts) {
99
120
  ] : [],
100
121
  strategy: "idle",
101
122
  gated: true,
102
- inline: `var _paq=window._paq=window._paq||[];` + (cookies ? "" : `_paq.push(['disableCookies']);`) + `_paq.push(['trackPageView']);_paq.push(['enableLinkTracking']);(function(){var u="${url}";_paq.push(['setTrackerUrl',u+'matomo.php']);_paq.push(['setSiteId','${siteId}']);var d=document,g=d.createElement('script'),s=d.getElementsByTagName('script')[0];g.async=true;g.src=u+'matomo.js';s.parentNode.insertBefore(g,s)})();`,
123
+ inline: `var _paq=window._paq=window._paq||[];` + (cookies ? "" : `_paq.push(['disableCookies']);`) + dimensionPushes + `_paq.push(['trackPageView']);_paq.push(['enableLinkTracking']);(function(){var u="${url}";_paq.push(['setTrackerUrl',u+'matomo.php']);_paq.push(['setSiteId','${siteId}']);var d=document,g=d.createElement('script'),s=d.getElementsByTagName('script')[0];g.async=true;g.src=u+'matomo.js';s.parentNode.insertBefore(g,s)})();`,
103
124
  src: null,
104
125
  attrs: {}
105
126
  };
106
127
  }
107
- function plausible(input) {
128
+ function plausible(input, experiments = {}) {
108
129
  const opts = typeof input === "string" ? { domain: input } : input;
109
130
  if (bad(opts.domain)) return `plausible: "${opts.domain}" is not a valid domain. Skipped.`;
131
+ const reads = experimentCookieReads(experiments);
110
132
  return {
111
133
  key: "plausible",
112
134
  provider: "Plausible Analytics",
@@ -115,7 +137,7 @@ function plausible(input) {
115
137
  cookies: [],
116
138
  strategy: "idle",
117
139
  gated: true,
118
- inline: "",
140
+ inline: reads ? `window.plausible=window.plausible||function(){(window.plausible.q=window.plausible.q||[]).push(arguments)};plausible.init({customProperties:function(){${reads}return _ab;}});` : "",
119
141
  src: opts.src ?? "https://plausible.io/js/script.js",
120
142
  attrs: { "data-domain": opts.domain }
121
143
  };
@@ -136,11 +158,12 @@ function resolveAnalytics(opts, ctx = {}) {
136
158
  warnings
137
159
  };
138
160
  const strategy = opts.strategy ?? "idle";
161
+ const experiments = ctx.experiments ?? {};
139
162
  const results = [
140
- opts.googleAnalytics === void 0 ? null : googleAnalytics(opts.googleAnalytics),
163
+ opts.googleAnalytics === void 0 ? null : googleAnalytics(opts.googleAnalytics, experiments),
141
164
  opts.clarity === void 0 ? null : clarity(opts.clarity),
142
- opts.matomo === void 0 ? null : matomo(opts.matomo),
143
- opts.plausible === void 0 ? null : plausible(opts.plausible)
165
+ opts.matomo === void 0 ? null : matomo(opts.matomo, experiments, ctx.matomoAbVariantDimensions ?? {}),
166
+ opts.plausible === void 0 ? null : plausible(opts.plausible, experiments)
144
167
  ];
145
168
  for (const result of results) {
146
169
  if (result === null) continue;
@@ -174,8 +197,7 @@ function consentCategories(tags) {
174
197
  ].filter((c) => used.has(c));
175
198
  }
176
199
 
177
- //#endregion
178
- //#region src/ads.ts
200
+ // src/ads.ts
179
201
  /**
180
202
  * The `<Ads />` resolution layer — the site's ad properties in, the exact tags to
181
203
  * emit out.
@@ -239,6 +261,18 @@ function resolveAds(ads, ctx = {}) {
239
261
  warnings.push(`ads: ${provider} ${platform.tag.needs} "${id}" is not a valid ID. Skipped.`);
240
262
  continue;
241
263
  }
264
+ let safeEntry = entry;
265
+ const labels = normalizeConversionLabels(entry.conversionLabel);
266
+ const invalidActions = Object.entries(labels).filter(([, label]) => label && !SAFE_ID.test(label)).map(([action]) => action);
267
+ if (invalidActions.length) {
268
+ for (const action of invalidActions) warnings.push(`ads: ${provider} conversionLabel.${action} "${labels[action]}" is not a valid label. Conversion firing skipped for that action.`);
269
+ const cleanLabels = Object.fromEntries(Object.entries(labels).filter(([action]) => !invalidActions.includes(action)));
270
+ const { conversionLabel: _drop, ...rest } = entry;
271
+ safeEntry = Object.keys(cleanLabels).length ? {
272
+ ...rest,
273
+ conversionLabel: cleanLabels
274
+ } : rest;
275
+ }
242
276
  const category = entry.category ?? "marketing";
243
277
  tags.push({
244
278
  key: provider,
@@ -250,7 +284,7 @@ function resolveAds(ads, ctx = {}) {
250
284
  gated: ctx.consent === true,
251
285
  inline: platform.tag.bootstrap(id, {
252
286
  provider,
253
- ...entry
287
+ ...safeEntry
254
288
  }),
255
289
  src: platform.tag.src ? platform.tag.src(id) : null,
256
290
  attrs: {}
@@ -273,8 +307,7 @@ function resolveAds(ads, ctx = {}) {
273
307
  };
274
308
  }
275
309
 
276
- //#endregion
277
- //#region src/fonts.ts
310
+ // src/fonts.ts
278
311
  /**
279
312
  * Provider names, matching `fontProviders`' keys 1:1 (verified against astro@7.1.2).
280
313
  * The generator's `SiteFontSchema` enumerates the same set, so a mismatch is a
@@ -291,7 +324,7 @@ const PROVIDERS = new Set(Object.keys(fontProviders));
291
324
  * key into a committed file or inventing an interpolation syntax, so Adobe families are
292
325
  * declared in `astro.config` directly — see the error thrown below.
293
326
  */
294
- const NEEDS_OPTIONS = new Set(["adobe"]);
327
+ const NEEDS_OPTIONS = /* @__PURE__ */ new Set(["adobe"]);
295
328
  /**
296
329
  * Map declarations onto Astro's `fonts:` entries.
297
330
  *
@@ -326,8 +359,93 @@ function resolveFonts(decls) {
326
359
  };
327
360
  }
328
361
 
329
- //#endregion
330
- //#region src/integration.ts
362
+ // src/i18n.ts
363
+ function codesOf(entry) {
364
+ return typeof entry === "string" ? [entry] : entry.codes;
365
+ }
366
+ /** Every BCP-47 code Astro's `i18n` block actually recognises. */
367
+ function astroLocaleCodes(i18n) {
368
+ return new Set(i18n.locales.flatMap(codesOf));
369
+ }
370
+ /**
371
+ * Warning strings for a mismatch — `[]` when either side is absent (nothing
372
+ * to reconcile) or the two agree.
373
+ */
374
+ function localeConsistencyWarnings(site, i18n) {
375
+ if (!site || !i18n) return [];
376
+ const warnings = [];
377
+ const astroCodes = astroLocaleCodes(i18n);
378
+ const siteCodes = new Set(Object.keys(site.locales));
379
+ const onlyInSite = [...siteCodes].filter((c) => !astroCodes.has(c));
380
+ const onlyInAstro = [...astroCodes].filter((c) => !siteCodes.has(c));
381
+ if (onlyInSite.length || onlyInAstro.length) warnings.push(`i18n: site.locales and astro.config.mjs's i18n.locales disagree` + (onlyInSite.length ? ` — site.locales has ${onlyInSite.map((c) => `"${c}"`).join(", ")} that astro's i18n doesn't` : "") + (onlyInAstro.length ? `${onlyInSite.length ? ", and" : " —"} astro's i18n has ${onlyInAstro.map((c) => `"${c}"`).join(", ")} that site.locales doesn't` : "") + ". EmDash reads locales from astro.config.mjs directly, so a locale missing there never reaches its admin regardless of what site.locales says.");
382
+ if (i18n.defaultLocale !== site.defaultLocale) warnings.push(`i18n: astro.config.mjs's i18n.defaultLocale ("${i18n.defaultLocale}") does not match site.defaultLocale ("${site.defaultLocale}") — SEO/legal prose renders in one locale by default while EmDash's own routing defaults to the other.`);
383
+ return warnings;
384
+ }
385
+
386
+ // src/seo-defaults.ts
387
+ /** Drop `undefined`/empty-string keys so the result carries only what the config actually states. */
388
+ function compact(value) {
389
+ return Object.fromEntries(Object.entries(value).filter(([, v]) => v !== void 0 && v !== ""));
390
+ }
391
+ function resolveSeoDefaults(config, options = {}) {
392
+ const seo = config.site.seo;
393
+ const defaultLocale = config.site.defaultLocale;
394
+ const locale = options.locale ?? defaultLocale;
395
+ const text = (value) => t(value, locale, defaultLocale) || void 0;
396
+ const robots = config.site.environments?.[options.env ?? "production"]?.robots ?? seo?.robots;
397
+ const image = seo?.openGraph?.image;
398
+ const openGraph = compact({
399
+ type: seo?.openGraph?.type,
400
+ locale: seo?.openGraph?.locale,
401
+ localeAlternates: seo?.openGraph?.localeAlternates,
402
+ image: image === void 0 ? void 0 : typeof image === "string" ? { url: image } : compact({
403
+ url: image.src,
404
+ alt: image.alt,
405
+ width: image.width,
406
+ height: image.height,
407
+ type: image.type
408
+ })
409
+ });
410
+ const twitter = compact({
411
+ card: seo?.twitter?.card,
412
+ site: seo?.twitter?.site,
413
+ creator: seo?.twitter?.creator
414
+ });
415
+ return compact({
416
+ siteName: text(seo?.siteName) ?? resolveOrganizationName(config, locale),
417
+ titleTemplate: text(seo?.titleTemplate),
418
+ defaultTitle: text(seo?.defaultTitle),
419
+ defaultDescription: text(seo?.defaultDescription),
420
+ robots,
421
+ googleSiteVerification: seo?.googleSiteVerification,
422
+ bingSiteVerification: seo?.bingSiteVerification,
423
+ ...Object.keys(openGraph).length ? { openGraph } : {},
424
+ ...Object.keys(twitter).length ? { twitter } : {}
425
+ });
426
+ }
427
+ /**
428
+ * Merge `vitops({ seo })` over config-derived defaults. The two nested objects merge key by key, so
429
+ * setting `openGraph.locale` in code doesn't discard the config's fallback image.
430
+ */
431
+ function mergeSeoDefaults(fromConfig, override) {
432
+ if (!override) return fromConfig;
433
+ const merged = {
434
+ ...fromConfig,
435
+ ...override
436
+ };
437
+ if (fromConfig.openGraph || override.openGraph) merged.openGraph = {
438
+ ...fromConfig.openGraph,
439
+ ...override.openGraph
440
+ };
441
+ if (fromConfig.twitter || override.twitter) merged.twitter = {
442
+ ...fromConfig.twitter,
443
+ ...override.twitter
444
+ };
445
+ return merged;
446
+ }
447
+
448
+ // src/integration.ts
331
449
  /**
332
450
  * @getvitops/astro — the Astro integration. One entry wires the design system's
333
451
  * `<head>` contributions into a consumer site:
@@ -367,6 +485,39 @@ function undisclosedProviders(configPath, analytics) {
367
485
  return Object.keys(SITE_CONFIG_KEYS).filter((key) => analytics[key] !== void 0 && !declared[SITE_CONFIG_KEYS[key]]);
368
486
  }
369
487
  /**
488
+ * Read `site.locales`/`site.defaultLocale` out of the site config, for
489
+ * `localeConsistencyWarnings` to check against Astro's own `i18n` block.
490
+ *
491
+ * Best-effort, matching `readSiteAds`'s shape exactly: a malformed or missing
492
+ * config is silence here, since every other consumer of this file already
493
+ * validates it loudly elsewhere in the same hook.
494
+ */
495
+ function readSiteLocales(configPath, siteEnv) {
496
+ try {
497
+ const cfg = resolveConfig(JSON.parse(readFileSync(configPath, "utf8")), siteEnv);
498
+ return {
499
+ locales: cfg.site.locales,
500
+ defaultLocale: cfg.site.defaultLocale
501
+ };
502
+ } catch {
503
+ return null;
504
+ }
505
+ }
506
+ /**
507
+ * Resolve `site.seo` (plus `organization.name` as the site-name fallback) out of the site config.
508
+ *
509
+ * Best-effort, like `readSiteLocales`: a malformed or missing config is silence here, since every
510
+ * other consumer of the file validates it loudly in the same hook. An empty object means "the
511
+ * config states no SEO defaults", which is also what no site config at all means.
512
+ */
513
+ function readSiteSeo(configPath, siteEnv) {
514
+ try {
515
+ return resolveSeoDefaults(resolveConfig(JSON.parse(readFileSync(configPath, "utf8")), siteEnv), { env: siteEnv });
516
+ } catch {
517
+ return {};
518
+ }
519
+ }
520
+ /**
370
521
  * Read `site.ads` and this environment's ads switch out of the site config.
371
522
  *
372
523
  * Best-effort, like `undisclosedProviders`: every other consumer of this file
@@ -395,6 +546,55 @@ function readSiteAds(configPath, siteEnv) {
395
546
  }
396
547
  }
397
548
  /**
549
+ * Read `site.experiments` + `site.analytics.matomo.abVariantDimensions` out of
550
+ * the site config, resolving each experiment's cookie name (default
551
+ * `_ab_<key>`, matching `vitopsAbTesting()`'s own default) so `<Analytics />`
552
+ * doesn't need to know the fallback rule itself.
553
+ *
554
+ * Mirrors `readSiteAds`'s shape: best-effort, only enabled experiments
555
+ * (a disabled one has no cookie to read and shouldn't cost every provider's
556
+ * bootstrap a no-op check), `null` when nothing's configured.
557
+ */
558
+ function readSiteExperiments(configPath, siteEnv) {
559
+ try {
560
+ const cfg = resolveConfig(JSON.parse(readFileSync(configPath, "utf8")), siteEnv);
561
+ const configured = cfg.site.experiments;
562
+ if (!configured || Object.keys(configured).length === 0) return null;
563
+ const experiments = {};
564
+ let primaryCookie = null;
565
+ for (const [key, experiment] of Object.entries(configured)) {
566
+ if (!experiment.enabled) continue;
567
+ const cookieName = experiment.cookieName ?? `_ab_${key}`;
568
+ experiments[key] = { cookieName };
569
+ primaryCookie ??= cookieName;
570
+ }
571
+ if (Object.keys(experiments).length === 0) return null;
572
+ return {
573
+ experiments,
574
+ matomoAbVariantDimensions: cfg.site.analytics?.matomo?.abVariantDimensions ?? {},
575
+ primaryCookie
576
+ };
577
+ } catch {
578
+ return null;
579
+ }
580
+ }
581
+ /**
582
+ * Is `site.crm` configured and not explicitly disabled?
583
+ *
584
+ * Mirrors `readSiteAds`'s "read the config, decide nothing else" shape — this
585
+ * only answers the one question `hasConversionRoute`'s warning needs, not the
586
+ * whole `CrmConfig`, which the consumer's own route file builds from the same
587
+ * config however it likes.
588
+ */
589
+ function siteCrmEnabled(configPath, siteEnv) {
590
+ try {
591
+ const cfg = resolveConfig(JSON.parse(readFileSync(configPath, "utf8")), siteEnv);
592
+ return Boolean(cfg.site.crm && cfg.site.crm.enabled !== false);
593
+ } catch {
594
+ return false;
595
+ }
596
+ }
597
+ /**
398
598
  * The consent + tracking facts a site config already states.
399
599
  *
400
600
  * These were the one pair the integration did not read. `site.tracking` and
@@ -547,7 +747,12 @@ function vitops(opts = {}) {
547
747
  if (opts.site && !opts.css) logger.warn("site: needs a `css` config — it is read during generation, which runs in the Vite plugin that `css` registers. Without it the site config changes nothing here.");
548
748
  const legalInput = opts.legal?.input ?? siteInput;
549
749
  if (opts.legal && !legalInput) throw new Error("[vitops] legal: needs `legal.input` (a site config path), the top-level `site` option, or a `css.input` that is itself a site config.");
550
- if (opts.seo) {
750
+ if (siteInput) {
751
+ const siteLocales = readSiteLocales(resolve(root, siteInput), opts.site?.siteEnv ?? "production");
752
+ for (const warning of localeConsistencyWarnings(siteLocales, config.i18n)) logger.warn(warning);
753
+ }
754
+ const seoDefaults = mergeSeoDefaults(siteInput ? readSiteSeo(resolve(root, siteInput), opts.site?.siteEnv ?? "production") : {}, opts.seo);
755
+ if (opts.seo || Object.keys(seoDefaults).some((k) => k !== "siteName")) {
551
756
  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.");
552
757
  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.");
553
758
  }
@@ -561,6 +766,7 @@ function vitops(opts = {}) {
561
766
  const analytics = resolveAnalytics(opts.analytics, { consent: consentEnabled });
562
767
  const trackingOpts = opts.tracking === true ? { enabled: true } : opts.tracking || (siteConsent?.tracking?.enabled ? siteConsent.tracking : void 0);
563
768
  const adsConfig = siteInput ? readSiteAds(resolve(root, siteInput), opts.site?.siteEnv ?? "production") : null;
769
+ const experimentsConfig = siteInput ? readSiteExperiments(resolve(root, siteInput), opts.site?.siteEnv ?? "production") : null;
564
770
  const detectedCategories = consentCategories([...analytics.tags, ...resolveAds(adsConfig?.properties, {
565
771
  consent: consentEnabled,
566
772
  enabled: true
@@ -576,6 +782,7 @@ function vitops(opts = {}) {
576
782
  });
577
783
  for (const warning of tracking.warnings) logger.warn(warning);
578
784
  if (tracking.enabled && !hasConversionRoute(root)) logger.warn(`tracking is on, so the capture script beacons \`tel:\` conversions to ${TRACKING_ENDPOINT}, but no route answers that path. Add \`src/pages/api/track.ts\` exporting \`createConversionRoute(...)\` from \`@getvitops/astro/routes\` — otherwise every call conversion is silently lost.`);
785
+ if (siteInput && siteCrmEnabled(resolve(root, siteInput), opts.site?.siteEnv ?? "production") && !hasConversionRoute(root)) logger.warn(`site.crm is configured, but no route answers ${TRACKING_ENDPOINT}. Add \`src/pages/api/track.ts\` exporting \`createConversionRoute({ crm: {...}, ... })\` from \`@getvitops/astro/routes\` — otherwise submissions never reach the CRM.`);
579
786
  const ads = resolveAds(adsConfig?.properties, {
580
787
  consent: consentEnabled,
581
788
  consentCategories: offeredCategories,
@@ -633,7 +840,8 @@ function vitops(opts = {}) {
633
840
  }
634
841
  const consentRuntime = consentEnabled || analytics.needsRuntime;
635
842
  if (webComponents || consentRuntime) {
636
- const coreDist = join(dirname(fileURLToPath(import.meta.resolve("@getvitops/core/package.json"))), "dist");
843
+ const corePkg = fileURLToPath(import.meta.resolve("@getvitops/core/package.json"));
844
+ const coreDist = join(dirname(corePkg), "dist");
637
845
  const dest = join(publicDir, "vitops");
638
846
  if (existsSync(coreDist)) {
639
847
  const bundles = webComponents ? [
@@ -781,7 +989,7 @@ function vitops(opts = {}) {
781
989
  sitemap: sitemapHref,
782
990
  fonts: headFonts,
783
991
  site: config.site ?? null,
784
- seo: opts.seo ?? {},
992
+ seo: seoDefaults,
785
993
  analytics: opts.analytics ?? {},
786
994
  consent: consentEnabled,
787
995
  consentCategories: offeredCategories,
@@ -790,8 +998,11 @@ function vitops(opts = {}) {
790
998
  tracking: tracking.enabled ? {
791
999
  enabled: true,
792
1000
  category: tracking.category,
793
- cookies: tracking.cookies
1001
+ cookies: tracking.cookies,
1002
+ abVariantCookie: experimentsConfig?.primaryCookie ?? null
794
1003
  } : null,
1004
+ experiments: experimentsConfig?.experiments ?? {},
1005
+ matomoAbVariantDimensions: experimentsConfig?.matomoAbVariantDimensions ?? {},
795
1006
  ads: adsConfig
796
1007
  })] } });
797
1008
  if (opts.css) {
@@ -837,8 +1048,7 @@ function vitops(opts = {}) {
837
1048
  };
838
1049
  }
839
1050
 
840
- //#endregion
841
- //#region src/lastmod.ts
1051
+ // src/lastmod.ts
842
1052
  /**
843
1053
  * Real per-page `<lastmod>` for the sitemap, derived from git.
844
1054
  *
@@ -924,7 +1134,7 @@ async function commitDates(dirs, cwd) {
924
1134
  ...dirs
925
1135
  ], {
926
1136
  cwd,
927
- maxBuffer: 64 * 1024 * 1024
1137
+ maxBuffer: 67108864
928
1138
  });
929
1139
  let current;
930
1140
  for (const line of stdout.split("\n")) {
@@ -997,8 +1207,7 @@ async function gitLastmod(options = {}) {
997
1207
  };
998
1208
  }
999
1209
 
1000
- //#endregion
1001
- //#region src/seo.ts
1210
+ // src/seo.ts
1002
1211
  /** `en-CA` → `en_CA`; Open Graph wants the underscore form. */
1003
1212
  const ogLocale = (locale) => locale.replace("-", "_");
1004
1213
  /**
@@ -1119,6 +1328,5 @@ function resolveSeo(defaults, props, ctx) {
1119
1328
  };
1120
1329
  }
1121
1330
 
1122
- //#endregion
1123
- export { TRACKING_COOKIE, consentCategories, vitops as default, gitLastmod, resolveAds, resolveAnalytics, resolveSeo, resolveTracking, routeFromPage, slugFromContent };
1331
+ export { TRACKING_COOKIE, consentCategories, vitops as default, gitLastmod, mergeSeoDefaults, resolveAds, resolveAnalytics, resolveSeo, resolveSeoDefaults, resolveTracking, routeFromPage, slugFromContent };
1124
1332
  //# sourceMappingURL=index.mjs.map