# Content — pages + blogs

Shopify Storefront `Page` / `Article` shapes **verbatim** so storefront code
ports 1:1. **PUBLISHED only** — drafts and deleted rows 404 (lib status
filter AND the anon RLS policy). Stored HTML is server-sanitized on write —
safe to render raw (`dangerouslySetInnerHTML`).

Path convention (`src/lib/content/paths.ts`, shared with menus + redirects):
`/pages/<handle>`, `/blogs/<handle>`, `/blogs/<blog>/<post>`. Handle renames
create URL redirects automatically — wire [redirects.md](redirects.md) into
your 404 handler and old content URLs keep working.

Policy pages every store carries (`privacy-policy`, `terms-of-service`,
`refund-policy`, `shipping-policy`) are seeded as **drafts** — they 404 here
until the merchant publishes them.

> Content is admin-authored; the shared dev tenant seeds no published
> pages/blogs, so the executable blocks prove the 404 contract and the
> happy-path shapes are pinned by `tests/store/content-store.test.ts` and
> `tests/contract/sdk-content.contract.test.ts` (which create fixtures with
> admin auth).

## GET /api/store/pages/:handle — one page

- **Purpose**: render a static page (`/pages/<handle>`).
- **Auth**: anon (`x-client-id`).
- **Request**: path handle only.
- **Response**:

```jsonc
{
  "page": {
    "id": "cpage_<hex>",
    "handle": "about-us",
    "title": "About us",
    "body": "<h2>…</h2>",              // sanitized HTML — render raw
    "bodySummary": "…",                 // stripped, ~160-char word-boundary cut
    "seo": {
      "title": "About us",              // fallback: title
      "description": "…"                // fallback: bodySummary
    },
    "publishedAt": "ISO-8601",
    "createdAt": "ISO-8601",
    "updatedAt": "ISO-8601"
  }
}
```

- **Errors**: `404 not_found` — unknown handle, draft, or deleted.
- **SDK**: `content.getPage(client, handle)`
- **Components**: static page template; footer policy links.
- **Settings**: page status (draft/published) in admin → Content → Pages.

```bash
# Unknown handle → the clean 404 contract (drafts behave identically).
STATUS=$(curl -s -o /dev/null -w '%{http_code}' \
  "$BASE/api/store/pages/doc-no-such-page-$RUN" -H "x-client-id: $CLIENT_ID")
test "$STATUS" = 404
curl -s "$BASE/api/store/pages/doc-no-such-page-$RUN" -H "x-client-id: $CLIENT_ID" \
  | grep -q '"code":"not_found"'
```

## GET /api/store/blogs/:handle/posts — blog listing

- **Purpose**: the blog index (`/blogs/<handle>`), paginated.
- **Auth**: anon (`x-client-id`).
- **Request**: query `{limit? (≤100, default 20), offset?}`. Published
  only, `published_at` desc.
- **Response**:

```jsonc
{
  "blog": { "handle": "news", "title": "News" },
  "articles": [ /* Article[] — the exact shape below */ ],
  "count": 12,
  "offset": 0,
  "limit": 20
}
```

- **Errors**: `404 not_found` (unknown blog handle) ·
  `400 validation_failed` (limit/offset out of range).
- **SDK**: `content.listBlogPosts(client, blogHandle, query?)`
- **Components**: blog index template.

```bash
STATUS=$(curl -s -o /dev/null -w '%{http_code}' \
  "$BASE/api/store/blogs/doc-no-such-blog-$RUN/posts" -H "x-client-id: $CLIENT_ID")
test "$STATUS" = 404
```

## GET /api/store/blogs/:handle/posts/:postHandle — one article

- **Purpose**: render one post (`/blogs/<blog>/<post>`).
- **Auth**: anon (`x-client-id`).
- **Response** — Shopify `Article` verbatim:

```jsonc
{
  "article": {
    "id": "bpost_<hex>",
    "handle": "hello-world",
    "title": "Hello world",
    "contentHtml": "<p>…</p>",         // sanitized HTML — render raw
    "content": "…",                     // plain-text version
    "excerpt": "…",                     // author-provided; falls back to truncated content
    "image": { "url": "https://…", "altText": null },  // null when no hero image
    "tags": ["news"],
    "author": { "name": "Maria" },     // null when unset
    "publishedAt": "ISO-8601",
    "seo": { "title": "…", "description": "…" },  // fallbacks: title / excerpt
    "blog": { "handle": "news", "title": "News" }
  }
}
```

- **Errors**: `404 not_found` — unknown blog OR unknown/draft post.
- **SDK**: `content.getBlogPost(client, blogHandle, postHandle)`
- **Components**: article template; article cards reuse the listing shape.

```bash
STATUS=$(curl -s -o /dev/null -w '%{http_code}' \
  "$BASE/api/store/blogs/doc-no-such-blog-$RUN/posts/doc-no-post-$RUN" \
  -H "x-client-id: $CLIENT_ID")
test "$STATUS" = 404
```
