@sarimarcus/content-sites-ui 0.31.6 → 0.32.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.
Files changed (42) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +10 -3
  3. package/package.json +2 -2
  4. package/src/affiliate/AffiliateCard.astro +7 -6
  5. package/src/affiliate/AffiliateCardSection.astro +2 -1
  6. package/src/affiliate/AffiliateCarousel.astro +4 -4
  7. package/src/affiliate/AffiliateComparisonTable.astro +11 -10
  8. package/src/affiliate/AmazonProductCard.astro +2 -1
  9. package/src/affiliate/AmazonProductSection.astro +2 -1
  10. package/src/affiliate/HotelAffiliateCTA.astro +12 -11
  11. package/src/affiliate/InlineAffiliateCTA.astro +2 -1
  12. package/src/affiliate/StickyCTA.astro +4 -3
  13. package/src/affiliate/TravelpayoutsWidget.astro +2 -6
  14. package/src/affiliate/provider.ts +3 -1
  15. package/src/composed/CardCarousel.astro +13 -5
  16. package/src/composed/EntityCard.astro +0 -2
  17. package/src/composed/FAQ.astro +2 -1
  18. package/src/composed/InlineImage.astro +0 -2
  19. package/src/composed/Pagination.astro +25 -8
  20. package/src/composed/TableOfContents.astro +10 -5
  21. package/src/editorial/AlsoExplore.astro +2 -1
  22. package/src/editorial/ArticleCategoryNav.astro +9 -5
  23. package/src/editorial/ArticleFeatureCard.astro +2 -1
  24. package/src/editorial/ArticleTopicPager.astro +7 -4
  25. package/src/editorial/NotFoundPage.astro +5 -2
  26. package/src/editorial/VisitTiming.astro +8 -4
  27. package/src/editorial/index.ts +1 -1
  28. package/src/editorial/types.ts +0 -4
  29. package/src/internal/labels.ts +102 -0
  30. package/src/layout/BreadcrumbBand.astro +4 -3
  31. package/src/layout/EntityDetailShell.astro +84 -0
  32. package/src/layout/ListingShell.astro +4 -2
  33. package/src/layout/analytics.ts +0 -2
  34. package/src/layout/index.ts +2 -1
  35. package/src/navigation/ArticlesNavMenu.astro +2 -1
  36. package/src/navigation/SiteFooter.astro +10 -4
  37. package/src/navigation/SiteHeader.astro +4 -3
  38. package/src/primitives/index.ts +1 -0
  39. package/src/seo/ArticleMetadata.astro +6 -5
  40. package/src/seo/AuthoritativeSources.astro +7 -6
  41. package/src/seo/Breadcrumb.astro +3 -2
  42. package/src/seo/author.ts +5 -3
package/CHANGELOG.md CHANGED
@@ -2,6 +2,24 @@
2
2
 
3
3
  All packages in the platform release in lockstep; entries are per release version.
4
4
 
5
+ ## 0.32.0 (2026-10-09)
6
+
7
+ - Optional label props, so every reader-visible string can come from the site: Pagination `ariaLabel`, `previousLabel`, `nextLabel`, `pageLabel`, `currentPageLabel`; TableOfContents `jumpLabel`, `stickyLabel`; CardCarousel `regionLabel`, `previousLabel`, `nextLabel`; SiteFooter `rightsLabel`, `legalAriaLabel`; NotFoundPage `searchShortcut`; VisitTiming `labels`. Optional `routes` on ArticleCategoryNav and on `toArticleAuthor` (4th argument). English defaults move from the templates into an internal labels module; output is byte-identical (render-diff 48/48) (SLA-2991).
8
+
9
+ ### Breaking
10
+
11
+ - The `formats` prop is removed from `EntityCard` and `InlineImage` (deprecated in 0.24.0, ignored since) (SLA-2991).
12
+ - `idOf` is removed from `AffiliateCarousel` and `SourceItem` from `./editorial` (use `SourceInput` from `./seo`), both deprecated in 0.31.4 (SLA-2991).
13
+ - `pageTypeOf` is no longer re-exported from `./layout` or `./analytics`; import it from `@sarimarcus/content-sites-core/url`. It was never deprecated here; no site imports it (SLA-2991).
14
+ - Each removal is asserted absent from every site by `scripts/platform/migrations/remove-deprecated.mjs` (SLA-2991).
15
+
16
+ ## 0.31.7 (2026-10-09)
17
+
18
+ - `./primitives` exports `ArrowGlyph`, the bare right-arrow svg that `ArrowLabel` and `LinkArrow` draw, for components that lay out their own label; `class` is emitted verbatim (SLA-3024).
19
+ - `./layout`: `EntityDetailShell`, the frame of an entity detail page. It renders the breadcrumb band, a `data-pagefind-body` wrapper typed by `pagefindType` around the `hero` slot with one sr-only `audience:` span per `audiences` entry, then `<main>` (`mainClass`) holding the default slot in a Container (`column`: `measure` | `content` | `false`), `after-column`, and `closer`. `pagefindScope="page"` wraps `<main>` too; `breadcrumbIgnore={false}` keeps the band indexed (SLA-3025).
20
+ - `./layout`: `BreadcrumbBand` takes `class`, which replaces the band's own classes (keep `pt-16`), and `ListingShell` takes `breadcrumbClass`. Default output is unchanged (SLA-3025).
21
+ - `./affiliate`: `HotelAffiliateCTA` and `AffiliateComparisonTable` derive `data-track-affiliate-provider="amazon"` from Amazon hosts (amazon.<tld>, amzn.to), as they already derived `booking`; an explicit provider still wins. Pages: 5 Dark Skies guides with Amazon comparison rows (SLA-3026).
22
+
5
23
  ## 0.31.6 (2026-10-08)
6
24
 
7
25
  - No changes; lockstep release with core (SLA-3023).
package/README.md CHANGED
@@ -12,7 +12,7 @@ text arrives through props and slots.
12
12
  ## Primitives
13
13
 
14
14
  ```ts
15
- import { Icon, Badge, Chip, ArrowLabel, SectionDivider, LinkArrow, Button, Callout, FeatureList, Blockquote, SectionHeader, ResponsiveImage, ResponsivePicture, PILL_BASE, isBadgeLength, PRICE_LABEL_MAX } from '@sarimarcus/content-sites-ui/primitives';
15
+ import { Icon, Badge, Chip, ArrowLabel, ArrowGlyph, SectionDivider, LinkArrow, Button, Callout, FeatureList, Blockquote, SectionHeader, ResponsiveImage, ResponsivePicture, PILL_BASE, isBadgeLength, PRICE_LABEL_MAX } from '@sarimarcus/content-sites-ui/primitives';
16
16
  ```
17
17
 
18
18
  - `Icon`: any Lucide icon by kebab-case name.
@@ -25,6 +25,8 @@ import { Icon, Badge, Chip, ArrowLabel, SectionDivider, LinkArrow, Button, Callo
25
25
  attributes pass through last, so markers such as `data-section-link` reach the element.
26
26
  - `ArrowLabel`: a label followed by an arrow that shifts right on `group-hover`. It carries no colour: the caller's
27
27
  `class` sets it; `arrowClass` sizes the arrow (default `h-4 w-4`).
28
+ - `ArrowGlyph`: the bare right arrow svg that `ArrowLabel` and `LinkArrow` draw, for a component that lays out its own
29
+ label. `class` (required) is emitted verbatim, motion included.
28
30
  - `SectionDivider`: a break between sections. `variant` is `dots` (default), `line`, or `quote` (with `quote` and
29
31
  an optional `source`).
30
32
  - `LinkArrow`: a text link with a trailing arrow. `color` is `primary` (default), `secondary`, `accent` or `onImage`;
@@ -88,7 +90,7 @@ state and one bundled controller (idempotent on `astro:page-load`, any number of
88
90
 
89
91
  ```ts
90
92
  import { StaticProsePage, NotFoundPage, ArticlesListingPage, ArticleFeatureCard } from '@sarimarcus/content-sites-ui/editorial';
91
- import { ListingShell } from '@sarimarcus/content-sites-ui/layout';
93
+ import { EntityDetailShell, ListingShell } from '@sarimarcus/content-sites-ui/layout';
92
94
  ```
93
95
 
94
96
  The route file keeps `getStaticPaths`, queries, URLs, `<Layout title description>` (the SEO length gate reads it) and
@@ -105,7 +107,12 @@ prose stay in the route so the site's link and outbound-domain scanners still se
105
107
  `overview-byline`. The overview and the author cards are props. A page above 1 renders only the nav, grid and
106
108
  pagination. `rankByCount` ranks a topic's authors or collections by article count.
107
109
  - `ListingShell`: listing hubs (breadcrumb, `hero`, `before-main`, Container'd `<main>`, `after-main`), one outer
108
- `data-pagefind-ignore`.
110
+ `data-pagefind-ignore`. `breadcrumbClass` replaces the band's classes (`BreadcrumbBand` `class`).
111
+ - `EntityDetailShell`: entity detail pages. Breadcrumb band, then a `data-pagefind-body` wrapper typed by
112
+ `pagefindType` around the `hero` slot (with an sr-only `audience:` filter span per `audiences` entry), then `<main>`
113
+ with the default slot in a measure Container, `after-column`, and `closer` in a default Container.
114
+ `pagefindScope="page"` wraps `<main>` too; no `pagefindType` means no wrapper; `column={false}` drops the column
115
+ Container; `closer` defaults to whether the slot was passed. The hero, body and closer stay site markup.
109
116
 
110
117
  A component exported from a barrel must not carry a `<style>` block: Astro ships it to every page that imports the
111
118
  barrel. Put such rules in a stylesheet entry the one route imports.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sarimarcus/content-sites-ui",
3
- "version": "0.31.6",
3
+ "version": "0.32.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.31.6"
52
+ "@sarimarcus/content-sites-core": "0.32.0"
53
53
  }
54
54
  }
@@ -23,6 +23,7 @@ import Icon from '../primitives/Icon.astro';
23
23
  import type { AffiliateCardOptions } from './types';
24
24
  import { templateLine } from './templateText';
25
25
  import ResponsivePicture from '../primitives/ResponsivePicture.astro';
26
+ import { LABELS } from '../internal/labels';
26
27
 
27
28
  interface Props extends AffiliateCardOptions {
28
29
  href: string;
@@ -65,7 +66,7 @@ const {
65
66
  reviewCount = 0,
66
67
  duration,
67
68
  index = 0,
68
- pricePrefix = 'From ',
69
+ pricePrefix = LABELS.affiliateCard.pricePrefix,
69
70
  legacyFractionDigits = false,
70
71
  bestSellerAt = 1000,
71
72
  skipTheLineBadge = true,
@@ -80,11 +81,11 @@ const {
80
81
  } = Astro.props;
81
82
 
82
83
  const {
83
- bestSeller = 'Best Seller',
84
- topRated = 'Top Rated',
85
- skipTheLine = 'Skip the line',
86
- cta = 'Reserve Your Spot',
87
- reviewCount: reviewCountLabel = (n: string) => `(${n} reviews)`,
84
+ bestSeller = LABELS.affiliateCard.bestSeller,
85
+ topRated = LABELS.affiliateCard.topRated,
86
+ skipTheLine = LABELS.affiliateCard.skipTheLine,
87
+ cta = LABELS.affiliateCard.cta,
88
+ reviewCount: reviewCountLabel = LABELS.affiliateCard.reviewCount,
88
89
  verified,
89
90
  booksUp,
90
91
  } = labels;
@@ -9,6 +9,7 @@ import AffiliateCard from './AffiliateCard.astro';
9
9
  import AffiliateBandShell from './AffiliateBandShell.astro';
10
10
  import { bandImageSizes } from './band';
11
11
  import type { AffiliateCardItem, AffiliateCardOptions } from './types';
12
+ import { LABELS } from '../internal/labels';
12
13
 
13
14
  interface Props {
14
15
  items: AffiliateCardItem[];
@@ -25,7 +26,7 @@ interface Props {
25
26
 
26
27
  const {
27
28
  items,
28
- title = "Recommended Tours & Activities",
29
+ title = LABELS.affiliateCardSection.title,
29
30
  subtitle,
30
31
  placement,
31
32
  imageFetchpriority,
@@ -11,14 +11,13 @@ import CardCarousel from '../composed/CardCarousel.astro';
11
11
  import AffiliateCard from './AffiliateCard.astro';
12
12
  import { selectActivities } from './select';
13
13
  import type { AffiliateCardItem, AffiliateCardOptions } from './types';
14
+ import { LABELS } from '../internal/labels';
14
15
 
15
16
  type Activity = { id?: string | number; title?: string; reviewCount?: number; [key: string]: unknown };
16
17
 
17
18
  interface Props {
18
19
  activities: Activity[];
19
20
  ids?: number[];
20
- /** @deprecated No site passes it; the default reading stays. Removed in the next minor (SLA-3020). */
21
- idOf?: (activity: Activity) => number;
22
21
  resolve?: (activity: any) => AffiliateCardItem;
23
22
  heading?: string;
24
23
  subtitle?: string;
@@ -35,9 +34,8 @@ interface Props {
35
34
  const {
36
35
  activities,
37
36
  ids,
38
- idOf = (activity) => Number(String(activity.id ?? '').replace(/^\D+-/, '')),
39
37
  resolve = (activity) => activity as AffiliateCardItem,
40
- heading = 'Recommended tours',
38
+ heading = LABELS.affiliateCarousel.heading,
41
39
  subtitle,
42
40
  headingTag = 'h3',
43
41
  headingClass = 'font-display text-lg font-semibold text-foreground',
@@ -49,6 +47,8 @@ const {
49
47
  class: className,
50
48
  } = Astro.props;
51
49
 
50
+ const idOf = (activity: Activity) => Number(String(activity.id ?? '').replace(/^\D+-/, ''));
51
+
52
52
  const shown = selectActivities(
53
53
  activities,
54
54
  ids ? { pinnedIds: ids, exclusive: true, match: (a, wanted) => idOf(a) === wanted, maxCards } : { dedupeTitles: true, maxCards },
@@ -9,6 +9,7 @@ import Icon from '../primitives/Icon.astro';
9
9
  import Badge from '../primitives/Badge.astro';
10
10
  import type { ComparisonRow, ComparisonTableLabels } from './types';
11
11
  import { providerFromHref } from './provider';
12
+ import { LABELS } from '../internal/labels';
12
13
 
13
14
  interface Props {
14
15
  rows: ComparisonRow[];
@@ -27,9 +28,9 @@ const {
27
28
  rows = [],
28
29
  title,
29
30
  titleTag = 'p',
30
- nameLabel = 'Option',
31
- priceLabel = 'Price',
32
- featureLabel = 'Highlight',
31
+ nameLabel = LABELS.affiliateComparisonTable.nameLabel,
32
+ priceLabel = LABELS.affiliateComparisonTable.priceLabel,
33
+ featureLabel = LABELS.affiliateComparisonTable.featureLabel,
33
34
  placement = 'comparison-table',
34
35
  provider,
35
36
  labels = {},
@@ -37,13 +38,13 @@ const {
37
38
  } = Astro.props;
38
39
 
39
40
  const {
40
- caption = 'Comparison of options',
41
- ctaColumn = 'Book or view',
42
- externalCta = 'Check availability',
43
- internalCta = 'View details',
44
- externalAria = (cta: string, name: string) => `${cta}: ${name} (opens in a new tab)`,
45
- internalAria = (cta: string, name: string) => `${cta}: ${name}`,
46
- rated = (rating: number, count?: number) => `Rated ${rating} out of 5${count ? ` from ${count} reviews` : ''}`,
41
+ caption = LABELS.affiliateComparisonTable.caption,
42
+ ctaColumn = LABELS.affiliateComparisonTable.ctaColumn,
43
+ externalCta = LABELS.affiliateComparisonTable.externalCta,
44
+ internalCta = LABELS.affiliateComparisonTable.internalCta,
45
+ externalAria = LABELS.affiliateComparisonTable.externalAria,
46
+ internalAria = LABELS.affiliateComparisonTable.internalAria,
47
+ rated = LABELS.affiliateComparisonTable.rated,
47
48
  } = labels;
48
49
 
49
50
  const captionText = title ?? caption;
@@ -8,6 +8,7 @@ import Badge from '../primitives/Badge.astro';
8
8
  import { CARD_LINK_CLASS, CARD_BODY_CLASS, CARD_CTA_CLASS, CARD_BADGE_CLASS } from './cardClasses';
9
9
  import Icon from '../primitives/Icon.astro';
10
10
  import { templateLine } from './templateText';
11
+ import { LABELS } from '../internal/labels';
11
12
 
12
13
  interface Props {
13
14
  href: string;
@@ -35,7 +36,7 @@ const {
35
36
  category = 'book',
36
37
  badge,
37
38
  placement = 'card',
38
- ctaLabel = 'View on Amazon',
39
+ ctaLabel = LABELS.amazonProductCard.ctaLabel,
39
40
  class: className,
40
41
  } = Astro.props;
41
42
 
@@ -5,6 +5,7 @@
5
5
  */
6
6
  import AmazonProductCard from './AmazonProductCard.astro';
7
7
  import AffiliateBandShell from './AffiliateBandShell.astro';
8
+ import { LABELS } from '../internal/labels';
8
9
 
9
10
  interface Product {
10
11
  href: string;
@@ -30,7 +31,7 @@ interface Props {
30
31
  }
31
32
 
32
33
  const {
33
- title = "Recommended reading",
34
+ title = LABELS.amazonProductSection.title,
34
35
  subtitle,
35
36
  products,
36
37
  maxCards,
@@ -14,6 +14,7 @@ import { isBadgeLength } from '../primitives/badgeText';
14
14
  import type { HotelCtaLabels } from './types';
15
15
  import { templateLine } from './templateText';
16
16
  import { providerFromHref } from './provider';
17
+ import { LABELS } from '../internal/labels';
17
18
 
18
19
 
19
20
  interface Props {
@@ -63,17 +64,17 @@ const {
63
64
  } = Astro.props;
64
65
 
65
66
  const {
66
- headline = (n: string) => `Find the best rate for ${n}`,
67
- bookCta = `Book this ${entityNoun}`,
68
- searchCta = 'Check prices & availability',
69
- bookSubhead = 'Check current availability and rates.',
70
- searchSubhead = `Search current prices and availability for this ${entityNoun}.`,
71
- from = 'from ',
72
- perNight = ' / night',
73
- reviews = (n: string) => `(${n} reviews)`,
74
- ratingOn = (s: string) => `on ${s}`,
75
- freeToSearch = 'Free to search · No sign-up needed',
76
- officialSite = `Visit the ${entityNoun}'s official site`,
67
+ headline = LABELS.hotelAffiliateCTA.headline,
68
+ bookCta = LABELS.hotelAffiliateCTA.bookCta(entityNoun),
69
+ searchCta = LABELS.hotelAffiliateCTA.searchCta,
70
+ bookSubhead = LABELS.hotelAffiliateCTA.bookSubhead,
71
+ searchSubhead = LABELS.hotelAffiliateCTA.searchSubhead(entityNoun),
72
+ from = LABELS.hotelAffiliateCTA.from,
73
+ perNight = LABELS.hotelAffiliateCTA.perNight,
74
+ reviews = LABELS.hotelAffiliateCTA.reviews,
75
+ ratingOn = LABELS.hotelAffiliateCTA.ratingOn,
76
+ freeToSearch = LABELS.hotelAffiliateCTA.freeToSearch,
77
+ officialSite = LABELS.hotelAffiliateCTA.officialSite(entityNoun),
77
78
  } = labels;
78
79
 
79
80
  const trackProvider = provider ?? providerFromHref(href);
@@ -6,6 +6,7 @@
6
6
  * so inline clicks land in the same analytics fields. Render an AffiliateDisclosure nearby.
7
7
  */
8
8
  import Icon from '../primitives/Icon.astro';
9
+ import { LABELS } from '../internal/labels';
9
10
 
10
11
  interface Props {
11
12
  href: string;
@@ -23,7 +24,7 @@ const {
23
24
  href,
24
25
  label,
25
26
  description,
26
- cta = 'See tours',
27
+ cta = LABELS.inlineAffiliateCTA.cta,
27
28
  trackId,
28
29
  trackPlacement,
29
30
  provider,
@@ -15,6 +15,7 @@
15
15
  * no secondary action: both only reproduce a site's earlier markup byte for byte.
16
16
  */
17
17
  import Icon from '../primitives/Icon.astro';
18
+ import { LABELS } from '../internal/labels';
18
19
 
19
20
  interface Props {
20
21
  label?: string;
@@ -37,16 +38,16 @@ interface Props {
37
38
  }
38
39
 
39
40
  const {
40
- label = 'Reserve Your Spot',
41
+ label = LABELS.stickyCTA.label,
41
42
  anchorId = 'affiliate-tours',
42
43
  eyebrow,
43
- note = 'Found the right tour? Reserve your spot before it fills.',
44
+ note = LABELS.stickyCTA.note,
44
45
  icon = 'ticket',
45
46
  secondary,
46
47
  showOnDesktop = false,
47
48
  hideWhenVisible,
48
49
  dismissKey = 'affiliate-bar-dismissed',
49
- dismissLabel = 'Dismiss',
50
+ dismissLabel = LABELS.stickyCTA.dismissLabel,
50
51
  track = true,
51
52
  trackId = 'affiliate-sticky-bar',
52
53
  inertWhenHidden = true,
@@ -14,6 +14,7 @@
14
14
  */
15
15
  import TravelpayoutsWidgetBody from './TravelpayoutsWidgetBody.astro';
16
16
  import type { WidgetKind as Kind } from './resources';
17
+ import { LABELS } from '../internal/labels';
17
18
 
18
19
  interface Props {
19
20
  kind: Kind;
@@ -38,12 +39,7 @@ const WRAPPER: Record<Kind, string> = {
38
39
  train: 'train-search-widget min-h-[280px]',
39
40
  car: 'car-rental-widget',
40
41
  };
41
- const LABEL: Record<Kind, string> = {
42
- hotel: 'Hotel search',
43
- activity: 'Activity search',
44
- train: 'Train and bus search',
45
- car: 'Car rental search',
46
- };
42
+ const LABEL: Record<Kind, string> = LABELS.travelpayoutsWidget;
47
43
 
48
44
  const {
49
45
  kind,
@@ -2,5 +2,7 @@
2
2
  export function providerFromHref(href: string | undefined): string | undefined {
3
3
  const host = /^https?:\/\/([^/?#:]+)/i.exec(href ?? '')?.[1]?.toLowerCase();
4
4
  if (!host) return undefined;
5
- return host === 'booking.com' || host.endsWith('.booking.com') ? 'booking' : undefined;
5
+ if (host === 'booking.com' || host.endsWith('.booking.com')) return 'booking';
6
+ if (host === 'amzn.to' || /(?:^|\.)amazon\.(?:com|co\.[a-z]{2}|com\.[a-z]{2}|[a-z]{2})$/.test(host)) return 'amazon';
7
+ return undefined;
6
8
  }
@@ -11,6 +11,7 @@
11
11
  */
12
12
  import Icon from '../primitives/Icon.astro';
13
13
  import { deterministicId } from '@sarimarcus/content-sites-core/dom';
14
+ import { LABELS } from '../internal/labels';
14
15
 
15
16
  interface Props {
16
17
  /** Section heading rendered above the track. */
@@ -29,8 +30,12 @@ interface Props {
29
30
  /** Optional "view all" link shown next to the arrows. */
30
31
  viewAllHref?: string;
31
32
  viewAllLabel?: string;
32
- /** Accessible label for the scroll region (falls back to the heading). */
33
+ /** Accessible label for the scroll region (falls back to the heading, then `regionLabel`). */
33
34
  ariaLabel?: string;
35
+ regionLabel?: string;
36
+ /** Accessible names of the arrows, given the carousel's name (undefined when it has none). */
37
+ previousLabel?: (name: string | undefined) => string;
38
+ nextLabel?: (name: string | undefined) => string;
34
39
  class?: string;
35
40
  }
36
41
 
@@ -41,8 +46,11 @@ const {
41
46
  description,
42
47
  id,
43
48
  viewAllHref,
44
- viewAllLabel = 'View all',
49
+ viewAllLabel = LABELS.cardCarousel.viewAllLabel,
45
50
  ariaLabel,
51
+ regionLabel = LABELS.cardCarousel.regionLabel,
52
+ previousLabel = LABELS.cardCarousel.previousLabel,
53
+ nextLabel = LABELS.cardCarousel.nextLabel,
46
54
  class: className,
47
55
  } = Astro.props;
48
56
 
@@ -82,7 +90,7 @@ const arrowClass =
82
90
  <button
83
91
  data-carousel-prev
84
92
  type="button"
85
- aria-label={`Previous, ${ariaLabel ?? heading ?? 'carousel'}`}
93
+ aria-label={previousLabel(ariaLabel ?? heading)}
86
94
  aria-disabled="true"
87
95
  class={arrowClass}
88
96
  >
@@ -91,7 +99,7 @@ const arrowClass =
91
99
  <button
92
100
  data-carousel-next
93
101
  type="button"
94
- aria-label={`Next, ${ariaLabel ?? heading ?? 'carousel'}`}
102
+ aria-label={nextLabel(ariaLabel ?? heading)}
95
103
  class={arrowClass}
96
104
  >
97
105
  <Icon name="chevron-right" class="h-4 w-4" aria-hidden="true" />
@@ -103,7 +111,7 @@ const arrowClass =
103
111
  id={trackId}
104
112
  data-carousel-track
105
113
  class="cc-track -mx-4 flex gap-4 overflow-x-auto scroll-px-4 px-4 py-1.5 snap-x snap-mandatory sm:mx-0 sm:scroll-px-0 sm:px-0"
106
- aria-label={ariaLabel ?? heading ?? 'Carousel'}
114
+ aria-label={ariaLabel ?? heading ?? regionLabel}
107
115
  role="group"
108
116
  tabindex="0"
109
117
  >
@@ -37,8 +37,6 @@ interface Props {
37
37
  /** Shown when `image` is absent. */
38
38
  fallbackSrc?: string;
39
39
  imageAlt: string;
40
- /** @deprecated Ignored: images are WebP only (SLA-2960). Removed in the next minor. */
41
- formats?: Array<'avif' | 'webp' | 'png' | 'jpeg' | 'jpg' | 'svg'>;
42
40
  /** Position in the list: the first `eager` load eagerly, the first one at high priority. */
43
41
  index?: number;
44
42
  eager?: number;
@@ -8,6 +8,7 @@
8
8
  */
9
9
  import Icon from '../primitives/Icon.astro';
10
10
  import type { TrustedHtml } from '../types/index.ts';
11
+ import { LABELS } from '../internal/labels';
11
12
 
12
13
  export interface FaqItem {
13
14
  questionHtml: TrustedHtml;
@@ -27,7 +28,7 @@ interface Props {
27
28
 
28
29
  const {
29
30
  items,
30
- title = 'Frequently asked questions',
31
+ title = LABELS.faq.title,
31
32
  icon = true,
32
33
  headingClass = 'text-2xl sm:text-3xl font-normal mb-6 flex items-center gap-3 font-display',
33
34
  id,
@@ -42,8 +42,6 @@ interface Props {
42
42
  aspectRatio?: string;
43
43
  /** Optional URL to wrap the image in a link */
44
44
  href?: string;
45
- /** @deprecated Ignored: images are WebP only (SLA-2960). Removed in the next minor. */
46
- formats?: 'webp'[];
47
45
  /** Additional CSS classes on the outer figure element */
48
46
  class?: string;
49
47
  }
@@ -5,15 +5,32 @@
5
5
  * all agree on URLs.
6
6
  */
7
7
  import Icon from '../primitives/Icon.astro';
8
+ import { LABELS } from '../internal/labels';
8
9
 
9
10
  interface Props {
10
11
  currentPage: number;
11
12
  totalPages: number;
12
13
  /** URL of a page number (base path included). */
13
14
  href: (page: number) => string;
15
+ ariaLabel?: string;
16
+ previousLabel?: string;
17
+ nextLabel?: string;
18
+ /** Accessible name of a link to another page. */
19
+ pageLabel?: (page: number) => string;
20
+ /** Accessible name of the current page. */
21
+ currentPageLabel?: (page: number) => string;
14
22
  }
15
23
 
16
- const { currentPage, totalPages, href } = Astro.props;
24
+ const {
25
+ currentPage,
26
+ totalPages,
27
+ href,
28
+ ariaLabel = LABELS.pagination.ariaLabel,
29
+ previousLabel = LABELS.pagination.previousLabel,
30
+ nextLabel = LABELS.pagination.nextLabel,
31
+ pageLabel = LABELS.pagination.pageLabel,
32
+ currentPageLabel = LABELS.pagination.currentPageLabel,
33
+ } = Astro.props;
17
34
 
18
35
  const prevPage = currentPage > 1 ? currentPage - 1 : null;
19
36
  const nextPage = currentPage < totalPages ? currentPage + 1 : null;
@@ -42,16 +59,16 @@ const edgeBase = `${linkBase} px-3`;
42
59
  ---
43
60
 
44
61
  {totalPages > 1 && (
45
- <nav aria-label="Pagination" class="mt-14 flex items-center justify-center gap-2 flex-wrap">
62
+ <nav aria-label={ariaLabel} class="mt-14 flex items-center justify-center gap-2 flex-wrap">
46
63
  {prevPage ? (
47
64
  <a href={href(prevPage)} rel="prev" class={edgeBase}>
48
65
  <Icon name="arrow-left" class="w-3.5 h-3.5" />
49
- <span>Previous</span>
66
+ <span>{previousLabel}</span>
50
67
  </a>
51
68
  ) : (
52
69
  <span aria-hidden="true" class={`${edgeBase} opacity-40 cursor-default pointer-events-none`}>
53
70
  <Icon name="arrow-left" class="w-3.5 h-3.5" />
54
- <span>Previous</span>
71
+ <span>{previousLabel}</span>
55
72
  </span>
56
73
  )}
57
74
 
@@ -64,7 +81,7 @@ const edgeBase = `${linkBase} px-3`;
64
81
  <li>
65
82
  <span
66
83
  aria-current="page"
67
- aria-label={`Page ${item}, current page`}
84
+ aria-label={currentPageLabel(item)}
68
85
  class={`${numberBase} border-primary bg-primary text-primary-foreground hover:text-primary-foreground`}
69
86
  >
70
87
  {item}
@@ -72,7 +89,7 @@ const edgeBase = `${linkBase} px-3`;
72
89
  </li>
73
90
  ) : (
74
91
  <li>
75
- <a href={href(item)} aria-label={`Go to page ${item}`} class={numberBase}>
92
+ <a href={href(item)} aria-label={pageLabel(item)} class={numberBase}>
76
93
  {item}
77
94
  </a>
78
95
  </li>
@@ -83,12 +100,12 @@ const edgeBase = `${linkBase} px-3`;
83
100
 
84
101
  {nextPage ? (
85
102
  <a href={href(nextPage)} rel="next" class={edgeBase}>
86
- <span>Next</span>
103
+ <span>{nextLabel}</span>
87
104
  <Icon name="arrow-right" class="w-3.5 h-3.5" />
88
105
  </a>
89
106
  ) : (
90
107
  <span aria-hidden="true" class={`${edgeBase} opacity-40 cursor-default pointer-events-none`}>
91
- <span>Next</span>
108
+ <span>{nextLabel}</span>
92
109
  <Icon name="arrow-right" class="w-3.5 h-3.5" />
93
110
  </span>
94
111
  )}
@@ -7,6 +7,7 @@
7
7
  // empty heading on every caller that omits one (SLA-2340); `mb-2` sits behind `title` so an eyebrow-only header does
8
8
  // not double the gap.
9
9
  import Chip from '../primitives/Chip.astro';
10
+ import { LABELS } from '../internal/labels';
10
11
 
11
12
  interface TableOfContentsItem {
12
13
  label: string;
@@ -19,6 +20,10 @@ interface Props {
19
20
  items: TableOfContentsItem[];
20
21
  /** Defaults to the title, else 'Table of contents'. */
21
22
  ariaLabel?: string;
23
+ /** The mobile disclosure's summary. */
24
+ jumpLabel?: string;
25
+ /** Accessible name of the sticky copy of the nav, given `ariaLabel`. */
26
+ stickyLabel?: (name: string) => string;
22
27
  eyebrow?: string;
23
28
  description?: string;
24
29
  class?: string;
@@ -37,7 +42,9 @@ interface Props {
37
42
  const {
38
43
  title = '',
39
44
  items,
40
- ariaLabel = title || 'Table of contents',
45
+ ariaLabel = title || LABELS.tableOfContents.ariaLabel,
46
+ jumpLabel = LABELS.tableOfContents.jumpLabel,
47
+ stickyLabel = LABELS.tableOfContents.stickyLabel,
41
48
  eyebrow,
42
49
  description,
43
50
  class: className = '',
@@ -74,7 +81,7 @@ const showMini = variant === 'card';
74
81
  inert
75
82
  >
76
83
  <div class="mx-auto max-w-shell px-4 sm:px-6 lg:px-8 py-2.5">
77
- <nav class="flex gap-2 overflow-x-auto scrollbar-hide" aria-label={`${ariaLabel} (sticky)`}>
84
+ <nav class="flex gap-2 overflow-x-auto scrollbar-hide" aria-label={stickyLabel(ariaLabel)}>
78
85
  {items.map((item) => (
79
86
  <Chip
80
87
  href={item.href}
@@ -114,9 +121,7 @@ const showMini = variant === 'card';
114
121
  )}
115
122
 
116
123
  <details class="group md:hidden">
117
- <summary class="flex min-h-11 cursor-pointer list-none items-center justify-between rounded-lg border border-border bg-muted/40 px-4 py-2.5 text-sm font-medium text-foreground marker:content-none">
118
- Jump to a section
119
- <span class="text-primary transition-transform group-open:rotate-180" aria-hidden="true">⌄</span>
124
+ <summary class="flex min-h-11 cursor-pointer list-none items-center justify-between rounded-lg border border-border bg-muted/40 px-4 py-2.5 text-sm font-medium text-foreground marker:content-none">{`\n${jumpLabel}\n`}<span class="text-primary transition-transform group-open:rotate-180" aria-hidden="true">⌄</span>
120
125
  </summary>
121
126
  <nav class="mt-3 grid gap-2" aria-label={ariaLabel}>
122
127
  {items.map((item) => (
@@ -1,6 +1,7 @@
1
1
  ---
2
2
  /** "Also explore": up to four sibling links under a hairline, at the foot of a hub or listing page. */
3
3
  import Icon from '../primitives/Icon.astro';
4
+ import { LABELS } from '../internal/labels';
4
5
 
5
6
  export interface AlsoExploreItem {
6
7
  href: string;
@@ -13,7 +14,7 @@ interface Props {
13
14
  class?: string;
14
15
  }
15
16
 
16
- const { eyebrow = 'Also explore', items, class: className } = Astro.props;
17
+ const { eyebrow = LABELS.alsoExplore.eyebrow, items, class: className } = Astro.props;
17
18
 
18
19
  if (import.meta.env.DEV && items.length > 4) {
19
20
  console.warn(`AlsoExplore: received ${items.length} items, capping at 4.`);