# 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:

```tsx
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.

```tsx
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](https://sonor.dev/press-kit#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.

```tsx
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

```tsx
// 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

```tsx
// 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`:

```ts
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

```tsx
// 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

| Component            | Import              | Purpose                                                                                                                        |
| -------------------- | ------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
| `Article`            | `article/server-ui` | Single post with content, TOC, author, related                                                                                 |
| `ArticleList`        | `article/server-ui` | Post grid with pagination and category filter                                                                                  |
| `PublicationLayout`  | `article/server-ui` | Full layout with optional sidebar                                                                                              |
| `PublicationSidebar` | `article/server-ui` | Categories, recent posts, tags                                                                                                 |
| `RelatedPosts`       | `article/server-ui` | Related articles widget                                                                                                        |
| `PublicationPage`    | `article/server-ui` | Drop-in publication index page (layout + list)                                                                                 |
| `ArticlePage`        | `article/server-ui` | Drop-in single-post page                                                                                                       |
| `CategoryPage`       | `article/server-ui` | Drop-in category archive page                                                                                                  |
| `ClusterLandingPage` | `article/server-ui` | Topic cluster overview with pillar + support articles                                                                          |
| `ClusterNavigation`  | `article`           | Breadcrumb-style cluster nav                                                                                                   |
| `AuthorCard`         | `article`           | Author profile with E-E-A-T fields                                                                                             |
| `TableOfContents`    | `article`           | Auto-generated from H2-H4 headings, read the way `Article` reads them; a heading without an `id` links to the slug of its text |
| `ArticleFAQ`         | `article`           | FAQ section with schema                                                                                                        |
| `ServiceCallout`     | `article`           | CTA callout for related services                                                                                               |
| `NewsletterWidget`   | `article`           | Email capture; needs `onSubmit` or `formSlug`                                                                                  |

## Server Functions (`article/server`)

```ts
// 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`:

```tsx
<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

```ts
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

```ts
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

```ts
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:

```tsx
```

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:

```tsx
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:

```tsx
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.
