docs
    press-kit: Articles and publications
    v1.4.0.md

    Articles: @sonordev/press-kit/articles

    Sharing a public article

    ArticleShare is a small client component exported from @sonordev/press-kit/articles/share. It doesn't wrap or fetch article content:

    import { ArticleShare } from '@sonordev/press-kit/articles/share'
    
    <ArticleShare url="https://example.com/journal/build-first" title="Build first" />

    Pass the absolute public HTTP(S) article URL, its title and optional className. Keep the component out of private previews. Invalid URLs render no controls. The component uses existing --sk-* color tokens and the sk-article-share class; host styles can refine its presentation.

    Copy link and an encoded email link are available without native sharing. The native Share button appears only in a secure context that supports it and passes the title and one URL, without file attachments. Cancellation is quiet. Copy success is announced only after the clipboard operation succeeds. If sharing or copying fails, a labeled, selectable URL and ordinary article link remain available. No social SDK, tracking call or automatic share is added.

    Sharing controls don't determine the receiving app's preview. To use a branded article card, render one through Site Kit's existing createOgImage factory with its optional logo input, and pass images: false to generateArticleMetadata. The wordmark option requires Site Kit 8.8.1 or later. This leaves the global article-image defaults and structured-data image selection unchanged.

    Publication routes and artwork (5.4.0)

    Use one routing object for the stock components and their SEO helpers. Existing sites keep /article/slug unless they opt in. basePath remains supported as a metadata alias; basePath takes precedence when both are provided.

    import { Article, ArticleList } from '@sonordev/press-kit/articles/server-ui'
    import {
      createPublicationRoutes, generateArticleMetadata, generateArticleSchema,
      generateArticleSitemap, generateArticleStaticParams, generateRssFeed,
      getArticle,
    } from '@sonordev/press-kit/articles/server'
    import type { PublicationRoutingOptions } from '@sonordev/press-kit/articles/server'
    
    const siteUrl = 'https://example.com'
    const routing = {
      basePath: '/journal',
      includeCategoryInPath: true,
      categoryPath: (slug: string) => `/journal?category=${encodeURIComponent(slug)}`,
    } satisfies PublicationRoutingOptions
    
    // Cards, related stories and cluster links now use /journal/category/slug.
    const article = <Article slug="build-first" routing={routing} />
    const archive = <ArticleList routing={routing} />
    
    // Use these helpers from their corresponding Next.js route exports.
    const metadata = await generateArticleMetadata('build-first', { siteUrl, ...routing })
    const post = await getArticle('build-first')
    const schema = post ? generateArticleSchema(post, { siteUrl, ...routing }) : null
    // In app/journal/[category]/[slug]/page.tsx, wrap it to pass the routing:
    // export function generateStaticParams() { return generateArticleStaticParams(routing) }
    const params = await generateArticleStaticParams(routing) // { category, slug }[]
    const sitemap = await generateArticleSitemap(siteUrl, {
      ...routing,
      includeCategories: false, // category filters don't need separate sitemap entries
      includeClusters: false, // enable when your site implements cluster routes
    })
    const rss = await generateRssFeed({ siteUrl, siteName: 'The Journal', ...routing })
    const href = createPublicationRoutes(routing).post({ slug: 'build-first', category: 'guides' })

    routing also works on PublicationLayout, PublicationSidebar, RelatedPosts, AuthorPage, ClusterLandingPage, and ClusterNavigation. To support another article shape, provide postPath: post => '/articles/' + encodeURIComponent(post.slug). Callbacks return local paths; generated metadata, feeds, and sitemaps separately honor a valid canonical_url. The same routes apply to Atom and generated breadcrumb/cluster schemas. Supplied schema/schema_json objects stay intact, apart from template placeholders (an Organization named "Example", example.com URLs, {post.title}), which generateAllArticleSchemas drops. When nothing real is left, it generates the article's schema from its own fields instead.

    For custom layouts, resolveArticleArtwork(post, 'article') prefers editorial_image, preserving an empty decorative editorial_image_alt. resolveArticleArtwork(post, 'card') keeps the complete featured_image. Older API responses fall back to the featured image for both. The stock UI keeps generated Sonor cards fully visible, while manually selected photos keep their existing layout. Social metadata and RSS enclosures use the featured share card.

    The stock article renders accessible table scroll regions automatically. Custom renderers can call wrapArticleTables(html) and include articleTableCss within their .sk-article-content scope. Both exports are available from article/server and article/server-ui. The wrapper preserves the original table and is safe to apply twice. It's a layout transform, not a sanitizer; keep your existing content trust policy. Scrollbars and keyboard focus use --sk-* tokens.

    Article HTML is sanitized upstream

    content_html and the answers in faq_items reach your site already cleaned by an allowlist at Sonor's public read path (live since 2026-10-20). Press Kit adds no sanitizer of its own, so insertArticlePhotos, wrapArticleTables and the stock Article all trust the HTML they're given. content_html is the only field Press Kit renders as HTML. content is the source text, which Sonor doesn't clean, so Article and the RSS and Atom feeds never render it, and a post with a blank content_html renders an empty body. Any HTML that reaches your site another way sits outside the guarantee too. Don't add dangerouslySetInnerHTML paths of your own for article content. The full list of what's kept and removed is in Article HTML and what Sonor sanitizes.

    Import server components from article/server-ui

    Article, ArticleList, PublicationLayout, PublicationSidebar and RelatedPosts are async server components. The @sonordev/press-kit/articles barrel is stamped 'use client' at build time, and an async component inside a client module is not something Next can render, so the route returns a 500.

    import { Article, ArticleList } from '@sonordev/press-kit/articles/server-ui'  // ✅
    import { Article } from '@sonordev/press-kit/articles'                      // ❌ 500s

    The barrel still re-exports them for backwards compatibility and they will move out in the next major. Reach for @sonordev/press-kit/articles only for genuine client components: TableOfContents, ArticleFAQ, AuthorCard, ServiceCallout, NewsletterWidget.

    NewsletterWidget needs either an onSubmit callback or a formSlug pointing at a managed Sonor form (newsletter routing). With neither it renders nothing rather than a form that discards emails. Article mounts a childless ArticleViewTracker client island that counts real readers (one POST per post per session, deferred to idle); it needs SiteKitLayout's globals and silently no-ops without them.

    Data helpers live in @sonordev/press-kit/articles/server, which is server-only, so a client import there is a build error rather than a runtime one.

    Sonor-managed article with SSG, topic clusters, E-E-A-T author profiles, and full SEO integration. Create posts in the Sonor dashboard and they appear on your site automatically.

    Quick Start

    Articles Index

    // app/article/page.tsx
    import { ArticleList, PublicationLayout } from '@sonordev/press-kit/articles/server-ui'
    import { generatePublicationMetadata } from '@sonordev/press-kit/articles/server'
    
    export async function generateMetadata() {
      return generatePublicationMetadata({ siteName: 'My Site', siteUrl: 'https://example.com' })
    }
    
    export default function PublicationPage() {
      return (
        <PublicationLayout hero={{ title: 'The Journal', subtitle: 'Latest articles' }}>
          <ArticleList showCategoryFilter showPagination />
        </PublicationLayout>
      )
    }

    Single Post

    // app/article/[slug]/page.tsx
    import { Article } from '@sonordev/press-kit/articles/server-ui'
    import {
      generateArticleStaticParams,
      generateArticleMetadata,
      requireArticle,
    } from '@sonordev/press-kit/articles/server'
    
    export const generateStaticParams = generateArticleStaticParams
    
    type Props = { params: Promise<{ slug: string }> }
    
    export async function generateMetadata({ params }: Props) {
      const { slug } = await params
      await requireArticle(slug) // 404 for an unknown slug, before anything streams
      return generateArticleMetadata(slug, { siteName: 'My Site', siteUrl: 'https://example.com' })
    }
    
    export default async function Post({ params }: Props) {
      const { slug } = await params
      return <Article slug={slug} showRelated showToc showAuthor />
    }

    A missing post is a 404. requireArticle, generateArticleMetadata and Article all call Next's notFound() when the post doesn't exist, so a mistyped or deleted post URL answers 404 with noindex. They used to render a "Post Not Found" page on a 200 with an indexable title, which is a soft 404. Calling requireArticle from generateMetadata is what guarantees the status: metadata resolves before the page streams. To render your own missing-post state instead, pass notFound: false to generateArticleMetadata (its placeholder is marked noindex) and notFound={false} to Article.

    A post with its own social card. If the post route has an opengraph-image.tsx beside the page (see @sonordev/site-kit/og/route), pass images: false:

    return generateArticleMetadata(slug, { siteName: 'My Site', images: false })

    Otherwise the featured image is declared as openGraph.images, and Next lets declared images beat the file convention: the card never ships. images: false leaves the keys out entirely (Next checks hasOwnProperty('images'), so even images: undefined would hide the card). sonor-setup doctor flags a post route that has a card but still declares the image.

    Topic Cluster Landing

    // app/article/topics/[slug]/page.tsx
    // From `article/server-ui`, not `article` — it's an async server component that fetches
    // the cluster, so it must stay out of the client-side `article` barrel.
    import { ClusterLandingPage } from '@sonordev/press-kit/articles/server-ui'
    
    export default function ClusterPage({ params }: { params: { slug: string } }) {
      return <ClusterLandingPage slug={params.slug} basePath="/article" />
    }

    Components

    ComponentImportPurpose
    Articlearticle/server-uiSingle post with content, TOC, author, related
    ArticleListarticle/server-uiPost grid with pagination and category filter
    PublicationLayoutarticle/server-uiFull layout with optional sidebar
    PublicationSidebararticle/server-uiCategories, recent posts, tags
    RelatedPostsarticle/server-uiRelated articles widget
    PublicationPagearticle/server-uiDrop-in publication index page (layout + list)
    ArticlePagearticle/server-uiDrop-in single-post page
    CategoryPagearticle/server-uiDrop-in category archive page
    ClusterLandingPagearticle/server-uiTopic cluster overview with pillar + support articles
    ClusterNavigationarticleBreadcrumb-style cluster nav
    AuthorCardarticleAuthor profile with E-E-A-T fields
    TableOfContentsarticleAuto-generated from H2-H4 headings, read the way Article reads them; a heading without an id links to the slug of its text
    ArticleFAQarticleFAQ section with schema
    ServiceCalloutarticleCTA callout for related services
    NewsletterWidgetarticleEmail capture; needs onSubmit or formSlug

    Server Functions (article/server)

    // Data fetching
    getArticle(slug)                    // Single post with full data, or null
    requireArticle(slug)                // Same, but calls notFound() when there's no post
    getAllArticleSlugs()                     // All published slugs (for generateStaticParams)
    getArticleCategories()                  // Categories with post counts
    getTopicCluster(slug)                // Cluster with pillar + support articles
    getTopicClusters()                   // All clusters
    
    // Next.js integration
    generateArticleStaticParams(routing?)   // [{ slug }], or [{ category, slug }] with includeCategoryInPath
    generateCategoryStaticParams()       // Returns [{ category }]
    generateAuthorStaticParams()         // Returns [{ slug }]
    // With no routing options, all three can be exported directly as
    // `generateStaticParams`; the props Next passes are ignored.
    generateArticleMetadata(slug, opts) // Next.js Metadata object; notFound() for a missing post.
                                         // opts.images: false when the route has its own opengraph-image card
    generatePublicationMetadata(opts)      // Index page metadata
    generateArticleCategoryMetadata(name, opts)
    
    // Schema & SEO
    generateArticleSchema(post, opts)   // JSON-LD Article with FAQ
    generateArticleListSchema()             // JSON-LD for publication index
    generateFaqSchema(items)             // FAQ Page schema
    generateArticleSitemap(siteUrl)         // Sitemap entries for article
    
    // Validation
    validateArticleSeo(post)            // Returns field-by-field SEO audit
    validateSeoTitle(title, keyphrase?)  // Title length + keyword checks
    validateMetaDescription(desc)        // Description length check

    getTopicClusters() returns an empty array only after a successful, valid empty response. getTopicCluster(slug) returns null only for a confirmed 404. Missing credentials, upstream errors and malformed or incomplete cluster responses throw ArticleUnavailableError, so a route can offer a retry instead of caching an empty topic or a false not-found page.

    Multi-site projects

    One Sonor project can serve many domains (example.com plus its city microsites), each with its own article. A post with no site is project-wide and shows on every host; a post tagged charlotte.example.com shows only there.

    Every article read sends the site host automatically, as ?site= on GETs and a site field on the related-posts and view-count POSTs. The host resolves from NEXT_PUBLIC_SITE_URL, which every microsite already sets, so most sites change nothing. To pin a host explicitly, pass site:

    <ArticleList site="charlotte.example.com" />           // also Article, PublicationSidebar, PublicationLayout, RelatedPosts, ClusterLandingPage
    await getArticle(slug, { site: 'charlotte.example.com' })
    await getAllArticles({ site: 'charlotte.example.com' })

    getAllArticleSlugs() and getAllAuthorSlugs() take no arguments, so they can still be exported as generateStaticParams. They always use NEXT_PUBLIC_SITE_URL. Single-site projects and older API servers ignore site.

    Article Props

    interface ArticleProps {
      slug: string
      showRelated?: boolean    // Related posts section
      showToc?: boolean        // Table of contents
      showAuthor?: boolean     // Author card
      unstyled?: boolean       // Skip default styles
      notFound?: boolean       // Default true: notFound() when the post is missing. false renders a message.
      className?: string
      children?: (props: { post, toc, related }) => ReactNode  // Render prop
    }

    ArticleList Props

    interface ArticleListProps {
      category?: string           // Filter by category slug
      tag?: string                // Filter by tag
      author?: string             // Filter by author
      featured?: boolean          // Featured posts only
      search?: string             // Search posts
      page?: number               // Default: 1
      perPage?: number            // Default: 12
      orderBy?: 'published_at' | 'title' | 'view_count'
      order?: 'asc' | 'desc'
      showCategoryFilter?: boolean
      showPagination?: boolean
      unstyled?: boolean
      className?: string
      children?: (props: { posts, pagination, categories }) => ReactNode
    }

    Key Types

    interface Article {
      slug: string; title: string; excerpt?: string;
      content: string;       // source text, never rendered
      content_html?: string; // the body; the only field rendered as HTML
      featured_image?: string; author?: ArticleAuthor; category?: ArticleCategory;
      tags?: string[]; meta_title?: string; meta_description?: string;
      faq_items?: { question: string; answer: string }[];
      article_type?: 'pillar' | 'support' | 'comparison' | 'faq' | 'glossary' | 'checklist';
      cluster_slug?: string; reading_time?: number; // the API column ('X min read')
      published_at?: string; status: 'draft' | 'published' | 'scheduled' | 'archived';
    }
    
    interface ArticleAuthor {
      name: string; slug: string; bio?: string; avatar_url?: string;
      title?: string; credentials?: string[]; expertise_areas?: string[];
      years_experience?: number; is_subject_matter_expert?: boolean;
    }
    
    interface TopicCluster {
      cluster_name: string; cluster_slug: string; core_topic: string;
      geo_target?: string; target_service_page?: string; article_count: number;
      pillar: Article | null; supports: Article[];
    }

    Styling

    Components use .sk-article-* and .sk-article-list-* classes. Import default styles:

    Or use unstyled prop + children render prop for complete control.

    Supporting photos

    Sonor's article photo plan can attach up to eight supporting images to exact places in the article. The public response includes editorial_photos, an optional array of approved snapshots with url, alt, optional caption, width, height, and placement. Placement is { kind: 'after_intro' } or { kind: 'after_heading', heading: 'Exact section heading', headingId?: 'stable-anchor' }. Photo briefs, file ownership and private source notes stay in the authenticated API.

    The default Article renderer and the RSS/Atom generators apply these placements automatically. Custom publications use the same pure helper on content_html before rendering their content:

    import { insertArticlePhotos, articlePhotoCss } from '@sonordev/press-kit/articles/server-ui'
    
    const html = insertArticlePhotos(post.content_html ?? '', post.editorial_photos)
    // Render articlePhotoCss once and use html with your existing trusted-content policy.

    For responsive images on a Next.js page, pass an optional delivery adapter. Keep the original two-argument call for feeds and public MCP content, which need portable source URLs instead of a page's image optimizer:

    import { getImageProps } from 'next/image'
    
    const html = insertArticlePhotos(articleHtml, post.editorial_photos, {
      imageAttributes: photo => getImageProps({
        src: photo.url,
        alt: photo.alt,
        width: photo.width,
        height: photo.height,
        sizes: '(max-width: 768px) 100vw, 800px',
      }).props,
    })

    Configure the approved image hosts in Next.js images.remotePatterns and choose sizes for the publication's actual content width. A scoped adapter can return undefined to retain an image's original delivery. The adapter controls only src, srcSet and sizes; Press Kit keeps approved alt text, dimensions, captions and lazy loading. It escapes every attribute and rejects unsafe URLs or malformed source sets with a TypeError. The original source is retained in data-sk-editorial-source, so responsive delivery doesn't change photo placement or duplicate detection, including repeated insertion.

    The helper inserts a semantic figure after the introduction's first prose paragraph or a section's first prose paragraph. It preserves the surrounding HTML and includes intrinsic dimensions, descriptions, captions and lazy loading. When a stable heading anchor is supplied it survives wording edits; otherwise the heading text must match. It uses the same pure placement resolver as Sonor's editor, including a warning when a section has no eligible paragraph. It skips missing or duplicate headings, code examples, tables, existing figures, lists, and callouts. An image already in the body isn't inserted again. It doesn't sanitize the original article HTML; retain your existing content policy. Articles without this field render as before. Custom styles can target .sk-article-photo and .sk-article-photo figcaption or use articlePhotoCss.

    Agents managing photos use the authenticated Sonor article media tools. The site's public get_article MCP tool only reads the approved photo descriptions and placements; it can't attach photos or read planning briefs.