@sarimarcus/content-sites-ui 0.31.7 → 0.32.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.
Files changed (37) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/package.json +2 -2
  3. package/src/affiliate/AffiliateCard.astro +7 -6
  4. package/src/affiliate/AffiliateCardSection.astro +2 -1
  5. package/src/affiliate/AffiliateCarousel.astro +4 -4
  6. package/src/affiliate/AffiliateComparisonTable.astro +11 -10
  7. package/src/affiliate/AmazonProductCard.astro +2 -1
  8. package/src/affiliate/AmazonProductSection.astro +2 -1
  9. package/src/affiliate/HotelAffiliateCTA.astro +12 -11
  10. package/src/affiliate/InlineAffiliateCTA.astro +2 -1
  11. package/src/affiliate/StickyCTA.astro +4 -3
  12. package/src/affiliate/TravelpayoutsWidget.astro +2 -6
  13. package/src/composed/CardCarousel.astro +13 -5
  14. package/src/composed/EntityCard.astro +0 -2
  15. package/src/composed/FAQ.astro +2 -1
  16. package/src/composed/InlineImage.astro +0 -2
  17. package/src/composed/Pagination.astro +25 -8
  18. package/src/composed/TableOfContents.astro +10 -5
  19. package/src/editorial/AlsoExplore.astro +2 -1
  20. package/src/editorial/ArticleCategoryNav.astro +9 -5
  21. package/src/editorial/ArticleFeatureCard.astro +2 -1
  22. package/src/editorial/ArticleTopicPager.astro +7 -4
  23. package/src/editorial/EventPage.astro +41 -20
  24. package/src/editorial/NotFoundPage.astro +5 -2
  25. package/src/editorial/VisitTiming.astro +8 -4
  26. package/src/editorial/index.ts +1 -1
  27. package/src/editorial/types.ts +0 -4
  28. package/src/internal/labels.ts +102 -0
  29. package/src/layout/analytics.ts +0 -2
  30. package/src/layout/index.ts +1 -1
  31. package/src/navigation/ArticlesNavMenu.astro +2 -1
  32. package/src/navigation/SiteFooter.astro +10 -4
  33. package/src/navigation/SiteHeader.astro +4 -3
  34. package/src/seo/ArticleMetadata.astro +6 -5
  35. package/src/seo/AuthoritativeSources.astro +7 -6
  36. package/src/seo/Breadcrumb.astro +3 -2
  37. package/src/seo/author.ts +5 -3
package/CHANGELOG.md CHANGED
@@ -2,6 +2,21 @@
2
2
 
3
3
  All packages in the platform release in lockstep; entries are per release version.
4
4
 
5
+ ## 0.32.1 (2026-10-09)
6
+
7
+ - `./editorial` `EventPage` takes `audiences` (one sr-only `audience:` Pagefind filter span per entry inside the hero wrapper), and `article`/`highlights` take `id`, an extra `class` and `section` (a bare `data-<section>-section` attribute). `bodyHtml` and `backLink` are optional. New slots: `back` (replaces the back link), `before-body`, `body` (replaces the `<article>`), `after-highlights` and `website` (replaces the official-website button). Default output is unchanged (render-diff 3/3; Dark Skies 0 of 687 pages). Site adoption: `scripts/platform/migrations/event-page.mjs` (17 Córdoba event pages, block-level whitespace; 12 Granada event pages, also `shrink-0` on heading and practical-row icons) and, for Granada walks, `scripts/platform/migrations/walk-pagefind.mjs` (8 pages indexed as `type:walk`) (SLA-3025).
8
+
9
+ ## 0.32.0 (2026-10-09)
10
+
11
+ - 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).
12
+
13
+ ### Breaking
14
+
15
+ - The `formats` prop is removed from `EntityCard` and `InlineImage` (deprecated in 0.24.0, ignored since) (SLA-2991).
16
+ - `idOf` is removed from `AffiliateCarousel` and `SourceItem` from `./editorial` (use `SourceInput` from `./seo`), both deprecated in 0.31.4 (SLA-2991).
17
+ - `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).
18
+ - Each removal is asserted absent from every site by `scripts/platform/migrations/remove-deprecated.mjs` (SLA-2991).
19
+
5
20
  ## 0.31.7 (2026-10-09)
6
21
 
7
22
  - `./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).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sarimarcus/content-sites-ui",
3
- "version": "0.31.7",
3
+ "version": "0.32.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.31.7"
52
+ "@sarimarcus/content-sites-core": "0.32.1"
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,
@@ -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.`);
@@ -3,10 +3,11 @@
3
3
  * ArticleCategoryNav — "All" plus one chip per populated topic, each with a count badge. `categories` is the site's
4
4
  * category map in display order; a category with no article is left out.
5
5
  */
6
- import { url } from '@sarimarcus/content-sites-core/url';
6
+ import { routePath, url, type SiteRoutes } from '@sarimarcus/content-sites-core/url';
7
7
  import Chip from '../primitives/Chip.astro';
8
8
  import Badge from '../primitives/Badge.astro';
9
9
  import type { ArticleCategoryLink, ArticleCategoryMeta } from './types';
10
+ import { LABELS } from '../internal/labels';
10
11
 
11
12
  interface Props {
12
13
  articles: readonly { data: { category?: string | null } }[];
@@ -14,6 +15,8 @@ interface Props {
14
15
  current?: string;
15
16
  ariaLabel?: string;
16
17
  allLabel?: string;
18
+ /** Overrides of the article routes (`articles`, `articleTopic`). */
19
+ routes?: SiteRoutes;
17
20
  class?: string;
18
21
  }
19
22
 
@@ -21,8 +24,9 @@ const {
21
24
  articles,
22
25
  categories: categoryMeta,
23
26
  current,
24
- ariaLabel = 'Browse articles by topic',
25
- allLabel = 'All',
27
+ ariaLabel = LABELS.articleCategoryNav.ariaLabel,
28
+ allLabel = LABELS.articleCategoryNav.allLabel,
29
+ routes,
26
30
  class: className,
27
31
  } = Astro.props;
28
32
 
@@ -33,13 +37,13 @@ for (const a of articles) {
33
37
  const categories: ArticleCategoryLink[] = Object.keys(categoryMeta)
34
38
  .filter((slug) => counts.has(slug))
35
39
  .map((slug) => ({
36
- href: url(`/articles/topic/${slug}`),
40
+ href: url(routePath('articleTopic', slug, routes)),
37
41
  label: categoryMeta[slug].label,
38
42
  icon: categoryMeta[slug].icon,
39
43
  count: counts.get(slug)!,
40
44
  active: current === slug,
41
45
  }));
42
- const all = { href: url('/articles'), label: allLabel, count: articles.length, active: !current };
46
+ const all = { href: url(routePath('articles', undefined, routes)), label: allLabel, count: articles.length, active: !current };
43
47
  const countClass = (active: boolean) => (active ? 'bg-overlay-foreground/20' : 'bg-muted');
44
48
  ---
45
49
 
@@ -7,6 +7,7 @@ import type { ImageMetadata } from 'astro';
7
7
  import ArticleByline from './ArticleByline.astro';
8
8
  import Icon from '../primitives/Icon.astro';
9
9
  import ResponsivePicture from '../primitives/ResponsivePicture.astro';
10
+ import { LABELS } from '../internal/labels';
10
11
 
11
12
  interface Props {
12
13
  href: string;
@@ -32,7 +33,7 @@ interface Props {
32
33
  class?: string;
33
34
  }
34
35
 
35
- const { href, title, description, author, label, image, imageAlt = '', dateTime, dateLabel, updated = false, readLabel = 'Read article', updatedLabel = 'Updated', readingLabel, loading = 'eager', headingLevel = 2, pagefindIgnore = false, class: className = 'mb-12' } = Astro.props;
36
+ const { href, title, description, author, label, image, imageAlt = '', dateTime, dateLabel, updated = false, readLabel = LABELS.articleFeatureCard.readLabel, updatedLabel = LABELS.articleFeatureCard.updatedLabel, readingLabel, loading = 'eager', headingLevel = 2, pagefindIgnore = false, class: className = 'mb-12' } = Astro.props;
36
37
  const Heading = `h${headingLevel}` as 'h2' | 'h3';
37
38
  ---
38
39
 
@@ -7,6 +7,7 @@
7
7
  import { formatUtcDate } from '@sarimarcus/content-sites-core/dates';
8
8
  import { Icon, LinkArrow } from '../primitives/index.ts';
9
9
  import type { TopicPagerLink } from './types';
10
+ import { LABELS } from '../internal/labels';
10
11
 
11
12
  interface Props {
12
13
  topic: { label: string; href: string; count?: number };
@@ -15,6 +16,8 @@ interface Props {
15
16
  heading?: string;
16
17
  newerLabel?: string;
17
18
  olderLabel?: string;
19
+ /** The topic hub link's text. */
20
+ allLabel?: string;
18
21
  class?: string;
19
22
  }
20
23
 
@@ -22,9 +25,10 @@ const {
22
25
  topic,
23
26
  newer,
24
27
  older,
25
- heading = `Next in ${topic.label}`,
26
- newerLabel = 'Newer',
27
- olderLabel = 'Older',
28
+ heading = LABELS.articleTopicPager.heading(topic.label),
29
+ newerLabel = LABELS.articleTopicPager.newerLabel,
30
+ olderLabel = LABELS.articleTopicPager.olderLabel,
31
+ allLabel = LABELS.articleTopicPager.allLabel(topic.label, topic.count),
28
32
  class: className,
29
33
  } = Astro.props;
30
34
 
@@ -33,7 +37,6 @@ const sides = [
33
37
  newer && { link: newer, label: newerLabel, dir: 'newer' as const },
34
38
  older && { link: older, label: olderLabel, dir: 'older' as const },
35
39
  ].filter(Boolean) as { link: TopicPagerLink; label: string; dir: 'newer' | 'older' }[];
36
- const allLabel = topic.count ? `All ${topic.count} ${topic.label} articles` : `All ${topic.label} articles`;
37
40
  ---
38
41
 
39
42
  {sides.length > 0 && (
@@ -2,12 +2,19 @@
2
2
  /**
3
3
  * EventPage — the shell and shared body of an event page. The route keeps paths, data, SEO and schema, and renders
4
4
  * the site Layout around this template. Slots:
5
- * breadcrumb — the site Breadcrumb, in the band under the header.
6
- * hero — the site hero, inside the Pagefind body with `type:<pagefindType>` meta.
7
- * after-body — site blocks between the description and the shared boxes.
8
- * after-details — site blocks after the tags, inside the measure column (affiliate, FAQ, onward links).
9
- * tail — full-width blocks after the column (related events, related content).
10
- * Every string arrives as a prop; the route renders markdown and passes it as TrustedHtml.
5
+ * breadcrumb — the site Breadcrumb, in the band under the header.
6
+ * hero — the site hero, inside the Pagefind body with `type:<pagefindType>` meta, after one sr-only
7
+ * `audience:<a>` filter span per `audiences` entry.
8
+ * back — replaces the `backLink` anchor.
9
+ * before-body — site blocks between the back link and the description (chips, ToC, metadata).
10
+ * body — replaces the built-in `<article>` (a route that renders its own sections).
11
+ * after-body — site blocks between the description and the shared boxes.
12
+ * after-highlights — site blocks between the highlights and the practical information.
13
+ * website — replaces the `officialWebsite` block.
14
+ * after-details — site blocks after the tags, inside the measure column (affiliate, FAQ, onward links).
15
+ * tail — full-width blocks after the column (related events, related content).
16
+ * Every string arrives as a prop; the route renders markdown and passes it as TrustedHtml. `article` and `highlights`
17
+ * take an `id`, extra classes and a `section` name, which emits `data-<section>-section` (a ToC scroll-spy target).
11
18
  */
12
19
  import Container from '../layout/Container.astro';
13
20
  import Badge from '../primitives/Badge.astro';
@@ -16,22 +23,35 @@ import { newTab } from '../primitives/newTab';
16
23
  import type { TrustedHtml } from '../types';
17
24
  import type { EventInfoRow } from './event';
18
25
 
26
+ interface Section {
27
+ id?: string;
28
+ /** Appended to the block's own classes. */
29
+ class?: string;
30
+ /** Emits `data-<section>-section`. */
31
+ section?: string;
32
+ }
33
+
19
34
  interface Props {
20
- bodyHtml: TrustedHtml;
35
+ /** Required unless the `body` slot replaces the article. */
36
+ bodyHtml?: TrustedHtml;
21
37
  /** Prose modifiers after `prose`, e.g. the site's prose theme and size. */
22
38
  proseClass?: string;
23
- backLink: { href: string; label: string };
24
- highlights?: { heading: string; items: string[] };
39
+ article?: Section;
40
+ /** Required unless the `back` slot replaces it. */
41
+ backLink?: { href: string; label: string };
42
+ highlights?: { heading: string; items: string[] } & Section;
25
43
  practicalInfo?: { heading: string; rows: EventInfoRow[] };
26
44
  price?: { label: string; value: string };
27
45
  officialWebsite?: { href: string; label: string };
28
46
  tags?: { heading: string; items: string[] };
29
47
  pagefindType?: string;
48
+ audiences?: readonly string[];
30
49
  }
31
50
 
32
51
  const {
33
52
  bodyHtml,
34
53
  proseClass = 'prose-lg',
54
+ article,
35
55
  backLink,
36
56
  highlights,
37
57
  practicalInfo,
@@ -39,9 +59,12 @@ const {
39
59
  officialWebsite,
40
60
  tags,
41
61
  pagefindType = 'event',
62
+ audiences = [],
42
63
  } = Astro.props;
43
64
 
44
65
  const toneText = { primary: 'text-primary', secondary: 'text-secondary', accent: 'text-accent' };
66
+ const sectionAttr = (s?: Section) => (s?.section ? { [`data-${s.section}-section`]: '' } : {});
67
+ const withClass = (base: string, s?: Section) => (s?.class ? `${base} ${s.class}` : base);
45
68
  ---
46
69
 
47
70
  <BreadcrumbBand>
@@ -49,12 +72,12 @@ const toneText = { primary: 'text-primary', secondary: 'text-secondary', accent:
49
72
  </BreadcrumbBand>
50
73
 
51
74
  <div data-pagefind-body data-pagefind-meta={`type:${pagefindType}`}>
52
- <slot name="hero" />
75
+ {audiences.map((a) => <span class="sr-only" data-pagefind-filter={`audience:${a}`}>{a}</span>)}<slot name="hero" />
53
76
  </div>
54
77
 
55
78
  <main class="pt-6 pb-12 lg:pt-8 lg:pb-20">
56
79
  <Container size="measure">
57
- <a
80
+ <slot name="back">{backLink && (<a
58
81
  href={backLink.href}
59
82
  class="inline-flex items-center gap-2 text-sm text-muted-foreground hover:text-primary transition-colors mb-8"
60
83
  >
@@ -62,16 +85,16 @@ const toneText = { primary: 'text-primary', secondary: 'text-secondary', accent:
62
85
  <path d="m12 19-7-7 7-7" /><path d="M19 12H5" />
63
86
  </svg>
64
87
  {backLink.label}
65
- </a>
88
+ </a>)}</slot>
66
89
 
67
- <article class={`prose ${proseClass} max-w-none`} data-pagefind-body>
90
+ <slot name="before-body" /><slot name="body"><article id={article?.id} {...sectionAttr(article)} class={withClass(`prose ${proseClass} max-w-none`, article)} data-pagefind-body>
68
91
  <div set:html={bodyHtml} />
69
- </article>
92
+ </article></slot>
70
93
 
71
94
  <slot name="after-body" />
72
95
 
73
96
  {highlights && highlights.items.length > 0 && (
74
- <div class="mt-12 p-6 bg-primary/5 rounded-xl border border-primary/20">
97
+ <div id={highlights.id} {...sectionAttr(highlights)} class={withClass('mt-12 p-6 bg-primary/5 rounded-xl border border-primary/20', highlights)}>
75
98
  <h2
76
99
  class="text-xl font-medium mb-6 flex items-center gap-2 font-display"
77
100
  >
@@ -93,7 +116,7 @@ const toneText = { primary: 'text-primary', secondary: 'text-secondary', accent:
93
116
  </div>
94
117
  )}
95
118
 
96
- {practicalInfo && (
119
+ <slot name="after-highlights" />{practicalInfo && (
97
120
  <div class="mt-12 p-6 bg-muted/50 rounded-xl border border-border">
98
121
  <h2
99
122
  class="text-xl font-medium mb-6 flex items-center gap-2 font-display"
@@ -141,7 +164,7 @@ const toneText = { primary: 'text-primary', secondary: 'text-secondary', accent:
141
164
  </div>
142
165
  )}
143
166
 
144
- {officialWebsite && (
167
+ <slot name="website">{officialWebsite && (
145
168
  <div class="mt-8">
146
169
  <a
147
170
  href={officialWebsite.href}
@@ -154,9 +177,7 @@ const toneText = { primary: 'text-primary', secondary: 'text-secondary', accent:
154
177
  {officialWebsite.label}
155
178
  </a>
156
179
  </div>
157
- )}
158
-
159
- {tags && tags.items.length > 0 && (
180
+ )}</slot>{tags && tags.items.length > 0 && (
160
181
  <div class="mt-8">
161
182
  <h3 class="text-sm font-medium text-muted-foreground mb-3">
162
183
  {tags.heading}
@@ -7,6 +7,7 @@
7
7
  import Button from '../primitives/Button.astro';
8
8
  import LinkArrow from '../primitives/LinkArrow.astro';
9
9
  import Icon from '../primitives/Icon.astro';
10
+ import { LABELS } from '../internal/labels';
10
11
 
11
12
  interface Props {
12
13
  tagline: string;
@@ -19,12 +20,14 @@ interface Props {
19
20
  exploreLabel: string;
20
21
  /** Accessible name of the section links. */
21
22
  exploreAriaLabel?: string;
23
+ /** The shortcut hint beside the search link. */
24
+ searchShortcut?: string;
22
25
  links: { href: string; label: string }[];
23
26
  /** Classes of the search link; a dark theme passes its own border and background tokens. */
24
27
  searchBoxClass?: string;
25
28
  }
26
29
 
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;
30
+ const { tagline, lede, homeHref, homeLabel, searchHref, searchLabel, searchAriaLabel, exploreLabel, exploreAriaLabel = LABELS.notFoundPage.exploreAriaLabel, searchShortcut = LABELS.notFoundPage.searchShortcut, 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;
28
31
  ---
29
32
 
30
33
  <main class="relative min-h-[80vh] flex items-center justify-center overflow-hidden bg-muted">
@@ -81,7 +84,7 @@ const { tagline, lede, homeHref, homeLabel, searchHref, searchLabel, searchAriaL
81
84
  >
82
85
  <Icon name="search" class="w-4 h-4 text-muted-foreground shrink-0" />
83
86
  <span class="text-muted-foreground text-sm flex-1">{searchLabel}</span>
84
- <kbd class="hidden sm:inline-flex items-center px-1.5 py-0.5 rounded text-xs font-mono border border-border text-muted-foreground/60 bg-muted/50">⌘K</kbd>
87
+ <kbd class="hidden sm:inline-flex items-center px-1.5 py-0.5 rounded text-xs font-mono border border-border text-muted-foreground/60 bg-muted/50">{searchShortcut}</kbd>
85
88
  </a>
86
89
  </div>
87
90
 
@@ -4,6 +4,7 @@
4
4
  * default top margin (`mt-12`); `labelClass` sets the slot label size.
5
5
  */
6
6
  import Icon from '../primitives/Icon.astro';
7
+ import { LABELS } from '../internal/labels';
7
8
 
8
9
  export interface VisitTimingData {
9
10
  bestSeason?: string;
@@ -19,26 +20,29 @@ interface Props {
19
20
  id?: string;
20
21
  class?: string;
21
22
  labelClass?: string;
23
+ /** Row labels; default English. */
24
+ labels?: { bestSeason: string; quietestHours: string; visitDuration: string };
22
25
  }
23
26
 
24
27
  const {
25
28
  visitTiming,
26
- title = 'When to Visit',
29
+ title = LABELS.visitTiming.title,
27
30
  id = 'when-to-visit',
28
31
  class: className = 'mt-12',
29
32
  labelClass = 'text-[0.8125rem]',
33
+ labels = LABELS.visitTiming,
30
34
  } = Astro.props;
31
35
 
32
36
  const slots = visitTiming
33
37
  ? [
34
38
  visitTiming.bestSeason
35
- ? { icon: 'calendar', label: 'Best season', value: visitTiming.bestSeason }
39
+ ? { icon: 'calendar', label: labels.bestSeason, value: visitTiming.bestSeason }
36
40
  : null,
37
41
  visitTiming.quietestHours
38
- ? { icon: 'clock', label: 'Quietest hours', value: visitTiming.quietestHours }
42
+ ? { icon: 'clock', label: labels.quietestHours, value: visitTiming.quietestHours }
39
43
  : null,
40
44
  visitTiming.visitDuration
41
- ? { icon: 'hourglass', label: 'Visit duration', value: visitTiming.visitDuration }
45
+ ? { icon: 'hourglass', label: labels.visitDuration, value: visitTiming.visitDuration }
42
46
  : null,
43
47
  ].filter((s): s is { icon: string; label: string; value: string } => s !== null)
44
48
  : [];
@@ -16,7 +16,7 @@ export { default as ArticleCategoryNav } from './ArticleCategoryNav.astro';
16
16
  export { default as ArticleTopicPage } from './ArticleTopicPage.astro';
17
17
  export { default as ArticleTopicPager } from './ArticleTopicPager.astro';
18
18
  export { rankByCount } from './topic';
19
- export type { AuthorSummary, ArticleSummary, KeyFact, SourceItem, ArticleCategoryLink, ArticleCategoryMeta, TopicAuthorCard, TopicPagerLink } from './types';
19
+ export type { AuthorSummary, ArticleSummary, KeyFact, ArticleCategoryLink, ArticleCategoryMeta, TopicAuthorCard, TopicPagerLink } from './types';
20
20
  export { default as InsiderTips } from './InsiderTips.astro';
21
21
  export type { InsiderTip, InsiderTipAuthor, InsiderTipKinds, InsiderTipTone } from './types';
22
22
  export { default as AuthorStrip } from './AuthorStrip.astro';
@@ -1,6 +1,5 @@
1
1
  import type { ImageMetadata } from 'astro';
2
2
  import type { BrandTone, TrustedHtml } from '../types';
3
- import type { SourceInput } from '../seo/types';
4
3
 
5
4
  /** A person as listed on author strips and cards. The byline shape is ui/seo `ArticleAuthor`. */
6
5
  export interface AuthorSummary {
@@ -34,9 +33,6 @@ export interface KeyFact {
34
33
  detailHtml?: TrustedHtml;
35
34
  }
36
35
 
37
- /** @deprecated Import `SourceInput` from `@sarimarcus/content-sites-ui/seo`. Removed in the next minor (SLA-3020). */
38
- export type SourceItem = SourceInput;
39
-
40
36
  /** The fields ArticleCategoryNav reads from a site's category map entry. */
41
37
  export interface ArticleCategoryMeta {
42
38
  label: string;
@@ -0,0 +1,102 @@
1
+ // The English copy ui components fall back to when the site passes no prop (SLA-2991). A template never writes
2
+ // reader-visible text itself (validate-packages refuses it); it reads a prop whose default is here.
3
+ export const LABELS = {
4
+ affiliateCard: {
5
+ pricePrefix: 'From ',
6
+ bestSeller: 'Best Seller',
7
+ topRated: 'Top Rated',
8
+ skipTheLine: 'Skip the line',
9
+ cta: 'Reserve Your Spot',
10
+ reviewCount: (n: string) => `(${n} reviews)`,
11
+ },
12
+ affiliateCardSection: { title: 'Recommended Tours & Activities' },
13
+ affiliateCarousel: { heading: 'Recommended tours' },
14
+ affiliateComparisonTable: {
15
+ nameLabel: 'Option',
16
+ priceLabel: 'Price',
17
+ featureLabel: 'Highlight',
18
+ caption: 'Comparison of options',
19
+ ctaColumn: 'Book or view',
20
+ externalCta: 'Check availability',
21
+ internalCta: 'View details',
22
+ externalAria: (cta: string, name: string) => `${cta}: ${name} (opens in a new tab)`,
23
+ internalAria: (cta: string, name: string) => `${cta}: ${name}`,
24
+ rated: (rating: number, count?: number) => `Rated ${rating} out of 5${count ? ` from ${count} reviews` : ''}`,
25
+ },
26
+ amazonProductCard: { ctaLabel: 'View on Amazon' },
27
+ amazonProductSection: { title: 'Recommended reading' },
28
+ hotelAffiliateCTA: {
29
+ headline: (name: string) => `Find the best rate for ${name}`,
30
+ bookCta: (noun: string) => `Book this ${noun}`,
31
+ searchCta: 'Check prices & availability',
32
+ bookSubhead: 'Check current availability and rates.',
33
+ searchSubhead: (noun: string) => `Search current prices and availability for this ${noun}.`,
34
+ from: 'from ',
35
+ perNight: ' / night',
36
+ reviews: (n: string) => `(${n} reviews)`,
37
+ ratingOn: (source: string) => `on ${source}`,
38
+ freeToSearch: 'Free to search · No sign-up needed',
39
+ officialSite: (noun: string) => `Visit the ${noun}'s official site`,
40
+ },
41
+ inlineAffiliateCTA: { cta: 'See tours' },
42
+ stickyCTA: {
43
+ label: 'Reserve Your Spot',
44
+ note: 'Found the right tour? Reserve your spot before it fills.',
45
+ dismissLabel: 'Dismiss',
46
+ },
47
+ travelpayoutsWidget: {
48
+ hotel: 'Hotel search',
49
+ activity: 'Activity search',
50
+ train: 'Train and bus search',
51
+ car: 'Car rental search',
52
+ },
53
+ cardCarousel: {
54
+ viewAllLabel: 'View all',
55
+ regionLabel: 'Carousel',
56
+ previousLabel: (name: string | undefined) => `Previous, ${name ?? 'carousel'}`,
57
+ nextLabel: (name: string | undefined) => `Next, ${name ?? 'carousel'}`,
58
+ },
59
+ faq: { title: 'Frequently asked questions' },
60
+ pagination: {
61
+ ariaLabel: 'Pagination',
62
+ previousLabel: 'Previous',
63
+ nextLabel: 'Next',
64
+ pageLabel: (page: number) => `Go to page ${page}`,
65
+ currentPageLabel: (page: number) => `Page ${page}, current page`,
66
+ },
67
+ tableOfContents: {
68
+ ariaLabel: 'Table of contents',
69
+ jumpLabel: 'Jump to a section',
70
+ stickyLabel: (name: string) => `${name} (sticky)`,
71
+ },
72
+ alsoExplore: { eyebrow: 'Also explore' },
73
+ articleCategoryNav: { ariaLabel: 'Browse articles by topic', allLabel: 'All' },
74
+ articleFeatureCard: { readLabel: 'Read article', updatedLabel: 'Updated' },
75
+ articleTopicPager: {
76
+ heading: (topic: string) => `Next in ${topic}`,
77
+ newerLabel: 'Newer',
78
+ olderLabel: 'Older',
79
+ allLabel: (topic: string, count?: number) => (count ? `All ${count} ${topic} articles` : `All ${topic} articles`),
80
+ },
81
+ notFoundPage: { exploreAriaLabel: 'Explore sections', searchShortcut: '⌘K' },
82
+ visitTiming: { title: 'When to Visit', bestSeason: 'Best season', quietestHours: 'Quietest hours', visitDuration: 'Visit duration' },
83
+ articlesNavMenu: { latestHeading: 'Latest' },
84
+ siteFooter: { cookieLabel: 'Cookie preferences', rightsLabel: 'All rights reserved.', legalAriaLabel: 'Legal' },
85
+ siteHeader: { label: 'Main navigation', openLabel: 'Open menu', closeLabel: 'Close menu' },
86
+ articleMetadata: {
87
+ writtenByLabel: 'Written by',
88
+ portraitLabel: 'Portrait of',
89
+ updatedLabel: 'Updated',
90
+ publishedLabel: 'Published',
91
+ ariaLabel: 'Article information',
92
+ },
93
+ authoritativeSources: {
94
+ title: 'Sources',
95
+ eyebrow: 'Further reading',
96
+ countLabel: 'references',
97
+ showLabel: 'Show',
98
+ hideLabel: 'Hide',
99
+ newTabLabel: '(opens in a new tab)',
100
+ },
101
+ breadcrumb: { homeLabel: 'Home', ariaLabel: 'Breadcrumb navigation' },
102
+ };
@@ -22,8 +22,6 @@ export function track(eventName: string, params: Record<string, unknown> = {}):
22
22
  window.dataLayer.push({ event: eventName, page_path: window.location.pathname, ...params });
23
23
  }
24
24
 
25
- export { pageTypeOf };
26
-
27
25
  // Param key derived from the data-track-* attribute name where the
28
26
  // event-specific prefix (e.g. 'cta_', 'affiliate_', 'link_') has been stripped.
29
27
  const HREF_DESTINATION_KEYS: Record<string, string> = {
@@ -8,5 +8,5 @@ export { default as DocumentShell } from './DocumentShell.astro';
8
8
  export { default as ConsentAnalytics } from './ConsentAnalytics.astro';
9
9
  export type { ConsentType } from './ConsentAnalytics.astro';
10
10
  export { shellResources, GTM_ORIGIN, SILKTIDE_BASE, SILKTIDE_STYLESHEET } from './resources.ts';
11
- export { track, pageTypeOf, setupDelegatedTracking, setupFaqTracking, setupScrollDepthTracking, setupAnalytics } from './analytics.ts';
11
+ export { track, setupDelegatedTracking, setupFaqTracking, setupScrollDepthTracking, setupAnalytics } from './analytics.ts';
12
12
  export type { AnalyticsOptions } from './analytics.ts';
@@ -15,6 +15,7 @@ import MobileNavLink from './MobileNavLink.astro';
15
15
  import { isActivePath } from './activePath.ts';
16
16
  import { TONES } from './tones.ts';
17
17
  import type { ArticlesNavProps } from './types.ts';
18
+ import { LABELS } from '../internal/labels';
18
19
 
19
20
  type Props = ArticlesNavProps;
20
21
 
@@ -26,7 +27,7 @@ const {
26
27
  tone = 'primary',
27
28
  topicsHeading,
28
29
  topics,
29
- latestHeading = 'Latest',
30
+ latestHeading = LABELS.articlesNavMenu.latestHeading,
30
31
  latest = [],
31
32
  all,
32
33
  iconStyle = 'plain',
@@ -7,12 +7,17 @@
7
7
  */
8
8
  import { Icon } from '../primitives/index.ts';
9
9
  import type { FooterSection, NavLink, SiteIdentity } from './types.ts';
10
+ import { LABELS } from '../internal/labels';
10
11
 
11
12
  interface Props {
12
13
  identity: SiteIdentity;
13
14
  sections?: FooterSection[];
14
15
  legal?: NavLink[];
15
16
  cookieLabel?: string;
17
+ /** Follows the copyright line's site name. */
18
+ rightsLabel?: string;
19
+ /** Accessible name of the legal links. */
20
+ legalAriaLabel?: string;
16
21
  /** Shown between legal items (aria-hidden), e.g. `|`. */
17
22
  separator?: string;
18
23
  year?: number;
@@ -34,7 +39,9 @@ const {
34
39
  identity,
35
40
  sections = [],
36
41
  legal = [],
37
- cookieLabel = 'Cookie preferences',
42
+ cookieLabel = LABELS.siteFooter.cookieLabel,
43
+ rightsLabel = LABELS.siteFooter.rightsLabel,
44
+ legalAriaLabel = LABELS.siteFooter.legalAriaLabel,
38
45
  separator,
39
46
  year = new Date().getFullYear(),
40
47
  class: className = 'bg-inverse text-inverse-muted',
@@ -81,9 +88,8 @@ const legalItemClass = `${smallClass} inline-flex min-h-11 items-center hover:te
81
88
  <slot name="bottom" />
82
89
  <div class="flex flex-col sm:flex-row justify-between items-center gap-4">
83
90
  <p class={smallClass}>
84
- &copy; {year} {identity.name}. All rights reserved.
85
- </p>
86
- <nav class:list={['flex flex-wrap items-center justify-center sm:justify-end', legalClass]} aria-label="Legal">
91
+ &copy; {year} {identity.name}. {`${rightsLabel}\n`}</p>
92
+ <nav class:list={['flex flex-wrap items-center justify-center sm:justify-end', legalClass]} aria-label={legalAriaLabel}>
87
93
  {legal.map((link) => (
88
94
  <>
89
95
  <a href={link.href} class={legalItemClass}>{link.label}</a>
@@ -12,6 +12,7 @@
12
12
  * `<nav>`, so it carries its own `data-pagefind-ignore`.
13
13
  */
14
14
  import { Icon } from '../primitives/index.ts';
15
+ import { LABELS } from '../internal/labels';
15
16
 
16
17
  interface Props {
17
18
  label?: string;
@@ -29,12 +30,12 @@ interface Props {
29
30
  }
30
31
 
31
32
  const {
32
- label = 'Main navigation',
33
+ label = LABELS.siteHeader.label,
33
34
  surfaceClass = 'bg-background/95 backdrop-blur-md',
34
35
  class: className = `fixed top-0 left-0 right-0 z-50 transition-[background-color,backdrop-filter,border-color,box-shadow] duration-300 border-b border-border ${surfaceClass}`,
35
36
  toggleClass = 'p-2 min-h-[44px] min-w-[44px] flex items-center justify-center rounded-lg transition-colors text-foreground hover:bg-muted cursor-pointer focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2',
36
- openLabel = 'Open menu',
37
- closeLabel = 'Close menu',
37
+ openLabel = LABELS.siteHeader.openLabel,
38
+ closeLabel = LABELS.siteHeader.closeLabel,
38
39
  searchPath,
39
40
  id = 'main-nav',
40
41
  panelId = 'mobile-menu',
@@ -15,6 +15,7 @@
15
15
  */
16
16
  import { Image } from 'astro:assets';
17
17
  import type { ArticleAuthor } from './types';
18
+ import { LABELS } from '../internal/labels';
18
19
 
19
20
  interface Props {
20
21
  author?: ArticleAuthor | null;
@@ -42,11 +43,11 @@ const {
42
43
  dateModified,
43
44
  timeZone = 'UTC',
44
45
  locale = 'en-GB',
45
- writtenByLabel = 'Written by',
46
- portraitLabel = 'Portrait of',
47
- updatedLabel = 'Updated',
48
- publishedLabel = 'Published',
49
- ariaLabel = 'Article information',
46
+ writtenByLabel = LABELS.articleMetadata.writtenByLabel,
47
+ portraitLabel = LABELS.articleMetadata.portraitLabel,
48
+ updatedLabel = LABELS.articleMetadata.updatedLabel,
49
+ publishedLabel = LABELS.articleMetadata.publishedLabel,
50
+ ariaLabel = LABELS.articleMetadata.ariaLabel,
50
51
  placeholderClass = 'bg-gradient-to-br from-primary/20 to-accent/20',
51
52
  updatedIconClass = 'text-primary',
52
53
  bylineWhitespace = 'flat',
@@ -17,6 +17,7 @@
17
17
  */
18
18
  import { outboundClassifier } from '@sarimarcus/content-sites-core/url';
19
19
  import type { SourceInput, DomainClassifier } from './types';
20
+ import { LABELS } from '../internal/labels';
20
21
 
21
22
  interface Props {
22
23
  sources: SourceInput[];
@@ -39,18 +40,18 @@ interface Props {
39
40
 
40
41
  const {
41
42
  sources,
42
- title = 'Sources',
43
+ title = LABELS.authoritativeSources.title,
43
44
  class: className = '',
44
- eyebrow = 'Further reading',
45
+ eyebrow = LABELS.authoritativeSources.eyebrow,
45
46
  eyebrowClass = 'text-xs font-semibold uppercase tracking-[0.18em] text-primary',
46
47
  domainLabels = {},
47
48
  domainClass = {},
48
49
  disambiguate = false,
49
50
  disambiguationSeparator = ' — ',
50
- countLabel = 'references',
51
- showLabel = 'Show',
52
- hideLabel = 'Hide',
53
- newTabLabel = '(opens in a new tab)',
51
+ countLabel = LABELS.authoritativeSources.countLabel,
52
+ showLabel = LABELS.authoritativeSources.showLabel,
53
+ hideLabel = LABELS.authoritativeSources.hideLabel,
54
+ newTabLabel = LABELS.authoritativeSources.newTabLabel,
54
55
  toggleSizeClass = 'text-sm',
55
56
  } = Astro.props;
56
57
 
@@ -13,6 +13,7 @@
13
13
  import { url } from '@sarimarcus/content-sites-core/url';
14
14
  import Icon from '../primitives/Icon.astro';
15
15
  import type { BreadcrumbItem } from './types';
16
+ import { LABELS } from '../internal/labels';
16
17
 
17
18
  interface Props {
18
19
  items: BreadcrumbItem[];
@@ -36,9 +37,9 @@ const {
36
37
  compactMobile = false,
37
38
  separator = 'chevron',
38
39
  tapTargets = true,
39
- homeLabel = 'Home',
40
+ homeLabel = LABELS.breadcrumb.homeLabel,
40
41
  homeHref = '/',
41
- ariaLabel = 'Breadcrumb navigation',
42
+ ariaLabel = LABELS.breadcrumb.ariaLabel,
42
43
  basePath = '/',
43
44
  } = Astro.props;
44
45
 
package/src/seo/author.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { ImageMetadata } from 'astro';
2
- import { url } from '@sarimarcus/content-sites-core/url';
2
+ import { routePath, url, type SiteRoutes } from '@sarimarcus/content-sites-core/url';
3
3
  import type { ArticleAuthor } from './types';
4
4
 
5
5
  /** The fields of a site's author profile that a byline uses. */
@@ -11,18 +11,20 @@ export interface ArticleAuthorProfile {
11
11
 
12
12
  /**
13
13
  * A byline name as ArticleMetadata's author: its profile (from `findProfile`) URL, portrait (from `portrait(slug)`),
14
- * photo URL and credentials. An unknown name keeps only the name; no name gives no author.
14
+ * photo URL and credentials. An unknown name keeps only the name; no name gives no author. `routes` overrides the
15
+ * `author` route.
15
16
  */
16
17
  export function toArticleAuthor(
17
18
  name: string | undefined,
18
19
  findProfile: (name: string) => ArticleAuthorProfile | undefined,
19
20
  portrait: (slug: string) => ImageMetadata | null,
21
+ routes?: SiteRoutes,
20
22
  ): ArticleAuthor | undefined {
21
23
  if (!name) return undefined;
22
24
  const profile = findProfile(name);
23
25
  return {
24
26
  name,
25
- href: profile ? url(`/author/${profile.slug}`) : null,
27
+ href: profile ? url(routePath('author', profile.slug, routes)) : null,
26
28
  image: profile?.slug ? portrait(profile.slug) : null,
27
29
  photoUrl: profile?.photo ? url(profile.photo) : null,
28
30
  credentials: profile?.credentials ?? null,