@sarimarcus/content-sites-ui
v0.29.2
Published
Universal Astro UI components, layout shell and design tokens shared by the content sites.
Downloads
6,657
Readme
@sarimarcus/content-sites-ui
Universal Astro UI shared by every content site. Named entry points, one per folder: primitives,
composed, editorial, affiliate, seo, layout, navigation, search and types, plus the script
entry points analytics, travelpayouts-styler and search-page. For example,
import { Container } from '@sarimarcus/content-sites-ui/layout'. src/internal is never exported,
and src/styles holds the Tailwind entry.
Depends on @sarimarcus/content-sites-core (exact lockstep peer) and @lucide/astro (the Icon primitive). It carries no reader-visible copy:
text arrives through props and slots.
Primitives
import { Icon, Badge, Chip, ArrowLabel, SectionDivider, LinkArrow, Button, Callout, FeatureList, Blockquote, SectionHeader, ResponsiveImage, ResponsivePicture, PILL_BASE, isBadgeLength, PRICE_LABEL_MAX } from '@sarimarcus/content-sites-ui/primitives';Icon: any Lucide icon by kebab-case name.Badge: a content label (<span data-badge>), clamped tolines(default 2). Tones:primary(default),secondary,accent,neutral,onImage, orcustomwith a site-ownedtoneClass.Chip: an interactive pill (<a>/<button>/<span>,data-chipfirst).tonesets the idle surface (outline,outlineMuted,outlineQuiet,muted,tint,onImage,custom),accentthe hover, active and focus colour (primary(default),secondary,accent,neutral).activeemitsdata-active="true"|"false"only when set; scripts must writedataset.active = 'true'|'false', never add or remove the attribute. Other attributes pass through last, so markers such asdata-section-linkreach the element.ArrowLabel: a label followed by an arrow that shifts right ongroup-hover. It carries no colour: the caller'sclasssets it;arrowClasssizes the arrow (defaulth-4 w-4).SectionDivider: a break between sections.variantisdots(default),line, orquote(withquoteand an optionalsource).LinkArrow: a text link with a trailing arrow.colorisprimary(default),secondary,accentoronImage;sizeissm(default) orbase. Anhttp(s)href opens in a new tab withrel="noopener noreferrer"; an explicittarget(_blankor_self) orreloverrides that. Attributes render ashref target rel class.Button: a link styled as a button.variantisprimary(default, solid),secondary(an outline in the primary colour),secondarySolid(solid in the secondary colour),hero(frosted glass over a photo) orghost(for dark themes). Same new-tab rule as LinkArrow: anhttp(s)href getstarget="_blank", and a new tab getsrel="noopener noreferrer", unlesstargetorrelis given. Attributes render ashref rel target class, then every other anchor attribute (data-*,aria-*,slot) in the order passed.Callout: a boxed note with an icon.typeisinfo(default),tip,warningorsuccess;titleis optional.FeatureList: a checklist or a row of tags fromitems(rendered withset:html).colorisprimary(default),secondaryoraccent;containerboxes it,compacttightens it,headingLevelsets the heading (defaulth2), which renders only for a non-emptytitle;footeradds a line under a border.Blockquote: a centred pull-quote,quoteplus an optionalsource.SectionHeader: an icon tile (sloticon), anh2titleand an optionalactionslot.iconBgandiconColorare raw classes the site supplies.ResponsiveImage: Astro's<Image>at everyIMAGE_LADDERwidth the source has variants for. Takes<Image>'s props lesswidths,densitiesandformat, and requiressizes. A fixed-size image (avatar, thumbnail) stays a plain<Image width={N}>.ResponsivePicture:ResponsiveImageinside a<picture>that takespictureAttributes, for a styled wrapper. Same HTML as Astro's<Picture>with WebP, less its redundant<source>.PILL_BASE: the shared pill geometry.isBadgeLength,BADGE_MAX_CHARS,PRICE_LABEL_MAX: the label-length limits a call site branches on (pill or prose); a price-range schema.max()must equalPRICE_LABEL_MAX.
data-badge and data-chip are what validate-badge-overflow.mjs selects on, so they are part of the API. Badge and
Chip colour only through theme roles, including -tint, -emphasis and the badge-on-image / chip-on-image
roles, which each site sets to its own values.
Navigation and search
import { SiteHeader, NavDropdown, MobileNavSection, SiteFooter, type NavLink, type FooterSection } from '@sarimarcus/content-sites-ui/navigation';
import { SearchSuggestions, appendResults } from '@sarimarcus/content-sites-ui/search';Shells for site chrome. The site composes every link, panel, logo and label; the shells own the landmarks, the ARIA
state and one bundled controller (idempotent on astro:page-load, any number of headers per page).
SiteHeader: the fixed<header>with a labelled<nav>, slotslogo,desktop,actions,mobile-actionsandmobile, the mobile-menu toggle, and the mobile panel rendered after the header (withdata-pagefind-ignore).searchPathturns on Ctrl/Cmd+K →<searchPath>?focus=1.surfaceClasssets the bar's and the panel's background. Ids default tomain-nav,mobile-menu,mobile-menu-btn.NavDropdown: trigger plus panel (default slot). Click, hover with a 150 ms close delay, ArrowDown/ArrowUp/Escape, outside click and focus leaving.tonetints the default trigger;buttonClassreplaces it.MobileNavSection: one section of the mobile panel's single-open accordion (its button'saria-controlsnames its content).NavMenuLink/MobileNavLink: one row of a dropdown panel / of the mobile panel, from aNavLink, tinted by aNavTone.activemarks the rowaria-current="page";emphasispaints hub links in the tone.isActivePath(currentPath, href): the page or a page below it, on a path-segment boundary.SiteFooter: brand slot, one column perFooterSection, abottomslot, the copyright line and a<nav aria-label="Legal">ending with the cookie-preferences button, which callswindow.openCookiePreferences()(the site's consent loader defines it). Defaults paint theinverseroles; class props restyle each part.appendResultsrenders Pagefind result cards from the site's type taxonomy;SearchSuggestionsrenders the empty-state chips.SearchPageis the whole page body (hero, type chips, an audience row whenaudienceFacetsis passed, result states); the route keeps Layout (noindex), importssearch.cssand callsinitSearchPagefrom@sarimarcus/content-sites-ui/search-pagein its own<script>with the same taxonomy and copy. The copy, the taxonomy and the index stay in the site.
Page templates
import { StaticProsePage, NotFoundPage, ArticlesListingPage, ArticleFeatureCard } from '@sarimarcus/content-sites-ui/editorial';
import { ListingShell } from '@sarimarcus/content-sites-ui/layout';The route file keeps getStaticPaths, queries, URLs, <Layout title description> (the SEO length gate reads it) and
<StructuredData slot="head"> as a direct Layout child; the template receives normalised props and slots. Hrefs and
prose stay in the route so the site's link and outbound-domain scanners still see them.
StaticProsePage: privacy, terms, contact, editorial standards. Prose in the default slot,afterslot, optionalctabox. Carries no Pagefind body.NotFoundPage: the 404 body; the route passesnoindexto Layout and imports@sarimarcus/content-sites-ui/not-found.css.ArticlesListingPage/ArticleFeatureCard: article index, topic hubs and their paginated pages.ArticleTopicPage: the body of/articles/topic/<category>and its paginated pages, around the topic nav. Blocks the site renders through its own adapters arrive as slots:start-here,grid,pagination,go-deeper,places,faq(wrapped in a measure column only when filled), plusbreadcrumb,header-extraandoverview-byline. The overview and the author cards are props. A page above 1 renders only the nav, grid and pagination.rankByCountranks a topic's authors or collections by article count.ListingShell: listing hubs (breadcrumb,hero,before-main, Container'd<main>,after-main), one outerdata-pagefind-ignore.
A component exported from a barrel must not carry a <style> block: Astro ships it to every page that imports the
barrel. Put such rules in a stylesheet entry the one route imports.
Editorial presentation
import { ArticleKeyFacts, AuthorCard, AuthorStrip, ArticleCard, ArticleGrid, ArticleCategoryNav, InsiderTips } from '@sarimarcus/content-sites-ui/editorial';
import { RelatedContent, RelatedContentCard } from '@sarimarcus/content-sites-ui/composed';
import type { ArticleSummary, AuthorSummary, KeyFact, SourceItem } from '@sarimarcus/content-sites-ui/editorial';
import type { RelatedItem } from '@sarimarcus/content-sites-ui/composed';Every visible and aria string is a required prop with no default, so the copy stays in the site. The site keeps a thin
adapter of the same name that reads its collections, globs, routes, markdown renderer and author profiles, then passes
view models: dates already formatted (the package never picks a time zone), hrefs with the base path applied, HTML that
crosses the boundary only through trustedHtml() (key-fact details and tip bodies keep their #fn-N anchors because
the site parses them with its source count). Palette props (figureClass, surfaceClass, ringClass, proseClass)
let a site keep its own roles.
ArticleKeyFacts: figure-first<dl>, rendered only with three or more facts.AuthorCard: whole-card link, polaroid, or plain card with a profile link.AuthorStrip: portraits row.ArticleCard/ArticleGrid: listing cards;pagefindIgnorefor a listing that is itself indexed.ArticleFeatureCardis the wide lead card.ArticleCategoryNav: "All" plus one chip per populated topic, with counts. Pass the articles and the site's category map (label,icon, in display order); it links/articlesand/articles/topic/<slug>.RelatedContent/RelatedContentCard(in./composed, beside the CardCarousel they render): grid or carousel ofRelatedItemcards,toneprimary/accent/secondary. Resolving which items to show (collections, scoring, backfill) stays a site adapter.InsiderTips: tip kinds are site keys mapped to a label and a tone.
These render inside the site's article styles (.article-body link rules, typography plugin presets); outside them
they look different.
Article interludes
---
import { Interlude, RelatedCard } from '@sarimarcus/content-sites-ui/editorial';
import { trustedHtml } from '@sarimarcus/content-sites-ui/types';
const renderInline = (s: string) => trustedHtml(parseMarkdownInline(s, basePath, sourcesLength));
---
{interlude.type === 'related-card' && <RelatedCard href={href} eyebrow={`Read next · ${label}`} title={name} />}
<Interlude interlude={interlude} renderInline={renderInline} labels={labels} author={author} />Interlude renders the eleven content interludes (quote, stat-fact, evidence-context, tldr, answer-box, field-note,
caution, checklist, did-you-know, timeline, comparison-table) and nothing for the entity ones (related-card,
monument-feature, article-feature): their target, route and image are site data, so the site's adapter renders
RelatedCard (inline row or featured card) for them. Every string field passes through the site's renderInline
except the stat figure. labels carries all the copy; didYouKnowVariant picks ruled or inline.
The pieces are exported too, for pages that place an interlude by hand: AnswerBox, ComparisonTable,
EvidenceContext, FieldNote (authorHref links the signature), Quote, StatFact, Timeline, Tldr,
DidYouKnow. The content schema is core's createInterludeSchema (@sarimarcus/content-sites-core/interludes).
Document shell
---
import { DocumentShell } from '@sarimarcus/content-sites-ui/layout';
---
<DocumentShell title={title} description={description} siteName={site.siteName} siteUrl={site.url} themeColor="#FAFAF8"
gtmId={site.analytics.gtmId} consentPrompt="…" consentTypes={consentTypes} skipLinkLabel="Skip to main content">
<Fragment slot="fonts">…</Fragment>
<Navigation slot="nav" />
<slot />
<Footer slot="footer" />
</DocumentShell>DocumentShell renders the whole document: description, robots (noindex, follow when noindex), canonical (from
the page path unless canonicalUrl), RSS and favicons, Open Graph / article / Twitter (twitterHandle optional),
title, non-critical stylesheets, the Emerald loader (travelpayoutsTrs), the skip link, the navigation and footer
wrapped in data-pagefind-ignore, #main-content and ConsentAnalytics. Slots: head-start, fonts, head,
body-start, nav, footer, default. It imports no stylesheet and emits no bundled script, so the site's thin
Layout keeps both (and with them its bundle names).
ConsentAnalytics is inline on purpose: the Consent Mode default must reach the dataLayer before the gtm.js event
in one synchronous step. It defines window.openCookiePreferences for the footer button. ./analytics
(setupAnalytics, track, pageTypeOf) and ./travelpayouts-styler (setupTravelpayoutsStyler) are their own
entry points so a client <script> imports no Astro component. The hosts the shell loads are declared in
layout/resources.ts; scripts/validate-csp-coverage.mjs checks every site's CSP against them.
Search and consent styles
@sarimarcus/content-sites-ui/search.css styles the search page markup (appendResults, SearchSuggestions and
the page's form, filters and states); @sarimarcus/content-sites-ui/consent.css styles the Silktide consent banner
and modal. Both read the theme roles, and optional --search-* / --consent-* custom properties, listed at the top
of each file, tune what differs per site. Load them as non-critical sheets with AsyncStylesheet (ui/layout):
---
import searchCssUrl from '@sarimarcus/content-sites-ui/search.css?url';
import { AsyncStylesheet } from '@sarimarcus/content-sites-ui/layout';
---
<AsyncStylesheet href={searchCssUrl} />A site's own additions to a sheet load after it.
Tailwind
Tailwind v4 does not scan node_modules, so a site must import the package's source entry next to Tailwind:
@import "tailwindcss";
@import "@sarimarcus/content-sites-ui/tailwind.css";Without it, utility classes used only inside package components are silently missing from the site's CSS.
Theme
theme.css declares the semantic roles package components use (primary, primary-strong, muted-foreground,
overlay, the radius scale, …) as --color-X: var(--X). Import it right after the site's own @theme inline
block, then set every --X, plus --radius, in the site's :root:
@theme inline { /* the site's palette and --font-display / --font-body */ }
@import "@sarimarcus/content-sites-ui/theme.css";
:root { --primary: …; --primary-strong: …; /* every role */ }The values are runtime vars, so a .dark block and plain (non-Tailwind) stylesheets read them too.
scripts/platform/validate-theme-contract.mjs checks that every role has a value.
