@sarimarcus/content-sites-ui 0.31.3 → 0.31.5

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.31.5 (2026-10-08)
6
+
7
+ - No changes; lockstep release with core (SLA-2998).
8
+
9
+ ## 0.31.4 (2026-10-08)
10
+
11
+ - `./affiliate`: `GygActivity`, a cached GYG activity with its provider `id` (required string) and `rating`/`reviewCount` (required), on top of `GygCardActivity`. It is the type the three sites declare locally, with `id` narrowed to required (Córdoba and Granada declare it optional; every cached activity has one); their copies move to this import at adoption (`extracted.json` partial entries: no-recreate enforces the move from 0.32.0, and a site may adopt earlier) (SLA-3020).
12
+ - `./affiliate`: `selectActivities` takes `exclusive` (only the pinned activities, in `pinnedIds` order, an id listed twice shown twice, no fill), `dedupeTitles` (keeps the first activity of each trimmed, lower-cased title) and `match` (how a pinned id finds its activity; default unchanged: ids equal once `gyg-` is stripped). `SelectableActivity` gains an optional `title`. Every option defaults off, so existing calls return what they did (SLA-3020).
13
+ - `./affiliate`: `AffiliateCarousel` picks its cards through `selectActivities` (`exclusive` with its own numeric id rule when `ids` is set, `dedupeTitles` otherwise). Output is unchanged: `render-diff.mjs` over `.planning/platform/sla-3020-render-diff-cases.json` renders 9 cases identically to 0.31.3 (SLA-3020).
14
+ - `./types`: `BrandTone` (`'primary' | 'secondary' | 'accent'`). `RelatedTone` and `NavTone` are now aliases of it, and `InsiderTipTone` is `BrandTone | 'muted'`; the inline unions of `EventInfoRow.tone`, `FeatureList` `color`, `QuickFacts` action `color`, `NumberedStep` `tone` and `Chip` `accent` use it. Type-only (SLA-3020).
15
+
16
+ ### Deprecated
17
+
18
+ - `./affiliate` `AffiliateCarousel` `idOf`: no site passes it, and the default id rule stays. Removed in the next minor (SLA-3020).
19
+ - `./editorial` `SourceItem`: an alias of `SourceInput`; import `SourceInput` from `./seo`. Removed in the next minor (SLA-3020).
20
+
5
21
  ## 0.31.3 (2026-10-08)
6
22
 
7
23
  - No changes; lockstep release with tourism (SLA-3019).
package/README.md CHANGED
@@ -115,7 +115,8 @@ barrel. Put such rules in a stylesheet entry the one route imports.
115
115
  ```ts
116
116
  import { ArticleKeyFacts, AuthorCard, AuthorStrip, ArticleCard, ArticleGrid, ArticleCategoryNav, InsiderTips } from '@sarimarcus/content-sites-ui/editorial';
117
117
  import { RelatedContent, RelatedContentCard } from '@sarimarcus/content-sites-ui/composed';
118
- import type { ArticleSummary, AuthorSummary, KeyFact, SourceItem } from '@sarimarcus/content-sites-ui/editorial';
118
+ import type { ArticleSummary, AuthorSummary, KeyFact } from '@sarimarcus/content-sites-ui/editorial';
119
+ import type { SourceInput } from '@sarimarcus/content-sites-ui/seo';
119
120
  import type { RelatedItem } from '@sarimarcus/content-sites-ui/composed';
120
121
  ```
121
122
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sarimarcus/content-sites-ui",
3
- "version": "0.31.3",
3
+ "version": "0.31.5",
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.31.3"
52
+ "@sarimarcus/content-sites-core": "0.31.5"
53
53
  }
54
54
  }
@@ -1,13 +1,15 @@
1
1
  ---
2
2
  /**
3
- * AffiliateCarousel — a CardCarousel strip of AffiliateCards. With `ids`, the strip renders exactly those
4
- * activities in that running order (an editor's list; `idOf` reads an activity's numeric id). Without, the most
5
- * reviewed come first, titles deduplicated so near-duplicate listings don't eat the strip. Either way at most
6
- * `maxCards`. `resolve` turns a visible activity into the card's props (called only for the cards rendered);
7
- * without it each activity already is one. The disclosure arrives through the `disclosure` slot.
3
+ * AffiliateCarousel — a CardCarousel strip of AffiliateCards, chosen by `selectActivities`. With `ids`, the strip
4
+ * renders exactly those activities in that running order (an editor's list, `exclusive`; an activity's numeric id is
5
+ * its `id` without a non-digit prefix, so `gyg-12`, `x-12` and `12` all match 12). Without, the most reviewed come
6
+ * first, titles deduplicated so near-duplicate listings don't eat the strip. Either way at most `maxCards`. `resolve`
7
+ * turns a visible activity into the card's props (called only for the cards rendered); without it each activity
8
+ * already is one. The disclosure arrives through the `disclosure` slot.
8
9
  */
9
10
  import CardCarousel from '../composed/CardCarousel.astro';
10
11
  import AffiliateCard from './AffiliateCard.astro';
12
+ import { selectActivities } from './select';
11
13
  import type { AffiliateCardItem, AffiliateCardOptions } from './types';
12
14
 
13
15
  type Activity = { id?: string | number; title?: string; reviewCount?: number; [key: string]: unknown };
@@ -15,6 +17,7 @@ type Activity = { id?: string | number; title?: string; reviewCount?: number; [k
15
17
  interface Props {
16
18
  activities: Activity[];
17
19
  ids?: number[];
20
+ /** @deprecated No site passes it; the default reading stays. Removed in the next minor (SLA-3020). */
18
21
  idOf?: (activity: Activity) => number;
19
22
  resolve?: (activity: any) => AffiliateCardItem;
20
23
  heading?: string;
@@ -46,21 +49,12 @@ const {
46
49
  class: className,
47
50
  } = Astro.props;
48
51
 
49
- const all = activities ?? [];
50
- const sorted = [...all].sort((a, b) => (b.reviewCount ?? 0) - (a.reviewCount ?? 0));
51
- const seen = new Set<string>();
52
- const deduped = sorted.filter((a) => {
53
- const key = String(a.title ?? '').trim().toLowerCase();
54
- if (seen.has(key)) return false;
55
- seen.add(key);
56
- return true;
57
- });
58
-
59
- const filtered = ids
60
- ? ids.map((wanted) => all.find((a) => idOf(a) === wanted)).filter((a): a is Activity => Boolean(a))
61
- : deduped;
52
+ const shown = selectActivities(
53
+ activities,
54
+ ids ? { pinnedIds: ids, exclusive: true, match: (a, wanted) => idOf(a) === wanted, maxCards } : { dedupeTitles: true, maxCards },
55
+ );
62
56
 
63
- const visible = filtered.slice(0, maxCards).map((a) => {
57
+ const visible = shown.map((a) => {
64
58
  const { id: _id, ...item } = resolve(a);
65
59
  return item;
66
60
  });
@@ -15,6 +15,13 @@ export interface GygCardActivity {
15
15
  language: string;
16
16
  }
17
17
 
18
+ /** A cached GYG activity with its provider id (`gyg-N` or N) and review figures, as the sites' card and carousel adapters read it. */
19
+ export interface GygActivity extends GygCardActivity {
20
+ id: string;
21
+ rating: number;
22
+ reviewCount: number;
23
+ }
24
+
18
25
  const LOCALES: Record<string, string> = { en: 'en-GB', fr: 'fr-FR' };
19
26
 
20
27
  /** The card's number/currency locale for an activity's language code; en-GB when unknown. */
@@ -13,7 +13,7 @@ export { default as AffiliateComparisonTable } from './AffiliateComparisonTable.
13
13
  export { default as HotelAffiliateCTA } from './HotelAffiliateCTA.astro';
14
14
  export { externalResources, emeraldResources, EMERALD_ORIGIN, widgetLoaders } from './resources';
15
15
  export { widgetSrc } from './widgetSrc';
16
- export { gygCardItem, gygCardLocale, type GygCardActivity } from './cardItem';
16
+ export { gygCardItem, gygCardLocale, type GygCardActivity, type GygActivity } from './cardItem';
17
17
  export type { WidgetKind, CspDirective, WidgetResources } from './resources';
18
18
  export type { AffiliateCardLabels, AffiliateCardOptions, AffiliateCardItem, HotelCtaLabels, ComparisonRow, ComparisonTableLabels } from './types';
19
19
  export { selectActivities, type SelectableActivity, type SelectOptions } from './select';
@@ -1,10 +1,11 @@
1
1
  /** An activity a card band can order: a provider id ("gyg-N" or N) and its review count. */
2
2
  export interface SelectableActivity {
3
3
  id?: string | number;
4
+ title?: string;
4
5
  reviewCount?: number;
5
6
  }
6
7
 
7
- export interface SelectOptions {
8
+ export interface SelectOptions<T extends SelectableActivity = SelectableActivity> {
8
9
  /** Ids shown first, in this order; "gyg-10" and 10 match the same activity. */
9
10
  pinnedIds?: ReadonlyArray<string | number>;
10
11
  /** Keep the rest in the given order instead of most-reviewed first. */
@@ -12,22 +13,53 @@ export interface SelectOptions {
12
13
  maxCards?: number;
13
14
  /** Called for a pinned id with no matching activity. */
14
15
  onMissingPin?: (id: string | number) => void;
16
+ /**
17
+ * Only the pinned activities, in pinnedIds order, with no fill: an editor's list. An id listed twice shows twice,
18
+ * and no id is skipped before `match` sees it.
19
+ */
20
+ exclusive?: boolean;
21
+ /** Drop an activity whose trimmed, lower-cased title an earlier one already has (an untitled one counts as ''). */
22
+ dedupeTitles?: boolean;
23
+ /** With `exclusive`: whether an activity is the one a pinned id names; default: ids equal once a `gyg-` prefix is stripped. */
24
+ match?: (activity: T, wanted: string | number) => boolean;
15
25
  }
16
26
 
17
27
  const idKey = (value: string | number | undefined) => (value === undefined ? '' : String(value).replace(/^gyg-/, ''));
18
28
 
29
+ const titleKey = (activity: SelectableActivity) => String(activity.title ?? '').trim().toLowerCase();
30
+
31
+ function withoutDuplicateTitles<T extends SelectableActivity>(list: T[]): T[] {
32
+ const seen = new Set<string>();
33
+ return list.filter((activity) => {
34
+ const key = titleKey(activity);
35
+ if (seen.has(key)) return false;
36
+ seen.add(key);
37
+ return true;
38
+ });
39
+ }
40
+
19
41
  /** The activities a card band shows: pinned ids first, then the rest by review count (or as given), capped. */
20
- export function selectActivities<T extends SelectableActivity>(activities: ReadonlyArray<T> | undefined, options: SelectOptions = {}): T[] {
21
- const { pinnedIds = [], keepOrder = false, maxCards = 4, onMissingPin } = options;
42
+ export function selectActivities<T extends SelectableActivity>(activities: ReadonlyArray<T> | undefined, options: SelectOptions<T> = {}): T[] {
43
+ const { pinnedIds = [], keepOrder = false, maxCards = 4, onMissingPin, exclusive = false, dedupeTitles = false, match } = options;
22
44
  const pool = [...(activities ?? [])];
45
+ if (exclusive) {
46
+ const matches = match ?? ((activity: T, wanted: string | number) => idKey(wanted) !== '' && idKey(activity.id) === idKey(wanted));
47
+ const listed: T[] = [];
48
+ for (const wanted of pinnedIds) {
49
+ const found = pool.find((activity) => matches(activity, wanted));
50
+ if (found) listed.push(found);
51
+ else onMissingPin?.(wanted);
52
+ }
53
+ return (dedupeTitles ? withoutDuplicateTitles(listed) : listed).slice(0, maxCards);
54
+ }
23
55
  const pinned: T[] = [];
24
56
  const pinnedKeys = new Set<string>();
25
57
  for (const wanted of pinnedIds) {
26
58
  const key = idKey(wanted);
27
59
  if (!key || pinnedKeys.has(key)) continue;
28
- const match = pool.find((activity) => idKey(activity.id) === key);
29
- if (match) {
30
- pinned.push(match);
60
+ const found = pool.find((activity) => idKey(activity.id) === key);
61
+ if (found) {
62
+ pinned.push(found);
31
63
  pinnedKeys.add(key);
32
64
  } else {
33
65
  onMissingPin?.(wanted);
@@ -36,5 +68,6 @@ export function selectActivities<T extends SelectableActivity>(activities: Reado
36
68
  const unpinned = pool.filter((activity) => !pinnedKeys.has(idKey(activity.id)));
37
69
  // A missing count sorts as 0, so it lands after every reviewed activity instead of breaking the comparator.
38
70
  const rest = keepOrder ? unpinned : unpinned.sort((a, b) => (b.reviewCount ?? 0) - (a.reviewCount ?? 0));
39
- return [...pinned, ...rest].slice(0, maxCards);
71
+ const ordered = [...pinned, ...rest];
72
+ return (dedupeTitles ? withoutDuplicateTitles(ordered) : ordered).slice(0, maxCards);
40
73
  }
@@ -22,6 +22,7 @@
22
22
  */
23
23
 
24
24
  import Icon from '../primitives/Icon.astro';
25
+ import type { BrandTone } from '../types';
25
26
 
26
27
  interface QuickFactItem {
27
28
  icon: string;
@@ -37,7 +38,7 @@ interface ActionLink {
37
38
  href: string;
38
39
  label: string;
39
40
  icon: string;
40
- color: 'primary' | 'secondary' | 'accent';
41
+ color: BrandTone;
41
42
  }
42
43
 
43
44
  interface Props {
@@ -1,6 +1,7 @@
1
1
  import type { ImageMetadata } from 'astro';
2
+ import type { BrandTone } from '../types';
2
3
 
3
- export type RelatedTone = 'primary' | 'accent' | 'secondary';
4
+ export type RelatedTone = BrandTone;
4
5
 
5
6
  /** A related-content card. Images are optimised imports, never public path strings. */
6
7
  export interface RelatedItem {
@@ -3,11 +3,13 @@
3
3
  * NumberedStep — one stage of a multi-stage plan: a round marker, a title and a subtitle, then the stage's prose in
4
4
  * a card (default slot). `tone` colours the marker and the card; the accent tone tints the card too.
5
5
  */
6
+ import type { BrandTone } from '../types';
7
+
6
8
  interface Props {
7
9
  marker: string;
8
10
  title: string;
9
11
  subtitle: string;
10
- tone?: 'primary' | 'accent' | 'secondary';
12
+ tone?: BrandTone;
11
13
  }
12
14
 
13
15
  const { marker, title, subtitle, tone = 'primary' } = Astro.props;
@@ -1,11 +1,11 @@
1
- import type { TrustedHtml } from '../types';
1
+ import type { BrandTone, TrustedHtml } from '../types';
2
2
 
3
3
  export type EventInfoIcon = 'clock' | 'sun' | 'pen' | 'bus';
4
4
 
5
5
  /** One row of an EventPage practical-information box. */
6
6
  export interface EventInfoRow {
7
7
  icon: EventInfoIcon;
8
- tone: 'primary' | 'secondary' | 'accent';
8
+ tone: BrandTone;
9
9
  label: string;
10
10
  /** Plain text, or `html` for rendered markdown. */
11
11
  text?: string;
@@ -1,5 +1,5 @@
1
1
  import type { ImageMetadata } from 'astro';
2
- import type { TrustedHtml } from '../types';
2
+ import type { BrandTone, TrustedHtml } from '../types';
3
3
  import type { SourceInput } from '../seo/types';
4
4
 
5
5
  /** A person as listed on author strips and cards. The byline shape is ui/seo `ArticleAuthor`. */
@@ -34,6 +34,7 @@ export interface KeyFact {
34
34
  detailHtml?: TrustedHtml;
35
35
  }
36
36
 
37
+ /** @deprecated Import `SourceInput` from `@sarimarcus/content-sites-ui/seo`. Removed in the next minor (SLA-3020). */
37
38
  export type SourceItem = SourceInput;
38
39
 
39
40
  /** The fields ArticleCategoryNav reads from a site's category map entry. */
@@ -59,7 +60,7 @@ export interface ArticleCategoryLink {
59
60
  active: boolean;
60
61
  }
61
62
 
62
- export type InsiderTipTone = 'primary' | 'accent' | 'secondary' | 'muted';
63
+ export type InsiderTipTone = BrandTone | 'muted';
63
64
 
64
65
  /** Site-defined tip kinds: the key stored in content, its badge label and its tone. */
65
66
  export type InsiderTipKinds = Record<string, { label: string; tone: InsiderTipTone }>;
@@ -1,5 +1,6 @@
1
1
  // Public contracts for site chrome. Sites own every value: labels, hrefs, icons, logos and copy.
2
2
  import type { ImageMetadata } from 'astro';
3
+ import type { BrandTone } from '../types';
3
4
 
4
5
  /** Who the site is, as the header, footer and copyright line name it. */
5
6
  export interface SiteIdentity {
@@ -31,7 +32,7 @@ export interface FooterSection {
31
32
  }
32
33
 
33
34
  /** The colour role a navigation item is tinted with. */
34
- export type NavTone = 'primary' | 'secondary' | 'accent';
35
+ export type NavTone = BrandTone;
35
36
 
36
37
  /** A hub link ("All articles") with an optional shorter or longer label for the mobile menu. */
37
38
  export interface NavHubLink extends NavLink {
@@ -25,12 +25,13 @@
25
25
  // ------------------------------------------------------------------
26
26
  import type { HTMLAttributes } from 'astro/types';
27
27
  import Icon from './Icon.astro';
28
+ import type { BrandTone } from '../types';
28
29
  import { PILL_BASE } from './pillRecipe';
29
30
 
30
31
  // Extends the span attributes so call sites can pass id / aria-* / data-* / slot through.
31
32
  interface Props extends Omit<HTMLAttributes<'span'>, 'class'> {
32
33
  /** Preset color. Use `custom` + `toneClass` for runtime category/type color maps. */
33
- tone?: 'primary' | 'secondary' | 'accent' | 'neutral' | 'onImage' | 'custom';
34
+ tone?: BrandTone | 'neutral' | 'onImage' | 'custom';
34
35
  /** Color classes for tone="custom" (e.g. typeColors[type]). */
35
36
  toneClass?: string;
36
37
  size?: 'sm' | 'md' | 'lg';
@@ -43,8 +43,9 @@
43
43
  import type { HTMLAttributes } from 'astro/types';
44
44
  import Icon from './Icon.astro';
45
45
  import { PILL_BASE } from './pillRecipe';
46
+ import type { BrandTone } from '../types';
46
47
 
47
- type Accent = 'primary' | 'secondary' | 'accent' | 'neutral';
48
+ type Accent = BrandTone | 'neutral';
48
49
  type Tone = 'outline' | 'outlineMuted' | 'outlineQuiet' | 'muted' | 'tint' | 'onImage' | 'custom';
49
50
 
50
51
  // `type`/`href`/`rel`/`target` are Omitted rather than just redeclared, because HTMLAttributes<'a'>
@@ -15,13 +15,13 @@
15
15
  */
16
16
 
17
17
  import Icon from './Icon.astro';
18
- import type { TrustedHtml } from '../types';
18
+ import type { BrandTone, TrustedHtml } from '../types';
19
19
 
20
20
  interface Props {
21
21
  title?: string;
22
22
  items: TrustedHtml[];
23
23
  variant?: 'checklist' | 'tags';
24
- color?: 'primary' | 'accent' | 'secondary';
24
+ color?: BrandTone;
25
25
  icon?: string;
26
26
  container?: boolean;
27
27
  /** Tighter padding/margin for space-constrained contexts (e.g. article interludes). */
@@ -1,10 +1,11 @@
1
1
  ---
2
2
  import { newTab } from './newTab';
3
+ import type { BrandTone } from '../types';
3
4
  import ArrowGlyph from './ArrowGlyph.astro';
4
5
  interface Props {
5
6
  href: string;
6
7
  size?: 'sm' | 'base';
7
- color?: 'primary' | 'secondary' | 'accent' | 'onImage';
8
+ color?: BrandTone | 'onImage';
8
9
  /** Overrides the http(s) detection: '_blank' on any href, '_self' to keep an off-site link in the tab. */
9
10
  target?: '_blank' | '_self';
10
11
  /** Defaults to 'noopener noreferrer' whenever the link opens a new tab. */
@@ -8,6 +8,9 @@ declare const trusted: unique symbol;
8
8
  */
9
9
  export type TrustedHtml = string & { readonly [trusted]: true };
10
10
 
11
+ /** The three brand colour roles a component can be tinted with; wider tone unions add their extra members to it. */
12
+ export type BrandTone = 'primary' | 'secondary' | 'accent';
13
+
11
14
  /** Mark site-rendered HTML as safe to inject with set:html. */
12
15
  export const trustedHtml = (html: string): TrustedHtml => html as TrustedHtml;
13
16