@getvitops/utils 8.3.0-alpha.3 → 8.3.0-alpha.5
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 +33 -0
- package/dist/ads/index.d.mts +10 -91
- package/dist/ads/index.d.mts.map +1 -1
- package/dist/ads/index.mjs +1 -314
- package/dist/ads/index.mjs.map +1 -1
- package/dist/audit/index.mjs +1 -0
- package/dist/audit/index.mjs.map +1 -1
- package/dist/crm/index.d.mts +1 -1
- package/dist/notify/index.d.mts +1 -1
- package/dist/notify/index.d.mts.map +1 -1
- package/dist/notify/index.mjs +1 -0
- package/dist/notify/index.mjs.map +1 -1
- package/dist/providers-CEHw-j4v.mjs +351 -0
- package/dist/providers-CEHw-j4v.mjs.map +1 -0
- package/dist/tracking/index.d.mts +53 -2
- package/dist/tracking/index.d.mts.map +1 -1
- package/dist/tracking/index.mjs +111 -1
- package/dist/tracking/index.mjs.map +1 -1
- package/dist/{types-oac2P5or.d.mts → types-ByK6OuXQ.d.mts} +6 -1
- package/dist/{types-oac2P5or.d.mts.map → types-ByK6OuXQ.d.mts.map} +1 -1
- package/dist/types-C0hQKdsM.d.mts +92 -0
- package/dist/types-C0hQKdsM.d.mts.map +1 -0
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,38 @@
|
|
|
1
1
|
# @getvitops/utils
|
|
2
2
|
|
|
3
|
+
## 8.3.0-alpha.5
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- bb61a35: **Call tracking numbers: show an ad visitor a number that attributes the phone call.** Two additive config blocks, no CSS, no breaking change — all formats unaffected.
|
|
8
|
+
|
|
9
|
+
- **`site.ads.google.callTracking`** — Google Ads _website call conversions_. `{ label, number, cache? }` makes the consent-gated Google tag fetch a Google forwarding number (Canada supported) and swap it in for visitors who arrived from a Google ad. Google replaces text only, so the capture script also rewrites the matching `tel:` href. Needs `pixelId` and `site.tracking.enabled`; rejected on any other platform (only Google can put a number on the website — Microsoft's exist only inside the ad, in the US/UK/AU).
|
|
10
|
+
- **`site.tracking.phoneNumbers`** — per-platform numbers for every other platform: the real `number`, `sources` keyed by `site.ads` platform (plus `default-paid`), and an informational `provider`. You provision the numbers with a call-tracking service (Twilio, CallRail, …). The landing URL decides first, then the `_ac` cookie, so a visitor who declines `marketing` sees a tracking number on the landing page only. An organic visitor is never swapped, and the server-rendered HTML always carries the real number.
|
|
11
|
+
- The `tel:` tap beacon now carries which number was shown (`phoneSource`), and the conversion notification says so.
|
|
12
|
+
- `vitops ads lint` flags a call-tracking provider that is not in `site.legal.privacyPolicy.processors`, and notes that `callTracking` plus `conversionLabel.call` can count one call twice (make one secondary in Google Ads).
|
|
13
|
+
- `@getvitops/utils/tracking` exports the pure `selectPhoneNumber()` and helpers; the capture script mirrors it and `tracking.test.ts` pins the two.
|
|
14
|
+
- `@getvitops/skills` gains a **`vitops-ads`** skill (overview plus a `call-tracking.md` topic). The templates gitignore synced skills by prefix (`vitops-*`, `emdash-*`), so it needs no `.gitignore` line of its own.
|
|
15
|
+
|
|
16
|
+
`@getvitops/core`, `@getvitops/vite` and `@getvitops/emdash` — no consumer-facing change in these packages.
|
|
17
|
+
|
|
18
|
+
### Patch Changes
|
|
19
|
+
|
|
20
|
+
- 785b8b3: Docs only: the `site.ads.google.conversionLabel` field descriptions and `vitops ads` now state which Google Ads category each conversion action needs (`call` → Phone call lead, `email` → Contact, `form` → Submit lead form). The category is set on the action in Google Ads, not by the tag, so a wrong one reports under the wrong goal with no code-side signal. `vitopsFormConversion()`'s docblock also warns against firing a form conversion from a success-page query parameter. No behaviour change.
|
|
21
|
+
- Updated dependencies [785b8b3]
|
|
22
|
+
- Updated dependencies [bb61a35]
|
|
23
|
+
- Updated dependencies [4d94c8b]
|
|
24
|
+
- @getvitops/config@8.3.0-alpha.5
|
|
25
|
+
|
|
26
|
+
## 8.3.0-alpha.4
|
|
27
|
+
|
|
28
|
+
### Patch Changes
|
|
29
|
+
|
|
30
|
+
- 00a9d86: - **`vitops audit` lets the page settle before axe reads colours.** At `load` Chromium can still be running the CSS transitions every pattern declares, so axe measured a filled button mid-way from its unstyled colour and reported ten `color-contrast` failures no visitor sees. The audit now waits (up to 2 s) for CSS transitions to finish first.
|
|
31
|
+
- `.carousel__hint` reads `text-surface-muted` instead of `x-muted` (the placeholder tier the contrast contract exempts), and its scroll-driven fade ends hidden instead of at 0.2 opacity (`--carousel-hint-dimmed` now defaults to 0): a hint parked at 20% was still text to an audit (1.34:1). all formats.
|
|
32
|
+
- The templates' `button` pattern becomes the toolkit's `btn` affordance (`fill: false`, transparent, text colour inherited), as `defaultSiteConfig()` already shipped: the old filled entry lost its white text to the new `link` pattern on `<a class="btn">` (dark green on green, 1.36:1). A filled call to action is `.cta`.
|
|
33
|
+
- The templates move the `info` and `danger` solids to step 700. Their step-600 solids (sky, rose) clear the generator's APCA bar for `text-on-<role>` (Lc 60) but not WCAG AA's 4.5:1 that axe and Lighthouse apply (4.2:1 and 4.4:1), so a `.cta-info` or a CTA inside an `info` callout failed the audit. Step 700 reads at 6.4:1 and 6.8:1 with the recomputed text. The APCA/WCAG gap in the contract itself is logged in TODO.md.
|
|
34
|
+
- @getvitops/config@8.3.0-alpha.4
|
|
35
|
+
|
|
3
36
|
## 8.3.0-alpha.3
|
|
4
37
|
|
|
5
38
|
### Patch Changes
|
package/dist/ads/index.d.mts
CHANGED
|
@@ -1,93 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { AdProvider as AdProvider$1 } from "@getvitops/config/providers";
|
|
3
|
-
/**
|
|
4
|
-
* The ad platforms this toolchain knows how to link a site to.
|
|
5
|
-
*
|
|
6
|
-
* A closed set, and `AD_PROVIDERS` in `@getvitops/config` is its single source: the capability
|
|
7
|
-
* table in `providers.ts` is a `Record<AdProvider, …>`, so a provider added there without an entry
|
|
8
|
-
* here is a compile error. (This was a hand-copied union held to the schema's enum by a test in the
|
|
9
|
-
* generator; the schema now lives below this package, so the copy and the test are gone.) An open
|
|
10
|
-
* string would mean a typo'd provider silently getting no verification step and no tag.
|
|
11
|
-
*/
|
|
12
|
-
type AdProvider = AdProvider$1;
|
|
13
|
-
/** Which consent category gates a provider's tag. */
|
|
14
|
-
type AdConsentCategory = 'marketing' | 'analytics';
|
|
15
|
-
/**
|
|
16
|
-
* The three interactions Google Ads can report a distinct conversion action
|
|
17
|
-
* for. `form` is fired client-side via `vitopsFormConversion()`
|
|
18
|
-
* (`@getvitops/astro`'s tracking script), the other two from the tel:/mailto:
|
|
19
|
-
* click handler.
|
|
20
|
-
*/
|
|
21
|
-
type ConversionAction = 'call' | 'email' | 'form';
|
|
22
|
-
/**
|
|
23
|
-
* One label per action this site wants Google Ads to tell apart.
|
|
24
|
-
*
|
|
25
|
-
* Written out field by field, `?: string | undefined` — not
|
|
26
|
-
* `Partial<Record<ConversionAction, string>>` — matching this file's own
|
|
27
|
-
* convention above: `exactOptionalPropertyTypes` treats `{ email?: string }`
|
|
28
|
-
* (present-or-absent) and `{ email?: string | undefined }` (present,
|
|
29
|
-
* absent, or explicitly `undefined`) as different types, and a parsed JSON
|
|
30
|
-
* config's optional field is the latter. `Partial<Record<...>>` produces the
|
|
31
|
-
* former, which rejected the zod-inferred shape config.ts actually produces.
|
|
32
|
-
*/
|
|
33
|
-
interface ConversionLabelMap {
|
|
34
|
-
call?: string | undefined;
|
|
35
|
-
email?: string | undefined;
|
|
36
|
-
form?: string | undefined;
|
|
37
|
-
}
|
|
38
|
-
/**
|
|
39
|
-
* A bare string is back-compat shorthand for `{ call: label }` — the only
|
|
40
|
-
* action that existed before this was a map. Absent input normalises to `{}`.
|
|
41
|
-
*/
|
|
42
|
-
export declare function normalizeConversionLabels(label: string | ConversionLabelMap | undefined): ConversionLabelMap;
|
|
43
|
-
/**
|
|
44
|
-
* One Google Ads conversion action **resource id** per action this site
|
|
45
|
-
* uploads server-side — a numeric id (`"123456789"`) or a full resource name
|
|
46
|
-
* (`"customers/123/conversionActions/456"`), never the `conversionLabel`
|
|
47
|
-
* string. Written out field by field for the same `exactOptionalPropertyTypes`
|
|
48
|
-
* reason as `ConversionLabelMap` above.
|
|
49
|
-
*
|
|
50
|
-
* **This is a genuinely different identifier from `conversionLabel`, not an
|
|
51
|
-
* alternate spelling of it.** `conversionLabel` is the opaque label the web
|
|
52
|
-
* tag embeds in `send_to: "AW-…/label"` — it identifies the conversion action
|
|
53
|
-
* to `gtag.js`, not to the Ads API. The API's click-conversion upload
|
|
54
|
-
* (`ConversionUploadService.UploadClickConversions`) addresses the action by
|
|
55
|
-
* `conversion_action` **resource name**, a value the label cannot be turned
|
|
56
|
-
* into locally — Google returns both when a conversion action is created, but
|
|
57
|
-
* only the label ships to the browser today. Until something in this
|
|
58
|
-
* toolchain calls `ConversionActionService` to create/list actions and print
|
|
59
|
-
* both back, this field has to be copied in by hand from the Ads UI/API.
|
|
60
|
-
*/
|
|
61
|
-
interface ConversionActionIdMap {
|
|
62
|
-
call?: string | undefined;
|
|
63
|
-
email?: string | undefined;
|
|
64
|
-
form?: string | undefined;
|
|
65
|
-
}
|
|
66
|
-
/** Same back-compat shorthand as `normalizeConversionLabels`, for ids. */
|
|
67
|
-
export declare function normalizeConversionActionIds(id: string | ConversionActionIdMap | undefined): ConversionActionIdMap;
|
|
68
|
-
/** One ad property to link: the config block, plus the platform it is keyed by. */
|
|
69
|
-
type AdPropertySetup = AdPropertyConfig & {
|
|
70
|
-
provider: AdProvider;
|
|
71
|
-
};
|
|
72
|
-
/** Everything `plan()` needs, once the CLI has read the config. */
|
|
73
|
-
interface AdsConfig {
|
|
74
|
-
/** The properties to link. Already filtered to `--provider` if that was passed. */
|
|
75
|
-
properties: AdPropertySetup[];
|
|
76
|
-
}
|
|
77
|
-
/**
|
|
78
|
-
* One property's observed live state, gathered by the executor before planning.
|
|
79
|
-
*
|
|
80
|
-
* Same shape of arrangement as `DomainState`: the decision is pure, the
|
|
81
|
-
* observation is not. `txtContents` is every apex TXT record on the domain, so
|
|
82
|
-
* the planner can decide both "our record is present" and "a *different* record
|
|
83
|
-
* from this provider is present" — the second is a token rotation, which needs
|
|
84
|
-
* saying rather than silently adding a second record.
|
|
85
|
-
*/
|
|
86
|
-
interface AdDomainState {
|
|
87
|
-
zoneId?: string | undefined;
|
|
88
|
-
/** Every apex TXT record content, unquoted. */
|
|
89
|
-
txtContents: string[];
|
|
90
|
-
}
|
|
1
|
+
import { a as AdsConfig, c as ConversionLabelMap, i as AdProvider, l as normalizeConversionActionIds, n as AdDomainState, o as ConversionAction, r as AdPropertySetup, s as ConversionActionIdMap, t as AdConsentCategory, u as normalizeConversionLabels } from "../types-C0hQKdsM.mjs";
|
|
91
2
|
/** A step's decision. `blocked` means the config is missing something. */
|
|
92
3
|
type StepAction = 'create' | 'skip' | 'blocked';
|
|
93
4
|
/** A config field the operator can supply to unblock a step. */
|
|
@@ -198,6 +109,14 @@ interface AdPlatform {
|
|
|
198
109
|
operatorCountry: string;
|
|
199
110
|
/** Click-ID parameters this platform stamps on an ad landing URL. */
|
|
200
111
|
clickIdParams: string[];
|
|
112
|
+
/**
|
|
113
|
+
* Can the platform itself swap a forwarding number into the website for its own
|
|
114
|
+
* visitors? Only Google can (website call conversions, Canada included). Microsoft's
|
|
115
|
+
* forwarding numbers exist only inside the ad, and only in the US/UK/AU; every other
|
|
116
|
+
* platform offers none. A site wanting a per-platform number provisions its own and
|
|
117
|
+
* states it in `site.tracking.phoneNumbers`.
|
|
118
|
+
*/
|
|
119
|
+
websiteCallTracking: boolean;
|
|
201
120
|
/** Where the operator manages the property. */
|
|
202
121
|
consoleUrl: string;
|
|
203
122
|
verification: AdVerificationSpec;
|
|
@@ -236,5 +155,5 @@ export declare const categoryOf: (entry: AdPropertySetup) => AdConsentCategory;
|
|
|
236
155
|
* than emit a snippet that initialises `undefined`.
|
|
237
156
|
*/
|
|
238
157
|
export declare function renderTag(entry: AdPropertySetup, strategy?: 'idle' | 'async' | 'interaction'): string | null;
|
|
239
|
-
export type
|
|
158
|
+
export { type AdConsentCategory, type AdDomainState, type AdPlatform, type AdPropertyPlan, type AdPropertyResult, type AdPropertySetup, type AdProvider, type AdTagSpec, type AdVerificationSpec, type AdsConfig, type AdsPlan, type ConversionAction, type ConversionActionIdMap, type ConversionLabelMap, type MissingField, type StepAction, type StepPlan, normalizeConversionActionIds, normalizeConversionLabels };
|
|
240
159
|
//# sourceMappingURL=index.d.mts.map
|
package/dist/ads/index.d.mts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.mts","names":[
|
|
1
|
+
{"version":3,"file":"index.d.mts","names":[],"sources":["../../src/ads/plan.ts","../../src/ads/providers.ts"],"mappings":";;KAyBY;;KAGA;UAEK;EACf,QAAQ;;EAER;;EAEA,QAAQ;;;UAIO;EACf,UAAU;;EAEV;;EAEA;;EAEA,KAAK;;EAEL,KAAK;;EAEL;;EAEA;;UAGe;EACf,YAAY;;EAEZ;;;;;;;;;;;wBAYc,aAAa,OAAO,iBAAiB,OAAO,gBAAgB;;wBAoD5D,KAAK,QAAQ,WAAW,QAAQ,YAAY,iBAAiB;;wBAe7D,SAAS,GAAG;;wBAKZ,cAAc,GAAG;EAAY;EAAkB,OAAO;;;wBAYtD,WAAW,GAAG;;UAcb;EACf;EACA;EACA;EACA;EACA;;;wBAIc,cAAc,SAAS;;UC7ItB;EACf;;;;;;;EAOA;;EAEA;;EAEA;;;UAIe;;EAEf;;EAEA,OAAO;;EAEP,YAAY,YAAY,OAAO;;;;;EAK/B;;EAEA;;EAEA;;UAGe;;EAEf;;;;;;;;;EASA;;EAEA;;;;;;;;EAQA;;EAEA;EACA,cAAc;EACd,KAAK;;EAEL,cAAc,OAAO;;qBAkFV,cAAc,OAAO,YAAY;;qBAwNjC,kBAAgD;;qBAGhD,eAAgB,kBAAgB,SAAS;;;;;;;;;;wBAWtC,UAAU,UAAU,YAAY;;wBAOhC,MAAM,OAAO;;qBAMhB,aAAc,OAAO,oBAAkB;;;;;;;;;;;;wBAiBpC,UACd,OAAO,iBACP"}
|
package/dist/ads/index.mjs
CHANGED
|
@@ -1,318 +1,5 @@
|
|
|
1
1
|
import { t as table } from "../plan-format-CIz6Feg2.mjs";
|
|
2
|
-
|
|
3
|
-
// src/ads/types.ts
|
|
4
|
-
/**
|
|
5
|
-
* A bare string is back-compat shorthand for `{ call: label }` — the only
|
|
6
|
-
* action that existed before this was a map. Absent input normalises to `{}`.
|
|
7
|
-
*/
|
|
8
|
-
function normalizeConversionLabels(label) {
|
|
9
|
-
if (label == null) return {};
|
|
10
|
-
return typeof label === "string" ? { call: label } : label;
|
|
11
|
-
}
|
|
12
|
-
/** Same back-compat shorthand as `normalizeConversionLabels`, for ids. */
|
|
13
|
-
function normalizeConversionActionIds(id) {
|
|
14
|
-
if (id == null) return {};
|
|
15
|
-
return typeof id === "string" ? { call: id } : id;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
// src/ads/providers.ts
|
|
19
|
-
/**
|
|
20
|
-
* What each ad platform actually lets a build tool do — the table every `vitops ads`
|
|
21
|
-
* subcommand reads.
|
|
22
|
-
*
|
|
23
|
-
* Written the way the indexing module was written: start from what the platforms
|
|
24
|
-
* accept, because it is much narrower than it looks, and record the *absence* of a
|
|
25
|
-
* path as a fact rather than leaving it as a gap.
|
|
26
|
-
*
|
|
27
|
-
* - **Four platforms verify a domain by DNS TXT** (Meta, TikTok, Pinterest,
|
|
28
|
-
* Snapchat). That record is the one thing here that can be created for you.
|
|
29
|
-
* - **Four have no domain verification at all** (Google Ads, LinkedIn, Reddit,
|
|
30
|
-
* Microsoft Ads). Linking is the tag and the account id, nothing else. Those
|
|
31
|
-
* resolve to a skip *plus* a named reason — a silent omission would read as
|
|
32
|
-
* "already done".
|
|
33
|
-
* - **No platform Marketing API is called.** Meta's Business Management
|
|
34
|
-
* `verify_domain` needs a system-user token; the Google Ads API needs an
|
|
35
|
-
* approved developer token and puts the consumer's own account on the line.
|
|
36
|
-
* That is the same reasoning that keeps the Google Indexing API unwired: a
|
|
37
|
-
* toolchain other people install must not ship a documented path to a terms
|
|
38
|
-
* violation. The final "Verify" click stays a reminder.
|
|
39
|
-
*
|
|
40
|
-
* The verification token is **not a secret** — it is published in DNS, and the
|
|
41
|
-
* platform fetching it back is the ownership proof, exactly like the IndexNow key.
|
|
42
|
-
* So it lives in the config while credentials stay in the environment.
|
|
43
|
-
*
|
|
44
|
-
* Each tag is the platform's documented bootstrap reduced to its queue stub, with
|
|
45
|
-
* the remote library on `data-src` for the consent gate to attach after the
|
|
46
|
-
* bootstrap has run (`@getvitops/core`'s `activateScript` emits the inline body
|
|
47
|
-
* first for exactly this reason). Two platforms — Microsoft and Pinterest — ship a
|
|
48
|
-
* snippet that creates its own `<script>`; those declare no `src`, and their body
|
|
49
|
-
* stays inert until activation, so an undecided visitor still issues no request.
|
|
50
|
-
*/
|
|
51
|
-
/**
|
|
52
|
-
* `gtag` is shared by every Google tag; the id shape (`AW-…`) is what differs.
|
|
53
|
-
*
|
|
54
|
-
* `gtag('config', id)` only loads the tag — it never reports a conversion. When
|
|
55
|
-
* `entry.conversionLabel` names one or more actions, this also exposes a
|
|
56
|
-
* `{action: 'id/label'}` map on `window.vitopsAdsConversions.google` — one entry
|
|
57
|
-
* per configured action, so a call, an email click and a form submission each
|
|
58
|
-
* carry their own `send_to` target instead of all three sharing one (that
|
|
59
|
-
* sharing was a real bug: a call and an email click used to report as the same
|
|
60
|
-
* Google Ads conversion action, indistinguishable in the account). Bootstrap
|
|
61
|
-
* time only loads the tag; `scripts/tracking.ts`'s click handler and
|
|
62
|
-
* `vitopsFormConversion()` each read their own action's key and fire
|
|
63
|
-
* `gtag('event', 'conversion', …)` at the moment of the actual interaction —
|
|
64
|
-
* bootstrap-time firing would count every pageview as a conversion.
|
|
65
|
-
*
|
|
66
|
-
* `entry` is trusted here — `resolveAds()` validates every configured label
|
|
67
|
-
* against `SAFE_ID` before it reaches this function, the same guard `id`
|
|
68
|
-
* already gets.
|
|
69
|
-
*
|
|
70
|
-
* **`entry.reporting === 'server'` suppresses an action here, never
|
|
71
|
-
* silently.** An action fires client-side unless reporting is `'server'`
|
|
72
|
-
* *and* that same action has a `conversionActionId` configured — so turning
|
|
73
|
-
* reporting to `'server'` before the id is filled in leaves the action firing
|
|
74
|
-
* client-side rather than dropping the conversion. Once both are set, the
|
|
75
|
-
* server-side upload (`@getvitops/utils/notify`'s `uploadClickConversion`,
|
|
76
|
-
* wired in by `createConversionRoute`'s `googleAdsOffline` option) is the one
|
|
77
|
-
* place that action reports from — this is what avoids double-counting.
|
|
78
|
-
*/
|
|
79
|
-
const gtagBootstrap = (id, entry) => {
|
|
80
|
-
const labels = normalizeConversionLabels(entry.conversionLabel);
|
|
81
|
-
const actionIds = normalizeConversionActionIds(entry.conversionActionId);
|
|
82
|
-
const actions = Object.entries(labels).filter((pair) => !!pair[1] && !(entry.reporting === "server" && !!actionIds[pair[0]]));
|
|
83
|
-
return `window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments)}gtag('js',new Date());gtag('config','${id}');` + (actions.length ? `window.vitopsAdsConversions=window.vitopsAdsConversions||{};window.vitopsAdsConversions.google=${JSON.stringify(Object.fromEntries(actions.map(([action, label]) => [action, `${id}/${label}`])))};` : "");
|
|
84
|
-
};
|
|
85
|
-
/** The queue-stub shape shared by fbq/ttq/rdt/snaptr/pintrk. */
|
|
86
|
-
const queueStub = (global, methods) => `window.${global}=window.${global}||function(){(window.${global}.q=window.${global}.q||[]).push(arguments)};window.${global}.methods=${methods};`;
|
|
87
|
-
const AD_PLATFORMS = {
|
|
88
|
-
google: {
|
|
89
|
-
name: "Google Ads",
|
|
90
|
-
operatorCountry: "the United States",
|
|
91
|
-
clickIdParams: [
|
|
92
|
-
"gclid",
|
|
93
|
-
"gbraid",
|
|
94
|
-
"wbraid"
|
|
95
|
-
],
|
|
96
|
-
consoleUrl: "https://ads.google.com/",
|
|
97
|
-
verification: {
|
|
98
|
-
method: "none",
|
|
99
|
-
reason: "Google Ads has no domain verification — the conversion tag is the link. (Merchant Center claims a website; that is a different product.)"
|
|
100
|
-
},
|
|
101
|
-
tag: {
|
|
102
|
-
needs: "pixelId",
|
|
103
|
-
src: (id) => `https://www.googletagmanager.com/gtag/js?id=${encodeURIComponent(id)}`,
|
|
104
|
-
bootstrap: gtagBootstrap,
|
|
105
|
-
cookies: [
|
|
106
|
-
"_gcl_au",
|
|
107
|
-
"_gcl_aw",
|
|
108
|
-
"_gcl_dc",
|
|
109
|
-
"_gac_*"
|
|
110
|
-
],
|
|
111
|
-
privacyUrl: "https://policies.google.com/privacy",
|
|
112
|
-
optOut: "https://adssettings.google.com/"
|
|
113
|
-
},
|
|
114
|
-
manualSteps: (e) => [...Object.keys(normalizeConversionLabels(e.conversionLabel)).length ? [] : ["create a conversion action in Google Ads and record its label as `conversionLabel`"], "link the Google Ads account to Search Console / GA4 in the UI if you want imported conversions"]
|
|
115
|
-
},
|
|
116
|
-
meta: {
|
|
117
|
-
name: "Meta",
|
|
118
|
-
operatorCountry: "the United States",
|
|
119
|
-
clickIdParams: ["fbclid"],
|
|
120
|
-
consoleUrl: "https://business.facebook.com/settings/owned-domains",
|
|
121
|
-
verification: {
|
|
122
|
-
method: "dns-txt",
|
|
123
|
-
txtPrefix: "facebook-domain-verification=",
|
|
124
|
-
where: "Business settings → Brand safety → Domains → Add → DNS verification"
|
|
125
|
-
},
|
|
126
|
-
tag: {
|
|
127
|
-
needs: "pixelId",
|
|
128
|
-
src: () => "https://connect.facebook.net/en_US/fbevents.js",
|
|
129
|
-
bootstrap: (id) => `${queueStub("fbq", `['init','track','trackCustom','consent']`)}fbq('init','${id}');fbq('track','PageView');`,
|
|
130
|
-
cookies: ["_fbp", "_fbc"],
|
|
131
|
-
privacyUrl: "https://www.facebook.com/privacy/policy/",
|
|
132
|
-
optOut: "https://www.facebook.com/adpreferences/ad_settings"
|
|
133
|
-
},
|
|
134
|
-
manualSteps: () => ["click Verify on the domain in Business settings once the TXT record has propagated — the Business Management API needs a system-user token and is deliberately not called"]
|
|
135
|
-
},
|
|
136
|
-
linkedin: {
|
|
137
|
-
name: "LinkedIn",
|
|
138
|
-
operatorCountry: "the United States",
|
|
139
|
-
clickIdParams: ["li_fat_id"],
|
|
140
|
-
consoleUrl: "https://www.linkedin.com/campaignmanager/",
|
|
141
|
-
verification: {
|
|
142
|
-
method: "none",
|
|
143
|
-
reason: "LinkedIn Ads has no domain verification — the Insight Tag is the link. (Verifying a LinkedIn *Page* is a separate, unrelated flow.)"
|
|
144
|
-
},
|
|
145
|
-
tag: {
|
|
146
|
-
needs: "accountId",
|
|
147
|
-
src: () => "https://snap.licdn.com/li.lms-analytics/insight.min.js",
|
|
148
|
-
bootstrap: (id) => `window._linkedin_partner_id='${id}';window._linkedin_data_partner_ids=window._linkedin_data_partner_ids||[];window._linkedin_data_partner_ids.push('${id}');`,
|
|
149
|
-
cookies: [
|
|
150
|
-
"li_sugr",
|
|
151
|
-
"li_gc",
|
|
152
|
-
"bcookie",
|
|
153
|
-
"lidc",
|
|
154
|
-
"UserMatchHistory"
|
|
155
|
-
],
|
|
156
|
-
privacyUrl: "https://www.linkedin.com/legal/privacy-policy",
|
|
157
|
-
optOut: "https://www.linkedin.com/psettings/guest-controls/retargeting-opt-out"
|
|
158
|
-
},
|
|
159
|
-
manualSteps: () => ["add conversions in Campaign Manager — the Insight Tag alone records page visits, not conversions"]
|
|
160
|
-
},
|
|
161
|
-
reddit: {
|
|
162
|
-
name: "Reddit",
|
|
163
|
-
operatorCountry: "the United States",
|
|
164
|
-
clickIdParams: ["rdt_cid"],
|
|
165
|
-
consoleUrl: "https://ads.reddit.com/",
|
|
166
|
-
verification: {
|
|
167
|
-
method: "none",
|
|
168
|
-
reason: "Reddit Ads has no domain verification — the Reddit Pixel is the link."
|
|
169
|
-
},
|
|
170
|
-
tag: {
|
|
171
|
-
needs: "pixelId",
|
|
172
|
-
src: () => "https://www.redditstatic.com/ads/pixel.js",
|
|
173
|
-
bootstrap: (id) => `${queueStub("rdt", `['init','track']`)}rdt('init','${id}');rdt('track','PageVisit');`,
|
|
174
|
-
cookies: ["_rdt_uuid"],
|
|
175
|
-
privacyUrl: "https://www.reddit.com/policies/privacy-policy"
|
|
176
|
-
},
|
|
177
|
-
manualSteps: () => []
|
|
178
|
-
},
|
|
179
|
-
tiktok: {
|
|
180
|
-
name: "TikTok",
|
|
181
|
-
operatorCountry: "Singapore",
|
|
182
|
-
clickIdParams: ["ttclid"],
|
|
183
|
-
consoleUrl: "https://ads.tiktok.com/",
|
|
184
|
-
verification: {
|
|
185
|
-
method: "dns-txt",
|
|
186
|
-
txtPrefix: "tiktok-developers-site-verification=",
|
|
187
|
-
where: "Ads Manager → Assets → Events → Web events → Verify domain → DNS"
|
|
188
|
-
},
|
|
189
|
-
tag: {
|
|
190
|
-
needs: "pixelId",
|
|
191
|
-
src: (id) => `https://analytics.tiktok.com/i18n/pixel/events.js?sdkid=${encodeURIComponent(id)}&lib=ttq`,
|
|
192
|
-
bootstrap: (id) => `window.TiktokAnalyticsObject='ttq';${queueStub("ttq", `['page','track','identify','instances','debug','on','off','once','ready','alias','group','enableCookie','disableCookie']`)}ttq('init','${id}');ttq('page');`,
|
|
193
|
-
cookies: ["_ttp"],
|
|
194
|
-
privacyUrl: "https://www.tiktok.com/legal/privacy-policy"
|
|
195
|
-
},
|
|
196
|
-
manualSteps: () => []
|
|
197
|
-
},
|
|
198
|
-
microsoft: {
|
|
199
|
-
name: "Microsoft Ads",
|
|
200
|
-
operatorCountry: "the United States",
|
|
201
|
-
clickIdParams: ["msclkid"],
|
|
202
|
-
consoleUrl: "https://ads.microsoft.com/",
|
|
203
|
-
verification: {
|
|
204
|
-
method: "none",
|
|
205
|
-
reason: "Microsoft Ads has no domain verification — the UET tag is the link."
|
|
206
|
-
},
|
|
207
|
-
tag: {
|
|
208
|
-
needs: "pixelId",
|
|
209
|
-
bootstrap: (id) => `(function(w,d,t,r,u){var f,n,i;w[u]=w[u]||[],f=function(){var o={ti:'${id}',enableAutoSpaTracking:true};o.q=w[u],w[u]=new UET(o),w[u].push('pageLoad')},n=d.createElement(t),n.src=r,n.async=1,n.onload=n.onreadystatechange=function(){var s=this.readyState;s&&s!=='loaded'&&s!=='complete'||(f(),n.onload=n.onreadystatechange=null)},i=d.getElementsByTagName(t)[0],i.parentNode.insertBefore(n,i)})(window,document,'script','https://bat.bing.com/bat.js','uetq');`,
|
|
210
|
-
cookies: [
|
|
211
|
-
"_uetsid",
|
|
212
|
-
"_uetvid",
|
|
213
|
-
"MUID"
|
|
214
|
-
],
|
|
215
|
-
privacyUrl: "https://privacy.microsoft.com/privacystatement",
|
|
216
|
-
optOut: "https://account.microsoft.com/privacy/ad-settings"
|
|
217
|
-
},
|
|
218
|
-
manualSteps: () => []
|
|
219
|
-
},
|
|
220
|
-
pinterest: {
|
|
221
|
-
name: "Pinterest",
|
|
222
|
-
operatorCountry: "the United States",
|
|
223
|
-
clickIdParams: ["epik"],
|
|
224
|
-
consoleUrl: "https://ads.pinterest.com/",
|
|
225
|
-
verification: {
|
|
226
|
-
method: "dns-txt",
|
|
227
|
-
txtPrefix: "pinterest-site-verification=",
|
|
228
|
-
where: "Business hub → Claim → Websites → Claim → Add TXT record"
|
|
229
|
-
},
|
|
230
|
-
tag: {
|
|
231
|
-
needs: "pixelId",
|
|
232
|
-
bootstrap: (id) => `${queueStub("pintrk", `['load','page','track']`)}!function(){var t=document.createElement('script');t.async=!0,t.src='https://s.pinimg.com/ct/core.js';var e=document.getElementsByTagName('script')[0];e.parentNode.insertBefore(t,e)}();pintrk('load','${id}');pintrk('page');`,
|
|
233
|
-
cookies: [
|
|
234
|
-
"_pinterest_ct_ua",
|
|
235
|
-
"_pin_unauth",
|
|
236
|
-
"_epik"
|
|
237
|
-
],
|
|
238
|
-
privacyUrl: "https://policy.pinterest.com/privacy-policy"
|
|
239
|
-
},
|
|
240
|
-
manualSteps: () => ["confirm the claim in Business hub → Claim once the TXT record has propagated"]
|
|
241
|
-
},
|
|
242
|
-
snapchat: {
|
|
243
|
-
name: "Snapchat",
|
|
244
|
-
operatorCountry: "the United States",
|
|
245
|
-
clickIdParams: ["ScCid"],
|
|
246
|
-
consoleUrl: "https://ads.snapchat.com/",
|
|
247
|
-
verification: {
|
|
248
|
-
method: "dns-txt",
|
|
249
|
-
txtPrefix: "snap-domain-verification=",
|
|
250
|
-
where: "Business Manager → Public Profiles / Domains → Verify domain → DNS"
|
|
251
|
-
},
|
|
252
|
-
tag: {
|
|
253
|
-
needs: "pixelId",
|
|
254
|
-
src: () => "https://sc-static.net/scevent.min.js",
|
|
255
|
-
bootstrap: (id) => `${queueStub("snaptr", `['init','track']`)}snaptr('init','${id}');snaptr('track','PAGE_VIEW');`,
|
|
256
|
-
cookies: [
|
|
257
|
-
"_scid",
|
|
258
|
-
"_scid_r",
|
|
259
|
-
"sc_at"
|
|
260
|
-
],
|
|
261
|
-
privacyUrl: "https://snap.com/privacy/privacy-policy"
|
|
262
|
-
},
|
|
263
|
-
manualSteps: () => []
|
|
264
|
-
}
|
|
265
|
-
};
|
|
266
|
-
/** Every provider, in table order — the one place iteration order is decided. */
|
|
267
|
-
const AD_PROVIDER_KEYS = Object.keys(AD_PLATFORMS);
|
|
268
|
-
/** Whether a string names a known provider (for CLI arg validation). */
|
|
269
|
-
const isAdProvider = (value) => value in AD_PLATFORMS;
|
|
270
|
-
/**
|
|
271
|
-
* The DNS TXT record content for a token.
|
|
272
|
-
*
|
|
273
|
-
* A value already containing `=` is taken as the whole record and left alone. That
|
|
274
|
-
* is the same escape hatch an icon name containing `:` gets: a platform can change
|
|
275
|
-
* its prefix spelling without waiting on a toolchain release, and an operator who
|
|
276
|
-
* pasted the full record from the UI gets what they meant rather than a
|
|
277
|
-
* double-prefixed record that verifies against nothing.
|
|
278
|
-
*/
|
|
279
|
-
function txtRecord(provider, token) {
|
|
280
|
-
const trimmed = token.trim();
|
|
281
|
-
if (trimmed.includes("=")) return trimmed;
|
|
282
|
-
return `${AD_PLATFORMS[provider].verification.txtPrefix ?? ""}${trimmed}`;
|
|
283
|
-
}
|
|
284
|
-
/** The tag id for an entry, or undefined when the field it needs is unset. */
|
|
285
|
-
function tagId(entry) {
|
|
286
|
-
return entry[AD_PLATFORMS[entry.provider].tag.needs];
|
|
287
|
-
}
|
|
288
|
-
/** The consent category gating an entry's tag. `marketing` unless stated otherwise. */
|
|
289
|
-
const categoryOf = (entry) => entry.category ?? "marketing";
|
|
290
|
-
const escapeAttr = (value) => value.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<");
|
|
291
|
-
/**
|
|
292
|
-
* One provider's tag, rendered as a **consent-gated, inert** `<script>`.
|
|
293
|
-
*
|
|
294
|
-
* `type="text/plain"` with the URL on `data-src` is what makes the gate real: the
|
|
295
|
-
* browser neither parses the body nor fetches the library, so an undecided
|
|
296
|
-
* visitor's page issues no third-party request. Never give one of these a live
|
|
297
|
-
* `src` — a gate that instead asks a loaded tracker not to track is a promise.
|
|
298
|
-
*
|
|
299
|
-
* Returns null when the entry has no tag id, so a caller can report the gap rather
|
|
300
|
-
* than emit a snippet that initialises `undefined`.
|
|
301
|
-
*/
|
|
302
|
-
function renderTag(entry, strategy = "idle") {
|
|
303
|
-
const platform = AD_PLATFORMS[entry.provider];
|
|
304
|
-
const id = tagId(entry);
|
|
305
|
-
if (!id) return null;
|
|
306
|
-
const attrs = [
|
|
307
|
-
"type=\"text/plain\"",
|
|
308
|
-
"data-vitops-tag",
|
|
309
|
-
`data-consent="${categoryOf(entry)}"`,
|
|
310
|
-
`data-strategy="${strategy}"`,
|
|
311
|
-
...platform.tag.src ? [`data-src="${escapeAttr(platform.tag.src(id))}"`] : [],
|
|
312
|
-
`data-consent-cookies="${escapeAttr(platform.tag.cookies.join(","))}"`
|
|
313
|
-
];
|
|
314
|
-
return `<!-- ${platform.name} — gated on ${categoryOf(entry)} consent -->\n<script ${attrs.join(" ")}>${platform.tag.bootstrap(id, entry)}<\/script>`;
|
|
315
|
-
}
|
|
2
|
+
import { a as renderTag, c as normalizeConversionActionIds, i as isAdProvider, l as normalizeConversionLabels, n as AD_PROVIDER_KEYS, o as tagId, r as categoryOf, s as txtRecord, t as AD_PLATFORMS } from "../providers-CEHw-j4v.mjs";
|
|
316
3
|
|
|
317
4
|
// src/ads/plan.ts
|
|
318
5
|
/**
|
package/dist/ads/index.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.mjs","names":[],"sources":["../../src/ads/types.ts","../../src/ads/providers.ts","../../src/ads/plan.ts"],"sourcesContent":["/**\n * The ads module's own option surface.\n *\n * `AdPropertySetup` is the `site.ads.<platform>` block from `@getvitops/config` plus the platform it\n * is keyed by — inferred from the schema `validateConfig` runs, not re-declared here.\n */\n\nimport type { AdPropertyConfig } from '@getvitops/config';\nimport type { AdProvider as AdProviderConfig } from '@getvitops/config/providers';\n\n/**\n * The ad platforms this toolchain knows how to link a site to.\n *\n * A closed set, and `AD_PROVIDERS` in `@getvitops/config` is its single source: the capability\n * table in `providers.ts` is a `Record<AdProvider, …>`, so a provider added there without an entry\n * here is a compile error. (This was a hand-copied union held to the schema's enum by a test in the\n * generator; the schema now lives below this package, so the copy and the test are gone.) An open\n * string would mean a typo'd provider silently getting no verification step and no tag.\n */\nexport type AdProvider = AdProviderConfig;\n\n/** Which consent category gates a provider's tag. */\nexport type AdConsentCategory = 'marketing' | 'analytics';\n\n/**\n * The three interactions Google Ads can report a distinct conversion action\n * for. `form` is fired client-side via `vitopsFormConversion()`\n * (`@getvitops/astro`'s tracking script), the other two from the tel:/mailto:\n * click handler.\n */\nexport type ConversionAction = 'call' | 'email' | 'form';\n\n/**\n * One label per action this site wants Google Ads to tell apart.\n *\n * Written out field by field, `?: string | undefined` — not\n * `Partial<Record<ConversionAction, string>>` — matching this file's own\n * convention above: `exactOptionalPropertyTypes` treats `{ email?: string }`\n * (present-or-absent) and `{ email?: string | undefined }` (present,\n * absent, or explicitly `undefined`) as different types, and a parsed JSON\n * config's optional field is the latter. `Partial<Record<...>>` produces the\n * former, which rejected the zod-inferred shape config.ts actually produces.\n */\nexport interface ConversionLabelMap {\n call?: string | undefined;\n email?: string | undefined;\n form?: string | undefined;\n}\n\n/**\n * A bare string is back-compat shorthand for `{ call: label }` — the only\n * action that existed before this was a map. Absent input normalises to `{}`.\n */\nexport function normalizeConversionLabels(\n label: string | ConversionLabelMap | undefined,\n): ConversionLabelMap {\n if (label == null) return {};\n return typeof label === 'string' ? { call: label } : label;\n}\n\n/**\n * One Google Ads conversion action **resource id** per action this site\n * uploads server-side — a numeric id (`\"123456789\"`) or a full resource name\n * (`\"customers/123/conversionActions/456\"`), never the `conversionLabel`\n * string. Written out field by field for the same `exactOptionalPropertyTypes`\n * reason as `ConversionLabelMap` above.\n *\n * **This is a genuinely different identifier from `conversionLabel`, not an\n * alternate spelling of it.** `conversionLabel` is the opaque label the web\n * tag embeds in `send_to: \"AW-…/label\"` — it identifies the conversion action\n * to `gtag.js`, not to the Ads API. The API's click-conversion upload\n * (`ConversionUploadService.UploadClickConversions`) addresses the action by\n * `conversion_action` **resource name**, a value the label cannot be turned\n * into locally — Google returns both when a conversion action is created, but\n * only the label ships to the browser today. Until something in this\n * toolchain calls `ConversionActionService` to create/list actions and print\n * both back, this field has to be copied in by hand from the Ads UI/API.\n */\nexport interface ConversionActionIdMap {\n call?: string | undefined;\n email?: string | undefined;\n form?: string | undefined;\n}\n\n/** Same back-compat shorthand as `normalizeConversionLabels`, for ids. */\nexport function normalizeConversionActionIds(\n id: string | ConversionActionIdMap | undefined,\n): ConversionActionIdMap {\n if (id == null) return {};\n return typeof id === 'string' ? { call: id } : id;\n}\n\n/** One ad property to link: the config block, plus the platform it is keyed by. */\nexport type AdPropertySetup = AdPropertyConfig & {\n provider: AdProvider;\n};\n\n/** Everything `plan()` needs, once the CLI has read the config. */\nexport interface AdsConfig {\n /** The properties to link. Already filtered to `--provider` if that was passed. */\n properties: AdPropertySetup[];\n}\n\n/**\n * One property's observed live state, gathered by the executor before planning.\n *\n * Same shape of arrangement as `DomainState`: the decision is pure, the\n * observation is not. `txtContents` is every apex TXT record on the domain, so\n * the planner can decide both \"our record is present\" and \"a *different* record\n * from this provider is present\" — the second is a token rotation, which needs\n * saying rather than silently adding a second record.\n */\nexport interface AdDomainState {\n zoneId?: string | undefined;\n /** Every apex TXT record content, unquoted. */\n txtContents: string[];\n}\n","/**\n * What each ad platform actually lets a build tool do — the table every `vitops ads`\n * subcommand reads.\n *\n * Written the way the indexing module was written: start from what the platforms\n * accept, because it is much narrower than it looks, and record the *absence* of a\n * path as a fact rather than leaving it as a gap.\n *\n * - **Four platforms verify a domain by DNS TXT** (Meta, TikTok, Pinterest,\n * Snapchat). That record is the one thing here that can be created for you.\n * - **Four have no domain verification at all** (Google Ads, LinkedIn, Reddit,\n * Microsoft Ads). Linking is the tag and the account id, nothing else. Those\n * resolve to a skip *plus* a named reason — a silent omission would read as\n * \"already done\".\n * - **No platform Marketing API is called.** Meta's Business Management\n * `verify_domain` needs a system-user token; the Google Ads API needs an\n * approved developer token and puts the consumer's own account on the line.\n * That is the same reasoning that keeps the Google Indexing API unwired: a\n * toolchain other people install must not ship a documented path to a terms\n * violation. The final \"Verify\" click stays a reminder.\n *\n * The verification token is **not a secret** — it is published in DNS, and the\n * platform fetching it back is the ownership proof, exactly like the IndexNow key.\n * So it lives in the config while credentials stay in the environment.\n *\n * Each tag is the platform's documented bootstrap reduced to its queue stub, with\n * the remote library on `data-src` for the consent gate to attach after the\n * bootstrap has run (`@getvitops/core`'s `activateScript` emits the inline body\n * first for exactly this reason). Two platforms — Microsoft and Pinterest — ship a\n * snippet that creates its own `<script>`; those declare no `src`, and their body\n * stays inert until activation, so an undecided visitor still issues no request.\n */\nimport { normalizeConversionActionIds, normalizeConversionLabels } from './types.ts';\nimport type { AdConsentCategory, AdProvider, AdPropertySetup } from './types.ts';\n\n/** How a platform proves you own the domain. */\nexport interface AdVerificationSpec {\n method: 'dns-txt' | 'none';\n /**\n * Prefix prepended to a bare token to form the record content. Owned here so a\n * config holds the token the platform shows, not a hand-assembled record — but\n * see `txtRecord`: a value already containing `=` passes through whole, which is\n * the escape hatch when a platform changes its spelling.\n */\n txtPrefix?: string;\n /** Where in the platform UI the token is found. Printed when asking for it. */\n where?: string;\n /** Why there is nothing to automate. Required when `method` is `none`. */\n reason?: string;\n}\n\n/** The tag, and which config field carries its id. */\nexport interface AdTagSpec {\n /** Which field the tag id comes from — and therefore what to ask for when absent. */\n needs: 'pixelId' | 'accountId';\n /** The remote library. Absent when the platform's own bootstrap loads itself. */\n src?: (id: string) => string;\n /** The inline bootstrap, which the gate runs before attaching `src`. */\n bootstrap: (id: string, entry: AdPropertySetup) => string;\n /**\n * Cookies the tag sets, for `data-consent-cookies` (what a revoke clears) and\n * for the generated cookie notice. A `*` suffix is a prefix match.\n */\n cookies: string[];\n /** The provider's own privacy policy, for the cookie notice. */\n privacyUrl: string;\n /** A visitor-facing opt-out, where the platform offers one. */\n optOut?: string;\n}\n\nexport interface AdPlatform {\n /** The platform as a reader would recognise it. */\n name: string;\n /**\n * The jurisdiction that can compel this platform to produce what it receives —\n * where the contracting entity is established. Feeds the generated privacy\n * policy's cross-border transfer disclosure, which is a claim about foreign\n * *access*, not foreign storage (see `Processor.operatorCountry` in the\n * generator's `legal/providers.ts`). A consumer whose contracting entity differs\n * declares their own processor entry instead.\n */\n operatorCountry: string;\n /** Click-ID parameters this platform stamps on an ad landing URL. */\n clickIdParams: string[];\n /** Where the operator manages the property. */\n consoleUrl: string;\n verification: AdVerificationSpec;\n tag: AdTagSpec;\n /** Follow-ups no API covers, given this entry. */\n manualSteps: (entry: AdPropertySetup) => string[];\n}\n\n/**\n * `gtag` is shared by every Google tag; the id shape (`AW-…`) is what differs.\n *\n * `gtag('config', id)` only loads the tag — it never reports a conversion. When\n * `entry.conversionLabel` names one or more actions, this also exposes a\n * `{action: 'id/label'}` map on `window.vitopsAdsConversions.google` — one entry\n * per configured action, so a call, an email click and a form submission each\n * carry their own `send_to` target instead of all three sharing one (that\n * sharing was a real bug: a call and an email click used to report as the same\n * Google Ads conversion action, indistinguishable in the account). Bootstrap\n * time only loads the tag; `scripts/tracking.ts`'s click handler and\n * `vitopsFormConversion()` each read their own action's key and fire\n * `gtag('event', 'conversion', …)` at the moment of the actual interaction —\n * bootstrap-time firing would count every pageview as a conversion.\n *\n * `entry` is trusted here — `resolveAds()` validates every configured label\n * against `SAFE_ID` before it reaches this function, the same guard `id`\n * already gets.\n *\n * **`entry.reporting === 'server'` suppresses an action here, never\n * silently.** An action fires client-side unless reporting is `'server'`\n * *and* that same action has a `conversionActionId` configured — so turning\n * reporting to `'server'` before the id is filled in leaves the action firing\n * client-side rather than dropping the conversion. Once both are set, the\n * server-side upload (`@getvitops/utils/notify`'s `uploadClickConversion`,\n * wired in by `createConversionRoute`'s `googleAdsOffline` option) is the one\n * place that action reports from — this is what avoids double-counting.\n */\nconst gtagBootstrap = (id: string, entry: AdPropertySetup): string => {\n const labels = normalizeConversionLabels(entry.conversionLabel);\n const actionIds = normalizeConversionActionIds(entry.conversionActionId);\n const actions = Object.entries(labels).filter(\n (pair): pair is [string, string] =>\n !!pair[1] &&\n !(entry.reporting === 'server' && !!actionIds[pair[0] as keyof typeof actionIds]),\n );\n return (\n `window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments)}` +\n `gtag('js',new Date());gtag('config','${id}');` +\n (actions.length\n ? `window.vitopsAdsConversions=window.vitopsAdsConversions||{};` +\n `window.vitopsAdsConversions.google=${JSON.stringify(\n Object.fromEntries(actions.map(([action, label]) => [action, `${id}/${label}`])),\n )};`\n : '')\n );\n};\n\n/** The queue-stub shape shared by fbq/ttq/rdt/snaptr/pintrk. */\nconst queueStub = (global: string, methods: string): string =>\n `window.${global}=window.${global}||function(){(window.${global}.q=window.${global}.q||[]).push(arguments)};` +\n `window.${global}.methods=${methods};`;\n\nexport const AD_PLATFORMS: Record<AdProvider, AdPlatform> = {\n google: {\n name: 'Google Ads',\n operatorCountry: 'the United States',\n clickIdParams: ['gclid', 'gbraid', 'wbraid'],\n consoleUrl: 'https://ads.google.com/',\n verification: {\n method: 'none',\n reason:\n 'Google Ads has no domain verification — the conversion tag is the link. (Merchant Center claims a website; that is a different product.)',\n },\n tag: {\n // The customer id (`123-456-7890`) is not the tag id: the conversion tag is\n // `AW-…`, which is why this asks for `pixelId` rather than reusing accountId.\n needs: 'pixelId',\n src: (id) => `https://www.googletagmanager.com/gtag/js?id=${encodeURIComponent(id)}`,\n bootstrap: gtagBootstrap,\n cookies: ['_gcl_au', '_gcl_aw', '_gcl_dc', '_gac_*'],\n privacyUrl: 'https://policies.google.com/privacy',\n optOut: 'https://adssettings.google.com/',\n },\n manualSteps: (e) => [\n ...(Object.keys(normalizeConversionLabels(e.conversionLabel)).length\n ? []\n : ['create a conversion action in Google Ads and record its label as `conversionLabel`']),\n 'link the Google Ads account to Search Console / GA4 in the UI if you want imported conversions',\n ],\n },\n\n meta: {\n name: 'Meta',\n operatorCountry: 'the United States',\n clickIdParams: ['fbclid'],\n consoleUrl: 'https://business.facebook.com/settings/owned-domains',\n verification: {\n method: 'dns-txt',\n txtPrefix: 'facebook-domain-verification=',\n where: 'Business settings → Brand safety → Domains → Add → DNS verification',\n },\n tag: {\n needs: 'pixelId',\n src: () => 'https://connect.facebook.net/en_US/fbevents.js',\n bootstrap: (id) =>\n `${queueStub('fbq', `['init','track','trackCustom','consent']`)}` +\n `fbq('init','${id}');fbq('track','PageView');`,\n cookies: ['_fbp', '_fbc'],\n privacyUrl: 'https://www.facebook.com/privacy/policy/',\n optOut: 'https://www.facebook.com/adpreferences/ad_settings',\n },\n manualSteps: () => [\n 'click Verify on the domain in Business settings once the TXT record has propagated — the Business Management API needs a system-user token and is deliberately not called',\n ],\n },\n\n linkedin: {\n name: 'LinkedIn',\n operatorCountry: 'the United States',\n clickIdParams: ['li_fat_id'],\n consoleUrl: 'https://www.linkedin.com/campaignmanager/',\n verification: {\n method: 'none',\n reason:\n 'LinkedIn Ads has no domain verification — the Insight Tag is the link. (Verifying a LinkedIn *Page* is a separate, unrelated flow.)',\n },\n tag: {\n needs: 'accountId',\n src: () => 'https://snap.licdn.com/li.lms-analytics/insight.min.js',\n bootstrap: (id) =>\n `window._linkedin_partner_id='${id}';` +\n `window._linkedin_data_partner_ids=window._linkedin_data_partner_ids||[];` +\n `window._linkedin_data_partner_ids.push('${id}');`,\n cookies: ['li_sugr', 'li_gc', 'bcookie', 'lidc', 'UserMatchHistory'],\n privacyUrl: 'https://www.linkedin.com/legal/privacy-policy',\n optOut: 'https://www.linkedin.com/psettings/guest-controls/retargeting-opt-out',\n },\n manualSteps: () => [\n 'add conversions in Campaign Manager — the Insight Tag alone records page visits, not conversions',\n ],\n },\n\n reddit: {\n name: 'Reddit',\n operatorCountry: 'the United States',\n clickIdParams: ['rdt_cid'],\n consoleUrl: 'https://ads.reddit.com/',\n verification: {\n method: 'none',\n reason: 'Reddit Ads has no domain verification — the Reddit Pixel is the link.',\n },\n tag: {\n needs: 'pixelId',\n src: () => 'https://www.redditstatic.com/ads/pixel.js',\n bootstrap: (id) =>\n `${queueStub('rdt', `['init','track']`)}rdt('init','${id}');rdt('track','PageVisit');`,\n cookies: ['_rdt_uuid'],\n privacyUrl: 'https://www.reddit.com/policies/privacy-policy',\n },\n manualSteps: () => [],\n },\n\n tiktok: {\n name: 'TikTok',\n operatorCountry: 'Singapore',\n clickIdParams: ['ttclid'],\n consoleUrl: 'https://ads.tiktok.com/',\n verification: {\n method: 'dns-txt',\n txtPrefix: 'tiktok-developers-site-verification=',\n where: 'Ads Manager → Assets → Events → Web events → Verify domain → DNS',\n },\n tag: {\n needs: 'pixelId',\n src: (id) =>\n `https://analytics.tiktok.com/i18n/pixel/events.js?sdkid=${encodeURIComponent(id)}&lib=ttq`,\n bootstrap: (id) =>\n `window.TiktokAnalyticsObject='ttq';` +\n `${queueStub('ttq', `['page','track','identify','instances','debug','on','off','once','ready','alias','group','enableCookie','disableCookie']`)}` +\n `ttq('init','${id}');ttq('page');`,\n cookies: ['_ttp'],\n privacyUrl: 'https://www.tiktok.com/legal/privacy-policy',\n },\n manualSteps: () => [],\n },\n\n microsoft: {\n name: 'Microsoft Ads',\n operatorCountry: 'the United States',\n clickIdParams: ['msclkid'],\n consoleUrl: 'https://ads.microsoft.com/',\n verification: {\n method: 'none',\n reason: 'Microsoft Ads has no domain verification — the UET tag is the link.',\n },\n tag: {\n needs: 'pixelId',\n // No `src`: UET must be constructed *after* bat.js evaluates, so the\n // platform's own snippet attaches an onload handler to a script it creates.\n // Inert until activation all the same — the body is never parsed before then.\n bootstrap: (id) =>\n `(function(w,d,t,r,u){var f,n,i;w[u]=w[u]||[],f=function(){var o={ti:'${id}',enableAutoSpaTracking:true};` +\n `o.q=w[u],w[u]=new UET(o),w[u].push('pageLoad')},n=d.createElement(t),n.src=r,n.async=1,` +\n `n.onload=n.onreadystatechange=function(){var s=this.readyState;s&&s!=='loaded'&&s!=='complete'||(f(),n.onload=n.onreadystatechange=null)},` +\n `i=d.getElementsByTagName(t)[0],i.parentNode.insertBefore(n,i)})(window,document,'script','https://bat.bing.com/bat.js','uetq');`,\n cookies: ['_uetsid', '_uetvid', 'MUID'],\n privacyUrl: 'https://privacy.microsoft.com/privacystatement',\n optOut: 'https://account.microsoft.com/privacy/ad-settings',\n },\n manualSteps: () => [],\n },\n\n pinterest: {\n name: 'Pinterest',\n operatorCountry: 'the United States',\n clickIdParams: ['epik'],\n consoleUrl: 'https://ads.pinterest.com/',\n verification: {\n method: 'dns-txt',\n txtPrefix: 'pinterest-site-verification=',\n where: 'Business hub → Claim → Websites → Claim → Add TXT record',\n },\n tag: {\n needs: 'pixelId',\n // Pinterest's stub creates the script itself once `pintrk('load')` runs.\n bootstrap: (id) =>\n `${queueStub('pintrk', `['load','page','track']`)}` +\n `!function(){var t=document.createElement('script');t.async=!0,t.src='https://s.pinimg.com/ct/core.js';` +\n `var e=document.getElementsByTagName('script')[0];e.parentNode.insertBefore(t,e)}();` +\n `pintrk('load','${id}');pintrk('page');`,\n cookies: ['_pinterest_ct_ua', '_pin_unauth', '_epik'],\n privacyUrl: 'https://policy.pinterest.com/privacy-policy',\n },\n manualSteps: () => [\n 'confirm the claim in Business hub → Claim once the TXT record has propagated',\n ],\n },\n\n snapchat: {\n name: 'Snapchat',\n operatorCountry: 'the United States',\n clickIdParams: ['ScCid'],\n consoleUrl: 'https://ads.snapchat.com/',\n verification: {\n method: 'dns-txt',\n txtPrefix: 'snap-domain-verification=',\n where: 'Business Manager → Public Profiles / Domains → Verify domain → DNS',\n },\n tag: {\n needs: 'pixelId',\n src: () => 'https://sc-static.net/scevent.min.js',\n bootstrap: (id) =>\n `${queueStub('snaptr', `['init','track']`)}snaptr('init','${id}');snaptr('track','PAGE_VIEW');`,\n cookies: ['_scid', '_scid_r', 'sc_at'],\n privacyUrl: 'https://snap.com/privacy/privacy-policy',\n },\n manualSteps: () => [],\n },\n};\n\n/** Every provider, in table order — the one place iteration order is decided. */\nexport const AD_PROVIDER_KEYS = Object.keys(AD_PLATFORMS) as AdProvider[];\n\n/** Whether a string names a known provider (for CLI arg validation). */\nexport const isAdProvider = (value: string): value is AdProvider => value in AD_PLATFORMS;\n\n/**\n * The DNS TXT record content for a token.\n *\n * A value already containing `=` is taken as the whole record and left alone. That\n * is the same escape hatch an icon name containing `:` gets: a platform can change\n * its prefix spelling without waiting on a toolchain release, and an operator who\n * pasted the full record from the UI gets what they meant rather than a\n * double-prefixed record that verifies against nothing.\n */\nexport function txtRecord(provider: AdProvider, token: string): string {\n const trimmed = token.trim();\n if (trimmed.includes('=')) return trimmed;\n return `${AD_PLATFORMS[provider].verification.txtPrefix ?? ''}${trimmed}`;\n}\n\n/** The tag id for an entry, or undefined when the field it needs is unset. */\nexport function tagId(entry: AdPropertySetup): string | undefined {\n const field = AD_PLATFORMS[entry.provider].tag.needs;\n return entry[field];\n}\n\n/** The consent category gating an entry's tag. `marketing` unless stated otherwise. */\nexport const categoryOf = (entry: AdPropertySetup): AdConsentCategory =>\n entry.category ?? 'marketing';\n\nconst escapeAttr = (value: string): string =>\n value.replace(/&/g, '&').replace(/\"/g, '"').replace(/</g, '<');\n\n/**\n * One provider's tag, rendered as a **consent-gated, inert** `<script>`.\n *\n * `type=\"text/plain\"` with the URL on `data-src` is what makes the gate real: the\n * browser neither parses the body nor fetches the library, so an undecided\n * visitor's page issues no third-party request. Never give one of these a live\n * `src` — a gate that instead asks a loaded tracker not to track is a promise.\n *\n * Returns null when the entry has no tag id, so a caller can report the gap rather\n * than emit a snippet that initialises `undefined`.\n */\nexport function renderTag(\n entry: AdPropertySetup,\n strategy: 'idle' | 'async' | 'interaction' = 'idle',\n): string | null {\n const platform = AD_PLATFORMS[entry.provider];\n const id = tagId(entry);\n if (!id) return null;\n const attrs = [\n 'type=\"text/plain\"',\n 'data-vitops-tag',\n `data-consent=\"${categoryOf(entry)}\"`,\n `data-strategy=\"${strategy}\"`,\n ...(platform.tag.src ? [`data-src=\"${escapeAttr(platform.tag.src(id))}\"`] : []),\n `data-consent-cookies=\"${escapeAttr(platform.tag.cookies.join(','))}\"`,\n ];\n return `<!-- ${platform.name} — gated on ${categoryOf(entry)} consent -->\\n<script ${attrs.join(' ')}>${platform.tag.bootstrap(id, entry)}</script>`;\n}\n","/**\n * Every decision `vitops ads setup` makes, as one pure function.\n *\n * Pure in the load-bearing sense: no `fetch`, no `fs`, no clock, no stdin. The\n * executor gathers each domain's live DNS state and applies what it is told; this\n * module decides, from that state, what is left to do. The same split\n * `onboarding/plan.ts` makes, for the same two payoffs: `--dry` is a *complete*\n * account of a run, and `--check` is an honest drift report.\n *\n * Idempotency lives here — a step whose desired state already holds resolves to\n * `skip`, so a re-run of a linked property is all skips by construction rather than\n * by the executor checking twice.\n *\n * One thing this planner adds over the onboarding one: a step that cannot proceed\n * for want of a **config value** resolves to `blocked` carrying the field name in\n * `needs`. That is a machine-readable gap, which is what lets the CLI *ask* for the\n * missing token and re-plan, while the planner itself stays pure and knows nothing\n * about a terminal. A blocked step is drift, never a skip — the first run of a\n * property whose token has not been fetched yet must not read as \"already linked\".\n */\nimport { AD_PLATFORMS, tagId, txtRecord } from './providers.ts';\nimport type { AdDomainState, AdPropertySetup, AdsConfig } from './types.ts';\nimport { table } from '../plan-format.ts';\n\n/** A step's decision. `blocked` means the config is missing something. */\nexport type StepAction = 'create' | 'skip' | 'blocked';\n\n/** A config field the operator can supply to unblock a step. */\nexport type MissingField = 'domainVerification' | 'accountId' | 'pixelId';\n\nexport interface StepPlan {\n action: StepAction;\n /** Why it's a skip, or what the mutation will do, or what is missing. */\n detail: string;\n /** Set only on `blocked`: which config field would unblock it. */\n needs?: MissingField | undefined;\n}\n\n/** The steps for one ad property, decided against its live state. */\nexport interface AdPropertyPlan {\n provider: AdPropertySetup['provider'];\n /** The platform name, so a formatter needn't reach back into the table. */\n name: string;\n /** The domain being verified, when the platform verifies one. */\n domain?: string | undefined;\n /** Ensure the apex verification TXT. */\n txt: StepPlan;\n /** Whether the tag can be emitted (`ads tags`). */\n tag: StepPlan;\n /** The exact TXT content to create, when `txt.action` is `create`. */\n txtContent?: string | undefined;\n /** Manual follow-ups — including *why* a platform has no verification step. */\n reminders: string[];\n}\n\nexport interface AdsPlan {\n properties: AdPropertyPlan[];\n /** Operator-facing warnings that span the whole run. */\n notes: string[];\n}\n\n/**\n * Decide one property's steps.\n *\n * The `dns-txt` branch has three outcomes, and keeping them distinct is the point:\n * our record is present (skip), a *different* record from the same platform is\n * present (create, and say so — a token rotation leaves the old record in place,\n * since this command never edits or deletes DNS), or no token is configured yet\n * (blocked, naming the field).\n */\nexport function planProperty(setup: AdPropertySetup, state: AdDomainState): AdPropertyPlan {\n const platform = AD_PLATFORMS[setup.provider];\n const reminders = [...platform.manualSteps(setup)];\n\n let txt: StepPlan;\n let txtContent: string | undefined;\n\n if (platform.verification.method === 'none') {\n txt = { action: 'skip', detail: platform.verification.reason ?? 'no domain verification' };\n } else if (!setup.domainVerification) {\n txt = {\n action: 'blocked',\n detail: `needs the verification token from ${platform.verification.where ?? platform.consoleUrl}`,\n needs: 'domainVerification',\n };\n } else {\n const desired = txtRecord(setup.provider, setup.domainVerification);\n const prefix = platform.verification.txtPrefix;\n const stale =\n prefix != null && state.txtContents.some((c) => c.startsWith(prefix) && c !== desired);\n txt = state.txtContents.includes(desired)\n ? { action: 'skip', detail: 'verification TXT already present' }\n : {\n action: 'create',\n detail: stale\n ? 'create verification TXT (an older token from this platform is still present — remove it yourself; this command never deletes DNS)'\n : 'create verification TXT',\n };\n if (txt.action === 'create') txtContent = desired;\n }\n\n const id = tagId(setup);\n const tag: StepPlan = id\n ? { action: 'skip', detail: `tag ready (${platform.tag.needs} ${id})` }\n : {\n action: 'blocked',\n detail: `needs ${platform.tag.needs} from ${platform.consoleUrl} to emit the tag`,\n needs: platform.tag.needs,\n };\n\n return {\n provider: setup.provider,\n name: platform.name,\n ...(setup.domain ? { domain: setup.domain } : {}),\n txt,\n tag,\n ...(txtContent ? { txtContent } : {}),\n reminders,\n };\n}\n\n/** Plan every configured property. */\nexport function plan(config: AdsConfig, states: Map<string, AdDomainState>): AdsPlan {\n const notes: string[] = [];\n const properties = config.properties.map((setup) => {\n const key = setup.domain ?? '';\n const state = states.get(key);\n if (!state && AD_PLATFORMS[setup.provider].verification.method === 'dns-txt')\n // Should not happen — the CLI observes every verifiable domain before\n // planning — but a missing observation must not read as \"nothing to do\".\n notes.push(`${setup.provider}: no observed DNS state for ${key || '(no domain)'}`);\n return planProperty(setup, state ?? { txtContents: [] });\n });\n return { properties, notes };\n}\n\n/** Any step that isn't a skip — i.e. the live state or the config has drifted. */\nexport function hasDrift(p: AdsPlan): boolean {\n return p.properties.some((x) => x.txt.action !== 'skip' || x.tag.action !== 'skip');\n}\n\n/** Every field a run still needs, per provider — what the CLI asks for. */\nexport function missingFields(p: AdsPlan): { provider: string; needs: MissingField }[] {\n const out: { provider: string; needs: MissingField }[] = [];\n for (const x of p.properties) {\n for (const step of [x.txt, x.tag]) {\n if (step.action === 'blocked' && step.needs)\n out.push({ provider: x.provider, needs: step.needs });\n }\n }\n return out;\n}\n\n/** Render a plan as the block `--dry` and `--check` print. */\nexport function formatPlan(p: AdsPlan): string {\n const glyph = (s: StepPlan) => (s.action === 'skip' ? '·' : s.action === 'blocked' ? '?' : '+');\n const lines: string[] = [];\n for (const x of p.properties) {\n lines.push(`${x.provider}${x.domain ? ` (${x.domain})` : ''}`);\n lines.push(` ${glyph(x.txt)} verify ${x.txt.detail}`);\n lines.push(` ${glyph(x.tag)} tag ${x.tag.detail}`);\n for (const r of x.reminders) lines.push(` ! ${r}`);\n }\n for (const n of p.notes) lines.push(`! ${n}`);\n return lines.join('\\n');\n}\n\n/** The outcome of one property's run, for the summary table. */\nexport interface AdPropertyResult {\n provider: string;\n domain: string;\n txt: 'created' | 'present' | 'blocked' | 'failed' | 'n/a';\n tag: 'ready' | 'blocked';\n reminders: string[];\n}\n\n/** Render the end-of-run summary: one row per property, plus any reminders. */\nexport function formatSummary(results: AdPropertyResult[]): string {\n const headers = ['PROVIDER', 'DOMAIN', 'VERIFY', 'TAG'];\n const rows = results.map((r) => [r.provider, r.domain, r.txt, r.tag]);\n const reminders = results.flatMap((r) => r.reminders.map((m) => ` ! ${r.provider}: ${m}`));\n return table(headers, rows, reminders);\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"index.mjs","names":[],"sources":["../../src/ads/plan.ts"],"sourcesContent":["/**\n * Every decision `vitops ads setup` makes, as one pure function.\n *\n * Pure in the load-bearing sense: no `fetch`, no `fs`, no clock, no stdin. The\n * executor gathers each domain's live DNS state and applies what it is told; this\n * module decides, from that state, what is left to do. The same split\n * `onboarding/plan.ts` makes, for the same two payoffs: `--dry` is a *complete*\n * account of a run, and `--check` is an honest drift report.\n *\n * Idempotency lives here — a step whose desired state already holds resolves to\n * `skip`, so a re-run of a linked property is all skips by construction rather than\n * by the executor checking twice.\n *\n * One thing this planner adds over the onboarding one: a step that cannot proceed\n * for want of a **config value** resolves to `blocked` carrying the field name in\n * `needs`. That is a machine-readable gap, which is what lets the CLI *ask* for the\n * missing token and re-plan, while the planner itself stays pure and knows nothing\n * about a terminal. A blocked step is drift, never a skip — the first run of a\n * property whose token has not been fetched yet must not read as \"already linked\".\n */\nimport { AD_PLATFORMS, tagId, txtRecord } from './providers.ts';\nimport type { AdDomainState, AdPropertySetup, AdsConfig } from './types.ts';\nimport { table } from '../plan-format.ts';\n\n/** A step's decision. `blocked` means the config is missing something. */\nexport type StepAction = 'create' | 'skip' | 'blocked';\n\n/** A config field the operator can supply to unblock a step. */\nexport type MissingField = 'domainVerification' | 'accountId' | 'pixelId';\n\nexport interface StepPlan {\n action: StepAction;\n /** Why it's a skip, or what the mutation will do, or what is missing. */\n detail: string;\n /** Set only on `blocked`: which config field would unblock it. */\n needs?: MissingField | undefined;\n}\n\n/** The steps for one ad property, decided against its live state. */\nexport interface AdPropertyPlan {\n provider: AdPropertySetup['provider'];\n /** The platform name, so a formatter needn't reach back into the table. */\n name: string;\n /** The domain being verified, when the platform verifies one. */\n domain?: string | undefined;\n /** Ensure the apex verification TXT. */\n txt: StepPlan;\n /** Whether the tag can be emitted (`ads tags`). */\n tag: StepPlan;\n /** The exact TXT content to create, when `txt.action` is `create`. */\n txtContent?: string | undefined;\n /** Manual follow-ups — including *why* a platform has no verification step. */\n reminders: string[];\n}\n\nexport interface AdsPlan {\n properties: AdPropertyPlan[];\n /** Operator-facing warnings that span the whole run. */\n notes: string[];\n}\n\n/**\n * Decide one property's steps.\n *\n * The `dns-txt` branch has three outcomes, and keeping them distinct is the point:\n * our record is present (skip), a *different* record from the same platform is\n * present (create, and say so — a token rotation leaves the old record in place,\n * since this command never edits or deletes DNS), or no token is configured yet\n * (blocked, naming the field).\n */\nexport function planProperty(setup: AdPropertySetup, state: AdDomainState): AdPropertyPlan {\n const platform = AD_PLATFORMS[setup.provider];\n const reminders = [...platform.manualSteps(setup)];\n\n let txt: StepPlan;\n let txtContent: string | undefined;\n\n if (platform.verification.method === 'none') {\n txt = { action: 'skip', detail: platform.verification.reason ?? 'no domain verification' };\n } else if (!setup.domainVerification) {\n txt = {\n action: 'blocked',\n detail: `needs the verification token from ${platform.verification.where ?? platform.consoleUrl}`,\n needs: 'domainVerification',\n };\n } else {\n const desired = txtRecord(setup.provider, setup.domainVerification);\n const prefix = platform.verification.txtPrefix;\n const stale =\n prefix != null && state.txtContents.some((c) => c.startsWith(prefix) && c !== desired);\n txt = state.txtContents.includes(desired)\n ? { action: 'skip', detail: 'verification TXT already present' }\n : {\n action: 'create',\n detail: stale\n ? 'create verification TXT (an older token from this platform is still present — remove it yourself; this command never deletes DNS)'\n : 'create verification TXT',\n };\n if (txt.action === 'create') txtContent = desired;\n }\n\n const id = tagId(setup);\n const tag: StepPlan = id\n ? { action: 'skip', detail: `tag ready (${platform.tag.needs} ${id})` }\n : {\n action: 'blocked',\n detail: `needs ${platform.tag.needs} from ${platform.consoleUrl} to emit the tag`,\n needs: platform.tag.needs,\n };\n\n return {\n provider: setup.provider,\n name: platform.name,\n ...(setup.domain ? { domain: setup.domain } : {}),\n txt,\n tag,\n ...(txtContent ? { txtContent } : {}),\n reminders,\n };\n}\n\n/** Plan every configured property. */\nexport function plan(config: AdsConfig, states: Map<string, AdDomainState>): AdsPlan {\n const notes: string[] = [];\n const properties = config.properties.map((setup) => {\n const key = setup.domain ?? '';\n const state = states.get(key);\n if (!state && AD_PLATFORMS[setup.provider].verification.method === 'dns-txt')\n // Should not happen — the CLI observes every verifiable domain before\n // planning — but a missing observation must not read as \"nothing to do\".\n notes.push(`${setup.provider}: no observed DNS state for ${key || '(no domain)'}`);\n return planProperty(setup, state ?? { txtContents: [] });\n });\n return { properties, notes };\n}\n\n/** Any step that isn't a skip — i.e. the live state or the config has drifted. */\nexport function hasDrift(p: AdsPlan): boolean {\n return p.properties.some((x) => x.txt.action !== 'skip' || x.tag.action !== 'skip');\n}\n\n/** Every field a run still needs, per provider — what the CLI asks for. */\nexport function missingFields(p: AdsPlan): { provider: string; needs: MissingField }[] {\n const out: { provider: string; needs: MissingField }[] = [];\n for (const x of p.properties) {\n for (const step of [x.txt, x.tag]) {\n if (step.action === 'blocked' && step.needs)\n out.push({ provider: x.provider, needs: step.needs });\n }\n }\n return out;\n}\n\n/** Render a plan as the block `--dry` and `--check` print. */\nexport function formatPlan(p: AdsPlan): string {\n const glyph = (s: StepPlan) => (s.action === 'skip' ? '·' : s.action === 'blocked' ? '?' : '+');\n const lines: string[] = [];\n for (const x of p.properties) {\n lines.push(`${x.provider}${x.domain ? ` (${x.domain})` : ''}`);\n lines.push(` ${glyph(x.txt)} verify ${x.txt.detail}`);\n lines.push(` ${glyph(x.tag)} tag ${x.tag.detail}`);\n for (const r of x.reminders) lines.push(` ! ${r}`);\n }\n for (const n of p.notes) lines.push(`! ${n}`);\n return lines.join('\\n');\n}\n\n/** The outcome of one property's run, for the summary table. */\nexport interface AdPropertyResult {\n provider: string;\n domain: string;\n txt: 'created' | 'present' | 'blocked' | 'failed' | 'n/a';\n tag: 'ready' | 'blocked';\n reminders: string[];\n}\n\n/** Render the end-of-run summary: one row per property, plus any reminders. */\nexport function formatSummary(results: AdPropertyResult[]): string {\n const headers = ['PROVIDER', 'DOMAIN', 'VERIFY', 'TAG'];\n const rows = results.map((r) => [r.provider, r.domain, r.txt, r.tag]);\n const reminders = results.flatMap((r) => r.reminders.map((m) => ` ! ${r.provider}: ${m}`));\n return table(headers, rows, reminders);\n}\n"],"mappings":"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"}
|
package/dist/audit/index.mjs
CHANGED
|
@@ -525,6 +525,7 @@ async function runAudit(opts) {
|
|
|
525
525
|
let violations;
|
|
526
526
|
try {
|
|
527
527
|
await page.goto(url, { waitUntil: "load" });
|
|
528
|
+
await page.waitForFunction("document.getAnimations().every((a) => a.constructor.name !== 'CSSTransition' || a.playState === 'finished')", void 0, { timeout: 2e3 }).catch(() => void 0);
|
|
528
529
|
violations = toViolations(await new axe.AxeBuilder({ page }).withTags(AXE_TAGS).analyze());
|
|
529
530
|
} finally {
|
|
530
531
|
await context.close();
|