@sarimarcus/content-sites-ui 0.17.0 → 0.18.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,13 @@
2
2
 
3
3
  All packages in the platform release in lockstep; entries are per release version.
4
4
 
5
+ ## 0.18.0 (2026-10-02)
6
+
7
+ - New page templates in `./editorial`: `StaticProsePage` (legal and site-information pages: heading, optional eyebrow, prose slot, `after` slot, closing CTA box; not Pagefind-indexed), `NotFoundPage` (the 404 body, every string a prop; its decoration ships as `./not-found.css` so a barrel import never carries it to other pages), `ArticlesListingPage` (article index and topic hub shell: breadcrumb band, header, `<main>`, Pagefind body optional) and `ArticleFeatureCard` (SLA-2744).
8
+ - `./search`: `SearchPage`, the search page markup (hero, type chips, optional audience row, result states, type-icon template), with its client controller `initSearchPage` on the new `./search-page` entry point (SLA-2744).
9
+ - `./layout`: `ListingShell`, the listing hub frame inside one Pagefind-ignore wrapper; StructuredData stays a direct Layout child so its JSON-LD renders in `<head>` (SLA-2744).
10
+ - `./composed`: `ArrowLinkGrid`, a grid of bordered link tiles (SLA-2744).
11
+
5
12
  ## 0.17.0 (2026-10-01)
6
13
 
7
14
  - New `DocumentShell` (`./layout`): the whole HTML document a site page renders into: description, robots (`noindex, follow` when noindex), canonical from the page path, RSS and favicons, Open Graph / article / Twitter (handle optional), title, non-critical stylesheets, the Emerald loader, the skip link, navigation and footer wrapped out of the Pagefind index, `#main-content`, and consent-gated analytics. Slots `head-start`, `fonts`, `head`, `body-start`, `nav`, `footer`. It imports no stylesheet and emits no bundled script, so a site's thin Layout keeps its bundle names (SLA-2741).
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Universal Astro UI shared by every content site. Named entry points, one per folder: `primitives`,
4
4
  `composed`, `editorial`, `affiliate`, `seo`, `layout`, `navigation`, `search` and `types`, plus the script
5
- entry points `analytics` and `travelpayouts-styler`. For example,
5
+ entry points `analytics`, `travelpayouts-styler` and `search-page`. For example,
6
6
  `import { Container } from '@sarimarcus/content-sites-ui/layout'`. `src/internal` is never exported,
7
7
  and `src/styles` holds the Tailwind entry.
8
8
 
@@ -74,7 +74,31 @@ state and one bundled controller (idempotent on `astro:page-load`, any number of
74
74
  `<nav aria-label="Legal">` ending with the cookie-preferences button, which calls `window.openCookiePreferences()`
75
75
  (the site's consent loader defines it). Defaults paint the `inverse` roles; class props restyle each part.
76
76
  - `appendResults` renders Pagefind result cards from the site's type taxonomy; `SearchSuggestions` renders the
77
- empty-state chips. The search page, its copy and its index stay in the site.
77
+ empty-state chips. `SearchPage` is the whole page body (hero, type chips, an audience row when `audienceFacets` is
78
+ passed, result states); the route keeps Layout (`noindex`), imports `search.css` and calls `initSearchPage` from
79
+ `@sarimarcus/content-sites-ui/search-page` in its own `<script>` with the same taxonomy and copy. The copy, the
80
+ taxonomy and the index stay in the site.
81
+
82
+ ## Page templates
83
+
84
+ ```ts
85
+ import { StaticProsePage, NotFoundPage, ArticlesListingPage, ArticleFeatureCard } from '@sarimarcus/content-sites-ui/editorial';
86
+ import { ListingShell } from '@sarimarcus/content-sites-ui/layout';
87
+ ```
88
+
89
+ The route file keeps `getStaticPaths`, queries, URLs, `<Layout title description>` (the SEO length gate reads it) and
90
+ `<StructuredData slot="head">` as a direct Layout child; the template receives normalised props and slots. Hrefs and
91
+ prose stay in the route so the site's link and outbound-domain scanners still see them.
92
+
93
+ - `StaticProsePage`: privacy, terms, contact, editorial standards. Prose in the default slot, `after` slot, optional
94
+ `cta` box. Carries no Pagefind body.
95
+ - `NotFoundPage`: the 404 body; the route passes `noindex` to Layout and imports `@sarimarcus/content-sites-ui/not-found.css`.
96
+ - `ArticlesListingPage` / `ArticleFeatureCard`: article index, topic hubs and their paginated pages.
97
+ - `ListingShell`: listing hubs (breadcrumb, `hero`, `before-main`, Container'd `<main>`, `after-main`), one outer
98
+ `data-pagefind-ignore`.
99
+
100
+ A component exported from a barrel must not carry a `<style>` block: Astro ships it to every page that imports the
101
+ barrel. Put such rules in a stylesheet entry the one route imports.
78
102
 
79
103
  ## Document shell
80
104
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sarimarcus/content-sites-ui",
3
- "version": "0.17.0",
3
+ "version": "0.18.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",
@@ -34,6 +34,7 @@
34
34
  "./seo": "./src/seo/index.ts",
35
35
  "./layout": "./src/layout/index.ts",
36
36
  "./analytics": "./src/layout/analytics.ts",
37
+ "./search-page": "./src/search/searchPage.ts",
37
38
  "./navigation": "./src/navigation/index.ts",
38
39
  "./search": "./src/search/index.ts",
39
40
  "./types": "./src/types/index.ts",
@@ -41,12 +42,13 @@
41
42
  "./widths.css": "./src/styles/widths.css",
42
43
  "./theme.css": "./src/styles/theme.css",
43
44
  "./consent.css": "./src/styles/consent.css",
44
- "./search.css": "./src/styles/search.css"
45
+ "./search.css": "./src/styles/search.css",
46
+ "./not-found.css": "./src/styles/not-found.css"
45
47
  },
46
48
  "peerDependencies": {
47
49
  "@lucide/astro": "^1.23.0",
48
50
  "astro": "^7.0.6",
49
51
  "tailwindcss": "^4.1.18",
50
- "@sarimarcus/content-sites-core": "0.17.0"
52
+ "@sarimarcus/content-sites-core": "0.18.0"
51
53
  }
52
54
  }
@@ -0,0 +1,36 @@
1
+ ---
2
+ import Icon from '../primitives/Icon.astro';
3
+
4
+ /** ArrowLinkGrid — a grid of bordered link tiles (title, short subtitle, trailing arrow) tinted on hover. */
5
+ interface Item {
6
+ href: string;
7
+ title: string;
8
+ subtitle: string;
9
+ }
10
+
11
+ interface Props {
12
+ items: Item[];
13
+ tone?: 'primary' | 'secondary';
14
+ columns?: 3 | 4;
15
+ }
16
+
17
+ const { items, tone = 'primary', columns = 4 } = Astro.props;
18
+ const TONES = {
19
+ primary: { border: 'hover:border-primary/50', text: 'group-hover:text-primary' },
20
+ secondary: { border: 'hover:border-secondary/50', text: 'group-hover:text-secondary' },
21
+ } as const;
22
+ const GRID = { 3: 'grid sm:grid-cols-2 lg:grid-cols-3 gap-3', 4: 'grid sm:grid-cols-2 lg:grid-cols-4 gap-3' } as const;
23
+ const t = TONES[tone];
24
+ ---
25
+
26
+ <div class={GRID[columns]}>
27
+ {items.map((item) => (
28
+ <a href={item.href} class={`group flex items-center justify-between gap-3 px-4 py-3 rounded-lg border border-border ${t.border} bg-card transition-all`}>
29
+ <div>
30
+ <p class={`text-sm font-medium text-foreground ${t.text} transition-colors`}>{item.title}</p>
31
+ <p class="text-xs text-muted-foreground mt-0.5">{item.subtitle}</p>
32
+ </div>
33
+ <Icon name="arrow-right" class={`w-4 h-4 text-muted-foreground shrink-0 ${t.text} group-hover:translate-x-0.5 transition-all`} />
34
+ </a>
35
+ ))}
36
+ </div>
@@ -6,3 +6,4 @@ export { default as FAQ } from './FAQ.astro';
6
6
  export { default as InlineImage } from './InlineImage.astro';
7
7
  export { default as Pagination } from './Pagination.astro';
8
8
  export { default as EntityCard } from './EntityCard.astro';
9
+ export { default as ArrowLinkGrid } from './ArrowLinkGrid.astro';
@@ -0,0 +1,77 @@
1
+ ---
2
+ /**
3
+ * ArticleFeatureCard — the wide lead card above an article grid: image left, label, title, byline with the
4
+ * published or "Updated" date, description and a read link. Dates arrive formatted; the image is the route's import.
5
+ */
6
+ import { Picture } from 'astro:assets';
7
+ import type { ImageMetadata } from 'astro';
8
+ import Badge from '../primitives/Badge.astro';
9
+ import Icon from '../primitives/Icon.astro';
10
+
11
+ interface Props {
12
+ href: string;
13
+ title: string;
14
+ description: string;
15
+ author: string;
16
+ /** "Featured" or "Latest". */
17
+ label: string;
18
+ image?: ImageMetadata;
19
+ imageAlt?: string;
20
+ /** The date shown: the modified one when `updated`, else the published one. */
21
+ dateTime: string;
22
+ dateLabel: string;
23
+ updated?: boolean;
24
+ readLabel?: string;
25
+ updatedLabel?: string;
26
+ }
27
+
28
+ const { href, title, description, author, label, image, imageAlt = '', dateTime, dateLabel, updated = false, readLabel = 'Read article', updatedLabel = 'Updated' } = Astro.props;
29
+ ---
30
+
31
+ <a href={href} class="group block mb-12 rounded-xl border border-border bg-card overflow-hidden hover:border-primary transition-colors focus:outline-none focus:ring-2 focus:ring-primary/50">
32
+ <div class="grid sm:grid-cols-[1.2fr_1fr] gap-0">
33
+ {image ? (
34
+ <div class="aspect-[4/3] sm:aspect-auto overflow-hidden">
35
+ <Picture
36
+ src={image}
37
+ alt={imageAlt}
38
+ widths={[480, 768, 1024]}
39
+ sizes="(max-width: 640px) 100vw, 50vw"
40
+ formats={['avif', 'webp']}
41
+ fallbackFormat="webp"
42
+ class="w-full h-full object-cover motion-safe:transition-transform motion-safe:duration-500 motion-safe:group-hover:scale-105"
43
+ loading="eager"
44
+ fetchpriority="high"
45
+ />
46
+ </div>
47
+ ) : (
48
+ <div class="aspect-[4/3] sm:aspect-auto bg-muted/40"></div>
49
+ )}
50
+ <div class="p-6 sm:p-8 flex flex-col justify-center">
51
+ <p class="text-xs font-semibold uppercase tracking-[0.18em] text-primary mb-2">{label}</p>
52
+ <h2 class="text-xl sm:text-2xl font-semibold font-display text-foreground leading-snug group-hover:text-primary transition-colors text-balance">
53
+ {title}
54
+ </h2>
55
+ <p class="mt-2 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground">
56
+ <span>{author}</span>
57
+ <span aria-hidden="true">·</span>
58
+ {updated ? (
59
+ <Badge tone="custom" toneClass="bg-muted" size="sm" lines={0} class="![font-size:inherit] ![line-height:inherit] ![font-weight:inherit] !gap-1">
60
+ <svg class="w-3 h-3 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
61
+ <polyline points="23 4 23 10 17 10" />
62
+ <path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10" />
63
+ </svg>
64
+ {updatedLabel}{' '}<time datetime={dateTime}>{dateLabel}</time>
65
+ </Badge>
66
+ ) : (
67
+ <time datetime={dateTime}>{dateLabel}</time>
68
+ )}
69
+ </p>
70
+ <p class="mt-3 text-sm text-muted-foreground leading-relaxed line-clamp-3">{description}</p>
71
+ <span class="inline-flex items-center gap-1 mt-5 text-sm font-medium text-primary">
72
+ {readLabel}
73
+ <Icon name="arrow-right" class="w-3.5 h-3.5 motion-safe:transition-transform motion-safe:group-hover:translate-x-0.5" />
74
+ </span>
75
+ </div>
76
+ </div>
77
+ </a>
@@ -0,0 +1,40 @@
1
+ ---
2
+ /**
3
+ * ArticlesListingPage — the shell of an article listing (index, topic hub, their paginated pages): breadcrumb band,
4
+ * a header (eyebrow, heading, accent rule, lede) and <main>. The route keeps Layout, schema, paging and image maps,
5
+ * passes the breadcrumb in `breadcrumb`, an optional line under the lede in `header-extra`, and the main content
6
+ * (its own Containers) as default-slot children.
7
+ */
8
+ import BreadcrumbBand from '../layout/BreadcrumbBand.astro';
9
+ import Container from '../layout/Container.astro';
10
+
11
+ interface Props {
12
+ eyebrow: string;
13
+ heading: string;
14
+ lede: string;
15
+ /** Mark <main> as the Pagefind body. */
16
+ pagefindBody?: boolean;
17
+ }
18
+
19
+ const { eyebrow, heading, lede, pagefindBody = false } = Astro.props;
20
+ ---
21
+
22
+ <BreadcrumbBand>
23
+ <slot name="breadcrumb" />
24
+ </BreadcrumbBand>
25
+
26
+ <header class="bg-muted/30 border-b border-border pt-12 pb-10">
27
+ <Container size="measure">
28
+ <p class="text-sm font-semibold uppercase tracking-[0.18em] text-primary mb-3">{eyebrow}</p>
29
+ <h1 class="text-3xl sm:text-4xl font-normal font-display text-foreground leading-tight text-balance">
30
+ {heading}
31
+ </h1>
32
+ <div class="mt-4 flex"><span class="block h-0.5 w-16 rounded-full bg-accent"></span></div>
33
+ <p class="mt-5 text-lg text-muted-foreground leading-relaxed">{lede}</p>
34
+ <slot name="header-extra" />
35
+ </Container>
36
+ </header>
37
+
38
+ <main class="py-14" data-pagefind-body={pagefindBody ? '' : undefined}>
39
+ <slot />
40
+ </main>
@@ -0,0 +1,98 @@
1
+ ---
2
+ /**
3
+ * NotFoundPage — the 404 body: a decorative arch-and-zellige backdrop, the large "404", a message, a home button, a
4
+ * search link and a short list of section links. The route keeps Layout (with `noindex`), passes every string and
5
+ * imports `@sarimarcus/content-sites-ui/not-found.css`.
6
+ */
7
+ import Button from '../primitives/Button.astro';
8
+ import LinkArrow from '../primitives/LinkArrow.astro';
9
+ import Icon from '../primitives/Icon.astro';
10
+
11
+ interface Props {
12
+ tagline: string;
13
+ lede: string;
14
+ homeHref: string;
15
+ homeLabel: string;
16
+ searchHref: string;
17
+ searchLabel: string;
18
+ searchAriaLabel: string;
19
+ exploreLabel: string;
20
+ /** Accessible name of the section links. */
21
+ exploreAriaLabel?: string;
22
+ links: { href: string; label: string }[];
23
+ }
24
+
25
+ const { tagline, lede, homeHref, homeLabel, searchHref, searchLabel, searchAriaLabel, exploreLabel, exploreAriaLabel = 'Explore sections', links } = Astro.props;
26
+ ---
27
+
28
+ <main class="relative min-h-[80vh] flex items-center justify-center overflow-hidden bg-muted">
29
+ {/* Geometric zellige-inspired background pattern — pure CSS */}
30
+ <div class="absolute inset-0 opacity-[0.04] pointer-events-none" aria-hidden="true">
31
+ <div class="not-found-zellige-grid"></div>
32
+ </div>
33
+
34
+ {/* Decorative horseshoe arches — layered behind content */}
35
+ <div class="absolute inset-0 flex items-end justify-center pointer-events-none" aria-hidden="true">
36
+ <div class="not-found-arch-row">
37
+ <div class="not-found-arch not-found-arch--left"></div>
38
+ <div class="not-found-arch not-found-arch--center"></div>
39
+ <div class="not-found-arch not-found-arch--right"></div>
40
+ </div>
41
+ </div>
42
+
43
+ <div class="relative z-10 max-w-deck mx-auto px-6 py-20 text-center">
44
+ {/* Decorative star motif */}
45
+ <div class="flex justify-center mb-6" aria-hidden="true">
46
+ <div class="not-found-islamic-star"></div>
47
+ </div>
48
+
49
+ {/* The 404 number — large and decorative */}
50
+ <h1 class="text-accent text-[8rem] sm:text-[10rem] md:text-[12rem] leading-none font-display tracking-tight select-none not-found-fade-in-up">
51
+ 404
52
+ </h1>
53
+
54
+ {/* Thin accent separator */}
55
+ <div class="mx-auto w-24 h-px bg-gradient-to-r from-transparent via-accent to-transparent my-6 not-found-fade-in-up not-found-delay-1" aria-hidden="true"></div>
56
+
57
+ {/* Message */}
58
+ <h2 class="font-display text-2xl sm:text-3xl text-foreground mb-4 not-found-fade-in-up not-found-delay-1">
59
+ {tagline}
60
+ </h2>
61
+ <p class="text-muted-foreground text-lg sm:text-xl leading-relaxed max-w-lg mx-auto mb-10 not-found-fade-in-up not-found-delay-2">
62
+ {lede}
63
+ </p>
64
+
65
+ {/* Primary CTA */}
66
+ <div class="mb-6 not-found-fade-in-up not-found-delay-2">
67
+ <Button href={homeHref} variant="primary">
68
+ {homeLabel}
69
+ </Button>
70
+ </div>
71
+
72
+ {/* Search prompt */}
73
+ <div class="mb-12 not-found-fade-in-up not-found-delay-3">
74
+ <a
75
+ href={searchHref}
76
+ data-search-trigger
77
+ class="group flex items-center gap-3 w-full max-w-sm mx-auto px-4 py-3 rounded-xl border border-foreground/20 bg-white/60 backdrop-blur-sm text-left hover:border-primary/40 hover:bg-white/80 transition-all no-underline"
78
+ aria-label={searchAriaLabel}
79
+ >
80
+ <Icon name="search" class="w-4 h-4 text-muted-foreground shrink-0" />
81
+ <span class="text-muted-foreground text-sm flex-1">{searchLabel}</span>
82
+ <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>
83
+ </a>
84
+ </div>
85
+
86
+ {/* Explore sections */}
87
+ <div class="not-found-fade-in-up not-found-delay-4">
88
+ <p class="text-sm uppercase tracking-widest text-muted-foreground/60 mb-5 font-medium">
89
+ {exploreLabel}
90
+ </p>
91
+ <nav aria-label={exploreAriaLabel} class="flex flex-wrap justify-center gap-x-6 gap-y-3">
92
+ {links.map((link, i) => (
93
+ <LinkArrow href={link.href} color={i % 2 === 0 ? 'secondary' : 'primary'} size="base">{link.label}</LinkArrow>
94
+ ))}
95
+ </nav>
96
+ </div>
97
+ </div>
98
+ </main>
@@ -0,0 +1,48 @@
1
+ ---
2
+ /**
3
+ * StaticProsePage — the shell of a legal or site-information page (privacy, terms, contact, editorial standards,
4
+ * agents). The route keeps Layout, schema and the prose itself (default slot), so copy and links stay in site source.
5
+ * Not Pagefind-indexed: it carries no body marker.
6
+ */
7
+ import Container from '../layout/Container.astro';
8
+ import Button from '../primitives/Button.astro';
9
+
10
+ interface CtaLink {
11
+ href: string;
12
+ label: string;
13
+ }
14
+
15
+ interface Props {
16
+ heading: string;
17
+ /** Small uppercase line above the heading ("Site policy"). */
18
+ eyebrow?: string;
19
+ /** Closing box under the prose: a short line and secondary buttons. */
20
+ cta?: { text: string; links: CtaLink[] };
21
+ }
22
+
23
+ const { heading, eyebrow, cta } = Astro.props;
24
+ ---
25
+
26
+ <main class="py-16 lg:py-24">
27
+ <Container size="measure">
28
+ {eyebrow && <p class="text-sm font-semibold uppercase tracking-[0.18em] text-primary mb-3">{eyebrow}</p>}
29
+ <h1 class="text-3xl sm:text-4xl font-medium font-display mb-8">{heading}</h1>
30
+
31
+ <div class="prose prose-stone max-w-none">
32
+ <slot />
33
+ </div>
34
+
35
+ <slot name="after" />
36
+
37
+ {cta && (
38
+ <div class="not-prose mt-12 rounded-2xl border border-border bg-muted/40 p-6">
39
+ <p class="text-sm text-muted-foreground mb-4">
40
+ {cta.text}
41
+ </p>
42
+ <div class="flex flex-wrap gap-3">
43
+ {cta.links.map((link) => <Button href={link.href} variant="secondary">{link.label}</Button>)}
44
+ </div>
45
+ </div>
46
+ )}
47
+ </Container>
48
+ </main>
@@ -3,4 +3,8 @@ export { default as EditorialGuidePage } from './EditorialGuidePage.astro';
3
3
  export { default as EventPage } from './EventPage.astro';
4
4
  export { default as BeforeYouGo } from './BeforeYouGo.astro';
5
5
  export { default as NumberedStep } from './NumberedStep.astro';
6
+ export { default as StaticProsePage } from './StaticProsePage.astro';
7
+ export { default as NotFoundPage } from './NotFoundPage.astro';
8
+ export { default as ArticlesListingPage } from './ArticlesListingPage.astro';
9
+ export { default as ArticleFeatureCard } from './ArticleFeatureCard.astro';
6
10
  export type { EventInfoRow, EventInfoIcon } from './event';
@@ -0,0 +1,32 @@
1
+ ---
2
+ /**
3
+ * ListingShell — the frame of a listing hub: breadcrumb band, hero, optional full-width bands, <main> with a
4
+ * Container, and trailing bands, all inside one Pagefind-ignore wrapper (listings are not indexed). The route keeps
5
+ * Layout and passes StructuredData to Layout directly, so its JSON-LD lands in <head>; everything else comes in by slot.
6
+ */
7
+ import BreadcrumbBand from './BreadcrumbBand.astro';
8
+ import Container from './Container.astro';
9
+
10
+ interface Props {
11
+ mainClass?: string;
12
+ }
13
+
14
+ const { mainClass = 'py-12 lg:py-20' } = Astro.props;
15
+ ---
16
+
17
+ <div data-pagefind-ignore>
18
+ <BreadcrumbBand ignore={false}>
19
+ <slot name="breadcrumb" />
20
+ </BreadcrumbBand>
21
+
22
+ <slot name="hero" />
23
+ <slot name="before-main" />
24
+
25
+ <main class={mainClass}>
26
+ <Container>
27
+ <slot />
28
+ </Container>
29
+ </main>
30
+
31
+ <slot name="after-main" />
32
+ </div>
@@ -1,6 +1,7 @@
1
1
  // Public surface of @sarimarcus/content-sites-ui/layout.
2
2
  export { default as Container } from './Container.astro';
3
3
  export { default as BreadcrumbBand } from './BreadcrumbBand.astro';
4
+ export { default as ListingShell } from './ListingShell.astro';
4
5
  export { default as AsyncStylesheet } from './AsyncStylesheet.astro';
5
6
  export { default as DocumentShell } from './DocumentShell.astro';
6
7
  export { default as ConsentAnalytics } from './ConsentAnalytics.astro';