@sarimarcus/content-sites-ui 0.16.0 → 0.17.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 CHANGED
@@ -2,6 +2,13 @@
2
2
 
3
3
  All packages in the platform release in lockstep; entries are per release version.
4
4
 
5
+ ## 0.17.0 (2026-10-01)
6
+
7
+ - New `DocumentShell` (`./layout`): the whole HTML document a site page renders into: description, robots (`noindex, follow` when noindex), canonical from the page path, RSS and favicons, Open Graph / article / Twitter (handle optional), title, non-critical stylesheets, the Emerald loader, the skip link, navigation and footer wrapped out of the Pagefind index, `#main-content`, and consent-gated analytics. Slots `head-start`, `fonts`, `head`, `body-start`, `nav`, `footer`. It imports no stylesheet and emits no bundled script, so a site's thin Layout keeps its bundle names (SLA-2741).
8
+ - New `ConsentAnalytics` (`./layout`): the inline GTM + Consent Mode v2 + Silktide bootstrap (consent default before the gtm.js event in one synchronous step; `window.openCookiePreferences`), production only, plus the `<noscript>` GTM iframe. `layout/resources.ts` declares the hosts it loads (SLA-2741).
9
+ - New `EmeraldLoader` (`./affiliate`): the Travelpayouts Emerald script from the site's trs; `emeraldResources` declares its host (SLA-2741).
10
+ - New entry points `./analytics` (`setupAnalytics`, `track`, `pageTypeOf`, delegated, FAQ and scroll-depth tracking) and `./travelpayouts-styler` (`setupTravelpayoutsStyler`, palette, fonts and copy as options), moved from the sites. FAQ tracking now fires: it tested `dataset.faqTrack`, which is empty on the bare `data-faq-track` attribute ui `FAQ` renders (SLA-2741).
11
+
5
12
  ## 0.16.0 (2026-10-01)
6
13
 
7
14
  - New `./navigation`: `SiteHeader` (fixed header, labelled main `<nav>`, logo/desktop/actions/mobile slots, mobile toggle, mobile panel after the header with `data-pagefind-ignore`, `surfaceClass`, Ctrl/Cmd+K → `searchPath?focus=1`), `NavDropdown` (click, hover with a 150 ms close delay, ArrowDown/ArrowUp/Escape, outside click, focus leaving; a click on a hover-opened dropdown keeps it open), `MobileNavSection` (single-open accordion, `aria-controls`), `NavMenuLink` / `MobileNavLink` (panel rows by `NavTone`), `SiteFooter` (sections, `<nav aria-label="Legal">`, cookie-preferences button through `data-cookie-preferences`, no inline handler), `isActivePath`, and the `SiteIdentity` / `NavLink` / `NavGroup` / `FooterSection` / `NavTone` contracts. One bundled controller, idempotent on `astro:page-load`, any number of headers per page; the toggle and chevron icons now switch state (SLA-2735).
package/README.md CHANGED
@@ -1,7 +1,8 @@
1
1
  # @sarimarcus/content-sites-ui
2
2
 
3
3
  Universal Astro UI shared by every content site. Named entry points, one per folder: `primitives`,
4
- `composed`, `editorial`, `affiliate`, `seo`, `layout`, `navigation`, `search` and `types`. For example,
4
+ `composed`, `editorial`, `affiliate`, `seo`, `layout`, `navigation`, `search` and `types`, plus the script
5
+ entry points `analytics` and `travelpayouts-styler`. For example,
5
6
  `import { Container } from '@sarimarcus/content-sites-ui/layout'`. `src/internal` is never exported,
6
7
  and `src/styles` holds the Tailwind entry.
7
8
 
@@ -75,6 +76,34 @@ state and one bundled controller (idempotent on `astro:page-load`, any number of
75
76
  - `appendResults` renders Pagefind result cards from the site's type taxonomy; `SearchSuggestions` renders the
76
77
  empty-state chips. The search page, its copy and its index stay in the site.
77
78
 
79
+ ## Document shell
80
+
81
+ ```astro
82
+ ---
83
+ import { DocumentShell } from '@sarimarcus/content-sites-ui/layout';
84
+ ---
85
+ <DocumentShell title={title} description={description} siteName={site.siteName} siteUrl={site.url} themeColor="#FAFAF8"
86
+ gtmId={site.analytics.gtmId} consentPrompt="…" consentTypes={consentTypes} skipLinkLabel="Skip to main content">
87
+ <Fragment slot="fonts">…</Fragment>
88
+ <Navigation slot="nav" />
89
+ <slot />
90
+ <Footer slot="footer" />
91
+ </DocumentShell>
92
+ ```
93
+
94
+ `DocumentShell` renders the whole document: description, robots (`noindex, follow` when `noindex`), canonical (from
95
+ the page path unless `canonicalUrl`), RSS and favicons, Open Graph / article / Twitter (`twitterHandle` optional),
96
+ title, non-critical `stylesheets`, the Emerald loader (`travelpayoutsTrs`), the skip link, the navigation and footer
97
+ wrapped in `data-pagefind-ignore`, `#main-content` and `ConsentAnalytics`. Slots: `head-start`, `fonts`, `head`,
98
+ `body-start`, `nav`, `footer`, default. It imports no stylesheet and emits no bundled script, so the site's thin
99
+ Layout keeps both (and with them its bundle names).
100
+
101
+ `ConsentAnalytics` is inline on purpose: the Consent Mode default must reach the dataLayer before the `gtm.js` event
102
+ in one synchronous step. It defines `window.openCookiePreferences` for the footer button. `./analytics`
103
+ (`setupAnalytics`, `track`, `pageTypeOf`) and `./travelpayouts-styler` (`setupTravelpayoutsStyler`) are their own
104
+ entry points so a client `<script>` imports no Astro component. The hosts the shell loads are declared in
105
+ `layout/resources.ts`; `scripts/validate-csp-coverage.mjs` checks every site's CSP against them.
106
+
78
107
  ## Search and consent styles
79
108
 
80
109
  `@sarimarcus/content-sites-ui/search.css` styles the search page markup (`appendResults`, `SearchSuggestions` and
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sarimarcus/content-sites-ui",
3
- "version": "0.16.0",
3
+ "version": "0.17.0",
4
4
  "description": "Universal Astro UI components, layout shell and design tokens shared by the content sites.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -30,8 +30,10 @@
30
30
  "./composed": "./src/composed/index.ts",
31
31
  "./editorial": "./src/editorial/index.ts",
32
32
  "./affiliate": "./src/affiliate/index.ts",
33
+ "./travelpayouts-styler": "./src/affiliate/travelpayoutsStyler.ts",
33
34
  "./seo": "./src/seo/index.ts",
34
35
  "./layout": "./src/layout/index.ts",
36
+ "./analytics": "./src/layout/analytics.ts",
35
37
  "./navigation": "./src/navigation/index.ts",
36
38
  "./search": "./src/search/index.ts",
37
39
  "./types": "./src/types/index.ts",
@@ -45,6 +47,6 @@
45
47
  "@lucide/astro": "^1.23.0",
46
48
  "astro": "^7.0.6",
47
49
  "tailwindcss": "^4.1.18",
48
- "@sarimarcus/content-sites-core": "0.16.0"
50
+ "@sarimarcus/content-sites-core": "0.17.0"
49
51
  }
50
52
  }
@@ -0,0 +1,40 @@
1
+ ---
2
+ /**
3
+ * The Travelpayouts Emerald script that turns the site's affiliate links into tracked ones. Deferred off the critical
4
+ * path: it loads on the first interaction, or once the page is idle after load. `trs` is the site's Travelpayouts id.
5
+ */
6
+ import { EMERALD_ORIGIN } from './resources.ts';
7
+
8
+ interface Props {
9
+ trs: string;
10
+ }
11
+
12
+ const { trs } = Astro.props;
13
+ const emeraldOrigin = EMERALD_ORIGIN;
14
+ ---
15
+
16
+ <script is:inline define:vars={{ trs, emeraldOrigin }}>
17
+ (function () {
18
+ if (!/^\d+$/.test(trs)) return;
19
+ var loaded = false;
20
+ function loadAffiliate() {
21
+ if (loaded) return;
22
+ loaded = true;
23
+ var script = document.createElement('script');
24
+ script.async = 1;
25
+ script.src = emeraldOrigin + '/' + btoa(trs) + '.js?t=' + trs;
26
+ document.head.appendChild(script);
27
+ }
28
+ var opts = { once: true, passive: true, capture: true };
29
+ ['pointerdown', 'keydown', 'touchstart', 'scroll'].forEach(function (evt) {
30
+ window.addEventListener(evt, loadAffiliate, opts);
31
+ });
32
+ window.addEventListener('load', function () {
33
+ if ('requestIdleCallback' in window) {
34
+ requestIdleCallback(loadAffiliate, { timeout: 5000 });
35
+ } else {
36
+ setTimeout(loadAffiliate, 4000);
37
+ }
38
+ });
39
+ })();
40
+ </script>
@@ -11,7 +11,10 @@ export { default as AffiliateCardSection } from './AffiliateCardSection.astro';
11
11
  export { default as AffiliateCarousel } from './AffiliateCarousel.astro';
12
12
  export { default as AffiliateComparisonTable } from './AffiliateComparisonTable.astro';
13
13
  export { default as HotelAffiliateCTA } from './HotelAffiliateCTA.astro';
14
- export { externalResources } from './resources';
14
+ export { externalResources, emeraldResources, EMERALD_ORIGIN } from './resources';
15
15
  export type { WidgetKind, CspDirective, WidgetResources } from './resources';
16
16
  export type { AffiliateCardLabels, AffiliateCardItem, HotelCtaLabels, ComparisonRow, ComparisonTableLabels } from './types';
17
17
  export { selectActivities, type SelectableActivity, type SelectOptions } from './select';
18
+ export { default as EmeraldLoader } from './EmeraldLoader.astro';
19
+ export { setupTravelpayoutsStyler } from './travelpayoutsStyler.ts';
20
+ export type { TravelpayoutsStylerOptions, TravelpayoutsStylerColors } from './travelpayoutsStyler.ts';
@@ -28,3 +28,10 @@ export const externalResources: Record<WidgetKind, WidgetResources> = {
28
28
  },
29
29
  };
30
30
 
31
+
32
+ /** The Travelpayouts Emerald loader every page carries (EmeraldLoader): https://emrldtp.com/<base64 trs>.js?t=<trs>. */
33
+ export const EMERALD_ORIGIN = 'https://emrldtp.com';
34
+
35
+ export const emeraldResources: Partial<Record<CspDirective, readonly string[]>> = {
36
+ 'script-src': [EMERALD_ORIGIN],
37
+ };
@@ -0,0 +1,557 @@
1
+ // TravelPayouts / Emerald contextual widgets render inside shadow DOM, so
2
+ // external CSS cannot reach them. This module injects the site's tokens
3
+ // directly into every .emerald-block shadow root, rewrites titles that read
4
+ // as ad copy, strips characters whose glyph isn't in the site fonts, and
5
+ // hides redundant CTAs per widget variant.
6
+ //
7
+ // Variants discovered in production (2026-05):
8
+ // - emerald-block__hotels (top hotels list)
9
+ // - emerald-block__recommendation (3-card Booking/Viator/Discover Cars block)
10
+ // - emerald-block__highlights (last-minute tours/experiences list)
11
+
12
+ /** CSS colour values, by role. Any CSS colour syntax; the site owns the palette. */
13
+ export interface TravelpayoutsStylerColors {
14
+ /** Links, button background, focus ring. */
15
+ accent: string;
16
+ /** Link and button hover/focus. */
17
+ accentHover: string;
18
+ /** Text on a button filled with the accent colour. */
19
+ onAccent: string;
20
+ /** Headings, card titles, disclosure text. */
21
+ foreground: string;
22
+ /** Descriptors, card body, metric labels. */
23
+ mutedForeground: string;
24
+ /** Card, info-box and disclosure background. */
25
+ surface: string;
26
+ /** Dividers and card/container borders. */
27
+ border: string;
28
+ /** Recommendation-card border on hover. */
29
+ borderHover: string;
30
+ /** Score/rating text and hotel rating labels. */
31
+ rating: string;
32
+ /** Affiliate-disclosure border. */
33
+ disclosureBorder: string;
34
+ }
35
+
36
+ export interface TravelpayoutsStylerOptions {
37
+ colors: TravelpayoutsStylerColors;
38
+ /** CSS font-family stacks. */
39
+ fonts: { body: string; heading: string };
40
+ /** Weight of widget section titles and generic headings. */
41
+ headingWeight: number;
42
+ /** Weight of recommendation-card titles. */
43
+ cardTitleWeight: number;
44
+ /** Border radii: `control` for buttons and the disclosure, `card` for cards and the hotels container. */
45
+ radius: { control: string; card: string };
46
+ /** Editorial replacements for the widget's ad-copy titles. */
47
+ titles: {
48
+ /** Replaces a highlights title mentioning "last minute". */
49
+ highlights: string;
50
+ /** Replaces a recommendation title carrying both "?" and "!". */
51
+ recommendation: string;
52
+ };
53
+ }
54
+
55
+ interface VariantConfig {
56
+ /** Replace the widget's H1–H4 title text. Receives the cleaned text, returns the new text. */
57
+ rewriteTitle?: (text: string) => string;
58
+ /** Selectors (run inside the shadow root) to hide via display:none. */
59
+ hide?: string[];
60
+ }
61
+
62
+ type Variants = Record<string, VariantConfig>;
63
+
64
+ // Web fonts rarely ship emoji or geometric-shape glyphs, so anything
65
+ // outside the BMP letterforms renders as a tofu rectangle. Strip:
66
+ // - all Extended_Pictographic (emoji)
67
+ // - misc symbols U+2600–26FF (★ ☆ ⛅ etc.)
68
+ // - geometric shapes U+25A0–25FF (■ □ ▲ etc.)
69
+ // - misc symbols & arrows U+2B00–2BFF (⬜ ⬛ ⭐ etc.)
70
+ // - variation selectors U+FE00–FE0F that follow them
71
+ const TOFU_RE = /[\p{Extended_Pictographic}☀-⛿■-◿⬀-⯿]️?/gu;
72
+
73
+ export function buildCss(o: TravelpayoutsStylerOptions): string {
74
+ const c = o.colors;
75
+ return [
76
+ // Host stays block-level and contained — NO !important on max-width so the
77
+ // site's light-DOM .emerald-block rule can size the host to the article column.
78
+ ":host { display: block; max-width: 100%; overflow: hidden; box-sizing: border-box; background: transparent; }",
79
+ ".wrapper,.hotels,.container { max-width: 100% !important; box-sizing: border-box !important; }",
80
+ `* { font-family: ${o.fonts.body}; }`,
81
+ "h1,h2,h3,h4,.title,[class*='heading'],[class*='title'] {",
82
+ ` font-family: ${o.fonts.heading};`,
83
+ ` font-weight: ${o.headingWeight};`,
84
+ " letter-spacing: -0.01em;",
85
+ "}",
86
+ // Link color — exclude buttons so the .button color rule below wins on <a class='button'>.
87
+ // Without :not(), `a:link` (specificity 0,0,1,1) would override `.button` (0,0,1,0).
88
+ `a:not(.button):not([class*='button']),a:not(.button):not([class*='button']):link,a:not(.button):not([class*='button']):visited { color: ${c.accent} !important; text-decoration: none; }`,
89
+ `a:not(.button):not([class*='button']):hover,a:not(.button):not([class*='button']):focus { color: ${c.accentHover} !important; text-decoration: underline; }`,
90
+ ".button,[class*='button'],button {",
91
+ ` background-color: ${c.accent} !important;`,
92
+ ` color: ${c.onAccent} !important;`,
93
+ " border: none !important;",
94
+ ` border-radius: ${o.radius.control} !important;`,
95
+ ` font-family: ${o.fonts.body} !important;`,
96
+ " font-weight: 500 !important;",
97
+ " font-size: 0.875rem !important;",
98
+ " line-height: 1.5 !important;",
99
+ " cursor: pointer;",
100
+ " transition: background-color 0.15s ease;",
101
+ " display: inline-flex !important;",
102
+ " align-items: center !important;",
103
+ " gap: 0.5rem !important;",
104
+ " padding: 0.5rem 1rem !important;",
105
+ " text-indent: 0 !important;",
106
+ " text-decoration: none !important;",
107
+ " visibility: visible !important;",
108
+ " opacity: 1 !important;",
109
+ "}",
110
+ `.button { color: ${c.onAccent} !important; }`,
111
+ `.button *,[class*='button'] * { color: ${c.onAccent} !important; visibility: visible !important; opacity: 1 !important; }`,
112
+ `.button span,.button a,.button p,[class*='button'] span,[class*='button'] a,[class*='button'] p,button span,button a,button p { color: ${c.onAccent} !important; }`,
113
+ `button { color: ${c.onAccent} !important; }`,
114
+ ".button:hover,.button:focus,[class*='button']:hover,[class*='button']:focus,button:hover,button:focus {",
115
+ ` background-color: ${c.accentHover} !important;`,
116
+ "}",
117
+ // Keyboard focus ring in the accent colour.
118
+ ".button:focus-visible,[class*='button']:focus-visible,button:focus-visible {",
119
+ ` outline: 2px solid ${c.accent} !important;`,
120
+ " outline-offset: 2px !important;",
121
+ "}",
122
+ "[class*='score'],[class*='rating'],[class*='grade'],[class*='stars'] {",
123
+ ` color: ${c.rating};`,
124
+ " font-weight: 600;",
125
+ "}",
126
+ // Highlights variant: editorial list style — dividers, no cards.
127
+ ".highlights { background-color: transparent !important; }",
128
+ ".highlight {",
129
+ " background-color: transparent !important;",
130
+ " border: none !important;",
131
+ ` border-bottom: 1px solid ${c.border} !important;`,
132
+ " padding: 0.875rem 0 !important;",
133
+ " margin: 0 !important;",
134
+ "}",
135
+ ".highlight:last-child { border-bottom: none !important; }",
136
+ // Descriptor on its own line, link on next line as the focal point.
137
+ ".highlight__title {",
138
+ " display: block !important;",
139
+ " font-size: 0.95rem !important;",
140
+ " line-height: 1.55 !important;",
141
+ ` color: ${c.mutedForeground} !important;`,
142
+ "}",
143
+ ".highlight__title > a {",
144
+ " display: inline-block !important;",
145
+ " margin-top: 0.25rem !important;",
146
+ " font-weight: 500 !important;",
147
+ " font-size: 1rem !important;",
148
+ "}",
149
+ // Hide broken-glyph emoji icons the widget injects — fonts don't include them.
150
+ ".highlight__icon { display: none !important; }",
151
+ // Widget section title: matches the H2-in-prose convention (clamp 1.5→1.875rem).
152
+ // .wrapper prefix for specificity
153
+ // (0,1,1,1) to beat widget's own h3.title rules (0,0,1,1) injected after our style.
154
+ ".wrapper h1.title, .wrapper h2.title, .wrapper h3.title, .wrapper h4.title {",
155
+ " background-color: transparent !important;",
156
+ " background: none !important;",
157
+ " border: none !important;",
158
+ " border-bottom: none !important;",
159
+ " padding: 0 0 0.5rem !important;",
160
+ " margin-bottom: 0.75rem !important;",
161
+ " font-size: clamp(1.5rem, 2.5vw + 0.5rem, 1.875rem) !important;",
162
+ ` font-weight: ${o.headingWeight} !important;`,
163
+ " line-height: 1.3 !important;",
164
+ ` color: ${c.foreground} !important;`,
165
+ ` font-family: ${o.fonts.heading} !important;`,
166
+ " text-align: left !important;",
167
+ "}",
168
+ // Metric/quality badge — "Good", "Excellent" etc. — styled for non-highlights variants.
169
+ "[class*='metric'] {",
170
+ " display: block !important;",
171
+ " font-size: 0.6875rem !important;",
172
+ ` color: ${c.mutedForeground} !important;`,
173
+ " font-weight: 500 !important;",
174
+ " text-transform: uppercase !important;",
175
+ " letter-spacing: 0.06em !important;",
176
+ ` font-family: ${o.fonts.body} !important;`,
177
+ " margin-top: 0.125rem !important;",
178
+ "}",
179
+ // Highlights metric "Good"/"Excellent" adds no signal without numeric context — hide.
180
+ // Must come AFTER [class*='metric'] { display: block } above: same specificity + !important
181
+ // means later declaration wins, so the hide rule must be last.
182
+ ".highlight__metric, .highlight__metrics { display: none !important; }",
183
+ // Wrapper + hotels: transparent — no blue containers.
184
+ ".wrapper, .hotels { background-color: transparent !important; }",
185
+ // Override blue backgrounds in widget info boxes (div only, not headings).
186
+ "div.title,.container,[class*='info'],[class*='fact'],[class*='note'],[class*='tip'],[class*='callout'],.bg-blue-50,[style*='blue'],[style*='rgb(219, 234, 254)'] {",
187
+ ` background-color: ${c.surface} !important;`,
188
+ ` border-color: ${c.border} !important;`,
189
+ "}",
190
+ // Buttons inside div.title already get the on-accent colour from the base rule above — no override needed.
191
+ // Affiliate disclosure styling — disclosure border, surface background, foreground text.
192
+ "[class*='affiliate'],[class*='disclosure'],[class*='commission'],[class*='disclaimer'] {",
193
+ ` border: 1px solid ${c.disclosureBorder} !important;`,
194
+ " border-opacity: 0.4 !important;",
195
+ ` background-color: ${c.surface} !important;`,
196
+ " padding: 0.5rem 0.75rem !important;",
197
+ ` border-radius: ${o.radius.control} !important;`,
198
+ ` color: ${c.foreground} !important;`,
199
+ "}",
200
+
201
+ // ── Recommendation widget (3-card affiliate block) ──
202
+ // Cards: surface background, tinted border, generous padding, full-bleed buttons.
203
+ // Subtle border-color shift on hover signals interactivity without faking
204
+ // whole-card clickability (only the button is the action).
205
+ ".item, .item_large {",
206
+ ` background-color: ${c.surface} !important;`,
207
+ ` border: 1px solid ${c.border} !important;`,
208
+ ` border-radius: ${o.radius.card} !important;`,
209
+ " padding: 1.25rem !important;",
210
+ " box-sizing: border-box !important;",
211
+ " display: flex !important;",
212
+ " flex-direction: column !important;",
213
+ " gap: 0.625rem !important;",
214
+ " transition: border-color 0.15s ease !important;",
215
+ "}",
216
+ ".item:hover, .item_large:hover {",
217
+ ` border-color: ${c.borderHover} !important;`,
218
+ "}",
219
+ // Card title: text-lg. white-space: normal
220
+ // overrides widget's nowrap which otherwise forces column min-widths to text width,
221
+ // breaking the 3-equal-column grid.
222
+ ".item_title {",
223
+ ` font-family: ${o.fonts.heading} !important;`,
224
+ " font-size: 1.125rem !important;",
225
+ ` font-weight: ${o.cardTitleWeight} !important;`,
226
+ " line-height: 1.3 !important;",
227
+ " letter-spacing: -0.01em !important;",
228
+ ` color: ${c.foreground} !important;`,
229
+ " margin: 0 !important;",
230
+ " white-space: normal !important;",
231
+ " overflow: visible !important;",
232
+ " text-overflow: clip !important;",
233
+ "}",
234
+ // Card body: body font, comfortable reading height, muted foreground.
235
+ ".item_content {",
236
+ ` font-family: ${o.fonts.body} !important;`,
237
+ " font-size: 0.875rem !important;",
238
+ " line-height: 1.55 !important;",
239
+ ` color: ${c.mutedForeground} !important;`,
240
+ " margin: 0 !important;",
241
+ " flex: 1 !important;",
242
+ "}",
243
+ // Card CTA: full-width, pushed to card bottom via auto-margin.
244
+ // align-self: stretch overrides widget's align-self: end on .item_large_button,
245
+ // which otherwise shrinks the button to its text width and right-aligns it.
246
+ // display: flex overrides base inline-flex so width: 100% fills the full card width.
247
+ ".item_button, .item_large_button {",
248
+ " display: flex !important;",
249
+ " align-self: stretch !important;",
250
+ " box-sizing: border-box !important;",
251
+ " width: 100% !important;",
252
+ " justify-content: center !important;",
253
+ " margin-top: auto !important;",
254
+ " padding: 0.625rem 1rem !important;",
255
+ " white-space: normal !important;",
256
+ "}",
257
+ // Recommendation grid: widget fixes grid-template-columns to a single pixel
258
+ // value that never expands to 3 columns within our max-w-measure container.
259
+ // Override to a responsive 3-up grid above 600px viewport width.
260
+ "@media (min-width: 600px) {",
261
+ " .recommendation {",
262
+ " grid-template-columns: repeat(3, 1fr) !important;",
263
+ " gap: 1rem !important;",
264
+ " align-items: stretch !important;",
265
+ " }",
266
+ "}",
267
+
268
+ // ── Hotels widget (top-3 list) ──
269
+ // Widget structure: div.hotels > div.title + div.container > ul > li > a + span(stars) + span(rating)
270
+ // Title row: heading + inline "Explore all" CTA side-by-side.
271
+ // div.title (not h3) is the actual element — target .hotels .title.
272
+ ".hotels .title {",
273
+ " display: flex !important;",
274
+ " align-items: baseline !important;",
275
+ " justify-content: space-between !important;",
276
+ " gap: 1rem !important;",
277
+ " flex-wrap: wrap !important;",
278
+ ` font-family: ${o.fonts.heading} !important;`,
279
+ " font-size: clamp(1.5rem, 2.5vw + 0.5rem, 1.875rem) !important;",
280
+ ` font-weight: ${o.headingWeight} !important;`,
281
+ " line-height: 1.3 !important;",
282
+ " letter-spacing: -0.01em !important;",
283
+ ` color: ${c.foreground} !important;`,
284
+ " background-color: transparent !important;",
285
+ " background: none !important;",
286
+ " border: none !important;",
287
+ " padding: 0 0 0.5rem !important;",
288
+ " margin-bottom: 0.75rem !important;",
289
+ "}",
290
+ // Title-row CTA: compact secondary button, not full primary size.
291
+ // Desktop rule — mobile override at max-width:639.98px below.
292
+ ".hotels a.button.title-button {",
293
+ " font-size: 0.8125rem !important;",
294
+ " padding: 0.375rem 0.875rem !important;",
295
+ " align-self: center !important;",
296
+ "}",
297
+ // Hide the redundant full-width bottom CTA via CSS (more reliable than JS
298
+ // inline style, which can be overridden by widget's own stylesheet cascade).
299
+ ".hotels a.button.content-button { display: none !important; }",
300
+ // Hotels container: cards-style padding inside the surface.
301
+ ".hotels .container {",
302
+ " padding: 0.5rem 1rem !important;",
303
+ ` border-radius: ${o.radius.card} !important;`,
304
+ ` border: 1px solid ${c.border} !important;`,
305
+ "}",
306
+ // Reset default ul/li list styling inside the container.
307
+ // gap: 0 overrides widget's gap:16px which otherwise puts dead space after each border-bottom divider.
308
+ ".hotels .container ul { list-style: none !important; margin: 0 !important; padding: 0 !important; gap: 0 !important; }",
309
+ // Divider lives on li (not on the link) so it closes the full row: name + rating together.
310
+ // padding-bottom creates breathing room between the rating label and the rule.
311
+ `.hotels .container li { margin: 0 !important; padding: 0 0 0.5rem 0 !important; border-bottom: 1px solid ${c.border} !important; }`,
312
+ ".hotels .container li:last-child { border-bottom: none !important; padding-bottom: 0 !important; }",
313
+ // Each hotel link: block, comfortable reading size. No border here — divider is on li.
314
+ // Actual DOM: ul > li > a (not direct children of .container).
315
+ ".hotels .container li a:not(.button) {",
316
+ " display: block !important;",
317
+ " padding: 0.75rem 0 0 !important;",
318
+ ` font-family: ${o.fonts.body} !important;`,
319
+ " font-size: 0.9375rem !important;",
320
+ " line-height: 1.45 !important;",
321
+ "}",
322
+ // Star-glyph spans: TOFU_RE strips ★ glyphs, leaving an empty span — hide it.
323
+ // Rating-text spans: "Rating 9" etc. have no class so [class*='rating'] misses them.
324
+ // Style as a small label in the rating colour.
325
+ ".hotels .container li > span:empty { display: none !important; }",
326
+ ".hotels .container li > span:not(:empty) {",
327
+ " display: block !important;",
328
+ " font-size: 0.625rem !important;",
329
+ " font-weight: 600 !important;",
330
+ ` color: ${c.rating} !important;`,
331
+ " text-transform: uppercase !important;",
332
+ " letter-spacing: 0.06em !important;",
333
+ ` font-family: ${o.fonts.body} !important;`,
334
+ " margin-top: 0.125rem !important;",
335
+ "}",
336
+
337
+ // Respect users who opt out of motion.
338
+ "@media (prefers-reduced-motion: reduce) {",
339
+ " .button, [class*='button'], button, .item, .item_large {",
340
+ " transition: none !important;",
341
+ " }",
342
+ "}",
343
+
344
+ // ── Narrow viewports (<640px) ──
345
+ // Cards: tighter padding + smaller body text so 3-up grid doesn't suffocate
346
+ // when it collapses to a stacked single column.
347
+ // Hotels list: tighter row padding, slightly smaller link text.
348
+ // Title row: stack heading above CTA (still flex-wrap'd but with extra gap).
349
+ "@media (max-width: 639.98px) {",
350
+ " .item, .item_large {",
351
+ " padding: 1rem !important;",
352
+ " gap: 0.5rem !important;",
353
+ " }",
354
+ " .item_title { font-size: 1rem !important; }",
355
+ " .item_content { font-size: 0.8125rem !important; }",
356
+ " .hotels .container { padding: 0.25rem 0.875rem !important; }",
357
+ " .hotels .container li a:not(.button) {",
358
+ " padding: 0.625rem 0 0 !important;",
359
+ " font-size: 0.875rem !important;",
360
+ " }",
361
+ // On narrow viewports, stack the title text above the CTA instead of
362
+ // side-by-side (which causes the 3-line title to collide with the button).
363
+ // font-size intentionally omitted — clamp(1.5rem,…,1.875rem) resolves to
364
+ // its 1.5rem minimum at narrow viewports, which is already mobile-appropriate.
365
+ " .hotels .title {",
366
+ " flex-direction: column !important;",
367
+ " align-items: flex-start !important;",
368
+ " gap: 0.375rem !important;",
369
+ " }",
370
+ " .hotels a.button.title-button {",
371
+ " font-size: 0.8125rem !important;",
372
+ " padding: 0.375rem 0.75rem !important;",
373
+ " }",
374
+ "}",
375
+ ].join("\n");
376
+ }
377
+
378
+ function buildVariants(titles: TravelpayoutsStylerOptions["titles"]): Variants {
379
+ return {
380
+ highlights: {
381
+ rewriteTitle: (text) =>
382
+ /last\s*minute/i.test(text) ? titles.highlights : text,
383
+ },
384
+ recommendation: {
385
+ // Widget rotates ad copy ("Ready for <place>? These sites…!", "<place> in
386
+ // your plans? Your adventure…!"). Any title carrying both ? and ! is the
387
+ // tell — replace with editorial copy.
388
+ rewriteTitle: (text) =>
389
+ /\?/.test(text) && /!/.test(text) ? titles.recommendation : text,
390
+ },
391
+ hotels: {},
392
+ };
393
+ }
394
+
395
+ function getVariant(host: HTMLElement): string | null {
396
+ const cls = Array.from(host.classList).find((c) => c.startsWith("emerald-block__"));
397
+ return cls ? cls.replace("emerald-block__", "") : null;
398
+ }
399
+
400
+ function injectStyle(root: ShadowRoot, css: string): void {
401
+ if (root.querySelector("[data-ec]")) return;
402
+ const style = document.createElement("style");
403
+ style.setAttribute("data-ec", "1");
404
+ style.textContent = css;
405
+ root.prepend(style);
406
+ }
407
+
408
+ function rewriteTitles(root: ShadowRoot, variant: string | null, variants: Variants): void {
409
+ const config = variant ? variants[variant] : null;
410
+ root.querySelectorAll("h1.title, h2.title, h3.title, h4.title").forEach((h) => {
411
+ const original = h.textContent ?? "";
412
+ let cleaned = original.replace(TOFU_RE, "").replace(/\s+/g, " ").trim();
413
+ if (config?.rewriteTitle) cleaned = config.rewriteTitle(cleaned);
414
+ if (cleaned !== original) h.textContent = cleaned;
415
+ });
416
+ }
417
+
418
+ // Strip tofu chars from every text node. Hotel name links and rating labels
419
+ // embed star emoji that the site fonts can't render, leaving ⬜ rectangles
420
+ // — kill them at the level of each text node.
421
+ function stripTofuFromTextNodes(root: ShadowRoot): void {
422
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
423
+ const updates: Array<[Text, string]> = [];
424
+ let node: Node | null;
425
+ while ((node = walker.nextNode())) {
426
+ const text = node as Text;
427
+ const original = text.nodeValue ?? "";
428
+ if (!TOFU_RE.test(original)) continue;
429
+ // Reset lastIndex — TOFU_RE has /g flag so it's stateful across .test() calls.
430
+ TOFU_RE.lastIndex = 0;
431
+ const cleaned = original.replace(TOFU_RE, "").replace(/[ \t]+/g, " ");
432
+ if (cleaned !== original) updates.push([text, cleaned]);
433
+ }
434
+ for (const [text, cleaned] of updates) text.nodeValue = cleaned;
435
+ }
436
+
437
+ function hideElements(root: ShadowRoot, variant: string | null, variants: Variants): void {
438
+ const config = variant ? variants[variant] : null;
439
+ if (!config?.hide) return;
440
+ for (const selector of config.hide) {
441
+ root.querySelectorAll<HTMLElement>(selector).forEach((el) => {
442
+ // The injected CSS sets `.button { display: inline-flex !important }`, so plain
443
+ // inline `display: none` loses. Mark our hide as !important too.
444
+ el.style.setProperty("display", "none", "important");
445
+ });
446
+ }
447
+ }
448
+
449
+ // After this many ms, give up waiting for shadow content and release the
450
+ // CLS reservation slot so a blocked/failing widget doesn't leave a permanent
451
+ // reserved void. Late-arriving content (>10s) will still be styled when it
452
+ // appears — only the visual reservation is dropped.
453
+ const FAILURE_TIMEOUT_MS = 10_000;
454
+
455
+ // Tracks hosts with a pending failure timeout so we don't schedule duplicates.
456
+ const watchedHosts = new WeakSet<HTMLElement>();
457
+
458
+ // True iff the shadow root has any element children other than the <style>
459
+ // we injected ourselves. The widget script populates the root with the
460
+ // `.wrapper` element (and descendants) once data arrives — until then, the
461
+ // only child is our [data-ec] style tag.
462
+ function hasRealContent(root: ShadowRoot): boolean {
463
+ for (const child of Array.from(root.children)) {
464
+ const isOurStyle =
465
+ child.tagName === "STYLE" &&
466
+ (child as HTMLElement).dataset.ec === "1";
467
+ if (!isOurStyle) return true;
468
+ }
469
+ return false;
470
+ }
471
+
472
+ function applyContentTransforms(root: ShadowRoot, variant: string | null, variants: Variants): void {
473
+ rewriteTitles(root, variant, variants);
474
+ stripTofuFromTextNodes(root);
475
+ hideElements(root, variant, variants);
476
+ }
477
+
478
+ // Release the site's CLS reservation on the host (keyed on data-loaded). Done either when the
479
+ // widget has real content (normal path) or when the failure timeout fires.
480
+ function markLoaded(host: HTMLElement): void {
481
+ (host as HTMLElement).dataset.loaded = "true";
482
+ }
483
+
484
+ function processHost(host: HTMLElement, css: string, variants: Variants): void {
485
+ const root = host.shadowRoot;
486
+ if (!root) return;
487
+ const variant = getVariant(host);
488
+ injectStyle(root, css);
489
+
490
+ if (hasRealContent(root)) {
491
+ applyContentTransforms(root, variant, variants);
492
+ markLoaded(host);
493
+ return;
494
+ }
495
+
496
+ // Shadow root exists but the widget hasn't injected its content yet.
497
+ // Two race conditions to handle:
498
+ // 1. Content arrives inside shadow DOM later. The light-DOM
499
+ // MutationObserver in setupTravelpayoutsStyler() can't see this
500
+ // (shadow boundaries hide it), so attach a dedicated observer to
501
+ // THIS shadow root that fires when the widget populates.
502
+ // 2. Content never arrives (script blocked, adblock, error). After
503
+ // FAILURE_TIMEOUT_MS, release the CLS slot anyway so users don't
504
+ // stare at a permanent reserved void blocking the page below.
505
+ if (watchedHosts.has(host)) return;
506
+ watchedHosts.add(host);
507
+
508
+ const shadowObserver = new MutationObserver(() => {
509
+ if (host.dataset.loaded === "true") return;
510
+ if (!hasRealContent(root)) return;
511
+ applyContentTransforms(root, getVariant(host), variants);
512
+ markLoaded(host);
513
+ shadowObserver.disconnect();
514
+ });
515
+ shadowObserver.observe(root, { childList: true, subtree: true });
516
+
517
+ window.setTimeout(() => {
518
+ if (host.dataset.loaded === "true") return;
519
+ if (hasRealContent(root)) applyContentTransforms(root, getVariant(host), variants);
520
+ markLoaded(host);
521
+ shadowObserver.disconnect();
522
+ }, FAILURE_TIMEOUT_MS);
523
+ }
524
+
525
+ let installed = false;
526
+
527
+ export function setupTravelpayoutsStyler(options: TravelpayoutsStylerOptions): void {
528
+ if (installed) return;
529
+ installed = true;
530
+ const css = buildCss(options);
531
+ const variants = buildVariants(options.titles);
532
+ const process = (host: HTMLElement) => processHost(host, css, variants);
533
+ const scanAll = () => document.querySelectorAll<HTMLElement>(".emerald-block").forEach(process);
534
+
535
+ // The TravelPayouts script mounts widgets asynchronously and re-renders
536
+ // their internals on data fetch. We watch the document tree for new hosts
537
+ // AND poll for the first 5 seconds to catch hosts whose shadow root mounts
538
+ // after the host element itself was added.
539
+ scanAll();
540
+
541
+ let ticks = 0;
542
+ const interval = window.setInterval(() => {
543
+ scanAll();
544
+ if (++ticks >= 20) window.clearInterval(interval);
545
+ }, 250);
546
+
547
+ new MutationObserver((mutations) => {
548
+ for (const mutation of mutations) {
549
+ mutation.addedNodes.forEach((node) => {
550
+ if (node.nodeType !== 1) return;
551
+ const el = node as HTMLElement;
552
+ if (el.classList?.contains("emerald-block")) process(el);
553
+ el.querySelectorAll?.<HTMLElement>(".emerald-block").forEach(process);
554
+ });
555
+ }
556
+ }).observe(document.documentElement, { childList: true, subtree: true });
557
+ }
@@ -0,0 +1,121 @@
1
+ ---
2
+ /**
3
+ * Google Tag Manager behind Consent Mode v2, with the Silktide consent manager. Production only. Deferred to the first
4
+ * interaction, or to idle after load (8 s timeout keeps GTM outside the Lighthouse TBT window). Inline on purpose:
5
+ * the consent default must be pushed before the gtm.js event in one synchronous step, which a deferred module cannot
6
+ * guarantee. Defines `window.openCookiePreferences`, which the footer's cookie-preferences button calls.
7
+ * The `<noscript>` iframe goes at the start of `<body>`: render with `part="noscript"` there and `part="script"` at
8
+ * the end of `<body>`.
9
+ */
10
+ import { GTM_ORIGIN, SILKTIDE_BASE } from './resources.ts';
11
+
12
+ export interface ConsentType {
13
+ id: string;
14
+ label: string;
15
+ description: string;
16
+ required?: boolean;
17
+ defaultValue?: boolean;
18
+ /** The Consent Mode v2 storage key(s) this category grants. */
19
+ gtag?: string | string[];
20
+ }
21
+
22
+ interface Props {
23
+ gtmId: string;
24
+ part: 'noscript' | 'script';
25
+ /** The banner's prompt text. */
26
+ prompt?: string;
27
+ consentTypes?: ConsentType[];
28
+ }
29
+
30
+ const { gtmId, part, prompt = '', consentTypes = [] } = Astro.props;
31
+ const isProd = import.meta.env.PROD;
32
+ const gtmOrigin = GTM_ORIGIN;
33
+ const silktideScript = `${SILKTIDE_BASE}/silktide-consent-manager.js`;
34
+ const silktideConfig = { text: { prompt: { description: prompt } }, consentTypes };
35
+ ---
36
+
37
+ {isProd && part === 'noscript' && (
38
+ <noscript set:html={`<iframe src="${gtmOrigin}/ns.html?id=${gtmId}" height="0" width="0" style="display:none;visibility:hidden"></iframe>`} />
39
+ )}
40
+ {isProd && part === 'script' && (
41
+ <script is:inline define:vars={{ gtmId, gtmOrigin, silktideScript, silktideConfig }}>
42
+ (function () {
43
+ let thirdPartyLoaded = false;
44
+
45
+ function loadThirdPartyScripts() {
46
+ if (thirdPartyLoaded) return;
47
+ thirdPartyLoaded = true;
48
+
49
+ window.dataLayer = window.dataLayer || [];
50
+
51
+ // GTM treats a dataLayer entry as a consent command only when it is an `arguments` object; a plain array is
52
+ // ignored, which leaves analytics_storage unset and blocks tags with an additional consent check.
53
+ function gtag() { window.dataLayer.push(arguments); }
54
+ gtag('consent', 'default', {
55
+ ad_storage: 'denied',
56
+ ad_user_data: 'denied',
57
+ ad_personalization: 'denied',
58
+ analytics_storage: 'granted',
59
+ });
60
+
61
+ window.dataLayer.push({
62
+ 'gtm.start': new Date().getTime(),
63
+ event: 'gtm.js',
64
+ });
65
+
66
+ // On the main thread: Partytown race-dropped the initial page_view (SLA-2661).
67
+ var gtmScript = document.createElement('script');
68
+ gtmScript.async = true;
69
+ gtmScript.src = gtmOrigin + '/gtm.js?id=' + gtmId;
70
+ document.head.appendChild(gtmScript);
71
+
72
+ var consentScript = document.createElement('script');
73
+ consentScript.src = silktideScript;
74
+ consentScript.async = true;
75
+ consentScript.onload = function () {
76
+ if (!window.silktideConsentManager) return;
77
+ window.silktideConsentManager.init(silktideConfig);
78
+ };
79
+ document.head.appendChild(consentScript);
80
+ }
81
+
82
+ var evtOpts = { once: true, passive: true, capture: true };
83
+ ['pointerdown', 'keydown', 'touchstart', 'scroll'].forEach(function (evt) {
84
+ window.addEventListener(evt, loadThirdPartyScripts, evtOpts);
85
+ });
86
+
87
+ function scheduleThirdPartyScripts() {
88
+ if ('requestIdleCallback' in window) {
89
+ window.requestIdleCallback(loadThirdPartyScripts, { timeout: 8000 });
90
+ } else {
91
+ setTimeout(loadThirdPartyScripts, 6000);
92
+ }
93
+ }
94
+
95
+ if (document.readyState === 'complete') {
96
+ scheduleThirdPartyScripts();
97
+ } else {
98
+ window.addEventListener('load', scheduleThirdPartyScripts, { once: true });
99
+ }
100
+
101
+ // Silktide is lazy-loaded: if this click is the first interaction, load it and poll briefly (about 4 s).
102
+ window.openCookiePreferences = function () {
103
+ if (window.silktideConsentManager) {
104
+ window.silktideConsentManager.getInstance().toggleModal(true);
105
+ return;
106
+ }
107
+ loadThirdPartyScripts();
108
+ var attempts = 0;
109
+ var poll = setInterval(function () {
110
+ attempts++;
111
+ if (window.silktideConsentManager) {
112
+ clearInterval(poll);
113
+ window.silktideConsentManager.getInstance().toggleModal(true);
114
+ } else if (attempts > 40) {
115
+ clearInterval(poll);
116
+ }
117
+ }, 100);
118
+ };
119
+ })();
120
+ </script>
121
+ )}
@@ -0,0 +1,171 @@
1
+ ---
2
+ /**
3
+ * The HTML document every site page renders into: head metadata (description, robots, canonical, Open Graph, article,
4
+ * Twitter, title), RSS and favicons, non-critical stylesheets, consent-gated analytics, the Travelpayouts Emerald
5
+ * loader, the skip link and the landmarks (navigation and footer wrapped out of the Pagefind index, `#main-content`).
6
+ * The site's thin Layout supplies identity, theme, fonts, chrome and policy; it keeps its own stylesheet import and
7
+ * its single `<script>` so bundle names stay the site's.
8
+ *
9
+ * Slots: `head-start` (after robots: preconnects), `fonts` (font-face block and preloads), `head` (page additions,
10
+ * JSON-LD), `body-start` (decorative layers), `nav`, `footer`, default (page content).
11
+ */
12
+ import { normalizePagePath, normalizeSiteUrl, toAbsoluteSiteUrl } from '@sarimarcus/content-sites-core/url';
13
+ import { OgImageMeta } from '../seo/index.ts';
14
+ import EmeraldLoader from '../affiliate/EmeraldLoader.astro';
15
+ import AsyncStylesheet from './AsyncStylesheet.astro';
16
+ import ConsentAnalytics, { type ConsentType } from './ConsentAnalytics.astro';
17
+ import { SILKTIDE_STYLESHEET } from './resources.ts';
18
+
19
+ interface Props {
20
+ title: string;
21
+ description: string;
22
+ siteName: string;
23
+ /** Absolute origin, e.g. `https://www.example.com`. */
24
+ siteUrl: string;
25
+ canonicalUrl?: string;
26
+ ogImage?: string;
27
+ ogImageAlt?: string;
28
+ ogType?: string;
29
+ ogLocale?: string;
30
+ articleAuthor?: string;
31
+ /** Absolute URL of the author's profile page, resolved by the site. */
32
+ articleAuthorUrl?: string;
33
+ articlePublishedTime?: string;
34
+ articleModifiedTime?: string;
35
+ articleSection?: string;
36
+ noindex?: boolean;
37
+ preloadImage?: string;
38
+ twitterHandle?: string;
39
+ themeColor: string;
40
+ lang?: string;
41
+ rssTitle?: string;
42
+ /** Non-critical stylesheet URLs (`?url` imports), loaded after the title in this order. */
43
+ stylesheets?: string[];
44
+ gtmId: string;
45
+ consentPrompt: string;
46
+ consentTypes: ConsentType[];
47
+ /** Travelpayouts id for the Emerald loader; omit to leave it out. */
48
+ travelpayoutsTrs?: string;
49
+ skipLinkLabel: string;
50
+ mainClass?: string;
51
+ footerClass?: string;
52
+ }
53
+
54
+ const {
55
+ title,
56
+ description,
57
+ siteName,
58
+ siteUrl,
59
+ canonicalUrl: providedCanonicalUrl,
60
+ ogImage = '/og-default.jpg',
61
+ ogImageAlt,
62
+ ogType,
63
+ ogLocale = 'en',
64
+ articleAuthor,
65
+ articleAuthorUrl,
66
+ articlePublishedTime,
67
+ articleModifiedTime,
68
+ articleSection,
69
+ noindex = false,
70
+ preloadImage,
71
+ twitterHandle,
72
+ themeColor,
73
+ lang = 'en',
74
+ rssTitle = siteName,
75
+ stylesheets = [],
76
+ gtmId,
77
+ consentPrompt,
78
+ consentTypes,
79
+ travelpayoutsTrs,
80
+ skipLinkLabel,
81
+ mainClass,
82
+ footerClass,
83
+ } = Astro.props;
84
+
85
+ const baseUrl = import.meta.env.BASE_URL.replace(/\/$/, '');
86
+ const canonicalUrl = providedCanonicalUrl
87
+ ? normalizeSiteUrl(providedCanonicalUrl, siteUrl, baseUrl)
88
+ : toAbsoluteSiteUrl(normalizePagePath(Astro.url.pathname, baseUrl), siteUrl, baseUrl);
89
+ const imageAlt = ogImageAlt ?? `${title} - ${siteName}`;
90
+
91
+ function normalizeArticleTime(dateStr: string | undefined): string | undefined {
92
+ if (!dateStr) return undefined;
93
+ if (dateStr.includes('T')) return dateStr;
94
+ const date = new Date(`${dateStr}T00:00:00Z`);
95
+ return Number.isNaN(date.getTime()) ? undefined : date.toISOString();
96
+ }
97
+
98
+ const publishedTime = normalizeArticleTime(articlePublishedTime);
99
+ const modifiedTime = normalizeArticleTime(articleModifiedTime);
100
+ const isArticleMeta = Boolean(articleAuthorUrl || publishedTime || modifiedTime || articleSection);
101
+ ---
102
+
103
+ <!doctype html>
104
+ <html lang={lang}>
105
+ <head>
106
+ <meta charset="UTF-8" />
107
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
108
+ <meta name="theme-color" content={themeColor} />
109
+ <meta name="description" content={description} />
110
+ <meta name="robots" content={noindex ? 'noindex, follow' : 'index, follow, max-snippet:-1, max-image-preview:large, max-video-preview:-1'} />
111
+ <slot name="head-start" />
112
+
113
+ <slot name="fonts" />
114
+
115
+ <link rel="canonical" href={canonicalUrl} />
116
+ <link rel="alternate" type="application/rss+xml" title={rssTitle} href={`${baseUrl}/rss.xml`} />
117
+ <link rel="icon" type="image/svg+xml" href={`${baseUrl}/favicon.svg`} />
118
+ <link rel="icon" type="image/x-icon" href={`${baseUrl}/favicon.ico`} />
119
+
120
+ <AsyncStylesheet href={SILKTIDE_STYLESHEET} />
121
+
122
+ {preloadImage && <link rel="preload" as="image" href={preloadImage} fetchpriority="high" />}
123
+
124
+ <meta property="og:type" content={ogType ?? (isArticleMeta ? 'article' : 'website')} />
125
+ <meta property="og:url" content={canonicalUrl} />
126
+ <meta property="og:title" content={title} />
127
+ <meta property="og:description" content={description} />
128
+ <OgImageMeta image={ogImage} alt={imageAlt} siteUrl={siteUrl} baseUrl={baseUrl} />
129
+ <meta property="og:site_name" content={siteName} />
130
+ <meta property="og:locale" content={ogLocale} />
131
+ {articleAuthor && <meta name="author" content={articleAuthor} />}
132
+ {articleAuthorUrl && <meta property="article:author" content={articleAuthorUrl} />}
133
+ {publishedTime && <meta property="article:published_time" content={publishedTime} />}
134
+ {modifiedTime && <meta property="article:modified_time" content={modifiedTime} />}
135
+ {articleSection && <meta property="article:section" content={articleSection} />}
136
+
137
+ <meta name="twitter:card" content="summary_large_image" />
138
+ {twitterHandle && <meta name="twitter:site" content={twitterHandle} />}
139
+ {twitterHandle && <meta name="twitter:creator" content={twitterHandle} />}
140
+ <meta name="twitter:title" content={title} />
141
+ <meta name="twitter:description" content={description} />
142
+
143
+ <title>{title}</title>
144
+
145
+ {stylesheets.map((href) => <AsyncStylesheet href={href} />)}
146
+
147
+ {travelpayoutsTrs && <EmeraldLoader trs={travelpayoutsTrs} />}
148
+
149
+ <slot name="head" />
150
+ </head>
151
+ <body class="min-h-screen bg-background flex flex-col">
152
+ <ConsentAnalytics gtmId={gtmId} part="noscript" />
153
+ <slot name="body-start" />
154
+ <a
155
+ href="#main-content"
156
+ class="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 focus:z-[100] focus:px-4 focus:py-2 focus:bg-primary focus:text-primary-foreground focus:rounded-lg focus:outline-none focus:ring-2 focus:ring-accent"
157
+ >
158
+ {skipLinkLabel}
159
+ </a>
160
+
161
+ {Astro.slots.has('nav') && <div data-pagefind-ignore><slot name="nav" /></div>}
162
+
163
+ <div id="main-content" class:list={[mainClass, 'flex-1']}>
164
+ <slot />
165
+ </div>
166
+
167
+ {Astro.slots.has('footer') && <div data-pagefind-ignore class={footerClass}><slot name="footer" /></div>}
168
+
169
+ <ConsentAnalytics gtmId={gtmId} part="script" prompt={consentPrompt} consentTypes={consentTypes} />
170
+ </body>
171
+ </html>
@@ -0,0 +1,151 @@
1
+ // Pushes custom events to the GTM dataLayer.
2
+ // GTM consent mode (Silktide analytics_storage) governs whether GA4 receives each event.
3
+
4
+ declare global {
5
+ interface Window {
6
+ dataLayer: Record<string, unknown>[];
7
+ silktideConsentManager?: { hasConsent(id: string): boolean };
8
+ track?: (eventName: string, params?: Record<string, unknown>) => void;
9
+ }
10
+ }
11
+
12
+ function hasAnalyticsConsent(): boolean {
13
+ const mgr = window.silktideConsentManager;
14
+ if (!mgr) return true; // not yet loaded — GTM consent mode enforces at send time
15
+ return typeof mgr.hasConsent === 'function' ? mgr.hasConsent('analytics') : true;
16
+ }
17
+
18
+ export function track(eventName: string, params: Record<string, unknown> = {}): void {
19
+ if (!hasAnalyticsConsent()) return;
20
+ window.dataLayer = window.dataLayer || [];
21
+ window.dataLayer.push({ event: eventName, page_path: window.location.pathname, ...params });
22
+ }
23
+
24
+ /**
25
+ * Page type: the first path segment, 'home' at the root.
26
+ *
27
+ * The single definition behind both the GA4 `page_type` param (runtime pathname, '/plan/x') and a site's GYG `cmp`
28
+ * segment (BUILD-time `Astro.url.pathname`, '/index.html' under build.format 'file'). The `.html`/`index`
29
+ * normalisation is what makes the two agree.
30
+ */
31
+ export function pageTypeOf(pathname: string): string {
32
+ const first = pathname.split('/').filter(Boolean)[0];
33
+ if (!first) return 'home';
34
+ const bare = first.replace(/\.html$/, '');
35
+ return bare === 'index' || bare === '' ? 'home' : bare;
36
+ }
37
+
38
+ // Param key derived from the data-track-* attribute name where the
39
+ // event-specific prefix (e.g. 'cta_', 'affiliate_', 'link_') has been stripped.
40
+ const HREF_DESTINATION_KEYS: Record<string, string> = {
41
+ outbound_click: 'link_destination',
42
+ cta_click: 'cta_destination',
43
+ internal_link_click: 'link_destination',
44
+ };
45
+
46
+ // Reads data-track-* attributes from the clicked element and fires track().
47
+ // Handles affiliate_click, cta_click, outbound_click, internal_link_click.
48
+ export function setupDelegatedTracking(): void {
49
+ // MPA — pathname is stable for the page's lifetime; compute once.
50
+ const pageType = pageTypeOf(window.location.pathname);
51
+
52
+ document.addEventListener('click', (e: MouseEvent) => {
53
+ const target = e.target;
54
+ if (!(target instanceof Element)) return;
55
+ const el = target.closest<HTMLElement>('[data-track-event]');
56
+ if (!el) return;
57
+ const eventName = el.dataset.trackEvent;
58
+ if (!eventName) return;
59
+
60
+ const params: Record<string, unknown> = { page_type: pageType };
61
+
62
+ for (const key of Object.keys(el.dataset)) {
63
+ if (key === 'trackEvent' || !key.startsWith('track')) continue;
64
+ const param = key
65
+ .slice('track'.length)
66
+ .replace(/([A-Z])/g, '_$1')
67
+ .toLowerCase()
68
+ .replace(/^_/, '');
69
+ params[param] = el.dataset[key];
70
+ }
71
+
72
+ // Fall back to href for the destination param when the markup didn't set it
73
+ // — saves repeating the URL on every CTA/link.
74
+ const destKey = HREF_DESTINATION_KEYS[eventName];
75
+ if (destKey && !params[destKey] && el instanceof HTMLAnchorElement) {
76
+ params[destKey] = el.href;
77
+ }
78
+
79
+ track(eventName, params);
80
+ });
81
+ }
82
+
83
+ // Fires faq_expand on <details data-faq-track> open. Uses capture because toggle doesn't bubble.
84
+ export function setupFaqTracking(): void {
85
+ document.addEventListener(
86
+ 'toggle',
87
+ (e: Event) => {
88
+ const details = e.target as HTMLDetailsElement;
89
+ if (!details.hasAttribute('data-faq-track') || !details.open) return;
90
+ const question = details.querySelector('summary h3')?.textContent?.trim() ?? '';
91
+ track('faq_expand', { question });
92
+ },
93
+ true,
94
+ );
95
+ }
96
+
97
+ // Fires scroll_depth at 25/50/75/100% — deduped per page view, removed after 100%.
98
+ // Reads layout-triggering geometry (scrollHeight) inside a rAF-throttled tick
99
+ // so rapid scroll events coalesce to one measurement per frame.
100
+ export function setupScrollDepthTracking(): void {
101
+ const thresholds = [25, 50, 75, 100];
102
+ const fired = new Set<number>();
103
+ let frameQueued = false;
104
+
105
+ function measure() {
106
+ frameQueued = false;
107
+ const total = document.documentElement.scrollHeight;
108
+ if (total <= window.innerHeight) return;
109
+ const pct = Math.round(((window.scrollY + window.innerHeight) / total) * 100);
110
+ for (const d of thresholds) {
111
+ if (pct >= d && !fired.has(d)) {
112
+ fired.add(d);
113
+ track('scroll_depth', { percent: d });
114
+ }
115
+ }
116
+ if (fired.size === thresholds.length) {
117
+ window.removeEventListener('scroll', onScroll);
118
+ }
119
+ }
120
+
121
+ function onScroll() {
122
+ if (frameQueued) return;
123
+ frameQueued = true;
124
+ requestAnimationFrame(measure);
125
+ }
126
+
127
+ window.addEventListener('scroll', onScroll, { passive: true });
128
+ }
129
+
130
+ export interface AnalyticsOptions {
131
+ /** Pages that get scroll-depth tracking, tested against `location.pathname`. Omitted or false: none; true: every page. */
132
+ scrollDepth?: boolean | RegExp | ((pathname: string) => boolean);
133
+ /** Track `<details data-faq-track>` opens. */
134
+ faq?: boolean;
135
+ }
136
+
137
+ let installed = false;
138
+
139
+ /** Sets `window.track` and binds delegated click, FAQ and scroll-depth tracking. A second call is a no-op. */
140
+ export function setupAnalytics(options: AnalyticsOptions = {}): void {
141
+ if (installed) return;
142
+ installed = true;
143
+ window.track = track;
144
+ setupDelegatedTracking();
145
+ if (options.faq) setupFaqTracking();
146
+ const { scrollDepth } = options;
147
+ const path = window.location.pathname;
148
+ const wantsScroll =
149
+ scrollDepth instanceof RegExp ? scrollDepth.test(path) : typeof scrollDepth === 'function' ? scrollDepth(path) : scrollDepth === true;
150
+ if (wantsScroll) setupScrollDepthTracking();
151
+ }
@@ -2,3 +2,9 @@
2
2
  export { default as Container } from './Container.astro';
3
3
  export { default as BreadcrumbBand } from './BreadcrumbBand.astro';
4
4
  export { default as AsyncStylesheet } from './AsyncStylesheet.astro';
5
+ export { default as DocumentShell } from './DocumentShell.astro';
6
+ export { default as ConsentAnalytics } from './ConsentAnalytics.astro';
7
+ export type { ConsentType } from './ConsentAnalytics.astro';
8
+ export { shellResources, GTM_ORIGIN, SILKTIDE_BASE, SILKTIDE_STYLESHEET } from './resources.ts';
9
+ export { track, pageTypeOf, setupDelegatedTracking, setupFaqTracking, setupScrollDepthTracking, setupAnalytics } from './analytics.ts';
10
+ export type { AnalyticsOptions } from './analytics.ts';
@@ -0,0 +1,19 @@
1
+ // The third-party hosts the document shell loads, per CSP directive. Every site renders the shell, so every site's
2
+ // Content-Security-Policy must allow these (scripts/validate-csp-coverage.mjs checks each site's _headers against this
3
+ // list). Plain data and erasable types only: the gate imports this file under Node.
4
+
5
+ import type { CspDirective } from '../affiliate/resources.ts';
6
+
7
+ /** Google Tag Manager: gtm.js and, for visitors without JavaScript, the ns.html iframe. */
8
+ export const GTM_ORIGIN = 'https://www.googletagmanager.com';
9
+ /** Silktide Consent Manager, pinned. */
10
+ export const SILKTIDE_BASE = 'https://cdn.jsdelivr.net/gh/silktide/consent-manager@v2.0.0';
11
+ /** The consent manager's stylesheet; the CSP gate recognises a shell page by it. */
12
+ export const SILKTIDE_STYLESHEET = `${SILKTIDE_BASE}/silktide-consent-manager.css`;
13
+ const SILKTIDE_ORIGIN = new URL(SILKTIDE_BASE).origin;
14
+
15
+ export const shellResources: Partial<Record<CspDirective, readonly string[]>> = {
16
+ 'script-src': [GTM_ORIGIN, SILKTIDE_ORIGIN],
17
+ 'style-src': [SILKTIDE_ORIGIN],
18
+ 'frame-src': [GTM_ORIGIN],
19
+ };