@sarimarcus/content-sites-ui 0.20.0 → 0.22.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 +8 -0
- package/package.json +2 -2
- package/src/affiliate/AffiliateCard.astro +3 -2
- package/src/affiliate/cardItem.ts +42 -0
- package/src/affiliate/index.ts +3 -1
- package/src/affiliate/resources.ts +13 -0
- package/src/affiliate/widgetSrc.ts +7 -0
- package/src/composed/EntityCard.astro +3 -2
- package/src/composed/InlineImage.astro +6 -10
- package/src/composed/RelatedContentCard.astro +3 -2
- package/src/editorial/ArticleCard.astro +3 -2
- package/src/editorial/ArticleFeatureCard.astro +3 -2
- package/src/editorial/interludes/RelatedCard.astro +3 -2
- package/src/layout/analytics.ts +2 -13
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
All packages in the platform release in lockstep; entries are per release version.
|
|
4
4
|
|
|
5
|
+
## 0.22.0 (2026-10-04)
|
|
6
|
+
|
|
7
|
+
- `./affiliate`: `widgetLoaders` in `resources.ts` declares each Travelpayouts widget kind's loader URL, promo id and campaign id (the CSP coverage gate reads the promo ids from it); `widgetSrc(account, options)` builds a widget loader URL from it; `gygCardItem` and `gygCardLocale` turn a cached GYG activity into an `AffiliateCardItem`. `pageTypeOf` is re-exported from core, unchanged (SLA-2919).
|
|
8
|
+
|
|
9
|
+
## 0.21.0 (2026-10-02)
|
|
10
|
+
|
|
11
|
+
- Responsive images pass core's `IMAGE_LADDER` instead of per-component width arrays, and serve WebP only: `InlineImage` and `EntityCard` default `formats` to `['webp']` (SLA-2912).
|
|
12
|
+
|
|
5
13
|
## 0.20.0 (2026-10-02)
|
|
6
14
|
|
|
7
15
|
- `./editorial` article interludes: `Interlude` dispatcher (site `renderInline`, `labels`, `didYouKnowVariant`; entity interludes left to the site), `AnswerBox`, `ComparisonTable`, `EvidenceContext`, `FieldNote` (`authorHref`, `class`), `Quote`, `StatFact`, `Timeline`, `Tldr`, `DidYouKnow` (`ruled` / `inline`, `labelClass`) and `RelatedCard` (`inline` / `featured`, `eyebrowClass`). Every visible and aria string is a required prop; HTML props are `TrustedHtml`. Types `InterludeData`, `InterludeLabels`, `RenderInline` (SLA-2740).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sarimarcus/content-sites-ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.22.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",
|
|
@@ -49,6 +49,6 @@
|
|
|
49
49
|
"@lucide/astro": "^1.23.0",
|
|
50
50
|
"astro": "^7.0.6",
|
|
51
51
|
"tailwindcss": "^4.1.18",
|
|
52
|
-
"@sarimarcus/content-sites-core": "0.
|
|
52
|
+
"@sarimarcus/content-sites-core": "0.22.0"
|
|
53
53
|
}
|
|
54
54
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
+
import { IMAGE_LADDER } from '@sarimarcus/content-sites-core/images';
|
|
2
3
|
/**
|
|
3
4
|
* AffiliateCard — a bookable activity as one sponsored link: image with the title over it, description, duration,
|
|
4
5
|
* price, rating and the CTA. `href` arrives finished (tagged by the site) and `productId` is the site's.
|
|
@@ -170,9 +171,9 @@ const priceFrame = skipQueueFrame && title.toLowerCase().includes('skip')
|
|
|
170
171
|
<Picture
|
|
171
172
|
src={image}
|
|
172
173
|
alt={title}
|
|
173
|
-
widths={
|
|
174
|
+
widths={IMAGE_LADDER}
|
|
174
175
|
sizes={imageSizes}
|
|
175
|
-
formats={['
|
|
176
|
+
formats={['webp']}
|
|
176
177
|
fallbackFormat="webp"
|
|
177
178
|
loading="lazy"
|
|
178
179
|
decoding="async"
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { gygProductId } from '@sarimarcus/content-sites-core/affiliate';
|
|
2
|
+
import type { AffiliateCardItem } from './types';
|
|
3
|
+
|
|
4
|
+
/** One GetYourGuide activity as cached by a site (`src/data/affiliates/gyg.json`). */
|
|
5
|
+
export interface GygCardActivity {
|
|
6
|
+
title: string;
|
|
7
|
+
description: string;
|
|
8
|
+
price: number;
|
|
9
|
+
currency: string;
|
|
10
|
+
rating?: number;
|
|
11
|
+
reviewCount?: number;
|
|
12
|
+
imageUrl: string;
|
|
13
|
+
affiliateUrl: string;
|
|
14
|
+
duration: string;
|
|
15
|
+
language: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const LOCALES: Record<string, string> = { en: 'en-GB', fr: 'fr-FR' };
|
|
19
|
+
|
|
20
|
+
/** The card's number/currency locale for an activity's language code; en-GB when unknown. */
|
|
21
|
+
export const gygCardLocale = (language: string) => LOCALES[language] || 'en-GB';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* A cached GYG activity as an AffiliateCard item. The site passes what it owns: the campaign-tagged `href` and the
|
|
25
|
+
* resolved `image`.
|
|
26
|
+
*/
|
|
27
|
+
export function gygCardItem(a: GygCardActivity, { href, image }: { href: string; image: AffiliateCardItem['image'] }): AffiliateCardItem {
|
|
28
|
+
return {
|
|
29
|
+
href,
|
|
30
|
+
provider: 'gyg',
|
|
31
|
+
productId: gygProductId(a.affiliateUrl),
|
|
32
|
+
image,
|
|
33
|
+
title: a.title,
|
|
34
|
+
description: a.description,
|
|
35
|
+
price: a.price,
|
|
36
|
+
currency: a.currency,
|
|
37
|
+
rating: a.rating,
|
|
38
|
+
reviewCount: a.reviewCount,
|
|
39
|
+
duration: a.duration,
|
|
40
|
+
locale: gygCardLocale(a.language),
|
|
41
|
+
};
|
|
42
|
+
}
|
package/src/affiliate/index.ts
CHANGED
|
@@ -11,7 +11,9 @@ 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, emeraldResources, EMERALD_ORIGIN } from './resources';
|
|
14
|
+
export { externalResources, emeraldResources, EMERALD_ORIGIN, widgetLoaders } from './resources';
|
|
15
|
+
export { widgetSrc } from './widgetSrc';
|
|
16
|
+
export { gygCardItem, gygCardLocale, type GygCardActivity } from './cardItem';
|
|
15
17
|
export type { WidgetKind, CspDirective, WidgetResources } from './resources';
|
|
16
18
|
export type { AffiliateCardLabels, AffiliateCardItem, HotelCtaLabels, ComparisonRow, ComparisonTableLabels } from './types';
|
|
17
19
|
export { selectActivities, type SelectableActivity, type SelectOptions } from './select';
|
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
// Every other component in this folder loads nothing: its outbound links are caller data, not resources.
|
|
7
7
|
// Plain data and erasable types only: the gate imports this file under Node.
|
|
8
8
|
|
|
9
|
+
import type { WidgetLoader } from '@sarimarcus/content-sites-core/affiliate';
|
|
10
|
+
|
|
9
11
|
export type WidgetKind = 'hotel' | 'activity' | 'train' | 'car';
|
|
10
12
|
export type CspDirective = 'script-src' | 'frame-src' | 'style-src';
|
|
11
13
|
export type WidgetResources = Partial<Record<CspDirective, readonly string[]>>;
|
|
@@ -29,6 +31,17 @@ export const externalResources: Record<WidgetKind, WidgetResources> = {
|
|
|
29
31
|
};
|
|
30
32
|
|
|
31
33
|
|
|
34
|
+
/**
|
|
35
|
+
* Each kind's loader script and the ids its URL reports under (SLA-2919). `promoId` is also how
|
|
36
|
+
* scripts/validate-csp-coverage.mjs recognises a widget in a built page that carries no `data-affiliate-widget` marker.
|
|
37
|
+
*/
|
|
38
|
+
export const widgetLoaders: Record<WidgetKind, WidgetLoader> = {
|
|
39
|
+
hotel: { url: 'https://tpwdg.com/content', promoId: '2693', campaignId: '84' },
|
|
40
|
+
activity: { url: 'https://tpwdg.com/content', promoId: '4039', campaignId: '108' },
|
|
41
|
+
train: { url: 'https://c91.travelpayouts.com/content', promoId: '4770' },
|
|
42
|
+
car: { url: 'https://tpwdg.com/content', promoId: '3873', campaignId: '117' },
|
|
43
|
+
};
|
|
44
|
+
|
|
32
45
|
/** The Travelpayouts Emerald loader every page carries (EmeraldLoader): https://emrldtp.com/<base64 trs>.js?t=<trs>. */
|
|
33
46
|
export const EMERALD_ORIGIN = 'https://emrldtp.com';
|
|
34
47
|
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { travelpayoutsWidgetSrc, type WidgetAccount, type WidgetOptions } from '@sarimarcus/content-sites-core/affiliate';
|
|
2
|
+
import { widgetLoaders } from './resources';
|
|
3
|
+
|
|
4
|
+
/** A Travelpayouts widget loader URL for `options.kind`, with the loader and ids declared in resources.ts. */
|
|
5
|
+
export function widgetSrc(account: WidgetAccount, options: WidgetOptions): string {
|
|
6
|
+
return travelpayoutsWidgetSrc(widgetLoaders[options.kind], account, options);
|
|
7
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
+
import { IMAGE_LADDER } from '@sarimarcus/content-sites-core/images';
|
|
2
3
|
/**
|
|
3
4
|
* EntityCard — the shared frame of an entity teaser card: a whole-card link with a focus ring, an image block
|
|
4
5
|
* (optimized Picture, or a fallback img), a content area and a footer row. Everything that varies between card types
|
|
@@ -38,7 +39,7 @@ const {
|
|
|
38
39
|
image,
|
|
39
40
|
fallbackSrc,
|
|
40
41
|
imageAlt,
|
|
41
|
-
formats = ['
|
|
42
|
+
formats = ['webp'],
|
|
42
43
|
index = 999,
|
|
43
44
|
class: className,
|
|
44
45
|
} = Astro.props;
|
|
@@ -65,7 +66,7 @@ const footerClass = poster
|
|
|
65
66
|
<Picture
|
|
66
67
|
src={image}
|
|
67
68
|
alt={imageAlt}
|
|
68
|
-
widths={
|
|
69
|
+
widths={IMAGE_LADDER}
|
|
69
70
|
sizes="(max-width: 639px) 100vw, (max-width: 1023px) 50vw, 33vw"
|
|
70
71
|
formats={formats}
|
|
71
72
|
fallbackFormat="webp"
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
+
import { IMAGE_LADDER } from '@sarimarcus/content-sites-core/images';
|
|
2
3
|
import { Picture } from 'astro:assets';
|
|
3
4
|
import type { ImageMetadata } from 'astro';
|
|
4
5
|
import type { TrustedHtml } from '../types/index.ts';
|
|
@@ -42,8 +43,8 @@ interface Props {
|
|
|
42
43
|
aspectRatio?: string;
|
|
43
44
|
/** Optional URL to wrap the image in a link */
|
|
44
45
|
href?: string;
|
|
45
|
-
/** Image formats for the <picture> sources (
|
|
46
|
-
formats?:
|
|
46
|
+
/** Image formats for the <picture> sources (WebP only: the variant service serves nothing else). */
|
|
47
|
+
formats?: 'webp'[];
|
|
47
48
|
/** Additional CSS classes on the outer figure element */
|
|
48
49
|
class?: string;
|
|
49
50
|
}
|
|
@@ -60,7 +61,7 @@ const {
|
|
|
60
61
|
size = 'medium',
|
|
61
62
|
aspectRatio = '16/9',
|
|
62
63
|
href,
|
|
63
|
-
formats = ['
|
|
64
|
+
formats = ['webp'],
|
|
64
65
|
class: className,
|
|
65
66
|
} = Astro.props;
|
|
66
67
|
|
|
@@ -89,11 +90,6 @@ const effectiveAspectRatio = isFloat ? '4/3' : aspectRatio;
|
|
|
89
90
|
const effectiveHeightClass = isFloat ? '' : imageHeightClass;
|
|
90
91
|
const effectiveUseAspectRatio = isFloat ? true : useAspectRatio;
|
|
91
92
|
|
|
92
|
-
// Filter widths to not exceed the source image width
|
|
93
|
-
const defaultWidths = isFloat ? [400, 640] : [640, 960, 1280];
|
|
94
|
-
const fitting = src.width ? defaultWidths.filter((w) => w <= src.width) : defaultWidths;
|
|
95
|
-
const widths = fitting.length ? fitting : [src.width];
|
|
96
|
-
|
|
97
93
|
const sizes = isFloat
|
|
98
94
|
? '(max-width: 640px) 100vw, 50vw'
|
|
99
95
|
: '(max-width: 1024px) 100vw, 1024px';
|
|
@@ -111,7 +107,7 @@ const sizes = isFloat
|
|
|
111
107
|
<Picture
|
|
112
108
|
src={src}
|
|
113
109
|
alt={alt}
|
|
114
|
-
widths={
|
|
110
|
+
widths={IMAGE_LADDER}
|
|
115
111
|
sizes={sizes}
|
|
116
112
|
formats={formats}
|
|
117
113
|
fallbackFormat="webp"
|
|
@@ -129,7 +125,7 @@ const sizes = isFloat
|
|
|
129
125
|
<Picture
|
|
130
126
|
src={src}
|
|
131
127
|
alt={alt}
|
|
132
|
-
widths={
|
|
128
|
+
widths={IMAGE_LADDER}
|
|
133
129
|
sizes={sizes}
|
|
134
130
|
formats={formats}
|
|
135
131
|
fallbackFormat="webp"
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
+
import { IMAGE_LADDER } from '@sarimarcus/content-sites-core/images';
|
|
2
3
|
/**
|
|
3
4
|
* RelatedContentCard — one card inside RelatedContent. The grid and the carousel render this same card; only the
|
|
4
5
|
* shell classes and the responsive image hints differ. Three shapes, by precedence: image-led, icon-led, plain text.
|
|
@@ -62,8 +63,8 @@ const hasImage = !!item.image;
|
|
|
62
63
|
<Picture
|
|
63
64
|
src={item.image!}
|
|
64
65
|
alt={item.imageAlt || item.title}
|
|
65
|
-
widths={
|
|
66
|
-
formats={['
|
|
66
|
+
widths={IMAGE_LADDER}
|
|
67
|
+
formats={['webp']}
|
|
67
68
|
fallbackFormat="webp"
|
|
68
69
|
sizes={pictureSizes}
|
|
69
70
|
class="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105"
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
+
import { IMAGE_LADDER } from '@sarimarcus/content-sites-core/images';
|
|
2
3
|
/**
|
|
3
4
|
* ArticleCard — one article in a grid: image, title, byline with the published or "Updated" date, description and
|
|
4
5
|
* a read link. Dates arrive formatted; the image is the route's import.
|
|
@@ -31,9 +32,9 @@ const { article: a, readLabel, updatedLabel, loading = 'lazy', pagefindIgnore =
|
|
|
31
32
|
<Picture
|
|
32
33
|
src={a.image}
|
|
33
34
|
alt={a.imageAlt ?? ''}
|
|
34
|
-
widths={
|
|
35
|
+
widths={IMAGE_LADDER}
|
|
35
36
|
sizes="(max-width: 640px) 100vw, 33vw"
|
|
36
|
-
formats={['
|
|
37
|
+
formats={['webp']}
|
|
37
38
|
fallbackFormat="webp"
|
|
38
39
|
class="w-full h-full object-cover motion-safe:transition-transform motion-safe:duration-500 motion-safe:group-hover:scale-105"
|
|
39
40
|
loading={loading}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
+
import { IMAGE_LADDER } from '@sarimarcus/content-sites-core/images';
|
|
2
3
|
/**
|
|
3
4
|
* ArticleFeatureCard — the wide lead card above an article grid: image left, label, title, byline with the
|
|
4
5
|
* published or "Updated" date, description and a read link. Dates arrive formatted; the image is the route's import.
|
|
@@ -43,9 +44,9 @@ const Heading = `h${headingLevel}` as 'h2' | 'h3';
|
|
|
43
44
|
<Picture
|
|
44
45
|
src={image}
|
|
45
46
|
alt={imageAlt}
|
|
46
|
-
widths={
|
|
47
|
+
widths={IMAGE_LADDER}
|
|
47
48
|
sizes="(max-width: 640px) 100vw, 50vw"
|
|
48
|
-
formats={['
|
|
49
|
+
formats={['webp']}
|
|
49
50
|
fallbackFormat="webp"
|
|
50
51
|
class="w-full h-full object-cover motion-safe:transition-transform motion-safe:duration-500 motion-safe:group-hover:scale-105"
|
|
51
52
|
loading={loading}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
+
import { IMAGE_LADDER } from '@sarimarcus/content-sites-core/images';
|
|
2
3
|
/**
|
|
3
4
|
* RelatedCard — an in-article link to another page. `inline`: a ruled "read next" row. `featured`: the mid-article
|
|
4
5
|
* feature card, image left. The site resolves href, eyebrow and image; an unresolved target should render nothing.
|
|
@@ -53,9 +54,9 @@ const { href, eyebrow, title, description, variant = 'inline', image, imageAlt,
|
|
|
53
54
|
<Picture
|
|
54
55
|
src={image}
|
|
55
56
|
alt={imageAlt ?? title}
|
|
56
|
-
widths={
|
|
57
|
+
widths={IMAGE_LADDER}
|
|
57
58
|
sizes="(min-width: 640px) 40vw, 100vw"
|
|
58
|
-
formats={['
|
|
59
|
+
formats={['webp']}
|
|
59
60
|
fallbackFormat="webp"
|
|
60
61
|
class="w-full h-full object-cover"
|
|
61
62
|
loading="lazy"
|
package/src/layout/analytics.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// Pushes custom events to the GTM dataLayer.
|
|
2
2
|
// GTM consent mode (Silktide analytics_storage) governs whether GA4 receives each event.
|
|
3
|
+
import { pageTypeOf } from '@sarimarcus/content-sites-core/url';
|
|
3
4
|
|
|
4
5
|
declare global {
|
|
5
6
|
interface Window {
|
|
@@ -21,19 +22,7 @@ export function track(eventName: string, params: Record<string, unknown> = {}):
|
|
|
21
22
|
window.dataLayer.push({ event: eventName, page_path: window.location.pathname, ...params });
|
|
22
23
|
}
|
|
23
24
|
|
|
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
|
-
}
|
|
25
|
+
export { pageTypeOf };
|
|
37
26
|
|
|
38
27
|
// Param key derived from the data-track-* attribute name where the
|
|
39
28
|
// event-specific prefix (e.g. 'cta_', 'affiliate_', 'link_') has been stripped.
|