@getvitops/astro 3.0.0 → 4.1.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
@@ -4,6 +4,7 @@ import { fileURLToPath } from "node:url";
4
4
  import { isConfig, resolveConfig } from "@getvitops/generator";
5
5
  import { collectIconRefs, faviconLinks, generateFavicons, generateIconInclude, resolveIcon, scanFiles, writeFaviconManifest } from "@getvitops/utils";
6
6
  import vitopsPlugin from "@getvitops/vite";
7
+ import { AD_PLATFORMS, AD_PROVIDER_KEYS } from "@getvitops/utils/ads";
7
8
  import { fontProviders } from "astro/config";
8
9
  import { execFile } from "node:child_process";
9
10
  import { promisify } from "node:util";
@@ -17,9 +18,9 @@ export * from "@getvitops/utils"
17
18
  * the guard is one regex and the cost of not having it is arbitrary script
18
19
  * injection from a config file.
19
20
  */
20
- const SAFE_ID = /^[\w.-]+$/;
21
+ const SAFE_ID$1 = /^[\w.-]+$/;
21
22
  function bad(value) {
22
- return !SAFE_ID.test(value);
23
+ return !SAFE_ID$1.test(value);
23
24
  }
24
25
  /** JSON is a subset of JS object-literal syntax; `<` can't appear unescaped. */
25
26
  function literal(value) {
@@ -172,6 +173,105 @@ function consentCategories(tags) {
172
173
  ].filter((c) => used.has(c));
173
174
  }
174
175
 
176
+ //#endregion
177
+ //#region src/ads.ts
178
+ /**
179
+ * The `<Ads />` resolution layer — the site's ad properties in, the exact tags to
180
+ * emit out.
181
+ *
182
+ * A sibling of `analytics.ts`, deliberately not part of it. The two render the
183
+ * same gated markup, but they answer different questions and four things differ:
184
+ *
185
+ * - **Where they come from.** Analytics providers are integration options
186
+ * (`vitops({ analytics })`); ad properties are `site.ads` in the site config,
187
+ * because the ad account is a fact about the site that `vitops ads setup`,
188
+ * `vitops ads lint` and the generated cookie notice all read too.
189
+ * - **Who decides the consent category.** `resolveAnalytics` *derives* it from
190
+ * whether the provider sets cookies, so a consumer cannot mark Google Analytics
191
+ * `necessary` to dodge a banner. Every ad pixel sets cookies and is advertising,
192
+ * so the answer is `marketing` unless the config says `analytics` — a stated
193
+ * fact, not a derivation.
194
+ * - **What the key means.** `ResolvedTag['key']` is the four analytics providers,
195
+ * and `analytics.ts` pairs that set against the generator's processor table.
196
+ * Widening it to eight ad platforms would make that check answer for tags it
197
+ * does not govern.
198
+ * - **When they fire.** Analytics is gated per environment by
199
+ * `environments.<env>.analytics`; a preview deployment sending pageviews is
200
+ * survivable, one firing conversion pixels is not, so ads gets its own switch.
201
+ *
202
+ * Everything decidable is here and unit-tested; `Ads.astro` renders what this
203
+ * returns and chooses nothing.
204
+ */
205
+ /**
206
+ * IDs land inside an inline `<script>`, so they are checked rather than trusted —
207
+ * the same guard, for the same reason, as `analytics.ts`'s `SAFE_ID`. A `<\/script>`
208
+ * in a pixel ID is not a plausible accident; arbitrary script injection from a
209
+ * config file is not a plausible thing to allow either.
210
+ */
211
+ const SAFE_ID = /^[\w.-]+$/;
212
+ /** Resolve the configured ad properties into the tags `<Ads />` emits. */
213
+ function resolveAds(ads, ctx = {}) {
214
+ const warnings = [];
215
+ const tags = [];
216
+ const strategy = ctx.strategy ?? "idle";
217
+ const configured = AD_PROVIDER_KEYS.filter((p) => ads?.[p] != null);
218
+ if (configured.length === 0) return {
219
+ tags,
220
+ needsRuntime: false,
221
+ warnings
222
+ };
223
+ if (ctx.enabled === false) return {
224
+ tags,
225
+ needsRuntime: false,
226
+ warnings
227
+ };
228
+ for (const provider of configured) {
229
+ const entry = ads[provider];
230
+ if (entry.enabled === false) continue;
231
+ const platform = AD_PLATFORMS[provider];
232
+ const id = entry[platform.tag.needs];
233
+ if (!id) {
234
+ warnings.push(`ads: ${provider} has no ${platform.tag.needs}, so no tag is emitted. Run \`vitops ads setup\` to be prompted for it.`);
235
+ continue;
236
+ }
237
+ if (!SAFE_ID.test(id)) {
238
+ warnings.push(`ads: ${provider} ${platform.tag.needs} "${id}" is not a valid ID. Skipped.`);
239
+ continue;
240
+ }
241
+ const category = entry.category ?? "marketing";
242
+ tags.push({
243
+ key: provider,
244
+ provider: platform.name,
245
+ category,
246
+ setsCookies: true,
247
+ cookies: platform.tag.cookies,
248
+ strategy,
249
+ gated: ctx.consent === true,
250
+ inline: platform.tag.bootstrap(id, {
251
+ provider,
252
+ ...entry
253
+ }),
254
+ src: platform.tag.src ? platform.tag.src(id) : null,
255
+ attrs: {}
256
+ });
257
+ }
258
+ if (tags.length === 0) return {
259
+ tags,
260
+ needsRuntime: false,
261
+ warnings
262
+ };
263
+ 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\`.`);
264
+ else {
265
+ const missing = [...new Set(tags.map((t) => t.category))].filter((c) => ctx.consentCategories && !ctx.consentCategories.includes(c));
266
+ 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.`);
267
+ }
268
+ return {
269
+ tags,
270
+ needsRuntime: true,
271
+ warnings
272
+ };
273
+ }
274
+
175
275
  //#endregion
176
276
  //#region src/fonts.ts
177
277
  /**
@@ -225,6 +325,39 @@ function resolveFonts(decls) {
225
325
  };
226
326
  }
227
327
 
328
+ //#endregion
329
+ //#region src/tracking.ts
330
+ /** The cookie the capture script writes. Mirrors `TRACKING_COOKIE` in utils. */
331
+ const TRACKING_COOKIE = "_ac";
332
+ /**
333
+ * Resolve the tracking config into what the component emits.
334
+ *
335
+ * Two warnings, both about the same failure — a persistent identifier written
336
+ * with no way for the visitor to decline, or a permission asked for with no way
337
+ * to answer. Returned as data rather than logged here so the check is a test
338
+ * rather than a line someone might scroll past, exactly as `resolveAnalytics`
339
+ * does.
340
+ */
341
+ function resolveTracking(opts, ctx = {}) {
342
+ const warnings = [];
343
+ const category = opts?.category ?? "marketing";
344
+ const enabled = opts?.enabled === true;
345
+ if (!enabled) return {
346
+ enabled: false,
347
+ category,
348
+ cookies: [],
349
+ warnings
350
+ };
351
+ 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\`.`);
352
+ 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.`);
353
+ return {
354
+ enabled,
355
+ category,
356
+ cookies: ["_ac"],
357
+ warnings
358
+ };
359
+ }
360
+
228
361
  //#endregion
229
362
  //#region src/integration.ts
230
363
  /**
@@ -265,6 +398,34 @@ function undisclosedProviders(configPath, analytics) {
265
398
  }
266
399
  return Object.keys(SITE_CONFIG_KEYS).filter((key) => analytics[key] !== void 0 && !declared[SITE_CONFIG_KEYS[key]]);
267
400
  }
401
+ /**
402
+ * Read `site.ads` and this environment's ads switch out of the site config.
403
+ *
404
+ * Best-effort, like `undisclosedProviders`: every other consumer of this file
405
+ * validates it loudly elsewhere in the same hook, and a second copy of that error
406
+ * helps nobody. `resolveConfig` is used rather than a bare `JSON.parse` so an A/B
407
+ * variant's `overrides` are applied first — a variant that changes which pixels
408
+ * run is exactly the kind of thing overrides exist for.
409
+ *
410
+ * The environment switch cascades `ads` → `analytics` → true. An environment that
411
+ * has already said "no analytics here" has said the interesting half of it; making
412
+ * a consumer repeat themselves to stop conversion pixels would mean a preview
413
+ * deploy fires them by default, which is the wrong way for this to fail.
414
+ */
415
+ function readSiteAds(configPath, siteEnv) {
416
+ try {
417
+ const cfg = resolveConfig(JSON.parse(readFileSync(configPath, "utf8")), siteEnv);
418
+ const properties = cfg.site.ads;
419
+ if (!properties || Object.keys(properties).length === 0) return null;
420
+ const env = cfg.site.environments?.[siteEnv];
421
+ return {
422
+ enabled: env?.ads ?? env?.analytics ?? true,
423
+ properties
424
+ };
425
+ } catch {
426
+ return null;
427
+ }
428
+ }
268
429
  const VIRTUAL_ID = "virtual:getvitops/head";
269
430
  const RESOLVED_ID = `\0${VIRTUAL_ID}`;
270
431
  function virtualHeadPlugin(data) {
@@ -358,9 +519,28 @@ function vitops(opts = {}) {
358
519
  const consentEnabled = !!opts.consent;
359
520
  const consentOpts = typeof opts.consent === "object" ? opts.consent : {};
360
521
  const analytics = resolveAnalytics(opts.analytics, { consent: consentEnabled });
361
- const detectedCategories = consentCategories(analytics.tags);
362
- const offeredCategories = consentOpts.categories ?? (detectedCategories.length ? detectedCategories : ["analytics"]);
522
+ const trackingOpts = opts.tracking === true ? { enabled: true } : opts.tracking || void 0;
523
+ const adsConfig = siteInput ? readSiteAds(resolve(root, siteInput), opts.site?.siteEnv ?? "production") : null;
524
+ const detectedCategories = consentCategories([...analytics.tags, ...resolveAds(adsConfig?.properties, {
525
+ consent: consentEnabled,
526
+ enabled: true
527
+ }).tags]);
528
+ const defaultCategories = ["analytics", "preferences"];
529
+ const trackingCategory = trackingOpts?.enabled ? trackingOpts.category ?? "marketing" : null;
530
+ if (detectedCategories.includes("marketing") || trackingCategory === "marketing") defaultCategories.push("marketing");
531
+ const offeredCategories = consentOpts.categories ?? defaultCategories;
363
532
  for (const warning of analytics.warnings) logger.warn(warning);
533
+ const tracking = resolveTracking(trackingOpts, {
534
+ consent: consentEnabled,
535
+ consentCategories: offeredCategories
536
+ });
537
+ for (const warning of tracking.warnings) logger.warn(warning);
538
+ const ads = resolveAds(adsConfig?.properties, {
539
+ consent: consentEnabled,
540
+ consentCategories: offeredCategories,
541
+ enabled: adsConfig?.enabled ?? true
542
+ });
543
+ for (const warning of ads.warnings) logger.warn(warning);
364
544
  if (opts.analytics && legalInput) {
365
545
  const undisclosed = undisclosedProviders(resolve(root, legalInput), opts.analytics);
366
546
  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}.`);
@@ -558,7 +738,13 @@ function vitops(opts = {}) {
558
738
  consent: consentEnabled,
559
739
  consentCategories: offeredCategories,
560
740
  consentPolicyUrl: consentOpts.policyUrl ?? null,
561
- consentRuntime
741
+ consentRuntime,
742
+ tracking: tracking.enabled ? {
743
+ enabled: true,
744
+ category: tracking.category,
745
+ cookies: tracking.cookies
746
+ } : null,
747
+ ads: adsConfig
562
748
  })] } });
563
749
  if (opts.css) {
564
750
  const format = opts.css.format ?? "tailwind";
@@ -609,7 +795,7 @@ function vitops(opts = {}) {
609
795
  * Real per-page `<lastmod>` for the sitemap, derived from git.
610
796
  *
611
797
  * This is the one lever that reliably moves Google. It is also load-bearing for
612
- * `vitops indexing`, which decides what to submit by diffing lastmod against the
798
+ * `vitops search notify`, which decides what to submit by diffing lastmod against the
613
799
  * previous build — with no lastmod, that diff can see pages appear and disappear
614
800
  * but never see one *change*, so an edited page is never resubmitted.
615
801
  *
@@ -886,5 +1072,5 @@ function resolveSeo(defaults, props, ctx) {
886
1072
  }
887
1073
 
888
1074
  //#endregion
889
- export { consentCategories, vitops as default, gitLastmod, resolveAnalytics, resolveSeo, routeFromPage, slugFromContent };
1075
+ export { TRACKING_COOKIE, consentCategories, vitops as default, gitLastmod, resolveAds, resolveAnalytics, resolveSeo, resolveTracking, routeFromPage, slugFromContent };
890
1076
  //# sourceMappingURL=index.mjs.map