@getvitops/astro 4.0.0 → 5.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +303 -0
- package/README.md +22 -0
- package/dist/exports/tracking.d.mts +3 -0
- package/dist/exports/tracking.mjs +5 -0
- package/dist/index.d.mts +66 -56
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +221 -41
- package/dist/index.mjs.map +1 -1
- package/dist/routes/conversion.d.mts +6 -3
- package/dist/routes/conversion.d.mts.map +1 -1
- package/dist/routes/conversion.mjs +21 -3
- package/dist/routes/conversion.mjs.map +1 -1
- package/dist/tracking-CmSfxWC3.mjs +47 -0
- package/dist/tracking-CmSfxWC3.mjs.map +1 -0
- package/dist/tracking-DdEKExlE.d.mts +65 -0
- package/dist/tracking-DdEKExlE.d.mts.map +1 -0
- package/package.json +15 -6
- package/src/ads.ts +156 -0
- package/src/analytics.ts +22 -4
- package/src/components/Ads.astro +47 -0
- package/src/components/Analytics.astro +7 -30
- package/src/components/Cards.astro +38 -24
- package/src/components/GatedTags.astro +46 -0
- package/src/components/Subgrid.astro +39 -21
- package/src/components/Tree.astro +2 -2
- package/src/components/TreeNode.astro +1 -1
- package/src/exports/tracking.ts +26 -0
- package/src/scripts/tracking.ts +13 -1
- package/src/tracking.ts +13 -0
- package/virtual.d.ts +6 -0
|
@@ -1,34 +1,48 @@
|
|
|
1
1
|
---
|
|
2
2
|
/**
|
|
3
|
-
* Cards — a `Subgrid` whose
|
|
4
|
-
*
|
|
3
|
+
* Cards — a `Subgrid` whose cards are clickable across their whole area, with their
|
|
4
|
+
* text still selectable.
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* **This component emits its own `<wc-cards>`** — do NOT wrap it in one. That is the
|
|
7
|
+
* tier-3 rule ("the wrapper emits its `<wc-*>` tag with the accessible fallback
|
|
8
|
+
* inside"), and `Tree.astro` and `CookieConsent.astro` do the same.
|
|
9
|
+
* `<wc-cards><Cards /></wc-cards>` nests two elements governing the same cards; the
|
|
10
|
+
* inner one defers to the enclosing one so it degrades to a no-op rather than two
|
|
11
|
+
* forwarded clicks, but the single call is the intended shape.
|
|
12
|
+
*
|
|
13
|
+
* <Cards>
|
|
14
|
+
* <li class="card subgrid-card">
|
|
15
|
+
* <p class="font-eyebrow">Service</p>
|
|
16
|
+
* <p class="font-heading"><a class="link" href="/a">Title</a></p>
|
|
17
|
+
* <p>Blurb…</p>
|
|
18
|
+
* </li>
|
|
19
|
+
* </Cards>
|
|
20
|
+
*
|
|
21
|
+
* The fallback is the card's own link, so with no JS every card is still reachable —
|
|
22
|
+
* only the click-anywhere convenience is missing, and the pointer cursor comes from
|
|
23
|
+
* the element too, so the affordance never appears without the behaviour.
|
|
24
|
+
*
|
|
25
|
+
* **`<Subgrid>` is the unenhanced form**, which is why there is no `enhance={false}`
|
|
26
|
+
* prop: a flag would be a second way to say the same thing. Reach for
|
|
27
|
+
* `.stretched-link` instead when you want whole-card clicking with no JS at all and
|
|
28
|
+
* can accept that the card's text stops being selectable.
|
|
29
|
+
*
|
|
30
|
+
* It no longer parses the slot to add `class="card"`. That was its only job, and it is
|
|
31
|
+
* what forced `Subgrid` to rebuild every child as an `<li>` — see that file. Write the
|
|
32
|
+
* class on the item.
|
|
9
33
|
*/
|
|
10
34
|
import type { HTMLAttributes } from 'astro/types';
|
|
11
35
|
import Subgrid from './Subgrid.astro';
|
|
12
|
-
import { parseRenderedSlots, getAttribute, serialize } from '@getvitops/utils';
|
|
13
|
-
|
|
14
|
-
export interface Props extends HTMLAttributes<'ul'> {}
|
|
15
36
|
|
|
16
|
-
|
|
37
|
+
export interface Props extends HTMLAttributes<'ul'> {
|
|
38
|
+
as?: 'ul' | 'ol' | 'div';
|
|
39
|
+
/** Selector for the cards the element governs. Defaults to `.card`. */
|
|
40
|
+
item?: string;
|
|
41
|
+
}
|
|
17
42
|
|
|
18
|
-
const
|
|
19
|
-
const slots = nodes.filter(node => 'tagName' in node).map(node => ({
|
|
20
|
-
html: serialize(node),
|
|
21
|
-
class: getAttribute(node, 'class'),
|
|
22
|
-
style: getAttribute(node, 'style'),
|
|
23
|
-
}));
|
|
43
|
+
const { item, ...rest } = Astro.props;
|
|
24
44
|
---
|
|
25
45
|
|
|
26
|
-
<
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
{...rest}
|
|
30
|
-
>
|
|
31
|
-
{slots.map((slot) => (
|
|
32
|
-
<li class:list={["card", slot.class]} style={slot.style} set:html={slot.html}></li>
|
|
33
|
-
))}
|
|
34
|
-
</Subgrid>
|
|
46
|
+
<wc-cards item={item}>
|
|
47
|
+
<Subgrid {...rest}><slot /></Subgrid>
|
|
48
|
+
</wc-cards>
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* <GatedTags /> — the one place a third-party tag becomes markup.
|
|
4
|
+
*
|
|
5
|
+
* Shared by <Analytics /> and <Ads />, and shared deliberately: the gate is
|
|
6
|
+
* `type="text/plain"` with the URL on `data-src`, so the browser never parses the
|
|
7
|
+
* body as script and never fetches the library. An undecided or declining
|
|
8
|
+
* visitor's page issues no third-party request at all. Consent implemented by
|
|
9
|
+
* *asking a tracker not to track* is a promise; this is a fact about the document
|
|
10
|
+
* — and a fact with two implementations is one rewrite away from being neither.
|
|
11
|
+
*
|
|
12
|
+
* Decides nothing. Every tag arrives fully resolved from `analytics.ts` or
|
|
13
|
+
* `ads.ts`, both of which are pure and unit-tested.
|
|
14
|
+
*
|
|
15
|
+
* Emits no `preconnect` on purpose: warming a connection at parse time is exactly
|
|
16
|
+
* the critical-path cost the `idle` strategy exists to avoid.
|
|
17
|
+
*/
|
|
18
|
+
import type { GatedTag } from '../analytics.ts';
|
|
19
|
+
|
|
20
|
+
interface Props {
|
|
21
|
+
tags: GatedTag[];
|
|
22
|
+
}
|
|
23
|
+
const { tags } = Astro.props;
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
{tags.map((tag) => tag.gated
|
|
27
|
+
? (
|
|
28
|
+
<script
|
|
29
|
+
is:inline
|
|
30
|
+
type="text/plain"
|
|
31
|
+
data-vitops-tag
|
|
32
|
+
data-consent={tag.category}
|
|
33
|
+
data-strategy={tag.strategy}
|
|
34
|
+
data-src={tag.src}
|
|
35
|
+
data-consent-cookies={tag.cookies.length ? tag.cookies.join(',') : undefined}
|
|
36
|
+
{...tag.attrs}
|
|
37
|
+
set:html={tag.inline}
|
|
38
|
+
/>
|
|
39
|
+
)
|
|
40
|
+
: (
|
|
41
|
+
<>
|
|
42
|
+
{tag.inline && <script is:inline set:html={tag.inline} />}
|
|
43
|
+
{tag.src && <script is:inline async src={tag.src} {...tag.attrs}></script>}
|
|
44
|
+
</>
|
|
45
|
+
)
|
|
46
|
+
)}
|
|
@@ -1,32 +1,50 @@
|
|
|
1
1
|
---
|
|
2
2
|
/**
|
|
3
|
-
* Subgrid —
|
|
4
|
-
*
|
|
5
|
-
* lives in the framework's `subgrid`
|
|
6
|
-
*
|
|
3
|
+
* Subgrid — a grid whose items re-declare the parent's row tracks, so the tranches
|
|
4
|
+
* inside every item (head, body, footer) land on the same row lines no matter how
|
|
5
|
+
* much content each one holds. Every declaration lives in the framework's `subgrid`
|
|
6
|
+
* pattern (patterns/subgrid.css); this file is markup only.
|
|
7
7
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
8
|
+
* **The slot is rendered verbatim — author the items yourself.** This used to parse
|
|
9
|
+
* the slotted HTML and replace each child with an `<li>`, carrying over its class
|
|
10
|
+
* and style. That existed only because a `<ul>` may contain nothing but `<li>`, and
|
|
11
|
+
* it cost more than it bought: the child's tag was discarded, and every attribute
|
|
12
|
+
* had to be copied by hand, so `id`, `data-*` and `aria-*` were silently dropped and
|
|
13
|
+
* `href` could not survive at all. Passing the slot through means there is nothing to
|
|
14
|
+
* copy and nothing to lose.
|
|
12
15
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
+
* <Subgrid>
|
|
17
|
+
* <li class="card subgrid-card">…</li>
|
|
18
|
+
* </Subgrid>
|
|
19
|
+
*
|
|
20
|
+
* `as` picks the container. It defaults to `ul`, because a set of cards IS a list and
|
|
21
|
+
* that is what tells assistive tech how many there are and where you are among them.
|
|
22
|
+
* Use `as="div"` when the items are genuinely not a list.
|
|
23
|
+
*
|
|
24
|
+
* **`role="list"` is restored automatically for `ul`/`ol`.** `.subgrid` sets
|
|
25
|
+
* `list-style: none`, and that stops Safari + VoiceOver announcing a list at all — so
|
|
26
|
+
* the marker reset silently costs the semantics the `<ul>` was chosen for. Passing
|
|
27
|
+
* your own `role` wins.
|
|
28
|
+
*
|
|
29
|
+
* Size it from the consumer's own CSS, which keeps media-query overrides working:
|
|
30
|
+
* `--subgrid-cols`, `--subgrid-row-span`, `--subgrid-gap`, `--subgrid-row-gap`.
|
|
31
|
+
*
|
|
32
|
+
* For a card whose whole area should be clickable, see `<Cards />` (selectable text,
|
|
33
|
+
* needs JS) or `.stretched-link` (no JS, text not selectable).
|
|
16
34
|
*/
|
|
17
35
|
import type { HTMLAttributes } from 'astro/types';
|
|
18
|
-
import { parseRenderedSlots, getAttribute, serialize, styleList } from '@getvitops/utils';
|
|
19
36
|
|
|
20
|
-
export interface Props extends HTMLAttributes<'ul'> {
|
|
37
|
+
export interface Props extends HTMLAttributes<'ul'> {
|
|
38
|
+
/** Container element. `ul`/`ol` also get `role="list"`. */
|
|
39
|
+
as?: 'ul' | 'ol' | 'div';
|
|
40
|
+
}
|
|
21
41
|
|
|
22
|
-
const { class: className,
|
|
42
|
+
const { as: Tag = 'ul', class: className, role, ...rest } = Astro.props;
|
|
23
43
|
|
|
24
|
-
|
|
25
|
-
const
|
|
44
|
+
// Only lists lose semantics to `list-style: none`, so only lists get the role back.
|
|
45
|
+
const listRole = Tag === 'ul' || Tag === 'ol' ? 'list' : undefined;
|
|
26
46
|
---
|
|
27
47
|
|
|
28
|
-
<
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
))}
|
|
32
|
-
</ul>
|
|
48
|
+
<Tag class:list={['subgrid', className]} role={role ?? listRole} {...rest}>
|
|
49
|
+
<slot />
|
|
50
|
+
</Tag>
|
|
@@ -65,14 +65,14 @@ const {
|
|
|
65
65
|
{
|
|
66
66
|
enhance ? (
|
|
67
67
|
<wc-tree label={label} {...rest}>
|
|
68
|
-
<ul class="tree tree--lines" aria-label={ariaLabel}>
|
|
68
|
+
<ul class="tree tree--lines" role="list" aria-label={ariaLabel}>
|
|
69
69
|
{items.map((item) => (
|
|
70
70
|
<TreeNode item={item} depth={0} openDepth={openDepth} />
|
|
71
71
|
))}
|
|
72
72
|
</ul>
|
|
73
73
|
</wc-tree>
|
|
74
74
|
) : (
|
|
75
|
-
<ul class="tree tree--lines" aria-label={ariaLabel}>
|
|
75
|
+
<ul class="tree tree--lines" role="list" aria-label={ariaLabel}>
|
|
76
76
|
{items.map((item) => (
|
|
77
77
|
<TreeNode item={item} depth={0} openDepth={openDepth} />
|
|
78
78
|
))}
|
|
@@ -41,7 +41,7 @@ const isBranch = kids.length > 0;
|
|
|
41
41
|
</span>
|
|
42
42
|
</summary>
|
|
43
43
|
{item.descriptionHtml && <p class="tree__desc" set:html={item.descriptionHtml} />}
|
|
44
|
-
<ul class="tree">
|
|
44
|
+
<ul class="tree" role="list">
|
|
45
45
|
{kids.map((kid) => (
|
|
46
46
|
<Astro.self item={kid} depth={depth + 1} openDepth={openDepth} />
|
|
47
47
|
))}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@getvitops/astro/tracking` — the attribution vocabulary, re-exported.
|
|
3
|
+
*
|
|
4
|
+
* The documented conversion flow spans two packages: `<Tracking />` captures the
|
|
5
|
+
* click ID from `@getvitops/astro`, and the route reads it back with
|
|
6
|
+
* `parseTrackingCookie` from `@getvitops/utils/tracking`. Under strict pnpm a
|
|
7
|
+
* consumer cannot resolve the second from app code without adding
|
|
8
|
+
* `@getvitops/utils` as a direct dependency — so the flow as written in the
|
|
9
|
+
* changelog needed two installs, and the obvious workaround (importing the same
|
|
10
|
+
* symbols from `@getvitops/astro`'s index) is the one that drags the whole
|
|
11
|
+
* integration, and its Node builtins, toward a Worker bundle.
|
|
12
|
+
*
|
|
13
|
+
* This entry is the third option and the correct one: one install, and a module
|
|
14
|
+
* graph that reaches only `@getvitops/utils/tracking` — no `sharp`, no
|
|
15
|
+
* `node:child_process`, nothing that cannot run in a Worker. It is a separate
|
|
16
|
+
* build entry for exactly the reason `./routes` is.
|
|
17
|
+
*/
|
|
18
|
+
export * from '@getvitops/utils/tracking';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Where the capture script beacons `tel:` conversions.
|
|
22
|
+
*
|
|
23
|
+
* Re-exported here so a consumer writing the route can assert against the same
|
|
24
|
+
* constant the script uses, rather than retyping the path.
|
|
25
|
+
*/
|
|
26
|
+
export { TRACKING_ENDPOINT } from '../tracking.ts';
|
package/src/scripts/tracking.ts
CHANGED
|
@@ -26,6 +26,16 @@ const COOKIE_NAME = '_ac';
|
|
|
26
26
|
const COOKIE_DAYS = 90;
|
|
27
27
|
const AB_COOKIE = '_ab';
|
|
28
28
|
|
|
29
|
+
// Where the `tel:` conversion beacon goes. Mirrors TRACKING_ENDPOINT in
|
|
30
|
+
// ../tracking.ts (pinned by `tracking.test.ts`), copied for the same reason as
|
|
31
|
+
// the parameter lists below — this file is inlined into the document.
|
|
32
|
+
//
|
|
33
|
+
// It was previously a bare literal at the call site, which made the contract
|
|
34
|
+
// between this script and the route the consumer writes invisible: name the
|
|
35
|
+
// file anything else and every beacon 404s, conversions vanish, and nothing
|
|
36
|
+
// errors anywhere. The integration now warns at build when the route is absent.
|
|
37
|
+
const TRACK_ENDPOINT = '/api/track';
|
|
38
|
+
|
|
29
39
|
// Mirrors CLICK_ID_PARAMS/UTM_PARAMS in @getvitops/utils/tracking, deliberately
|
|
30
40
|
// copied rather than imported: this file is inlined into the document by
|
|
31
41
|
// <Tracking />, so a bare import would either fail or drag a module graph into
|
|
@@ -39,6 +49,8 @@ const CLICK_ID_PARAMS = [
|
|
|
39
49
|
'rdt_cid', // Reddit
|
|
40
50
|
'ScCid', // Snapchat
|
|
41
51
|
'msclkid', // Microsoft/Bing
|
|
52
|
+
'li_fat_id', // LinkedIn
|
|
53
|
+
'epik', // Pinterest
|
|
42
54
|
] as const;
|
|
43
55
|
|
|
44
56
|
const UTM_PARAMS = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_content', 'utm_term'] as const;
|
|
@@ -142,6 +154,6 @@ document.addEventListener('click', (e) => {
|
|
|
142
154
|
|
|
143
155
|
// Fire-and-forget beacon; don't block the tel: navigation
|
|
144
156
|
if (navigator.sendBeacon) {
|
|
145
|
-
navigator.sendBeacon(
|
|
157
|
+
navigator.sendBeacon(TRACK_ENDPOINT, JSON.stringify({ event: 'call', phone: link.href }));
|
|
146
158
|
}
|
|
147
159
|
});
|
package/src/tracking.ts
CHANGED
|
@@ -27,6 +27,19 @@ export interface GetvitopsTrackingOptions {
|
|
|
27
27
|
/** The cookie the capture script writes. Mirrors `TRACKING_COOKIE` in utils. */
|
|
28
28
|
export const TRACKING_COOKIE = '_ac';
|
|
29
29
|
|
|
30
|
+
/**
|
|
31
|
+
* Where the capture script sends its `tel:` conversion beacon.
|
|
32
|
+
*
|
|
33
|
+
* The route itself is the consumer's to write (`createConversionRoute()` in
|
|
34
|
+
* `@getvitops/astro/routes`), so this is the one place the two halves agree on
|
|
35
|
+
* a path. It was a bare literal inside the inlined script: a consumer who named
|
|
36
|
+
* the file anything else got a silent 404 on every beacon and lost conversions
|
|
37
|
+
* with no error anywhere to explain it. Exported so a consumer can assert
|
|
38
|
+
* against it, mirrored in `scripts/tracking.ts` because that file is inlined
|
|
39
|
+
* into the document and cannot import, and pinned by `tracking.test.ts`.
|
|
40
|
+
*/
|
|
41
|
+
export const TRACKING_ENDPOINT = '/api/track';
|
|
42
|
+
|
|
30
43
|
export interface ResolvedTracking {
|
|
31
44
|
enabled: boolean;
|
|
32
45
|
category: TrackingCategory;
|
package/virtual.d.ts
CHANGED
|
@@ -52,6 +52,12 @@ declare module 'virtual:getvitops/head' {
|
|
|
52
52
|
// the others, and the offered consent categories it checks against are known
|
|
53
53
|
// only there.
|
|
54
54
|
tracking: { enabled: boolean; category: string; cookies: string[] } | null;
|
|
55
|
+
// The site config's `site.ads` block plus this environment's switch —
|
|
56
|
+
// structurally `{ enabled, properties }` (src/ads.ts), re-typed by <Ads /> on
|
|
57
|
+
// the way into `resolveAds`. Read in the integration because that is where the
|
|
58
|
+
// site config is already parsed, and because a pixel is a `marketing` demand
|
|
59
|
+
// the banner must have a row for.
|
|
60
|
+
ads: { enabled: boolean; properties: Record<string, unknown> } | null;
|
|
55
61
|
}
|
|
56
62
|
const data: HeadData;
|
|
57
63
|
export default data;
|