@sarimarcus/content-sites-ui 0.29.4 → 0.30.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,16 @@
2
2
 
3
3
  All packages in the platform release in lockstep; entries are per release version.
4
4
 
5
+ ## 0.30.0 (2026-10-07)
6
+
7
+ - `./affiliate`: `HotelAffiliateCTA` and `AffiliateComparisonTable` links carry `data-track-affiliate-provider` and `data-track-affiliate-placement` so booking clicks are attributed; both take `provider`, `ComparisonRow` takes a per-row one, and without either the provider is read from the link's host (Booking.com links only; any other host is left unattributed) (SLA-2985).
8
+ - `./editorial`: `Interlude` renders a stat-fact `figure` through `renderInline`, so `**80 titles**` is bold and `<20%` is escaped (SLA-2985).
9
+ - `./search`: the load-more button ignores clicks while a page is loading and drops fragments fetched for a superseded query, so a double click no longer repeats 12 results and a new query no longer receives stale ones (SLA-2985).
10
+
11
+ ### Breaking
12
+
13
+ - `./primitives`: `FeatureList`'s `items` and `footer` are typed `TrustedHtml` (were `string`); wrap plain text with `textAsHtml()` and parsed markdown with `trustedHtml()`. Sites run `scripts/platform/migrations/feature-list-trusted.mjs` (`items` → `.map(textAsHtml)`, `footer` → `trustedHtml(…)`) (SLA-2985).
14
+
5
15
  ## 0.29.4 (2026-10-07)
6
16
 
7
17
  - `./composed`: `Pagination`'s current page uses `text-primary-foreground` (was `text-white`, 1.8:1 on a light primary) and Previous/Next are named by their visible text; `TableOfContents`' hidden mini bar is `inert`; `CardCarousel`'s arrows use `aria-disabled`, so the focused arrow keeps focus at an edge, and arrows and dots have 44px targets (SLA-2984).
package/README.md CHANGED
@@ -36,7 +36,7 @@ import { Icon, Badge, Chip, ArrowLabel, SectionDivider, LinkArrow, Button, Callo
36
36
  `rel="noopener noreferrer"`, unless `target` or `rel` is given. Attributes render as `href rel target class`,
37
37
  then every other anchor attribute (`data-*`, `aria-*`, `slot`) in the order passed.
38
38
  - `Callout`: a boxed note with an icon. `type` is `info` (default), `tip`, `warning` or `success`; `title` is optional.
39
- - `FeatureList`: a checklist or a row of tags from `items` (rendered with `set:html`). `color` is `primary`
39
+ - `FeatureList`: a checklist or a row of tags from `items` and an optional `footer`, both `TrustedHtml` (rendered with `set:html`; wrap plain text with `textAsHtml()`). `color` is `primary`
40
40
  (default), `secondary` or `accent`; `container` boxes it, `compact` tightens it, `headingLevel` sets the heading
41
41
  (default `h2`), which renders only for a non-empty `title`; `footer` adds a line under a border.
42
42
  - `Blockquote`: a centred pull-quote, `quote` plus an optional `source`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sarimarcus/content-sites-ui",
3
- "version": "0.29.4",
3
+ "version": "0.30.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.29.4"
52
+ "@sarimarcus/content-sites-core": "0.30.0"
53
53
  }
54
54
  }
@@ -8,6 +8,7 @@
8
8
  import Icon from '../primitives/Icon.astro';
9
9
  import Badge from '../primitives/Badge.astro';
10
10
  import type { ComparisonRow, ComparisonTableLabels } from './types';
11
+ import { providerFromHref } from './provider';
11
12
 
12
13
  interface Props {
13
14
  rows: ComparisonRow[];
@@ -17,6 +18,7 @@ interface Props {
17
18
  priceLabel?: string;
18
19
  featureLabel?: string;
19
20
  placement?: string;
21
+ provider?: string;
20
22
  labels?: ComparisonTableLabels;
21
23
  class?: string;
22
24
  }
@@ -29,6 +31,7 @@ const {
29
31
  priceLabel = 'Price',
30
32
  featureLabel = 'Highlight',
31
33
  placement = 'comparison-table',
34
+ provider,
32
35
  labels = {},
33
36
  class: className = '',
34
37
  } = Astro.props;
@@ -118,6 +121,8 @@ const defaultCta = (external: boolean) => (external ? externalCta : internalCta)
118
121
  data-track-event="affiliate_click"
119
122
  data-track-cta-id={row.trackId}
120
123
  data-track-cta-placement={placement}
124
+ data-track-affiliate-provider={row.provider ?? provider ?? providerFromHref(row.href)}
125
+ data-track-affiliate-placement={placement}
121
126
  class={ctaAffiliate}
122
127
  aria-label={externalAria(cta, row.name)}
123
128
  >
@@ -188,6 +193,8 @@ const defaultCta = (external: boolean) => (external ? externalCta : internalCta)
188
193
  data-track-event="affiliate_click"
189
194
  data-track-cta-id={row.trackId}
190
195
  data-track-cta-placement={placement}
196
+ data-track-affiliate-provider={row.provider ?? provider ?? providerFromHref(row.href)}
197
+ data-track-affiliate-placement={placement}
191
198
  class={`${ctaAffiliate} w-full`}
192
199
  aria-label={externalAria(cta, row.name)}
193
200
  >
@@ -13,6 +13,7 @@
13
13
  import { isBadgeLength } from '../primitives/badgeText';
14
14
  import type { HotelCtaLabels } from './types';
15
15
  import { templateLine } from './templateText';
16
+ import { providerFromHref } from './provider';
16
17
 
17
18
 
18
19
  interface Props {
@@ -23,6 +24,7 @@ interface Props {
23
24
  entityNoun?: string;
24
25
  trackId?: string;
25
26
  placement?: string;
27
+ provider?: string;
26
28
  priceText?: string | null;
27
29
  priceCompactMax?: number;
28
30
  priceNoteSlot?: boolean;
@@ -45,6 +47,7 @@ const {
45
47
  entityNoun = 'hotel',
46
48
  trackId = 'hotel-affiliate-cta',
47
49
  placement,
50
+ provider,
48
51
  priceText = null,
49
52
  priceCompactMax,
50
53
  priceNoteSlot = false,
@@ -73,6 +76,9 @@ const {
73
76
  officialSite = `Visit the ${entityNoun}'s official site`,
74
77
  } = labels;
75
78
 
79
+ const trackProvider = provider ?? providerFromHref(href);
80
+ const placementAbove = placement ?? 'above-fold';
81
+ const placementBook = placement ?? 'book-section';
76
82
  const ctaLabel = isSearchFallback ? searchCta : bookCta;
77
83
  const subhead = isSearchFallback ? searchSubhead : bookSubhead;
78
84
  const priceIsCompact = priceCompactMax === undefined || isBadgeLength(priceText, priceCompactMax);
@@ -89,7 +95,9 @@ const ctaClasses =
89
95
  rel="sponsored noopener noreferrer"
90
96
  data-track-event="affiliate_click"
91
97
  data-track-cta-id={trackId}
92
- data-track-cta-placement={placement ?? 'above-fold'}
98
+ data-track-cta-placement={placementAbove}
99
+ data-track-affiliate-provider={trackProvider}
100
+ data-track-affiliate-placement={placementAbove}
93
101
  class:list={[ctaClasses, 'sm:w-auto', className]}
94
102
  >
95
103
  <svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
@@ -142,7 +150,9 @@ const ctaClasses =
142
150
  rel="sponsored noopener noreferrer"
143
151
  data-track-event="affiliate_click"
144
152
  data-track-cta-id={trackId}
145
- data-track-cta-placement={placement ?? 'book-section'}
153
+ data-track-cta-placement={placementBook}
154
+ data-track-affiliate-provider={trackProvider}
155
+ data-track-affiliate-placement={placementBook}
146
156
  class:list={[ctaClasses, 'mt-4']}
147
157
  >
148
158
  <svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
@@ -0,0 +1,6 @@
1
+ /** The affiliate provider a link belongs to, read from its host; undefined for a host no provider is known for. */
2
+ export function providerFromHref(href: string | undefined): string | undefined {
3
+ const host = /^https?:\/\/([^/?#:]+)/i.exec(href ?? '')?.[1]?.toLowerCase();
4
+ if (!host) return undefined;
5
+ return host === 'booking.com' || host.endsWith('.booking.com') ? 'booking' : undefined;
6
+ }
@@ -72,6 +72,8 @@ export interface ComparisonRow {
72
72
  sponsored?: boolean;
73
73
  cta?: string;
74
74
  trackId?: string;
75
+ /** Affiliate provider for click attribution; falls back to the table's `provider`. */
76
+ provider?: string;
75
77
  }
76
78
 
77
79
  /** AffiliateComparisonTable copy, all with English defaults. */
@@ -37,7 +37,7 @@ const { interlude: il, renderInline: md, labels, author, didYouKnowVariant, didY
37
37
  />
38
38
  )}
39
39
 
40
- {il.type === 'stat-fact' && <StatFact figure={trustedHtml(il.figure)} caption={md(il.caption)} />}
40
+ {il.type === 'stat-fact' && <StatFact figure={md(il.figure)} caption={md(il.caption)} />}
41
41
 
42
42
  {il.type === 'evidence-context' && (
43
43
  <EvidenceContext
@@ -2,34 +2,31 @@
2
2
  /**
3
3
  * FeatureList — reusable list display for specialties, ingredients, features.
4
4
  *
5
- * `items[]` and `footer` render with set:html, so callers may pass inline markup
6
- * (already-parsed markdown from parseMarkdownInline) or plain text. Plain strings
7
- * are unaffected: at the time of the change the 9,923 item strings in content
8
- * JSON contained no angle brackets and no entity-like ampersands, and the
9
- * rendered list output was byte-identical across all 462 list items on the three
10
- * active sites. Callers passing author-written markdown must parse it first —
11
- * see the `checklist` branch of the site's interlude dispatcher.
5
+ * `items[]` and `footer` render with set:html and are typed TrustedHtml: parse author-written
6
+ * markdown first (see the `checklist` branch of the interlude dispatcher) or wrap plain
7
+ * text with `textAsHtml()`.
12
8
  *
13
9
  * The heading renders only for a non-empty `title`.
14
10
  *
15
11
  * Usage:
16
12
  * <FeatureList title="Specialities" items={bar.specialties} icon="star" />
17
13
  * <FeatureList title="House specialities" items={restaurant.specialties} variant="tags" color="accent" container />
18
- * <FeatureList title="Main ingredients" items={dish.mainIngredients} icon="star" color="accent" container footer="<strong>Allergens:</strong> gluten, dairy" />
14
+ * <FeatureList title="Main ingredients" items={dish.mainIngredients} icon="star" color="accent" container footer={trustedHtml("<strong>Allergens:</strong> gluten, dairy")} />
19
15
  */
20
16
 
21
17
  import Icon from './Icon.astro';
18
+ import type { TrustedHtml } from '../types';
22
19
 
23
20
  interface Props {
24
21
  title?: string;
25
- items: string[];
22
+ items: TrustedHtml[];
26
23
  variant?: 'checklist' | 'tags';
27
24
  color?: 'primary' | 'accent' | 'secondary';
28
25
  icon?: string;
29
26
  container?: boolean;
30
27
  /** Tighter padding/margin for space-constrained contexts (e.g. article interludes). */
31
28
  compact?: boolean;
32
- footer?: string;
29
+ footer?: TrustedHtml;
33
30
  headingLevel?: 'h2' | 'h3' | 'h4';
34
31
  class?: string;
35
32
  }
@@ -294,6 +294,7 @@ export function initSearchPage<T extends string>(config: SearchPageConfig<T>): v
294
294
 
295
295
  writeURL(query, typeFilter, audienceFilter);
296
296
  const myToken = ++searchToken;
297
+ loadMoreBtn.disabled = false;
297
298
 
298
299
  if (!query) {
299
300
  updateAudienceCounts(undefined); // back to corpus counts
@@ -514,6 +515,7 @@ export function initSearchPage<T extends string>(config: SearchPageConfig<T>): v
514
515
  });
515
516
 
516
517
  loadMoreBtn.addEventListener('click', async () => {
518
+ if (loadMoreBtn.disabled) return;
517
519
  if (activeType) {
518
520
  // All type-filtered fragments are already in cachedFragments. An audience
519
521
  // filter needs no branch here: it narrowed cachedHandles server-side, so
@@ -528,11 +530,20 @@ export function initSearchPage<T extends string>(config: SearchPageConfig<T>): v
528
530
 
529
531
  const nextHandles = cachedHandles.slice(renderedCount, renderedCount + pageSize);
530
532
  if (nextHandles.length === 0) return;
531
- const fragments = await Promise.all(nextHandles.map((h) => h.data()));
532
- cachedFragments.push(...fragments);
533
- appendResults(listEl, fragments, renderedCount, taxonomy);
534
- renderedCount += fragments.length;
535
- if (renderedCount >= cachedHandles.length) loadMoreBtn.hidden = true;
533
+ const myToken = searchToken;
534
+ loadMoreBtn.disabled = true;
535
+ try {
536
+ const fragments = await Promise.all(nextHandles.map((h) => h.data()));
537
+ if (myToken !== searchToken) return;
538
+ cachedFragments.push(...fragments);
539
+ appendResults(listEl, fragments, renderedCount, taxonomy);
540
+ renderedCount += fragments.length;
541
+ if (renderedCount >= cachedHandles.length) loadMoreBtn.hidden = true;
542
+ } catch {
543
+ // leave the button visible so the click can be retried
544
+ } finally {
545
+ loadMoreBtn.disabled = false;
546
+ }
536
547
  });
537
548
 
538
549
  listEl.addEventListener('click', (e) => {