@getvitops/utils 8.3.0-alpha.4 → 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 +23 -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/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
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"providers-CEHw-j4v.mjs","names":[],"sources":["../src/ads/types.ts","../src/ads/providers.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 /**\n * Can the platform itself swap a forwarding number into the website for its own\n * visitors? Only Google can (website call conversions, Canada included). Microsoft's\n * forwarding numbers exist only inside the ad, and only in the US/UK/AU; every other\n * platform offers none. A site wanting a per-platform number provisions its own and\n * states it in `site.tracking.phoneNumbers`.\n */\n websiteCallTracking: boolean;\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 callTrackingConfig(id, entry) +\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/**\n * Google's website call conversions: `gtag('config', 'AW-…/<label>', { phone_conversion_number })`.\n *\n * Google fetches a forwarding number for a visitor who arrived from a Google ad and\n * replaces the displayed number's **text** — never the `tel:` href, which Google says\n * needs the callback. The callback hands the result to `vitopsSwapPhone`\n * (`@getvitops/astro`'s tracking script) through a queue, because this inline body and\n * that module load in no guaranteed order: a call that lands first is replayed when the\n * script defines the function, rather than lost.\n *\n * `entry.callTracking` is trusted — `resolveAds()` checks the label against `SAFE_ID`\n * and the number against a phone pattern before it reaches here.\n */\nconst callTrackingConfig = (id: string, entry: AdPropertySetup): string => {\n const call = entry.callTracking;\n if (!call) return '';\n const options = call.cache === false ? ',phone_conversion_options:{cache:false}' : '';\n return (\n `window.vitopsPhoneSwaps=window.vitopsPhoneSwaps||[];` +\n `gtag('config','${id}/${call.label}',{phone_conversion_number:${JSON.stringify(call.number)},` +\n `phone_conversion_callback:function(f,m){var a=[f,m,${JSON.stringify(call.number)}];` +\n `window.vitopsSwapPhone?window.vitopsSwapPhone.apply(null,a):window.vitopsPhoneSwaps.push(a)}${options}});`\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 websiteCallTracking: true,\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 'confirm each conversion action\\'s category in Google Ads (call → \"Phone call lead\", email → \"Contact\", form → \"Submit lead form\"); the tag cannot set it, and a wrong one reports under the wrong goal',\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 ...(e.callTracking\n ? [\n 'in Google Ads, create a conversion action of type \"Calls from a phone number on your website\", set its minimum call length, and make either it or the `conversionLabel.call` (tap) action secondary so one call is not counted twice',\n \"verify the domain through Search Console, not the tag — Google cannot scan a page whose number is swapped (a tag installed through Google Tag Manager can't verify it either)\",\n 'test with `#google-wcc-debug` appended to a landing URL after granting marketing consent',\n ]\n : []),\n ],\n },\n\n meta: {\n name: 'Meta',\n operatorCountry: 'the United States',\n clickIdParams: ['fbclid'],\n websiteCallTracking: false,\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 websiteCallTracking: false,\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 websiteCallTracking: false,\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 websiteCallTracking: false,\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 websiteCallTracking: false,\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 websiteCallTracking: false,\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 websiteCallTracking: false,\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"],"mappings":"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"}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { i as
|
|
1
|
+
import { i as AdProvider } from "../types-C0hQKdsM.mjs";
|
|
2
|
+
import { i as TrackingData, n as TrackingCategory, r as TrackingConfig, t as ConversionEvent } from "../types-ByK6OuXQ.mjs";
|
|
2
3
|
/**
|
|
3
4
|
* The ad-platform vocabulary — the one place that says which URL parameters mean
|
|
4
5
|
* "this visit came from an ad", and which platform each belongs to.
|
|
@@ -83,6 +84,56 @@ export declare function getPrimaryClickId(data: TrackingData): {
|
|
|
83
84
|
param: string;
|
|
84
85
|
value: string;
|
|
85
86
|
} | null;
|
|
87
|
+
/** The fallback key: any paid visit with no platform-specific number. */
|
|
88
|
+
export declare const DEFAULT_PAID = "default-paid";
|
|
89
|
+
interface PhoneNumbersConfig {
|
|
90
|
+
/** The real number, exactly as it is displayed on the page. */
|
|
91
|
+
number: string;
|
|
92
|
+
/** One tracking number per platform, plus an optional `default-paid` fallback. */
|
|
93
|
+
sources: Readonly<Record<string, string | undefined>>;
|
|
94
|
+
}
|
|
95
|
+
interface SelectedNumber {
|
|
96
|
+
/** The number to display, in the format it was configured in. */
|
|
97
|
+
number: string;
|
|
98
|
+
/** The `sources` key that matched, for the conversion notification. */
|
|
99
|
+
source: string;
|
|
100
|
+
}
|
|
101
|
+
/** Click-ID parameter → the `site.ads` provider key it belongs to. */
|
|
102
|
+
export declare const PROVIDER_BY_PARAM: Readonly<Record<string, AdProvider>>;
|
|
103
|
+
/**
|
|
104
|
+
* `utm_source` values people actually write → provider key. A UTM is self-declared, so
|
|
105
|
+
* this is matched loosely; a click ID needs no such help.
|
|
106
|
+
*/
|
|
107
|
+
export declare const UTM_SOURCE_ALIASES: Readonly<Record<string, AdProvider>>;
|
|
108
|
+
/** `utm_medium` values that mean "paid" and so qualify for `default-paid`. */
|
|
109
|
+
export declare const PAID_MEDIUM: RegExp;
|
|
110
|
+
/**
|
|
111
|
+
* The digits of a phone number with a leading North American `1` removed, so
|
|
112
|
+
* `+1 (613) 555-0100`, `613-555-0100` and `tel:+16135550100` all compare equal.
|
|
113
|
+
*/
|
|
114
|
+
export declare function phoneDigits(value: string): string;
|
|
115
|
+
/** Do two displayed numbers name the same line? Empty never matches. */
|
|
116
|
+
export declare function samePhone(a: string, b: string): boolean;
|
|
117
|
+
/**
|
|
118
|
+
* The `tel:` URI for a displayed number. A bare ten-digit number is North American
|
|
119
|
+
* (this toolchain's market) and gets `+1`; anything already carrying a country code
|
|
120
|
+
* keeps it.
|
|
121
|
+
*/
|
|
122
|
+
export declare function telUri(display: string): string;
|
|
123
|
+
/**
|
|
124
|
+
* Pick the number for this visit, or null to leave the real one alone.
|
|
125
|
+
*
|
|
126
|
+
* `current` is what this page's URL carried and `stored` is the `_ac` cookie. The URL
|
|
127
|
+
* wins: a visitor arriving from a Meta ad today with a stale Google click ID stored
|
|
128
|
+
* from last month is a Meta visitor now. An organic visit (neither carries anything)
|
|
129
|
+
* always returns null — nobody is shown a tracking number they were not sent to.
|
|
130
|
+
*
|
|
131
|
+
* `googleNative` is true when `site.ads.google.callTracking` is configured; Google's
|
|
132
|
+
* tag owns that swap, so a Google visit returns null here rather than competing.
|
|
133
|
+
*/
|
|
134
|
+
export declare function selectPhoneNumber(current: TrackingData | null, stored: TrackingData | null, config: PhoneNumbersConfig | undefined, opts?: {
|
|
135
|
+
googleNative?: boolean;
|
|
136
|
+
}): SelectedNumber | null;
|
|
86
137
|
/**
|
|
87
138
|
* Pulling a submitter's identity out of a form's fields.
|
|
88
139
|
*
|
|
@@ -108,5 +159,5 @@ interface SubmitterIdentity {
|
|
|
108
159
|
company?: string | undefined;
|
|
109
160
|
}
|
|
110
161
|
export declare function identifySubmitter(formData: Record<string, string>): SubmitterIdentity;
|
|
111
|
-
export type { ConversionEvent, SubmitterIdentity, TrackingCategory, TrackingConfig, TrackingData };
|
|
162
|
+
export type { ConversionEvent, PhoneNumbersConfig, SelectedNumber, SubmitterIdentity, TrackingCategory, TrackingConfig, TrackingData };
|
|
112
163
|
//# sourceMappingURL=index.d.mts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.mts","names":[],"sources":["../../src/tracking/params.ts","../../src/tracking/cookie.ts","../../src/tracking/platform.ts","../../src/tracking/identity.ts"],"mappings":"
|
|
1
|
+
{"version":3,"file":"index.d.mts","names":[],"sources":["../../src/tracking/params.ts","../../src/tracking/cookie.ts","../../src/tracking/platform.ts","../../src/tracking/phone.ts","../../src/tracking/identity.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;qBAuBa,iBAAiB,SAAS;;qBAc1B;qBAEA;;qBASA;;;;;;;;qBASA;;qBAGA;;qBAGA;;wBChDG,WAAW,yCAAyC;;;;;;;;;wBAiBpD,oBAAoB,0CAA0C;;wBAa9D,wBAAwB,MAAM;;;;;;;;;wBAe9B,iBAAiB,aAAa;;;;;;;;;wBAa9B,cACd,UAAU,qBACV,UAAU,cACV,cACC;;;;;;;;wBC3Da,iBAAiB,MAAM;;wBASvB,kBAAkB,MAAM;EAAiB;EAAe;;;qBCR3D;UAEI;;EAEf;;EAEA,SAAS,SAAS;;UAGH;;EAEf;;EAEA;;;qBAIW,mBAAmB,SAAS,eAAe;;;;;qBAU3C,oBAAoB,SAAS,eAAe;;qBAkB5C,aAAW;;;;;wBAMR,YAAY;;wBAMZ,UAAU,WAAW;;;;;;wBAUrB,OAAO;;;;;;;;;;;;wBAkCP,kBACd,SAAS,qBACT,QAAQ,qBACR,QAAQ,gCACR;EAAQ;IACP;;;;;;;;;;;;;;;UC9Gc;EACf;EACA;;;EAGA;EACA;EACA;EACA;;wBAWc,kBAAkB,UAAU,yBAAyB"}
|
package/dist/tracking/index.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { t as AD_PLATFORMS } from "../providers-CEHw-j4v.mjs";
|
|
1
2
|
import { t as identifySubmitter } from "../identity-C4yInICW.mjs";
|
|
2
3
|
import { a as PLATFORM_PARAMS, c as TRACKING_COOKIE_DAYS, i as CLICK_ID_PARAMS, l as UTM_PARAMS, n as identifyPlatform, o as TRACKED_PARAMS, r as AB_COOKIE, s as TRACKING_COOKIE, t as getPrimaryClickId } from "../platform-Cb_RtsF1.mjs";
|
|
3
4
|
|
|
@@ -73,5 +74,114 @@ function mergeTracking(existing, captured, now) {
|
|
|
73
74
|
};
|
|
74
75
|
}
|
|
75
76
|
|
|
76
|
-
|
|
77
|
+
// src/tracking/phone.ts
|
|
78
|
+
/**
|
|
79
|
+
* Which phone number a visitor should be shown — the pure decision behind call tracking.
|
|
80
|
+
*
|
|
81
|
+
* `site.tracking.phoneNumbers` records facts: the real number, and one number per ad
|
|
82
|
+
* platform the client has provisioned with a call-tracking provider (Twilio, CallRail,
|
|
83
|
+
* …), each forwarding to the real line. This module decides which one a given arrival
|
|
84
|
+
* gets. It touches no DOM, cookie or clock, so it runs in a Worker and in a test, and
|
|
85
|
+
* `scripts/tracking.ts` in `@getvitops/astro` mirrors it (that file is inlined into the
|
|
86
|
+
* document and cannot import) — `tracking.test.ts` there pins the two.
|
|
87
|
+
*
|
|
88
|
+
* Google is deliberately absent from the swap when `site.ads.google.callTracking` is
|
|
89
|
+
* set: Google's own tag fetches and inserts its forwarding number, and swapping a second
|
|
90
|
+
* number on top would show a visitor one number and attribute the call to another.
|
|
91
|
+
*/
|
|
92
|
+
/** The fallback key: any paid visit with no platform-specific number. */
|
|
93
|
+
const DEFAULT_PAID = "default-paid";
|
|
94
|
+
/** Click-ID parameter → the `site.ads` provider key it belongs to. */
|
|
95
|
+
const PROVIDER_BY_PARAM = Object.fromEntries(Object.keys(AD_PLATFORMS).flatMap((provider) => AD_PLATFORMS[provider].clickIdParams.map((param) => [param, provider])));
|
|
96
|
+
/**
|
|
97
|
+
* `utm_source` values people actually write → provider key. A UTM is self-declared, so
|
|
98
|
+
* this is matched loosely; a click ID needs no such help.
|
|
99
|
+
*/
|
|
100
|
+
const UTM_SOURCE_ALIASES = {
|
|
101
|
+
google: "google",
|
|
102
|
+
facebook: "meta",
|
|
103
|
+
instagram: "meta",
|
|
104
|
+
meta: "meta",
|
|
105
|
+
fb: "meta",
|
|
106
|
+
ig: "meta",
|
|
107
|
+
bing: "microsoft",
|
|
108
|
+
microsoft: "microsoft",
|
|
109
|
+
linkedin: "linkedin",
|
|
110
|
+
reddit: "reddit",
|
|
111
|
+
tiktok: "tiktok",
|
|
112
|
+
pinterest: "pinterest",
|
|
113
|
+
snapchat: "snapchat",
|
|
114
|
+
snap: "snapchat"
|
|
115
|
+
};
|
|
116
|
+
/** `utm_medium` values that mean "paid" and so qualify for `default-paid`. */
|
|
117
|
+
const PAID_MEDIUM = /^(cpc|ppc|paid.*|cpm|display|paidsocial|paid_social)$/i;
|
|
118
|
+
/**
|
|
119
|
+
* The digits of a phone number with a leading North American `1` removed, so
|
|
120
|
+
* `+1 (613) 555-0100`, `613-555-0100` and `tel:+16135550100` all compare equal.
|
|
121
|
+
*/
|
|
122
|
+
function phoneDigits(value) {
|
|
123
|
+
const digits = value.replace(/\D/g, "");
|
|
124
|
+
return digits.length === 11 && digits.startsWith("1") ? digits.slice(1) : digits;
|
|
125
|
+
}
|
|
126
|
+
/** Do two displayed numbers name the same line? Empty never matches. */
|
|
127
|
+
function samePhone(a, b) {
|
|
128
|
+
const da = phoneDigits(a);
|
|
129
|
+
return da.length > 0 && da === phoneDigits(b);
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* The `tel:` URI for a displayed number. A bare ten-digit number is North American
|
|
133
|
+
* (this toolchain's market) and gets `+1`; anything already carrying a country code
|
|
134
|
+
* keeps it.
|
|
135
|
+
*/
|
|
136
|
+
function telUri(display) {
|
|
137
|
+
const digits = display.replace(/\D/g, "");
|
|
138
|
+
if (digits.length === 10) return `tel:+1${digits}`;
|
|
139
|
+
return `tel:+${digits}`;
|
|
140
|
+
}
|
|
141
|
+
/** Which provider a set of tracking data points at, by click ID first and `utm_source` second. */
|
|
142
|
+
function providerOf(data) {
|
|
143
|
+
for (const [param, provider] of Object.entries(PROVIDER_BY_PARAM)) if (data[param]) return provider;
|
|
144
|
+
const source = data["utm_source"];
|
|
145
|
+
return typeof source === "string" ? UTM_SOURCE_ALIASES[source.trim().toLowerCase()] ?? null : null;
|
|
146
|
+
}
|
|
147
|
+
function isPaid(data) {
|
|
148
|
+
for (const param of Object.keys(PROVIDER_BY_PARAM)) if (data[param]) return true;
|
|
149
|
+
const medium = data["utm_medium"];
|
|
150
|
+
return typeof medium === "string" && PAID_MEDIUM.test(medium.trim());
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Pick the number for this visit, or null to leave the real one alone.
|
|
154
|
+
*
|
|
155
|
+
* `current` is what this page's URL carried and `stored` is the `_ac` cookie. The URL
|
|
156
|
+
* wins: a visitor arriving from a Meta ad today with a stale Google click ID stored
|
|
157
|
+
* from last month is a Meta visitor now. An organic visit (neither carries anything)
|
|
158
|
+
* always returns null — nobody is shown a tracking number they were not sent to.
|
|
159
|
+
*
|
|
160
|
+
* `googleNative` is true when `site.ads.google.callTracking` is configured; Google's
|
|
161
|
+
* tag owns that swap, so a Google visit returns null here rather than competing.
|
|
162
|
+
*/
|
|
163
|
+
function selectPhoneNumber(current, stored, config, opts = {}) {
|
|
164
|
+
if (!config) return null;
|
|
165
|
+
for (const data of [current, stored]) {
|
|
166
|
+
if (!data) continue;
|
|
167
|
+
const provider = providerOf(data);
|
|
168
|
+
if (provider === "google" && opts.googleNative) return null;
|
|
169
|
+
const specific = provider ? config.sources[provider] : void 0;
|
|
170
|
+
if (specific && !samePhone(specific, config.number)) return {
|
|
171
|
+
number: specific,
|
|
172
|
+
source: provider
|
|
173
|
+
};
|
|
174
|
+
if (isPaid(data) || provider) {
|
|
175
|
+
const fallback = config.sources[DEFAULT_PAID];
|
|
176
|
+
if (fallback && !samePhone(fallback, config.number) && !(provider === "google" && opts.googleNative)) return {
|
|
177
|
+
number: fallback,
|
|
178
|
+
source: DEFAULT_PAID
|
|
179
|
+
};
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export { AB_COOKIE, CLICK_ID_PARAMS, DEFAULT_PAID, PAID_MEDIUM, PLATFORM_PARAMS, PROVIDER_BY_PARAM, TRACKED_PARAMS, TRACKING_COOKIE, TRACKING_COOKIE_DAYS, UTM_PARAMS, UTM_SOURCE_ALIASES, cookieAttributes, getPrimaryClickId, identifyPlatform, identifySubmitter, mergeTracking, parseTrackingCookie, phoneDigits, readCookie, samePhone, selectPhoneNumber, serializeTrackingCookie, telUri };
|
|
77
187
|
//# sourceMappingURL=index.mjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.mjs","names":[],"sources":["../../src/tracking/cookie.ts"],"sourcesContent":["/**\n * Reading and writing the `_ac` cookie, as functions over a cookie **string**.\n *\n * String in, string out, so one implementation serves both ends: the browser\n * passes `document.cookie`, the conversion handler passes the request's `Cookie`\n * header. A separate server-side parser would be a second thing to keep in step\n * with the writer, and the failure — attribution silently absent from a\n * notification — looks identical to a visitor who arrived organically.\n *\n * The same shape, for the same reason, as `@getvitops/core`'s consent store.\n */\nimport { TRACKING_COOKIE, TRACKING_COOKIE_DAYS } from './params.ts';\nimport type { TrackingData } from './types.ts';\n\n/** Read one cookie out of a `document.cookie`-shaped string. */\nexport function readCookie(cookieString: string | null | undefined, name: string): string | null {\n if (!cookieString) return null;\n for (const part of cookieString.split(';')) {\n const raw = part.trim();\n if (raw.slice(0, name.length + 1) === `${name}=`) return raw.slice(name.length + 1);\n }\n return null;\n}\n\n/**\n * Cookie header → attribution, or `null`.\n *\n * Anything unparseable reads as `null` — no attribution — rather than as a\n * partial record. A half-read cookie would attribute a conversion to whichever\n * fields happened to survive, which is worse than reporting it unattributed:\n * \"we don't know\" is actionable, a wrong campaign is not.\n */\nexport function parseTrackingCookie(cookieHeader: string | null | undefined): TrackingData | null {\n const raw = readCookie(cookieHeader, TRACKING_COOKIE);\n if (!raw) return null;\n try {\n const data: unknown = JSON.parse(decodeURIComponent(raw));\n if (typeof data !== 'object' || data === null || Array.isArray(data)) return null;\n return data as TrackingData;\n } catch {\n return null;\n }\n}\n\n/** Attribution → cookie value (not the full `Set-Cookie` — see `cookieAttributes`). */\nexport function serializeTrackingCookie(data: TrackingData): string {\n // Undefined values would serialize as absent keys anyway; dropping them first\n // keeps the cookie small and the round-trip exact.\n const clean = Object.fromEntries(Object.entries(data).filter(([, v]) => v !== undefined));\n return encodeURIComponent(JSON.stringify(clean));\n}\n\n/**\n * Attributes for the attribution cookie.\n *\n * `SameSite=Lax` rather than `Strict`: the visitor arrives here by following a\n * link from the ad platform, and `Strict` withholds the cookie on exactly that\n * cross-site navigation — so a returning click would read as a fresh one. No\n * `HttpOnly`, because the capture script has to write it from the page.\n */\nexport function cookieAttributes(now: number, secure: boolean): string {\n const expires = new Date(now + TRACKING_COOKIE_DAYS * 864e5).toUTCString();\n return `;expires=${expires};path=/;SameSite=Lax${secure ? ';Secure' : ''}`;\n}\n\n/**\n * Fold a fresh capture into whatever was already stored.\n *\n * New values win, but `ts` is the **first** capture's — it dates the visitor's\n * original arrival, which is what a 90-day attribution window is measured from.\n * Taking the latest would restart the window on every visit and make the cookie\n * outlive the click it represents.\n */\nexport function mergeTracking(\n existing: TrackingData | null,\n captured: TrackingData,\n now: number,\n): TrackingData {\n return { ...existing, ...captured, ts: existing?.ts ?? now };\n}\n"],"mappings":";;;;;;;;;;;;;;;;AAeA,SAAgB,WAAW,cAAyC,MAA6B;CAC/F,IAAI,CAAC,cAAc,OAAO;CAC1B,KAAK,MAAM,QAAQ,aAAa,MAAM,GAAG,GAAG;EAC1C,MAAM,MAAM,KAAK,KAAK;EACtB,IAAI,IAAI,MAAM,GAAG,KAAK,SAAS,CAAC,MAAM,GAAG,KAAK,IAAI,OAAO,IAAI,MAAM,KAAK,SAAS,CAAC;CACpF;CACA,OAAO;AACT;;;;;;;;;AAUA,SAAgB,oBAAoB,cAA8D;CAChG,MAAM,MAAM,WAAW,mBAA6B;CACpD,IAAI,CAAC,KAAK,OAAO;CACjB,IAAI;EACF,MAAM,OAAgB,KAAK,MAAM,mBAAmB,GAAG,CAAC;EACxD,IAAI,OAAO,SAAS,YAAY,SAAS,QAAQ,MAAM,QAAQ,IAAI,GAAG,OAAO;EAC7E,OAAO;CACT,QAAQ;EACN,OAAO;CACT;AACF;;AAGA,SAAgB,wBAAwB,MAA4B;CAGlE,MAAM,QAAQ,OAAO,YAAY,OAAO,QAAQ,IAAI,CAAC,CAAC,QAAQ,GAAG,OAAO,MAAM,MAAS,CAAC;CACxF,OAAO,mBAAmB,KAAK,UAAU,KAAK,CAAC;AACjD;;;;;;;;;AAUA,SAAgB,iBAAiB,KAAa,QAAyB;CAErE,OAAO,YADS,IAAI,KAAK,WAA6B,KAAK,CAAC,CAAC,YACpC,EAAE,sBAAsB,SAAS,YAAY;AACxE;;;;;;;;;AAUA,SAAgB,cACd,UACA,UACA,KACc;CACd,OAAO;EAAE,GAAG;EAAU,GAAG;EAAU,IAAI,UAAU,MAAM;CAAI;AAC7D"}
|
|
1
|
+
{"version":3,"file":"index.mjs","names":[],"sources":["../../src/tracking/cookie.ts","../../src/tracking/phone.ts"],"sourcesContent":["/**\n * Reading and writing the `_ac` cookie, as functions over a cookie **string**.\n *\n * String in, string out, so one implementation serves both ends: the browser\n * passes `document.cookie`, the conversion handler passes the request's `Cookie`\n * header. A separate server-side parser would be a second thing to keep in step\n * with the writer, and the failure — attribution silently absent from a\n * notification — looks identical to a visitor who arrived organically.\n *\n * The same shape, for the same reason, as `@getvitops/core`'s consent store.\n */\nimport { TRACKING_COOKIE, TRACKING_COOKIE_DAYS } from './params.ts';\nimport type { TrackingData } from './types.ts';\n\n/** Read one cookie out of a `document.cookie`-shaped string. */\nexport function readCookie(cookieString: string | null | undefined, name: string): string | null {\n if (!cookieString) return null;\n for (const part of cookieString.split(';')) {\n const raw = part.trim();\n if (raw.slice(0, name.length + 1) === `${name}=`) return raw.slice(name.length + 1);\n }\n return null;\n}\n\n/**\n * Cookie header → attribution, or `null`.\n *\n * Anything unparseable reads as `null` — no attribution — rather than as a\n * partial record. A half-read cookie would attribute a conversion to whichever\n * fields happened to survive, which is worse than reporting it unattributed:\n * \"we don't know\" is actionable, a wrong campaign is not.\n */\nexport function parseTrackingCookie(cookieHeader: string | null | undefined): TrackingData | null {\n const raw = readCookie(cookieHeader, TRACKING_COOKIE);\n if (!raw) return null;\n try {\n const data: unknown = JSON.parse(decodeURIComponent(raw));\n if (typeof data !== 'object' || data === null || Array.isArray(data)) return null;\n return data as TrackingData;\n } catch {\n return null;\n }\n}\n\n/** Attribution → cookie value (not the full `Set-Cookie` — see `cookieAttributes`). */\nexport function serializeTrackingCookie(data: TrackingData): string {\n // Undefined values would serialize as absent keys anyway; dropping them first\n // keeps the cookie small and the round-trip exact.\n const clean = Object.fromEntries(Object.entries(data).filter(([, v]) => v !== undefined));\n return encodeURIComponent(JSON.stringify(clean));\n}\n\n/**\n * Attributes for the attribution cookie.\n *\n * `SameSite=Lax` rather than `Strict`: the visitor arrives here by following a\n * link from the ad platform, and `Strict` withholds the cookie on exactly that\n * cross-site navigation — so a returning click would read as a fresh one. No\n * `HttpOnly`, because the capture script has to write it from the page.\n */\nexport function cookieAttributes(now: number, secure: boolean): string {\n const expires = new Date(now + TRACKING_COOKIE_DAYS * 864e5).toUTCString();\n return `;expires=${expires};path=/;SameSite=Lax${secure ? ';Secure' : ''}`;\n}\n\n/**\n * Fold a fresh capture into whatever was already stored.\n *\n * New values win, but `ts` is the **first** capture's — it dates the visitor's\n * original arrival, which is what a 90-day attribution window is measured from.\n * Taking the latest would restart the window on every visit and make the cookie\n * outlive the click it represents.\n */\nexport function mergeTracking(\n existing: TrackingData | null,\n captured: TrackingData,\n now: number,\n): TrackingData {\n return { ...existing, ...captured, ts: existing?.ts ?? now };\n}\n","/**\n * Which phone number a visitor should be shown — the pure decision behind call tracking.\n *\n * `site.tracking.phoneNumbers` records facts: the real number, and one number per ad\n * platform the client has provisioned with a call-tracking provider (Twilio, CallRail,\n * …), each forwarding to the real line. This module decides which one a given arrival\n * gets. It touches no DOM, cookie or clock, so it runs in a Worker and in a test, and\n * `scripts/tracking.ts` in `@getvitops/astro` mirrors it (that file is inlined into the\n * document and cannot import) — `tracking.test.ts` there pins the two.\n *\n * Google is deliberately absent from the swap when `site.ads.google.callTracking` is\n * set: Google's own tag fetches and inserts its forwarding number, and swapping a second\n * number on top would show a visitor one number and attribute the call to another.\n */\nimport { AD_PLATFORMS } from '../ads/providers.ts';\nimport type { AdProvider } from '../ads/types.ts';\nimport type { TrackingData } from './types.ts';\n\n/** The fallback key: any paid visit with no platform-specific number. */\nexport const DEFAULT_PAID = 'default-paid';\n\nexport interface PhoneNumbersConfig {\n /** The real number, exactly as it is displayed on the page. */\n number: string;\n /** One tracking number per platform, plus an optional `default-paid` fallback. */\n sources: Readonly<Record<string, string | undefined>>;\n}\n\nexport interface SelectedNumber {\n /** The number to display, in the format it was configured in. */\n number: string;\n /** The `sources` key that matched, for the conversion notification. */\n source: string;\n}\n\n/** Click-ID parameter → the `site.ads` provider key it belongs to. */\nexport const PROVIDER_BY_PARAM: Readonly<Record<string, AdProvider>> = Object.fromEntries(\n (Object.keys(AD_PLATFORMS) as AdProvider[]).flatMap((provider) =>\n AD_PLATFORMS[provider].clickIdParams.map((param) => [param, provider] as const),\n ),\n);\n\n/**\n * `utm_source` values people actually write → provider key. A UTM is self-declared, so\n * this is matched loosely; a click ID needs no such help.\n */\nexport const UTM_SOURCE_ALIASES: Readonly<Record<string, AdProvider>> = {\n google: 'google',\n facebook: 'meta',\n instagram: 'meta',\n meta: 'meta',\n fb: 'meta',\n ig: 'meta',\n bing: 'microsoft',\n microsoft: 'microsoft',\n linkedin: 'linkedin',\n reddit: 'reddit',\n tiktok: 'tiktok',\n pinterest: 'pinterest',\n snapchat: 'snapchat',\n snap: 'snapchat',\n};\n\n/** `utm_medium` values that mean \"paid\" and so qualify for `default-paid`. */\nexport const PAID_MEDIUM = /^(cpc|ppc|paid.*|cpm|display|paidsocial|paid_social)$/i;\n\n/**\n * The digits of a phone number with a leading North American `1` removed, so\n * `+1 (613) 555-0100`, `613-555-0100` and `tel:+16135550100` all compare equal.\n */\nexport function phoneDigits(value: string): string {\n const digits = value.replace(/\\D/g, '');\n return digits.length === 11 && digits.startsWith('1') ? digits.slice(1) : digits;\n}\n\n/** Do two displayed numbers name the same line? Empty never matches. */\nexport function samePhone(a: string, b: string): boolean {\n const da = phoneDigits(a);\n return da.length > 0 && da === phoneDigits(b);\n}\n\n/**\n * The `tel:` URI for a displayed number. A bare ten-digit number is North American\n * (this toolchain's market) and gets `+1`; anything already carrying a country code\n * keeps it.\n */\nexport function telUri(display: string): string {\n const digits = display.replace(/\\D/g, '');\n if (digits.length === 10) return `tel:+1${digits}`;\n return `tel:+${digits}`;\n}\n\n/** Which provider a set of tracking data points at, by click ID first and `utm_source` second. */\nfunction providerOf(data: TrackingData): AdProvider | null {\n for (const [param, provider] of Object.entries(PROVIDER_BY_PARAM)) {\n if (data[param]) return provider;\n }\n const source = data['utm_source'];\n return typeof source === 'string'\n ? (UTM_SOURCE_ALIASES[source.trim().toLowerCase()] ?? null)\n : null;\n}\n\nfunction isPaid(data: TrackingData): boolean {\n for (const param of Object.keys(PROVIDER_BY_PARAM)) if (data[param]) return true;\n const medium = data['utm_medium'];\n return typeof medium === 'string' && PAID_MEDIUM.test(medium.trim());\n}\n\n/**\n * Pick the number for this visit, or null to leave the real one alone.\n *\n * `current` is what this page's URL carried and `stored` is the `_ac` cookie. The URL\n * wins: a visitor arriving from a Meta ad today with a stale Google click ID stored\n * from last month is a Meta visitor now. An organic visit (neither carries anything)\n * always returns null — nobody is shown a tracking number they were not sent to.\n *\n * `googleNative` is true when `site.ads.google.callTracking` is configured; Google's\n * tag owns that swap, so a Google visit returns null here rather than competing.\n */\nexport function selectPhoneNumber(\n current: TrackingData | null,\n stored: TrackingData | null,\n config: PhoneNumbersConfig | undefined,\n opts: { googleNative?: boolean } = {},\n): SelectedNumber | null {\n if (!config) return null;\n for (const data of [current, stored]) {\n if (!data) continue;\n const provider = providerOf(data);\n if (provider === 'google' && opts.googleNative) return null;\n const specific = provider ? config.sources[provider] : undefined;\n if (specific && !samePhone(specific, config.number)) {\n return { number: specific, source: provider! };\n }\n if (isPaid(data) || provider) {\n const fallback = config.sources[DEFAULT_PAID];\n if (\n fallback &&\n !samePhone(fallback, config.number) &&\n !(provider === 'google' && opts.googleNative)\n ) {\n return { number: fallback, source: DEFAULT_PAID };\n }\n return null;\n }\n }\n return null;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;AAeA,SAAgB,WAAW,cAAyC,MAA6B;CAC/F,IAAI,CAAC,cAAc,OAAO;CAC1B,KAAK,MAAM,QAAQ,aAAa,MAAM,GAAG,GAAG;EAC1C,MAAM,MAAM,KAAK,KAAK;EACtB,IAAI,IAAI,MAAM,GAAG,KAAK,SAAS,CAAC,MAAM,GAAG,KAAK,IAAI,OAAO,IAAI,MAAM,KAAK,SAAS,CAAC;CACpF;CACA,OAAO;AACT;;;;;;;;;AAUA,SAAgB,oBAAoB,cAA8D;CAChG,MAAM,MAAM,WAAW,mBAA6B;CACpD,IAAI,CAAC,KAAK,OAAO;CACjB,IAAI;EACF,MAAM,OAAgB,KAAK,MAAM,mBAAmB,GAAG,CAAC;EACxD,IAAI,OAAO,SAAS,YAAY,SAAS,QAAQ,MAAM,QAAQ,IAAI,GAAG,OAAO;EAC7E,OAAO;CACT,QAAQ;EACN,OAAO;CACT;AACF;;AAGA,SAAgB,wBAAwB,MAA4B;CAGlE,MAAM,QAAQ,OAAO,YAAY,OAAO,QAAQ,IAAI,CAAC,CAAC,QAAQ,GAAG,OAAO,MAAM,MAAS,CAAC;CACxF,OAAO,mBAAmB,KAAK,UAAU,KAAK,CAAC;AACjD;;;;;;;;;AAUA,SAAgB,iBAAiB,KAAa,QAAyB;CAErE,OAAO,YADS,IAAI,KAAK,WAA6B,KAAK,CAAC,CAAC,YACpC,EAAE,sBAAsB,SAAS,YAAY;AACxE;;;;;;;;;AAUA,SAAgB,cACd,UACA,UACA,KACc;CACd,OAAO;EAAE,GAAG;EAAU,GAAG;EAAU,IAAI,UAAU,MAAM;CAAI;AAC7D;;;;;;;;;;;;;;;;;;AC5DA,MAAa,eAAe;;AAiB5B,MAAa,oBAA0D,OAAO,YAC3E,OAAO,KAAK,YAAY,CAAC,CAAkB,SAAS,aACnD,aAAa,SAAS,CAAC,cAAc,KAAK,UAAU,CAAC,OAAO,QAAQ,CAAU,CAChF,CACF;;;;;AAMA,MAAa,qBAA2D;CACtE,QAAQ;CACR,UAAU;CACV,WAAW;CACX,MAAM;CACN,IAAI;CACJ,IAAI;CACJ,MAAM;CACN,WAAW;CACX,UAAU;CACV,QAAQ;CACR,QAAQ;CACR,WAAW;CACX,UAAU;CACV,MAAM;AACR;;AAGA,MAAa,cAAc;;;;;AAM3B,SAAgB,YAAY,OAAuB;CACjD,MAAM,SAAS,MAAM,QAAQ,OAAO,EAAE;CACtC,OAAO,OAAO,WAAW,MAAM,OAAO,WAAW,GAAG,IAAI,OAAO,MAAM,CAAC,IAAI;AAC5E;;AAGA,SAAgB,UAAU,GAAW,GAAoB;CACvD,MAAM,KAAK,YAAY,CAAC;CACxB,OAAO,GAAG,SAAS,KAAK,OAAO,YAAY,CAAC;AAC9C;;;;;;AAOA,SAAgB,OAAO,SAAyB;CAC9C,MAAM,SAAS,QAAQ,QAAQ,OAAO,EAAE;CACxC,IAAI,OAAO,WAAW,IAAI,OAAO,SAAS;CAC1C,OAAO,QAAQ;AACjB;;AAGA,SAAS,WAAW,MAAuC;CACzD,KAAK,MAAM,CAAC,OAAO,aAAa,OAAO,QAAQ,iBAAiB,GAC9D,IAAI,KAAK,QAAQ,OAAO;CAE1B,MAAM,SAAS,KAAK;CACpB,OAAO,OAAO,WAAW,WACpB,mBAAmB,OAAO,KAAK,CAAC,CAAC,YAAY,MAAM,OACpD;AACN;AAEA,SAAS,OAAO,MAA6B;CAC3C,KAAK,MAAM,SAAS,OAAO,KAAK,iBAAiB,GAAG,IAAI,KAAK,QAAQ,OAAO;CAC5E,MAAM,SAAS,KAAK;CACpB,OAAO,OAAO,WAAW,YAAY,YAAY,KAAK,OAAO,KAAK,CAAC;AACrE;;;;;;;;;;;;AAaA,SAAgB,kBACd,SACA,QACA,QACA,OAAmC,CAAC,GACb;CACvB,IAAI,CAAC,QAAQ,OAAO;CACpB,KAAK,MAAM,QAAQ,CAAC,SAAS,MAAM,GAAG;EACpC,IAAI,CAAC,MAAM;EACX,MAAM,WAAW,WAAW,IAAI;EAChC,IAAI,aAAa,YAAY,KAAK,cAAc,OAAO;EACvD,MAAM,WAAW,WAAW,OAAO,QAAQ,YAAY;EACvD,IAAI,YAAY,CAAC,UAAU,UAAU,OAAO,MAAM,GAChD,OAAO;GAAE,QAAQ;GAAU,QAAQ;EAAU;EAE/C,IAAI,OAAO,IAAI,KAAK,UAAU;GAC5B,MAAM,WAAW,OAAO,QAAQ;GAChC,IACE,YACA,CAAC,UAAU,UAAU,OAAO,MAAM,KAClC,EAAE,aAAa,YAAY,KAAK,eAEhC,OAAO;IAAE,QAAQ;IAAU,QAAQ;GAAa;GAElD,OAAO;EACT;CACF;CACA,OAAO;AACT"}
|
|
@@ -37,6 +37,11 @@ interface ConversionEvent {
|
|
|
37
37
|
/** Submitted fields, for `type: 'form'`. Honeypots are stripped before this. */
|
|
38
38
|
formData?: Record<string, string> | undefined;
|
|
39
39
|
phone?: string | undefined;
|
|
40
|
+
/**
|
|
41
|
+
* For `type: 'call'` on a swapped number: which `site.tracking.phoneNumbers` source (or
|
|
42
|
+
* `google`) the visitor was shown. Lets the notification say "called the Meta number".
|
|
43
|
+
*/
|
|
44
|
+
phoneSource?: string | undefined;
|
|
40
45
|
/** The `mailto:` address, for `type: 'email'`. */
|
|
41
46
|
email?: string | undefined;
|
|
42
47
|
/** Attribution from the `_ac` cookie, or null when the visitor arrived cold. */
|
|
@@ -47,4 +52,4 @@ interface ConversionEvent {
|
|
|
47
52
|
at: number;
|
|
48
53
|
}
|
|
49
54
|
export { TrackingData as i, TrackingCategory as n, TrackingConfig as r, ConversionEvent as t };
|
|
50
|
-
//# sourceMappingURL=types-
|
|
55
|
+
//# sourceMappingURL=types-ByK6OuXQ.d.mts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types-
|
|
1
|
+
{"version":3,"file":"types-ByK6OuXQ.d.mts","names":[],"sources":["../src/tracking/types.ts"],"mappings":";;KAsBY,mBAAmB,YAAY;;;;;;;;;UAU1B;GACd;;EAED;;EAEA;;EAEA;;EAEA;;;;;;;;;;;;;;UAee;EACf;;EAEA,WAAW;EACX;;;;;EAKA;;EAEA;;EAEA,UAAU;EACV;EACA;;EAEA"}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { AdPropertyConfig } from "@getvitops/config";
|
|
2
|
+
import { AdProvider } 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$1 = AdProvider;
|
|
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
|
+
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
|
+
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$1;
|
|
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
|
+
}
|
|
91
|
+
export { AdsConfig as a, ConversionLabelMap as c, AdProvider$1 as i, normalizeConversionActionIds as l, AdDomainState as n, ConversionAction as o, AdPropertySetup as r, ConversionActionIdMap as s, AdConsentCategory as t, normalizeConversionLabels as u };
|
|
92
|
+
//# sourceMappingURL=types-C0hQKdsM.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types-C0hQKdsM.d.mts","names":["AdProvider","AdProviderConfig"],"sources":["../src/ads/types.ts"],"mappings":";;;;;;;;;;;KAmBYA,eAAaC;;KAGb;;;;;;;KAQA;;;;;;;;;;;;UAaK;EACf;EACA;EACA;;;;;;iBAOc,0BACd,gBAAgB,iCACf;;;;;;;;;;;;;;;;;;;UAuBc;EACf;EACA;EACA;;;iBAIc,6BACd,aAAa,oCACZ;;KAMS,kBAAkB;EAC5B,UAAUD;;;UAIK;;EAEf,YAAY;;;;;;;;;;;UAYG;EACf;;EAEA"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@getvitops/utils",
|
|
3
|
-
"version": "8.3.0-alpha.
|
|
3
|
+
"version": "8.3.0-alpha.5",
|
|
4
4
|
"description": "Shared build-time utilities for the Vitops toolchain — favicon generation and friends.",
|
|
5
5
|
"homepage": "https://docs.vitops.ca",
|
|
6
6
|
"bugs": "https://github.com/getvitops/design-system/issues",
|
|
@@ -107,7 +107,7 @@
|
|
|
107
107
|
"access": "public"
|
|
108
108
|
},
|
|
109
109
|
"dependencies": {
|
|
110
|
-
"@getvitops/config": "8.3.0-alpha.
|
|
110
|
+
"@getvitops/config": "8.3.0-alpha.5",
|
|
111
111
|
"colorjs.io": "^0.5.2",
|
|
112
112
|
"parse5": "^7.3.0",
|
|
113
113
|
"png-to-ico": "^3.0.2",
|