@getvitops/astro 4.0.0 → 5.0.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/dist/index.mjs CHANGED
@@ -1,9 +1,11 @@
1
+ import { n as TRACKING_ENDPOINT, r as resolveTracking, t as TRACKING_COOKIE } from "./tracking-CmSfxWC3.mjs";
1
2
  import { cpSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
3
  import { dirname, join, relative, resolve } from "node:path";
3
4
  import { fileURLToPath } from "node:url";
4
5
  import { isConfig, resolveConfig } from "@getvitops/generator";
5
6
  import { collectIconRefs, faviconLinks, generateFavicons, generateIconInclude, resolveIcon, scanFiles, writeFaviconManifest } from "@getvitops/utils";
6
7
  import vitopsPlugin from "@getvitops/vite";
8
+ import { AD_PLATFORMS, AD_PROVIDER_KEYS } from "@getvitops/utils/ads";
7
9
  import { fontProviders } from "astro/config";
8
10
  import { execFile } from "node:child_process";
9
11
  import { promisify } from "node:util";
@@ -17,9 +19,9 @@ export * from "@getvitops/utils"
17
19
  * the guard is one regex and the cost of not having it is arbitrary script
18
20
  * injection from a config file.
19
21
  */
20
- const SAFE_ID = /^[\w.-]+$/;
22
+ const SAFE_ID$1 = /^[\w.-]+$/;
21
23
  function bad(value) {
22
- return !SAFE_ID.test(value);
24
+ return !SAFE_ID$1.test(value);
23
25
  }
24
26
  /** JSON is a subset of JS object-literal syntax; `<` can't appear unescaped. */
25
27
  function literal(value) {
@@ -172,6 +174,105 @@ function consentCategories(tags) {
172
174
  ].filter((c) => used.has(c));
173
175
  }
174
176
 
177
+ //#endregion
178
+ //#region src/ads.ts
179
+ /**
180
+ * The `<Ads />` resolution layer — the site's ad properties in, the exact tags to
181
+ * emit out.
182
+ *
183
+ * A sibling of `analytics.ts`, deliberately not part of it. The two render the
184
+ * same gated markup, but they answer different questions and four things differ:
185
+ *
186
+ * - **Where they come from.** Analytics providers are integration options
187
+ * (`vitops({ analytics })`); ad properties are `site.ads` in the site config,
188
+ * because the ad account is a fact about the site that `vitops ads setup`,
189
+ * `vitops ads lint` and the generated cookie notice all read too.
190
+ * - **Who decides the consent category.** `resolveAnalytics` *derives* it from
191
+ * whether the provider sets cookies, so a consumer cannot mark Google Analytics
192
+ * `necessary` to dodge a banner. Every ad pixel sets cookies and is advertising,
193
+ * so the answer is `marketing` unless the config says `analytics` — a stated
194
+ * fact, not a derivation.
195
+ * - **What the key means.** `ResolvedTag['key']` is the four analytics providers,
196
+ * and `analytics.ts` pairs that set against the generator's processor table.
197
+ * Widening it to eight ad platforms would make that check answer for tags it
198
+ * does not govern.
199
+ * - **When they fire.** Analytics is gated per environment by
200
+ * `environments.<env>.analytics`; a preview deployment sending pageviews is
201
+ * survivable, one firing conversion pixels is not, so ads gets its own switch.
202
+ *
203
+ * Everything decidable is here and unit-tested; `Ads.astro` renders what this
204
+ * returns and chooses nothing.
205
+ */
206
+ /**
207
+ * IDs land inside an inline `<script>`, so they are checked rather than trusted —
208
+ * the same guard, for the same reason, as `analytics.ts`'s `SAFE_ID`. A `<\/script>`
209
+ * in a pixel ID is not a plausible accident; arbitrary script injection from a
210
+ * config file is not a plausible thing to allow either.
211
+ */
212
+ const SAFE_ID = /^[\w.-]+$/;
213
+ /** Resolve the configured ad properties into the tags `<Ads />` emits. */
214
+ function resolveAds(ads, ctx = {}) {
215
+ const warnings = [];
216
+ const tags = [];
217
+ const strategy = ctx.strategy ?? "idle";
218
+ const configured = AD_PROVIDER_KEYS.filter((p) => ads?.[p] != null);
219
+ if (configured.length === 0) return {
220
+ tags,
221
+ needsRuntime: false,
222
+ warnings
223
+ };
224
+ if (ctx.enabled === false) return {
225
+ tags,
226
+ needsRuntime: false,
227
+ warnings
228
+ };
229
+ for (const provider of configured) {
230
+ const entry = ads[provider];
231
+ if (entry.enabled === false) continue;
232
+ const platform = AD_PLATFORMS[provider];
233
+ const id = entry[platform.tag.needs];
234
+ if (!id) {
235
+ warnings.push(`ads: ${provider} has no ${platform.tag.needs}, so no tag is emitted. Run \`vitops ads setup\` to be prompted for it.`);
236
+ continue;
237
+ }
238
+ if (!SAFE_ID.test(id)) {
239
+ warnings.push(`ads: ${provider} ${platform.tag.needs} "${id}" is not a valid ID. Skipped.`);
240
+ continue;
241
+ }
242
+ const category = entry.category ?? "marketing";
243
+ tags.push({
244
+ key: provider,
245
+ provider: platform.name,
246
+ category,
247
+ setsCookies: true,
248
+ cookies: platform.tag.cookies,
249
+ strategy,
250
+ gated: ctx.consent === true,
251
+ inline: platform.tag.bootstrap(id, {
252
+ provider,
253
+ ...entry
254
+ }),
255
+ src: platform.tag.src ? platform.tag.src(id) : null,
256
+ attrs: {}
257
+ });
258
+ }
259
+ if (tags.length === 0) return {
260
+ tags,
261
+ needsRuntime: false,
262
+ warnings
263
+ };
264
+ if (ctx.consent !== true) warnings.push(`ads: ${tags.map((t) => t.provider).join(", ")} ${tags.length === 1 ? "sets" : "set"} advertising cookies, and \`consent\` is off — so they load for every visitor with no way to decline. Set \`consent: true\`.`);
265
+ else {
266
+ const missing = [...new Set(tags.map((t) => t.category))].filter((c) => ctx.consentCategories && !ctx.consentCategories.includes(c));
267
+ if (missing.length) warnings.push(`ads: the tags demand the ${missing.join(", ")} consent categor${missing.length === 1 ? "y" : "ies"}, but the banner offers ${ctx.consentCategories?.join(", ") || "nothing"}. Add ${missing.join(", ")} to \`consent.categories\`, or the tags wait on a question the banner never asks.`);
268
+ }
269
+ return {
270
+ tags,
271
+ needsRuntime: true,
272
+ warnings
273
+ };
274
+ }
275
+
175
276
  //#endregion
176
277
  //#region src/fonts.ts
177
278
  /**
@@ -225,39 +326,6 @@ function resolveFonts(decls) {
225
326
  };
226
327
  }
227
328
 
228
- //#endregion
229
- //#region src/tracking.ts
230
- /** The cookie the capture script writes. Mirrors `TRACKING_COOKIE` in utils. */
231
- const TRACKING_COOKIE = "_ac";
232
- /**
233
- * Resolve the tracking config into what the component emits.
234
- *
235
- * Two warnings, both about the same failure — a persistent identifier written
236
- * with no way for the visitor to decline, or a permission asked for with no way
237
- * to answer. Returned as data rather than logged here so the check is a test
238
- * rather than a line someone might scroll past, exactly as `resolveAnalytics`
239
- * does.
240
- */
241
- function resolveTracking(opts, ctx = {}) {
242
- const warnings = [];
243
- const category = opts?.category ?? "marketing";
244
- const enabled = opts?.enabled === true;
245
- if (!enabled) return {
246
- enabled: false,
247
- category,
248
- cookies: [],
249
- warnings
250
- };
251
- if (!ctx.consent) warnings.push(`tracking is on, so this site writes the \`${"_ac"}\` cookie — a 90-day identifier tying a visitor to the ad that brought them — and \`consent\` is off, so it is written for every visitor with no way to decline. Set \`consent: true\`.`);
252
- else if (ctx.consentCategories && !ctx.consentCategories.includes(category)) warnings.push(`tracking demands the \`${category}\` consent category, but the banner offers ${ctx.consentCategories.join(", ") || "nothing"}. Add \`${category}\` to \`consent.categories\`, or the visitor is asked about something the banner never names.`);
253
- return {
254
- enabled,
255
- category,
256
- cookies: ["_ac"],
257
- warnings
258
- };
259
- }
260
-
261
329
  //#endregion
262
330
  //#region src/integration.ts
263
331
  /**
@@ -298,6 +366,101 @@ function undisclosedProviders(configPath, analytics) {
298
366
  }
299
367
  return Object.keys(SITE_CONFIG_KEYS).filter((key) => analytics[key] !== void 0 && !declared[SITE_CONFIG_KEYS[key]]);
300
368
  }
369
+ /**
370
+ * Read `site.ads` and this environment's ads switch out of the site config.
371
+ *
372
+ * Best-effort, like `undisclosedProviders`: every other consumer of this file
373
+ * validates it loudly elsewhere in the same hook, and a second copy of that error
374
+ * helps nobody. `resolveConfig` is used rather than a bare `JSON.parse` so an A/B
375
+ * variant's `overrides` are applied first — a variant that changes which pixels
376
+ * run is exactly the kind of thing overrides exist for.
377
+ *
378
+ * The environment switch cascades `ads` → `analytics` → true. An environment that
379
+ * has already said "no analytics here" has said the interesting half of it; making
380
+ * a consumer repeat themselves to stop conversion pixels would mean a preview
381
+ * deploy fires them by default, which is the wrong way for this to fail.
382
+ */
383
+ function readSiteAds(configPath, siteEnv) {
384
+ try {
385
+ const cfg = resolveConfig(JSON.parse(readFileSync(configPath, "utf8")), siteEnv);
386
+ const properties = cfg.site.ads;
387
+ if (!properties || Object.keys(properties).length === 0) return null;
388
+ const env = cfg.site.environments?.[siteEnv];
389
+ return {
390
+ enabled: env?.ads ?? env?.analytics ?? true,
391
+ properties
392
+ };
393
+ } catch {
394
+ return null;
395
+ }
396
+ }
397
+ /**
398
+ * The consent + tracking facts a site config already states.
399
+ *
400
+ * These were the one pair the integration did not read. `site.tracking` and
401
+ * `site.legal.cookieConsent` drive the **generated cookie notice**, while
402
+ * `vitops({ consent, tracking })` drove the **runtime** — two hand-synced
403
+ * declarations of the same fact with nothing checking them against each other.
404
+ * The failure that produces is precisely the one the rest of the toolchain is
405
+ * fastidious about avoiding: a site can ship a cookie notice naming categories
406
+ * its banner never offers, or promising attribution that no capture script
407
+ * implements, and the build is clean. That is a compliance defect, not a
408
+ * documentation gap.
409
+ *
410
+ * Read as DEFAULTS, the same as `css` / `fonts` / `favicon` / `ads` — the
411
+ * config is where a site's facts live, and an integration option is how you
412
+ * override one. A genuine contradiction is an error rather than a warning; see
413
+ * `consentTrackingConflicts`.
414
+ */
415
+ function readSiteConsentTracking(configPath, siteEnv) {
416
+ try {
417
+ const cfg = resolveConfig(JSON.parse(readFileSync(configPath, "utf8")), siteEnv);
418
+ const t = cfg.site.tracking;
419
+ const cc = cfg.site.legal?.cookieConsent;
420
+ if (!t && !cc) return null;
421
+ return {
422
+ ...t ? { tracking: t } : {},
423
+ ...cc ? { consentEnabled: cc.enabled } : {},
424
+ ...cc?.categories ? { categories: cc.categories } : {}
425
+ };
426
+ } catch {
427
+ return null;
428
+ }
429
+ }
430
+ /**
431
+ * Contradictions between the config and an explicit integration option.
432
+ *
433
+ * Absent-vs-present is a default and resolves silently. `true` vs `false` on the
434
+ * same fact is not resolvable — one of the two documents is wrong, and picking
435
+ * either quietly is how a notice and a banner get out of step. The message names
436
+ * both sides so the fix does not require guessing which one won.
437
+ */
438
+ function consentTrackingConflicts(site, opts, configPath) {
439
+ const out = [];
440
+ const optConsent = opts.consent === void 0 ? void 0 : !!opts.consent;
441
+ if (site.consentEnabled !== void 0 && optConsent !== void 0 && site.consentEnabled !== optConsent) out.push(`consent: ${configPath} sets site.legal.cookieConsent.enabled: ${site.consentEnabled}, but vitops({ consent: ${optConsent} }) says the opposite. The generated cookie notice follows the config and the banner follows the option, so one of them would describe a site that does not exist. Set them the same, or drop the option and let the config decide.`);
442
+ const optTracking = opts.tracking === void 0 ? void 0 : opts.tracking === true ? true : opts.tracking === false ? false : opts.tracking.enabled ?? true;
443
+ if (site.tracking?.enabled !== void 0 && optTracking !== void 0 && site.tracking.enabled !== optTracking) out.push(`tracking: ${configPath} sets site.tracking.enabled: ${site.tracking.enabled}, but vitops({ tracking: ${optTracking} }) says the opposite. The cookie notice discloses \`_ac\` from the config while the capture script ships from the option — so the site would either disclose a cookie it never sets, or set one it never discloses.`);
444
+ return out;
445
+ }
446
+ /**
447
+ * Does a route answer `TRACKING_ENDPOINT`?
448
+ *
449
+ * Checked by filename rather than through Astro's route manifest, because this
450
+ * runs in `astro:config:setup` — before routes are collected. The extension set
451
+ * covers the ways a consumer can author the same endpoint; a false negative
452
+ * costs a warning, a false positive costs the whole point of the check, so this
453
+ * errs toward looking in more places.
454
+ */
455
+ function hasConversionRoute(root) {
456
+ const base = resolve(root, "src/pages", TRACKING_ENDPOINT.replace(/^\//, ""));
457
+ return [
458
+ ".ts",
459
+ ".js",
460
+ ".mjs",
461
+ ".astro"
462
+ ].some((ext) => existsSync(`${base}${ext}`) || existsSync(join(base, `index${ext}`)));
463
+ }
301
464
  const VIRTUAL_ID = "virtual:getvitops/head";
302
465
  const RESOLVED_ID = `\0${VIRTUAL_ID}`;
303
466
  function virtualHeadPlugin(data) {
@@ -388,11 +551,20 @@ function vitops(opts = {}) {
388
551
  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.");
389
552
  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.");
390
553
  }
391
- const consentEnabled = !!opts.consent;
392
- const consentOpts = typeof opts.consent === "object" ? opts.consent : {};
554
+ const siteConsent = siteInput ? readSiteConsentTracking(resolve(root, siteInput), opts.site?.siteEnv ?? "production") : null;
555
+ if (siteConsent) {
556
+ const conflicts = consentTrackingConflicts(siteConsent, opts, siteInput);
557
+ if (conflicts.length) throw new Error(`vitops: the site config and the integration options disagree about consent.\n\n` + conflicts.map((c) => ` • ${c}`).join("\n\n"));
558
+ }
559
+ const consentEnabled = opts.consent !== void 0 ? !!opts.consent : !!siteConsent?.consentEnabled;
560
+ const consentOpts = typeof opts.consent === "object" ? opts.consent : siteConsent?.categories ? { categories: siteConsent.categories } : {};
393
561
  const analytics = resolveAnalytics(opts.analytics, { consent: consentEnabled });
394
- const trackingOpts = opts.tracking === true ? { enabled: true } : opts.tracking || void 0;
395
- const detectedCategories = consentCategories(analytics.tags);
562
+ const trackingOpts = opts.tracking === true ? { enabled: true } : opts.tracking || (siteConsent?.tracking?.enabled ? siteConsent.tracking : void 0);
563
+ const adsConfig = siteInput ? readSiteAds(resolve(root, siteInput), opts.site?.siteEnv ?? "production") : null;
564
+ const detectedCategories = consentCategories([...analytics.tags, ...resolveAds(adsConfig?.properties, {
565
+ consent: consentEnabled,
566
+ enabled: true
567
+ }).tags]);
396
568
  const defaultCategories = ["analytics", "preferences"];
397
569
  const trackingCategory = trackingOpts?.enabled ? trackingOpts.category ?? "marketing" : null;
398
570
  if (detectedCategories.includes("marketing") || trackingCategory === "marketing") defaultCategories.push("marketing");
@@ -403,6 +575,13 @@ function vitops(opts = {}) {
403
575
  consentCategories: offeredCategories
404
576
  });
405
577
  for (const warning of tracking.warnings) logger.warn(warning);
578
+ 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.`);
579
+ const ads = resolveAds(adsConfig?.properties, {
580
+ consent: consentEnabled,
581
+ consentCategories: offeredCategories,
582
+ enabled: adsConfig?.enabled ?? true
583
+ });
584
+ for (const warning of ads.warnings) logger.warn(warning);
406
585
  if (opts.analytics && legalInput) {
407
586
  const undisclosed = undisclosedProviders(resolve(root, legalInput), opts.analytics);
408
587
  if (undisclosed.length) logger.warn(`analytics: ${undisclosed.join(", ")} ${undisclosed.length === 1 ? "is" : "are"} configured here but absent from your site config's \`analytics\` block, so the cookie notice \`legal\` generates will not disclose ${undisclosed.length === 1 ? "it" : "them"}. Add ${undisclosed.map((k) => SITE_CONFIG_KEYS[k]).join(", ")} to ${legalInput}.`);
@@ -605,7 +784,8 @@ function vitops(opts = {}) {
605
784
  enabled: true,
606
785
  category: tracking.category,
607
786
  cookies: tracking.cookies
608
- } : null
787
+ } : null,
788
+ ads: adsConfig
609
789
  })] } });
610
790
  if (opts.css) {
611
791
  const format = opts.css.format ?? "tailwind";
@@ -933,5 +1113,5 @@ function resolveSeo(defaults, props, ctx) {
933
1113
  }
934
1114
 
935
1115
  //#endregion
936
- export { TRACKING_COOKIE, consentCategories, vitops as default, gitLastmod, resolveAnalytics, resolveSeo, resolveTracking, routeFromPage, slugFromContent };
1116
+ export { TRACKING_COOKIE, consentCategories, vitops as default, gitLastmod, resolveAds, resolveAnalytics, resolveSeo, resolveTracking, routeFromPage, slugFromContent };
937
1117
  //# sourceMappingURL=index.mjs.map