@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/CHANGELOG.md +237 -0
- package/README.md +1 -1
- package/dist/index.d.mts +132 -6
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +193 -7
- package/dist/index.mjs.map +1 -1
- package/dist/routes/conversion.d.mts +45 -0
- package/dist/routes/conversion.d.mts.map +1 -0
- package/dist/routes/conversion.mjs +95 -0
- package/dist/routes/conversion.mjs.map +1 -0
- package/package.json +23 -6
- package/src/ads.ts +156 -0
- package/src/analytics.ts +22 -4
- package/src/components/Ads.astro +47 -0
- package/src/components/Analytics.astro +7 -30
- package/src/components/CookieConsent.astro +23 -15
- package/src/components/GatedTags.astro +46 -0
- package/src/components/Head.astro +39 -1
- package/src/components/Tracking.astro +43 -0
- package/src/components/Tree.astro +82 -0
- package/src/components/TreeNode.astro +63 -0
- package/src/components/WebComponentLoader.astro +1 -1
- package/src/components/tree.ts +19 -0
- package/src/scripts/tracking.ts +149 -0
- package/src/tracking.ts +82 -0
- package/virtual.d.ts +11 -0
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
|
|
362
|
-
const
|
|
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
|
|
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
|