@sarimarcus/content-sites-ui 0.16.0 → 0.18.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 +14 -0
- package/README.md +55 -2
- package/package.json +7 -3
- package/src/affiliate/EmeraldLoader.astro +40 -0
- package/src/affiliate/index.ts +4 -1
- package/src/affiliate/resources.ts +7 -0
- package/src/affiliate/travelpayoutsStyler.ts +557 -0
- package/src/composed/ArrowLinkGrid.astro +36 -0
- package/src/composed/index.ts +1 -0
- package/src/editorial/ArticleFeatureCard.astro +77 -0
- package/src/editorial/ArticlesListingPage.astro +40 -0
- package/src/editorial/NotFoundPage.astro +98 -0
- package/src/editorial/StaticProsePage.astro +48 -0
- package/src/editorial/index.ts +4 -0
- package/src/layout/ConsentAnalytics.astro +121 -0
- package/src/layout/DocumentShell.astro +171 -0
- package/src/layout/ListingShell.astro +32 -0
- package/src/layout/analytics.ts +151 -0
- package/src/layout/index.ts +7 -0
- package/src/layout/resources.ts +19 -0
- package/src/search/SearchPage.astro +222 -0
- package/src/search/index.ts +2 -0
- package/src/search/searchPage.ts +591 -0
- package/src/styles/not-found.css +151 -0
|
@@ -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,36 @@
|
|
|
1
|
+
---
|
|
2
|
+
import Icon from '../primitives/Icon.astro';
|
|
3
|
+
|
|
4
|
+
/** ArrowLinkGrid — a grid of bordered link tiles (title, short subtitle, trailing arrow) tinted on hover. */
|
|
5
|
+
interface Item {
|
|
6
|
+
href: string;
|
|
7
|
+
title: string;
|
|
8
|
+
subtitle: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface Props {
|
|
12
|
+
items: Item[];
|
|
13
|
+
tone?: 'primary' | 'secondary';
|
|
14
|
+
columns?: 3 | 4;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const { items, tone = 'primary', columns = 4 } = Astro.props;
|
|
18
|
+
const TONES = {
|
|
19
|
+
primary: { border: 'hover:border-primary/50', text: 'group-hover:text-primary' },
|
|
20
|
+
secondary: { border: 'hover:border-secondary/50', text: 'group-hover:text-secondary' },
|
|
21
|
+
} as const;
|
|
22
|
+
const GRID = { 3: 'grid sm:grid-cols-2 lg:grid-cols-3 gap-3', 4: 'grid sm:grid-cols-2 lg:grid-cols-4 gap-3' } as const;
|
|
23
|
+
const t = TONES[tone];
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
<div class={GRID[columns]}>
|
|
27
|
+
{items.map((item) => (
|
|
28
|
+
<a href={item.href} class={`group flex items-center justify-between gap-3 px-4 py-3 rounded-lg border border-border ${t.border} bg-card transition-all`}>
|
|
29
|
+
<div>
|
|
30
|
+
<p class={`text-sm font-medium text-foreground ${t.text} transition-colors`}>{item.title}</p>
|
|
31
|
+
<p class="text-xs text-muted-foreground mt-0.5">{item.subtitle}</p>
|
|
32
|
+
</div>
|
|
33
|
+
<Icon name="arrow-right" class={`w-4 h-4 text-muted-foreground shrink-0 ${t.text} group-hover:translate-x-0.5 transition-all`} />
|
|
34
|
+
</a>
|
|
35
|
+
))}
|
|
36
|
+
</div>
|
package/src/composed/index.ts
CHANGED
|
@@ -6,3 +6,4 @@ export { default as FAQ } from './FAQ.astro';
|
|
|
6
6
|
export { default as InlineImage } from './InlineImage.astro';
|
|
7
7
|
export { default as Pagination } from './Pagination.astro';
|
|
8
8
|
export { default as EntityCard } from './EntityCard.astro';
|
|
9
|
+
export { default as ArrowLinkGrid } from './ArrowLinkGrid.astro';
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* ArticleFeatureCard — the wide lead card above an article grid: image left, label, title, byline with the
|
|
4
|
+
* published or "Updated" date, description and a read link. Dates arrive formatted; the image is the route's import.
|
|
5
|
+
*/
|
|
6
|
+
import { Picture } from 'astro:assets';
|
|
7
|
+
import type { ImageMetadata } from 'astro';
|
|
8
|
+
import Badge from '../primitives/Badge.astro';
|
|
9
|
+
import Icon from '../primitives/Icon.astro';
|
|
10
|
+
|
|
11
|
+
interface Props {
|
|
12
|
+
href: string;
|
|
13
|
+
title: string;
|
|
14
|
+
description: string;
|
|
15
|
+
author: string;
|
|
16
|
+
/** "Featured" or "Latest". */
|
|
17
|
+
label: string;
|
|
18
|
+
image?: ImageMetadata;
|
|
19
|
+
imageAlt?: string;
|
|
20
|
+
/** The date shown: the modified one when `updated`, else the published one. */
|
|
21
|
+
dateTime: string;
|
|
22
|
+
dateLabel: string;
|
|
23
|
+
updated?: boolean;
|
|
24
|
+
readLabel?: string;
|
|
25
|
+
updatedLabel?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const { href, title, description, author, label, image, imageAlt = '', dateTime, dateLabel, updated = false, readLabel = 'Read article', updatedLabel = 'Updated' } = Astro.props;
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
<a href={href} class="group block mb-12 rounded-xl border border-border bg-card overflow-hidden hover:border-primary transition-colors focus:outline-none focus:ring-2 focus:ring-primary/50">
|
|
32
|
+
<div class="grid sm:grid-cols-[1.2fr_1fr] gap-0">
|
|
33
|
+
{image ? (
|
|
34
|
+
<div class="aspect-[4/3] sm:aspect-auto overflow-hidden">
|
|
35
|
+
<Picture
|
|
36
|
+
src={image}
|
|
37
|
+
alt={imageAlt}
|
|
38
|
+
widths={[480, 768, 1024]}
|
|
39
|
+
sizes="(max-width: 640px) 100vw, 50vw"
|
|
40
|
+
formats={['avif', 'webp']}
|
|
41
|
+
fallbackFormat="webp"
|
|
42
|
+
class="w-full h-full object-cover motion-safe:transition-transform motion-safe:duration-500 motion-safe:group-hover:scale-105"
|
|
43
|
+
loading="eager"
|
|
44
|
+
fetchpriority="high"
|
|
45
|
+
/>
|
|
46
|
+
</div>
|
|
47
|
+
) : (
|
|
48
|
+
<div class="aspect-[4/3] sm:aspect-auto bg-muted/40"></div>
|
|
49
|
+
)}
|
|
50
|
+
<div class="p-6 sm:p-8 flex flex-col justify-center">
|
|
51
|
+
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-primary mb-2">{label}</p>
|
|
52
|
+
<h2 class="text-xl sm:text-2xl font-semibold font-display text-foreground leading-snug group-hover:text-primary transition-colors text-balance">
|
|
53
|
+
{title}
|
|
54
|
+
</h2>
|
|
55
|
+
<p class="mt-2 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground">
|
|
56
|
+
<span>{author}</span>
|
|
57
|
+
<span aria-hidden="true">·</span>
|
|
58
|
+
{updated ? (
|
|
59
|
+
<Badge tone="custom" toneClass="bg-muted" size="sm" lines={0} class="![font-size:inherit] ![line-height:inherit] ![font-weight:inherit] !gap-1">
|
|
60
|
+
<svg class="w-3 h-3 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
61
|
+
<polyline points="23 4 23 10 17 10" />
|
|
62
|
+
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10" />
|
|
63
|
+
</svg>
|
|
64
|
+
{updatedLabel}{' '}<time datetime={dateTime}>{dateLabel}</time>
|
|
65
|
+
</Badge>
|
|
66
|
+
) : (
|
|
67
|
+
<time datetime={dateTime}>{dateLabel}</time>
|
|
68
|
+
)}
|
|
69
|
+
</p>
|
|
70
|
+
<p class="mt-3 text-sm text-muted-foreground leading-relaxed line-clamp-3">{description}</p>
|
|
71
|
+
<span class="inline-flex items-center gap-1 mt-5 text-sm font-medium text-primary">
|
|
72
|
+
{readLabel}
|
|
73
|
+
<Icon name="arrow-right" class="w-3.5 h-3.5 motion-safe:transition-transform motion-safe:group-hover:translate-x-0.5" />
|
|
74
|
+
</span>
|
|
75
|
+
</div>
|
|
76
|
+
</div>
|
|
77
|
+
</a>
|