@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 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.20.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.20.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={[320, 480, 640, 960]}
174
+ widths={IMAGE_LADDER}
174
175
  sizes={imageSizes}
175
- formats={['avif', 'webp']}
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
+ }
@@ -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 = ['avif', 'webp'],
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={[240, 320, 400, 480, 640]}
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 (default: avif + webp). */
46
- formats?: ('avif' | 'webp')[];
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 = ['avif', 'webp'],
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={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={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={[320, 480, 640]}
66
- formats={['avif', 'webp']}
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={[320, 480, 640]}
35
+ widths={IMAGE_LADDER}
35
36
  sizes="(max-width: 640px) 100vw, 33vw"
36
- formats={['avif', 'webp']}
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={[480, 768, 1024]}
47
+ widths={IMAGE_LADDER}
47
48
  sizes="(max-width: 640px) 100vw, 50vw"
48
- formats={['avif', 'webp']}
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={[320, 480]}
57
+ widths={IMAGE_LADDER}
57
58
  sizes="(min-width: 640px) 40vw, 100vw"
58
- formats={['avif', 'webp']}
59
+ formats={['webp']}
59
60
  fallbackFormat="webp"
60
61
  class="w-full h-full object-cover"
61
62
  loading="lazy"
@@ -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.