@sarimarcus/content-sites-ui 0.28.1 → 0.29.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,15 @@
2
2
 
3
3
  All packages in the platform release in lockstep; entries are per release version.
4
4
 
5
+ ## 0.29.1 (2026-10-06)
6
+
7
+ - `./editorial`: `ArticleTopicPage` takes `proseClass`, the overview's prose theme modifier added to `prose` (default `prose-lg`, as before), so a dark site can render the overview in its own prose colours (SLA-2994).
8
+
9
+ ## 0.29.0 (2026-10-06)
10
+
11
+ - `./navigation`: `ArticlesNavMenu`, the header's Articles menu (desktop dropdown via `variant="desktop"`, mobile accordion section via `variant="mobile"`): a topics column, and with `latest` a second column where the newest article is a feature card (16:9 image, topic, date), the next ones are dated rows, then the archive link. Without `latest` it renders the single-column menu. Types `ArticleNavItem`, `ArticlesNavProps`, `NavHubLink` (SLA-2993).
12
+ - `./editorial`: `ArticleTopicPager`, a text-only "Next in <topic>" pager for the end of an article (newer and older article of the same topic, dated, then the topic hub link); renders nothing without a neighbour. Type `TopicPagerLink` (SLA-2995).
13
+
5
14
  ## 0.28.1 (2026-10-06)
6
15
 
7
16
  - No changes; lockstep release with core and tourism (SLA-2983).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sarimarcus/content-sites-ui",
3
- "version": "0.28.1",
3
+ "version": "0.29.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.28.1"
52
+ "@sarimarcus/content-sites-core": "0.29.1"
53
53
  }
54
54
  }
@@ -35,9 +35,11 @@ interface Props {
35
35
  listHeading?: string;
36
36
  overview?: { heading: string; html: TrustedHtml; guide?: { title: string; lead: string; href: string; linkLabel: string } };
37
37
  authors?: { heading: string; items: TopicAuthorCard[] };
38
+ /** The site's prose theme modifier for the overview, added to `prose` (a dark site needs its own body colour). */
39
+ proseClass?: string;
38
40
  }
39
41
 
40
- const { eyebrow, heading, lede, articles, categories, category, currentPage, listHeading, overview, authors } = Astro.props;
42
+ const { eyebrow, heading, lede, articles, categories, category, currentPage, listHeading, overview, authors, proseClass = 'prose-lg' } = Astro.props;
41
43
  const hub = currentPage === 1;
42
44
  // Astro.slots.has() is true for a slotted expression that evaluated to false, so test what it rendered.
43
45
  const faqHtml = hub && Astro.slots.has('faq') ? (await Astro.slots.render('faq')).trim() : '';
@@ -56,7 +58,7 @@ const faqHtml = hub && Astro.slots.has('faq') ? (await Astro.slots.render('faq')
56
58
  <Container size="measure">
57
59
  <h2 class="text-2xl sm:text-3xl font-normal font-display text-foreground mb-4">{overview.heading}</h2>
58
60
  <slot name="overview-byline" />
59
- <div class="prose prose-lg max-w-none mt-6" set:html={overview.html} />
61
+ <div class:list={['prose', proseClass, 'max-w-none mt-6']} set:html={overview.html} />
60
62
  {overview.guide && (
61
63
  <Callout type="tip" title={overview.guide.title} class="mt-8">
62
64
  {overview.guide.lead}<a href={overview.guide.href} class="font-medium text-primary hover:underline">{overview.guide.linkLabel}</a>
@@ -0,0 +1,63 @@
1
+ ---
2
+ /**
3
+ * ArticleTopicPager — "Next in <topic>" at the end of an article: the newer and the older article of the same topic,
4
+ * text only (the Related block below it carries the images), then the topic hub link. The site resolves the
5
+ * neighbours and every href. Renders nothing without a neighbour.
6
+ */
7
+ import { formatUtcDate } from '@sarimarcus/content-sites-core/dates';
8
+ import { Icon, LinkArrow } from '../primitives/index.ts';
9
+ import type { TopicPagerLink } from './types';
10
+
11
+ interface Props {
12
+ topic: { label: string; href: string; count?: number };
13
+ newer?: TopicPagerLink;
14
+ older?: TopicPagerLink;
15
+ heading?: string;
16
+ newerLabel?: string;
17
+ olderLabel?: string;
18
+ class?: string;
19
+ }
20
+
21
+ const {
22
+ topic,
23
+ newer,
24
+ older,
25
+ heading = `Next in ${topic.label}`,
26
+ newerLabel = 'Newer',
27
+ olderLabel = 'Older',
28
+ class: className,
29
+ } = Astro.props;
30
+
31
+ const day = (iso?: string) => formatUtcDate(iso, { day: 'numeric', month: 'short', year: 'numeric' }, 'en-GB');
32
+ const sides = [
33
+ newer && { link: newer, label: newerLabel, dir: 'newer' as const },
34
+ older && { link: older, label: olderLabel, dir: 'older' as const },
35
+ ].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
+ ---
38
+
39
+ {sides.length > 0 && (
40
+ <nav aria-label={heading} class:list={['mt-14 pt-8 border-t border-border', className]} data-pagefind-ignore>
41
+ <h2 class="text-xs font-semibold uppercase tracking-wide text-muted-foreground">{heading}</h2>
42
+ <div class:list={['mt-4 grid gap-px overflow-hidden rounded-xl border border-border bg-border', sides.length === 2 && 'sm:grid-cols-2']}>
43
+ {sides.map(({ link, label, dir }) => (
44
+ <a
45
+ href={link.href}
46
+ class:list={[
47
+ 'group flex flex-col gap-1 bg-card p-5 transition-colors hover:bg-muted focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-primary',
48
+ dir === 'older' && sides.length === 2 && 'sm:text-right sm:items-end',
49
+ ]}
50
+ >
51
+ <span class="inline-flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-primary">
52
+ {dir === 'newer' && <Icon name="arrow-left" class="w-3.5 h-3.5" />}
53
+ {label}
54
+ {dir === 'older' && <Icon name="arrow-right" class="w-3.5 h-3.5" />}
55
+ </span>
56
+ <span class="text-base font-semibold leading-snug text-foreground line-clamp-2 transition-colors group-hover:text-primary">{link.title}</span>
57
+ {link.date && <span class="text-xs text-muted-foreground">{day(link.date)}</span>}
58
+ </a>
59
+ ))}
60
+ </div>
61
+ <LinkArrow href={topic.href} class="mt-4 rounded-sm focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary">{allLabel}</LinkArrow>
62
+ </nav>
63
+ )}
@@ -14,8 +14,9 @@ export { default as ArticleCard } from './ArticleCard.astro';
14
14
  export { default as ArticleGrid } from './ArticleGrid.astro';
15
15
  export { default as ArticleCategoryNav } from './ArticleCategoryNav.astro';
16
16
  export { default as ArticleTopicPage } from './ArticleTopicPage.astro';
17
+ export { default as ArticleTopicPager } from './ArticleTopicPager.astro';
17
18
  export { rankByCount } from './topic';
18
- export type { AuthorSummary, ArticleSummary, KeyFact, SourceItem, ArticleCategoryLink, ArticleCategoryMeta, TopicAuthorCard } from './types';
19
+ export type { AuthorSummary, ArticleSummary, KeyFact, SourceItem, ArticleCategoryLink, ArticleCategoryMeta, TopicAuthorCard, TopicPagerLink } from './types';
19
20
  export { default as InsiderTips } from './InsiderTips.astro';
20
21
  export type { InsiderTip, InsiderTipAuthor, InsiderTipKinds, InsiderTipTone } from './types';
21
22
  export { default as AuthorStrip } from './AuthorStrip.astro';
@@ -80,3 +80,11 @@ export interface InsiderTipAuthor {
80
80
  expertise?: string;
81
81
  credentials?: string;
82
82
  }
83
+
84
+ /** One side of ArticleTopicPager. */
85
+ export interface TopicPagerLink {
86
+ title: string;
87
+ href: string;
88
+ /** Publication date or datetime (ISO). */
89
+ date?: string;
90
+ }
@@ -0,0 +1,117 @@
1
+ ---
2
+ /**
3
+ * The header's Articles menu: a desktop dropdown (`variant="desktop"`) or a mobile accordion section
4
+ * (`variant="mobile"`); a header renders both, one in each slot. Desktop shows the topic hubs and, when `latest` is
5
+ * given, a second column: the newest article as a feature card (when it has an image), the next ones as dated rows,
6
+ * then the archive link. Mobile lists the archive link and the topics. The site passes every link and label.
7
+ */
8
+ import { normalizePagePath } from '@sarimarcus/content-sites-core/url';
9
+ import { formatUtcDate } from '@sarimarcus/content-sites-core/dates';
10
+ import ResponsiveImage from '../primitives/ResponsiveImage.astro';
11
+ import NavDropdown from './NavDropdown.astro';
12
+ import NavMenuLink from './NavMenuLink.astro';
13
+ import MobileNavSection from './MobileNavSection.astro';
14
+ import MobileNavLink from './MobileNavLink.astro';
15
+ import { isActivePath } from './activePath.ts';
16
+ import { TONES } from './tones.ts';
17
+ import type { ArticlesNavProps } from './types.ts';
18
+
19
+ type Props = ArticlesNavProps;
20
+
21
+ const {
22
+ variant,
23
+ id = 'nav-articles-menu',
24
+ label,
25
+ icon,
26
+ tone = 'primary',
27
+ topicsHeading,
28
+ topics,
29
+ latestHeading = 'Latest',
30
+ latest = [],
31
+ all,
32
+ iconStyle = 'plain',
33
+ align = 'left',
34
+ buttonClass,
35
+ current,
36
+ panelClass = 'bg-card rounded-xl border border-border shadow-xl shadow-foreground/10',
37
+ iconClass,
38
+ class: className,
39
+ } = Astro.props;
40
+
41
+ const t = TONES[tone];
42
+ const currentPath = normalizePagePath(Astro.url.pathname);
43
+ const on = (href: string) => isActivePath(currentPath, href);
44
+ const here = (href: string) => normalizePagePath(href) === currentPath;
45
+ const sectionActive = on(all.href);
46
+
47
+ const heading = 'text-xs font-semibold text-muted-foreground uppercase tracking-wide';
48
+ const ring = `focus-visible:ring-2 ${t.ring} focus-visible:ring-offset-2`;
49
+ const day = (iso?: string) => formatUtcDate(iso, { day: 'numeric', month: 'short' }, 'en-GB');
50
+
51
+ const [first, ...rest] = latest;
52
+ const feature = first?.image ? first : undefined;
53
+ const rows = feature ? rest : latest;
54
+ ---
55
+
56
+ {variant === 'desktop' ? (
57
+ <NavDropdown id={id} label={label} icon={icon} tone={tone} active={sectionActive} align={align} buttonClass={buttonClass} current={current}>
58
+ {latest.length > 0 ? (
59
+ <div class={`w-[34rem] ${panelClass} overflow-hidden`}>
60
+ <div class="grid grid-cols-2">
61
+ <div class="p-2 border-r border-border">
62
+ <div class={`px-3 pt-2 pb-1 ${heading}`}>{topicsHeading}</div>
63
+ {topics.map((link) => <NavMenuLink link={link} tone={tone} size="sm" iconStyle={iconStyle} active={on(link.href)} />)}
64
+ </div>
65
+ <div class="p-2 flex flex-col">
66
+ <div class={`px-3 pt-2 pb-1 ${heading}`}>{latestHeading}</div>
67
+ {feature && (
68
+ <a href={feature.href} aria-current={here(feature.href) ? 'page' : undefined} class:list={[`group block p-2 rounded-lg hover:bg-muted transition-colors ${ring}`, here(feature.href) && 'bg-muted']}>
69
+ <div class="aspect-video rounded-md overflow-hidden bg-muted">
70
+ <ResponsiveImage src={feature.image!} alt={feature.imageAlt ?? ''} sizes="16rem" loading="lazy" decoding="async" class="w-full h-full object-cover transition-transform duration-300 group-hover:scale-[1.03]" />
71
+ </div>
72
+ {(feature.eyebrow || feature.date) && (
73
+ <span class="mt-2 block text-xs">
74
+ {feature.eyebrow && <span class={`font-semibold uppercase tracking-wide ${t.text}`}>{feature.eyebrow}</span>}
75
+ {feature.eyebrow && feature.date && <span class="text-muted-foreground" aria-hidden="true"> · </span>}
76
+ {feature.date && <span class="text-muted-foreground whitespace-nowrap">{day(feature.date)}</span>}
77
+ </span>
78
+ )}
79
+ <span class:list={['mt-0.5 block text-sm font-semibold leading-snug line-clamp-2 transition-colors', here(feature.href) ? t.text : `text-foreground ${t.groupHoverText}`]}>{feature.label}</span>
80
+ </a>
81
+ )}
82
+ {rows.length > 0 && (
83
+ <div class:list={[feature && 'mt-1 border-t border-border pt-1']}>
84
+ {rows.map((item) => (
85
+ <a href={item.href} aria-current={here(item.href) ? 'page' : undefined} class:list={[`group block px-2 py-1.5 rounded-lg hover:bg-muted transition-colors ${ring}`, here(item.href) && 'bg-muted']}>
86
+ {(item.date || item.eyebrow) && (
87
+ <span class="block text-xs text-muted-foreground">{[day(item.date), item.eyebrow].filter(Boolean).join(' · ')}</span>
88
+ )}
89
+ <span class:list={['block text-sm leading-snug line-clamp-2 transition-colors', here(item.href) ? `font-semibold ${t.text}` : `font-medium text-foreground ${t.groupHoverText}`]}>{item.label}</span>
90
+ </a>
91
+ ))}
92
+ </div>
93
+ )}
94
+ <div class="mt-auto border-t border-border pt-2">
95
+ <NavMenuLink link={all} tone={tone} align="center" emphasis iconStyle={iconStyle} active={here(all.href)} />
96
+ </div>
97
+ </div>
98
+ </div>
99
+ </div>
100
+ ) : (
101
+ <div class={`w-56 ${panelClass} overflow-hidden`}>
102
+ <div class="p-2">
103
+ {topics.map((link) => <NavMenuLink link={link} tone={tone} iconStyle={iconStyle} active={on(link.href)} />)}
104
+ </div>
105
+ <div class="border-t border-border p-2">
106
+ <NavMenuLink link={all} tone={tone} align="center" emphasis iconStyle={iconStyle} active={here(all.href)} />
107
+ </div>
108
+ </div>
109
+ )}
110
+ </NavDropdown>
111
+ ) : (
112
+ <MobileNavSection label={label} icon={icon} iconClass={iconClass} class={className} buttonClass={buttonClass} current={current}>
113
+ <MobileNavLink link={{ ...all, label: all.mobileLabel ?? all.label }} tone={tone} emphasis="semibold" active={here(all.href)} />
114
+ <div class={`px-4 pt-2 pb-1 ${heading}`}>{topicsHeading}</div>
115
+ {topics.map((link) => <MobileNavLink link={link} tone={tone} active={on(link.href)} />)}
116
+ </MobileNavSection>
117
+ )}
@@ -6,5 +6,6 @@ export { default as MobileNavSection } from './MobileNavSection.astro';
6
6
  export { default as SiteFooter } from './SiteFooter.astro';
7
7
  export { default as NavMenuLink } from './NavMenuLink.astro';
8
8
  export { default as MobileNavLink } from './MobileNavLink.astro';
9
+ export { default as ArticlesNavMenu } from './ArticlesNavMenu.astro';
9
10
  export { isActivePath } from './activePath.ts';
10
- export type { SiteIdentity, NavLink, NavGroup, FooterSection, NavTone } from './types.ts';
11
+ export type { SiteIdentity, NavLink, NavGroup, FooterSection, NavTone, ArticleNavItem, ArticlesNavProps, NavHubLink } from './types.ts';
@@ -1,4 +1,5 @@
1
1
  // Public contracts for site chrome. Sites own every value: labels, hrefs, icons, logos and copy.
2
+ import type { ImageMetadata } from 'astro';
2
3
 
3
4
  /** Who the site is, as the header, footer and copyright line name it. */
4
5
  export interface SiteIdentity {
@@ -31,3 +32,43 @@ export interface FooterSection {
31
32
 
32
33
  /** The colour role a navigation item is tinted with. */
33
34
  export type NavTone = 'primary' | 'secondary' | 'accent';
35
+
36
+ /** A hub link ("All articles") with an optional shorter or longer label for the mobile menu. */
37
+ export interface NavHubLink extends NavLink {
38
+ mobileLabel?: string;
39
+ }
40
+
41
+ /** A Latest entry of the Articles menu. The first entry becomes the feature card when it has an `image`. */
42
+ export interface ArticleNavItem extends NavLink {
43
+ /** Topic label shown with the date. */
44
+ eyebrow?: string;
45
+ /** Publication date or datetime (ISO). */
46
+ date?: string;
47
+ image?: ImageMetadata;
48
+ imageAlt?: string;
49
+ }
50
+
51
+ export interface ArticlesNavProps {
52
+ variant: 'desktop' | 'mobile';
53
+ /** Desktop panel id. */
54
+ id?: string;
55
+ label: string;
56
+ icon?: string;
57
+ tone?: NavTone;
58
+ topicsHeading: string;
59
+ topics: NavLink[];
60
+ latestHeading?: string;
61
+ /** Newest articles, newest first; omit for the single-column menu. */
62
+ latest?: ArticleNavItem[];
63
+ all: NavHubLink;
64
+ iconStyle?: 'plain' | 'tile';
65
+ align?: 'left' | 'center' | 'right';
66
+ buttonClass?: string;
67
+ current?: boolean;
68
+ /** Desktop panel surface (background, border, radius, shadow). */
69
+ panelClass?: string;
70
+ /** Mobile section icon classes. */
71
+ iconClass?: string;
72
+ /** Mobile section wrapper classes. */
73
+ class?: string;
74
+ }