# @sonordev/press-kit

Articles, publication pages, author profiles, topic clusters, newsletter signup, RSS/Atom feeds and Google News sitemaps for Sonor-powered Next.js sites. Press Kit owns the publication UI and data helpers; Sonor's app and APIs own editorial planning, Broadcast artwork and publishing.

```sh
pnpm add @sonordev/press-kit @sonordev/site-kit
```

Use Site Kit 8.4 or later in the 8.x series, React 19 and Next.js 16. Keep `SiteKitLayout` in your root layout and `SONOR_API_KEY` in `.env.local`. Press Kit uses the site's existing credential, transport, shared styling tokens and managed forms. It needs no extra key or provider.

```tsx
import { Article } from '@sonordev/press-kit/articles/server-ui'
import { generateArticleMetadata, generateArticleStaticParams } from '@sonordev/press-kit/articles/server'

export const generateStaticParams = generateArticleStaticParams
export async function generateMetadata({ params }) {
  const { slug } = await params
  return generateArticleMetadata(slug, { siteName: 'Our journal' })
}
export default async function Page({ params }) {
  const { slug } = await params
  return <Article slug={slug} showRelated showToc />
}
```

| Entry                 | Use                                                                                      |
| --------------------- | ---------------------------------------------------------------------------------------- |
| Root                  | Types only, including `Article`, `ArticleEditorialPhoto`, `ArticleAuthor`                |
| `/articles`           | Client widgets, newsletter signup and synchronous presentation helpers                   |
| `/articles/share`     | Lightweight public article share and copy controls                                       |
| `/articles/server`    | Server-only article reads, metadata, static params, schemas, feeds and news sitemap      |
| `/articles/server-ui` | Server-only publication, article, category and cluster components; table/photo insertion |

Never import asynchronous article components from the client entry. Server reads stay behind `server-only`; client calls use Site Kit's shared transport. The article flow uses the same photo placement contract as Sonor's planning API, including stable heading anchors and exact missing-placement diagnostics.

See [Articles](https://sonor.dev/press-kit/articles) for component props and feed/sitemap examples. Existing `@sonordev/site-kit/articles` imports remain compatible in Site Kit 8.3, built from this exact source; migrate by replacing only the package name. Removal belongs to a future Site Kit major release. Neither installation nor migration changes a published article or schedules a broadcast.

## Reliable reads and public archives

Press Kit 1.1 uses Site Kit 8.4 or later. `getArticle(slug)` returns `null` only
for a confirmed 404. An outage, invalid credential or malformed upstream response
throws `ArticleUnavailableError`; let your route error boundary offer a retry
instead of caching a false “not found.” `getAllArticles({ maxPosts: Infinity })`
reads the whole archive and throws on incomplete reads. `getArticleResult` offers
an explicit `ready | not_found | unavailable` result for custom interfaces.

Topic-cluster reads follow the same rule: `getTopicClusters()` returns `[]`
only for a valid empty list, and `getTopicCluster(slug)` returns `null` only
for a confirmed 404. Unavailable or incomplete cluster data throws
`ArticleUnavailableError`.

`insertArticlePhotos` accepts an optional `imageAttributes` adapter for
responsive page images, including Next.js `getImageProps`. Feeds and MCP keep
the original two-argument call and canonical image URLs. Approved captions,
dimensions and source identities survive optimization; see the
[supporting photo example](https://sonor.dev/press-kit/articles#supporting-photos).

`getPublicationIssues({ site, limit, cursor })` and `getPublicationIssue(id, { site })`
return that same result shape. A ready list contains `{ issues, nextCursor? }`;
a ready issue is the immutable sent snapshot `{ id, title, subject, sentAt,
articles, html?, text? }`. Draft campaigns and recipient data never belong in this
public archive. Keep a sample honestly labeled until a sent issue exists; don't
present an unavailable archive as an empty one. Render archived HTML in an iframe
with an empty sandbox (or `allow-popups allow-popups-to-escape-sandbox` for its
read links), never inject email HTML into your site's document.

Managed newsletter forms acknowledge the accepted signup request. They don't
claim a visitor is subscribed before the asynchronous enrollment completes, or
reveal whether an address is already subscribed. `useForm` from Site Kit exposes
`submissionResult.publicationEnrollment` and `reload()`; retries preserve entered
values.

## Article HTML and what Sonor sanitizes

The article HTML Press Kit renders arrives already cleaned. Sonor's public
article reads (every post list and detail response, the `blog` alias, and the
private draft preview) pass `content_html` and the answers in `faq_items`
through an allowlist before they leave the API. The stored body
isn't changed; only the copy a site receives is. This has been live since
2026-10-20.

- **`content_html`** keeps ordinary article markup: headings (with `id`
  anchors), paragraphs, lists, blockquotes, `pre` and `code` (with `language-*`
  classes), tables, figures and captions, `img` over `https`, inline emphasis,
  line breaks, rules, and `span` or `div` with a plain class name. Links keep
  `http`, `https`, `mailto` and `tel` URLs. It drops `script` and `style`
  elements, every `on*` handler and `style` attribute, `javascript:` and
  `data:` URLs, forms and form controls, `object`, `embed`, `svg`, `math` and
  all iframes.
- **FAQ answers** get a narrower list: bold, italic, underline, paragraphs, line
  breaks, lists, links and `span`, with no attributes other than a link's
  `href`, `rel` and `target`.

Press Kit adds no sanitizer of its own. `insertArticlePhotos`,
`wrapArticleTables` and the external-link rewrite are layout transforms that
trust their input, and the stock `Article` renders the HTML it's given.

Those transforms, the heading anchors and the table of contents find tags the
way a browser's HTML tokenizer does. A quote opens an attribute value only right
after `=`, so a stray quote in an attribute name or an unquoted value doesn't pair
with the next one. A tag that never closes is left alone, along with all the text
after it, because a browser reads that text as part of the tag. Text that
doesn't start a tag, such as `1 < 2`, is left as written. Ordinary article HTML
comes out byte for byte as it went in, apart from the attributes the transforms
add. The text of an RSS description or an Atom summary made from the article body
carries no `<` or `>`: a bracket that is left once the tags are gone is written as
`&lt;` or `&gt;`.

`content_html` is the only article field Press Kit renders as HTML. `content`
is the source text, not a render field: Sonor doesn't clean it, so the stock
`Article`, the RSS feed and the Atom feed never render it, not even as a
fallback. A post with a blank `content_html` renders an empty body. `Article`
doesn't convert `content_html` from Markdown either; the body is the HTML Sonor
sent. Keep `content_html` populated. If a custom renderer reads `content`, treat
it as text and escape it.

HTML that reaches your site any other way sits outside the guarantee too, such
as a sent-issue archive's `html` or anything from your own database or another
API. So don't render fields Press Kit doesn't expose, or the fields above when
they come from somewhere else, as page HTML. Don't add `dangerouslySetInnerHTML`
paths of your own for article content, and don't build markup from article text
or URLs in a custom renderer without escaping it. A strict
`Content-Security-Policy` stays worth having as a second layer.

## Private previews on the actual site

Sonor issues a short-lived token for one project, site, article and saved revision.
No additional credential is needed. Configure the entry route at
`app/api/press-preview/route.ts`:

```ts
import { createPublicationPreviewHandler } from '@sonordev/press-kit/articles/server'
export const dynamic = 'force-dynamic'
export const GET = createPublicationPreviewHandler({
  siteUrl: 'https://example.com', previewPath: '/journal/preview',
})
```

Give the preview route a synchronous server layout returning
`<div data-sk-private-preview>{children}</div>`. The marker is present even while
the draft is streaming; Site Kit suppresses analytics snapshots, SitemapSync,
chat/popups and reporting for the whole private document.

At the fixed preview page, read the scoped HttpOnly cookie and exchange it on the
server for every render. Use your ordinary article rendering component with the
returned post. Never pass the token to a client component or log it.

```tsx
import { cookies } from 'next/headers'
import { getArticlePreview, PUBLICATION_PREVIEW_COOKIE } from '@sonordev/press-kit/articles/server'
export const dynamic = 'force-dynamic'
export const metadata = { robots: { index: false, follow: false }, referrer: 'no-referrer' }
export default async function Preview() {
  const token = (await cookies()).get(PUBLICATION_PREVIEW_COOKIE)?.value || ''
  const result = await getArticlePreview(token, { site: 'example.com' })
  if (result.state !== 'ready') return <p>Return to Sonor to open a current preview.</p>
  return <YourArticleRenderer post={result.value.post} preview />
}
```

Set both the token entry route and preview page response headers to `PUBLICATION_PREVIEW_HEADERS` (using
Next's `headers()` configuration, after any broad global header rule): private/no-store, noindex/nofollow/noarchive,
and no-referrer. On Netlify, also set `Netlify-CDN-Cache-Control: no-store`.
Exclude this route from the sitemap and public feed. The helper strips the token
from the browser URL via a fixed redirect; changed revisions and expired tokens
fail closed. Previewing never publishes or schedules anything.

## Discovery images, freshness and agent reading

`resolveRepresentativeImage(post)` prefers the API's approved `representative_image`,
then editorial art, then the legacy featured image. Known asset dimensions are
preserved; missing sizes are omitted. Metadata lists the representative image
first and keeps the social card available; Twitter retains the composed card.
Schemas and image/news sitemaps use the same representative image. Article
metadata enables `max-image-preview:large`. An editor-approved
`editorial_verification` note may include `reviewedAt` and `reviewDueAt`; an
ordinary `updated_at` is never presented as fact verification.

The site's MCP should use the same routing and readers:

```ts
articles: {
  path: '/journal', routing: { includeCategoryInPath: true },
  readMany: getAllArticles, readOne: getArticle,
}
```

`list_articles` searches the complete archive and body text, returns canonical
URLs and supports `offset`. `get_article` returns `next_offset` so agents can
continue past the first 12,000 characters instead of mistaking an excerpt for the
whole article.

## Launch from a brand-owned definition

`definePublication` from `/articles/server` validates a versioned definition for
your publication identity, sections, newsletter form and brand. The `newsroom`,
`journal` and `briefing` starters use distinct grid densities, reading widths and
typography. Preview generated routes with
`npx sonor-setup publication --config publication.json --json`, then add `--write`
to create them. The command preserves existing files and never copies another
customer's content or credentials. See the CLI launch guide
for the definition and the remaining content, preview and launch checks.

Approved `publication_cta: { label, url, goal }` data renders a call to action in
the stock article. Custom links should carry `data-sk-article-id` and
`data-sk-article-goal`; Site Kit's analytics provider sends `article_cta_click`
with `{ article_id, goal, href }` through the site's existing gated transport.
Clicks measure interest, not completed conversions.
