@sarimarcus/content-sites-ui 0.29.4 → 0.30.1

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,22 @@
2
2
 
3
3
  All packages in the platform release in lockstep; entries are per release version.
4
4
 
5
+ ## 0.30.1 (2026-10-07)
6
+
7
+ - `./editorial`: `StaticProsePage` takes `proseClass`, the variant beside `prose` (default `prose-stone`) and `NotFoundPage` takes `searchBoxClass` (default: the current search-link classes), so a dark theme can pass its own prose variant and input tokens; default output is unchanged (SLA-2989).
8
+ - `./layout`: `ListingShell` takes `contained` (default `true`) and `pagefindIgnore` (default `true`); `false` drops the Container inside `<main>` for a main of full-bleed bands, and keeps a listing's `data-pagefind-body` bands indexed. `mainClass={null}` renders a class-less `<main>`. Default output is unchanged (SLA-2989).
9
+ - `./search`: with `?focus`, `initSearchPage` re-claims input focus every 300 ms for 3 s, stopping at the first pointer or key press, instead of once at 1.7 s and only from `<body>`; the deferred cookie banner moves focus to its own button, which the single reclaim missed. This reaches every site (SLA-2989).
10
+
11
+ ## 0.30.0 (2026-10-07)
12
+
13
+ - `./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).
14
+ - `./editorial`: `Interlude` renders a stat-fact `figure` through `renderInline`, so `**80 titles**` is bold and `<20%` is escaped (SLA-2985).
15
+ - `./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).
16
+
17
+ ### Breaking
18
+
19
+ - `./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).
20
+
5
21
  ## 0.29.4 (2026-10-07)
6
22
 
7
23
  - `./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.1",
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.1"
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. */
@@ -20,9 +20,11 @@ interface Props {
20
20
  /** Accessible name of the section links. */
21
21
  exploreAriaLabel?: string;
22
22
  links: { href: string; label: string }[];
23
+ /** Classes of the search link; a dark theme passes its own border and background tokens. */
24
+ searchBoxClass?: string;
23
25
  }
24
26
 
25
- const { tagline, lede, homeHref, homeLabel, searchHref, searchLabel, searchAriaLabel, exploreLabel, exploreAriaLabel = 'Explore sections', links } = Astro.props;
27
+ const { tagline, lede, homeHref, homeLabel, searchHref, searchLabel, searchAriaLabel, exploreLabel, exploreAriaLabel = 'Explore sections', links, searchBoxClass = 'group flex items-center gap-3 w-full max-w-sm mx-auto px-4 py-3 rounded-xl border border-foreground/20 bg-white/60 backdrop-blur-sm text-left hover:border-primary/40 hover:bg-white/80 transition-all no-underline' } = Astro.props;
26
28
  ---
27
29
 
28
30
  <main class="relative min-h-[80vh] flex items-center justify-center overflow-hidden bg-muted">
@@ -74,7 +76,7 @@ const { tagline, lede, homeHref, homeLabel, searchHref, searchLabel, searchAriaL
74
76
  <a
75
77
  href={searchHref}
76
78
  data-search-trigger
77
- class="group flex items-center gap-3 w-full max-w-sm mx-auto px-4 py-3 rounded-xl border border-foreground/20 bg-white/60 backdrop-blur-sm text-left hover:border-primary/40 hover:bg-white/80 transition-all no-underline"
79
+ class={searchBoxClass}
78
80
  aria-label={searchAriaLabel}
79
81
  >
80
82
  <Icon name="search" class="w-4 h-4 text-muted-foreground shrink-0" />
@@ -18,9 +18,11 @@ interface Props {
18
18
  eyebrow?: string;
19
19
  /** Closing box under the prose: a short line and secondary buttons. */
20
20
  cta?: { text: string; links: CtaLink[] };
21
+ /** Prose variant beside the base `prose` class; a dark theme passes its own. */
22
+ proseClass?: string;
21
23
  }
22
24
 
23
- const { heading, eyebrow, cta } = Astro.props;
25
+ const { heading, eyebrow, cta, proseClass = 'prose-stone' } = Astro.props;
24
26
  ---
25
27
 
26
28
  <main class="py-16 lg:py-24">
@@ -28,7 +30,7 @@ const { heading, eyebrow, cta } = Astro.props;
28
30
  {eyebrow && <p class="text-sm font-semibold uppercase tracking-[0.18em] text-primary mb-3">{eyebrow}</p>}
29
31
  <h1 class="text-3xl sm:text-4xl font-medium font-display mb-8">{heading}</h1>
30
32
 
31
- <div class="prose prose-stone max-w-none">
33
+ <div class:list={['prose', proseClass, 'max-w-none']}>
32
34
  <slot />
33
35
  </div>
34
36
 
@@ -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
@@ -1,20 +1,26 @@
1
1
  ---
2
2
  /**
3
3
  * ListingShell — the frame of a listing hub: breadcrumb band, hero, optional full-width bands, <main> with a
4
- * Container, and trailing bands, all inside one Pagefind-ignore wrapper (listings are not indexed). The route keeps
4
+ * Container, and trailing bands, all inside one wrapper that is Pagefind-ignored by default (most listings are not
5
+ * indexed; `pagefindIgnore={false}` keeps a listing's own `data-pagefind-body` bands). The route keeps
5
6
  * Layout and passes StructuredData to Layout directly, so its JSON-LD lands in <head>; everything else comes in by slot.
6
7
  */
7
8
  import BreadcrumbBand from './BreadcrumbBand.astro';
8
9
  import Container from './Container.astro';
9
10
 
10
11
  interface Props {
11
- mainClass?: string;
12
+ /** `null` renders a class-less <main>. */
13
+ mainClass?: string | null;
14
+ /** `false` drops the Container inside <main>, for a main made of full-bleed bands that carry their own. */
15
+ contained?: boolean;
16
+ /** `false` keeps the page indexable, for a listing whose bands carry `data-pagefind-body` prose. */
17
+ pagefindIgnore?: boolean;
12
18
  }
13
19
 
14
- const { mainClass = 'py-12 lg:py-20' } = Astro.props;
20
+ const { mainClass = 'py-12 lg:py-20', contained = true, pagefindIgnore = true } = Astro.props;
15
21
  ---
16
22
 
17
- <div data-pagefind-ignore>
23
+ <div data-pagefind-ignore={pagefindIgnore ? '' : undefined}>
18
24
  <BreadcrumbBand ignore={false}>
19
25
  <slot name="breadcrumb" />
20
26
  </BreadcrumbBand>
@@ -23,9 +29,13 @@ const { mainClass = 'py-12 lg:py-20' } = Astro.props;
23
29
  <slot name="before-main" />
24
30
 
25
31
  <main class={mainClass}>
26
- <Container>
32
+ {contained ? (
33
+ <Container>
34
+ <slot />
35
+ </Container>
36
+ ) : (
27
37
  <slot />
28
- </Container>
38
+ )}
29
39
  </main>
30
40
 
31
41
  <slot name="after-main" />
@@ -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) => {
@@ -580,12 +591,22 @@ export function initSearchPage<T extends string>(config: SearchPageConfig<T>): v
580
591
  }
581
592
  };
582
593
  requestAnimationFrame(claimFocus);
583
- // Cookie consent banner may steal focus ~1.5s after load (deferred init).
584
- // Reclaim once if the user hasn't moved focus elsewhere.
594
+ // The deferred cookie banner can steal focus (to its own button, not <body>) up to ~2s after load. Re-claim it
595
+ // every 300 ms for 3 s, and stop as soon as the user interacts.
585
596
  if (initial.focus) {
586
- window.setTimeout(() => {
587
- if (document.activeElement === document.body) claimFocus();
588
- }, 1700);
597
+ let tries = 0;
598
+ const stop = () => {
599
+ window.clearInterval(reclaim);
600
+ document.removeEventListener('pointerdown', stop, true);
601
+ document.removeEventListener('keydown', stop, true);
602
+ };
603
+ const reclaim = window.setInterval(() => {
604
+ tries += 1;
605
+ if (tries > 10) stop();
606
+ else claimFocus();
607
+ }, 300);
608
+ document.addEventListener('pointerdown', stop, true);
609
+ document.addEventListener('keydown', stop, true);
589
610
  }
590
611
  }
591
612
  }