chaingrow-blog 0.2.2 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,102 +2,302 @@
2
2
 
3
3
  Drop-in Next.js SDK for rendering blogs published from [ChainGrow](https://chaingrow.de).
4
4
 
5
+ > **0.6.0 breaking change.** The high-level `<BlogIndex>` API has been simplified
6
+ > to a single `format` prop (`'cards' | 'list' | 'bare'`). The `components`,
7
+ > `extra`, and `cards` props are removed — for custom design, compose
8
+ > `listBlogs()` + your own JSX directly. `createBlogPage()` factory is also
9
+ > removed; only the zero-config `BlogPage` default export remains. The
10
+ > standalone sitemap route handlers (`chaingrow-blog/next/sitemap`,
11
+ > `chaingrow-blog/next/news-sitemap`, `chaingrow-blog/next/robots`) are gone —
12
+ > use `getBlogSitemapEntries()` with Next.js's native `app/sitemap.ts` instead.
13
+
5
14
  - **Zero database.** Your site stores nothing — ChainGrow is the source of truth.
6
- - **Instant publishing.** When you press Publish in ChainGrow, the SDK's webhook handler invalidates the relevant page via `revalidateTag`. Next request serves fresh HTML.
7
- - **Headless-first.** The core SDK is unstyled semantic HTML. Supply your own components for any block type you want to customize.
15
+ - **Three layouts out of the box.** Pick `cards`, `list`, or `bare` with one prop.
16
+ - **Headless when you want it.** For full design control, skip the high-level components and compose `listBlogs()` + `<BlogDetail>` + `<BlogJsonLd>` yourself.
17
+ - **Webhook-driven freshness.** Publish, unpublish, and delete propagate to your site instantly via the webhook handler.
8
18
 
9
19
  ## Install
10
20
 
11
21
  ```bash
22
+ npm install chaingrow-blog
23
+ # or
12
24
  pnpm add chaingrow-blog
13
25
  # or
14
- npm install chaingrow-blog
26
+ yarn add chaingrow-blog
15
27
  ```
16
28
 
17
29
  Requires Next.js ≥ 14 (App Router) and React ≥ 18.
18
30
 
19
- ## Setup (3 files)
31
+ ## Quickstart
20
32
 
21
- ### 1. Environment variable
33
+ ### 1. Environment
22
34
 
23
35
  ```env
24
- CHAINGROW_API_KEY=ck_live_xxx
36
+ # .env.local
37
+ CHAINGROW_API_KEY=cg_live_xxx
38
+ ```
39
+
40
+ > That's the only required variable. Generate the key in your ChainGrow dashboard under **Integrations → API Keys**. It authenticates your API calls and verifies incoming webhooks — one secret total. The webhook delivery URL is derived automatically from the website you set on your business profile; you never paste a URL anywhere.
41
+ ### 2. List page `app/blog/page.tsx`
42
+
43
+ ```tsx
44
+ import { BlogIndex } from 'chaingrow-blog/react';
45
+
46
+ export default async function Page() {
47
+ return <BlogIndex/>;
48
+ }
25
49
  ```
50
+ > Default is `format="minimalist"`: a stark monochrome grid (square image, bold title, gray date) — black on white in light mode, white on black in dark mode, no borders or shadows. Pass `format="list"` for a single-column thumbnail feed, or `format="bare"` for unstyled HTML.
51
+ ### 3. Blog page `app/blog/[slug]/page.tsx`
26
52
 
27
- That's the only required variable. Generate the key in your ChainGrow dashboard under **Integrations → API Keys**. It authenticates your API calls AND verifies incoming webhooks, so there's only one secret to manage. The webhook delivery URL is derived automatically from the website you set on your business profile, so you never paste a URL anywhere.
53
+ ```tsx
54
+ import { BlogPage, generateBlogPageMetadata } from 'chaingrow-blog/next';
28
55
 
29
- (Optional: set `CHAINGROW_API_URL` if you're pointing at staging or a self-hosted deploy. Defaults to `https://api.chaingrow.de`.)
56
+ export const generateMetadata = generateBlogPageMetadata();
57
+ export default BlogPage;
58
+ ```
30
59
 
31
- ### 2. Webhook receiver
60
+ > That's it for rendering: fetch, metadata, JSON-LD, body, 404. Typography (h1/h2/p/ul/blockquote/img/code/etc.) is styled via self-contained CSS scoped under `.cg-blog-article` — no Tailwind, no `prose`, no CSS framework needed. Light + dark mode follow `prefers-color-scheme` by default. To override styling, target the scoped classes from your own stylesheet, or skip this component entirely (see [Custom design](#custom-design) below).
32
61
 
33
- Mount the SDK's handler at the convention path `/api/chaingrow/webhook` — ChainGrow always POSTs to `${your-website}/api/chaingrow/webhook`:
62
+ ### 4. Webhook `app/api/chaingrow/webhook/route.ts`
34
63
 
35
64
  ```ts
36
- // app/api/chaingrow/webhook/route.ts
37
65
  export { POST } from 'chaingrow-blog/next/webhook';
38
66
  ```
39
67
 
40
- ### 3. Blog page
68
+ Webhook allows instant publish/unpublishWhen you publish, unpublish, or delete a blog in the ChainGrow dashboard, the SDK invalidates the relevant ISR cache tags and your site updates immediately. Without this file, freshness is governed by the ISR window (default 7 days) — fine for development, not great for production. The handler verifies the `X-ChainGrow-Signature` HMAC; no manual URL configuration is needed (ChainGrow derives it from your business profile's website URL).
69
+
70
+ That's the whole quickstart. Three files, one env var.
71
+
72
+ > **Want a custom design?** Skip the high-level components and compose the data fetchers + the headless React components yourself. See [Custom design](#custom-design) below.
73
+
74
+ ## Localized routes (i18n)
75
+
76
+ ChainGrow publishes language variants of a post under **one shared slug** — `getBlog(slug, language)` picks the variant. If your site prefixes routes per locale (e.g. `/blog/...` for the default locale, `/en/blog/...` for others), wire the locale through in three places:
77
+
78
+ ### 1. Index links — cards must link to the active locale's route
41
79
 
42
- **Option A Custom rendering (bring your own JSX):**
80
+ `<BlogIndex>` defaults to `/blog/<slug>` hrefs regardless of `language`. Derive `pathPrefix` from the active locale:
43
81
 
44
82
  ```tsx
45
- // app/blog/[slug]/page.tsx
46
- import { notFound } from 'next/navigation'; // Next.js built-in — serves a real HTTP 404 and renders app/not-found.tsx
47
- import { getBlog } from 'chaingrow-blog/next';
83
+ // app/[locale]/blog/page.tsx
84
+ export default async function Page({ params }) {
85
+ const { locale } = await params;
86
+ return (
87
+ <BlogIndex
88
+ language={locale}
89
+ pathPrefix={locale === 'de' ? '/blog' : `/${locale}/blog`}
90
+ />
91
+ );
92
+ }
93
+ ```
48
94
 
49
- export default async function BlogPage({
50
- params,
51
- }: {
52
- params: { slug: string };
53
- }) {
54
- const blog = await getBlog(params.slug);
55
- if (!blog) notFound();
95
+ Or take full control per card with `hrefFor` (takes precedence over `pathPrefix`):
96
+
97
+ ```tsx
98
+ <BlogIndex
99
+ language={locale}
100
+ hrefFor={(b) => (b.language === 'de' ? `/blog/${b.slug}` : `/${b.language}/blog/${b.slug}`)}
101
+ />
102
+ ```
103
+
104
+ ### 2. Index metadata
105
+
106
+ The index page has no post to derive metadata from — without this, `/blog` falls back to your site-default title:
107
+
108
+ ```ts
109
+ import { generateBlogIndexMetadata } from 'chaingrow-blog/next';
110
+
111
+ export const metadata = generateBlogIndexMetadata({
112
+ title: 'Blog | Acme',
113
+ description: 'Product updates and guides from Acme.',
114
+ canonical: 'https://example.com/blog',
115
+ });
116
+ ```
117
+
118
+ ### 3. Detail metadata — canonical + hreflang alternates
119
+
120
+ Pass `alternates` and the SDK emits `alternates.canonical` plus hreflang `alternates.languages` for every published language variant of the post (it reuses the ISR-cached list response — no extra backend hit):
121
+
122
+ ```ts
123
+ export const generateMetadata = generateBlogPageMetadata({
124
+ alternates: {
125
+ siteUrl: 'https://example.com',
126
+ pathPrefix: (lang) => (lang === 'de' ? '/blog' : `/${lang}/blog`),
127
+ xDefaultLanguage: 'de',
128
+ },
129
+ });
130
+ ```
131
+
132
+ Sitemaps: pass the same per-locale prefix to `getBlogSitemapEntries({ language, pathPrefix })` once per locale, or build from `listBlogs({ language })` yourself.
133
+
134
+ ## Custom design
135
+
136
+ When `format="minimalist" | "list"` aren't your style, skip `<BlogIndex>` and `BlogPage` entirely and compose your own pages. Two ChainGrow primitives do the heavy lifting:
137
+
138
+ - `listBlogs()` — fetch the list (ISR-cached, webhook-invalidated)
139
+ - `getBlog(slug, language?)` — fetch one (same caching)
140
+
141
+ …and three React components compose into the detail layout:
142
+
143
+ - `<BlogJsonLd blog={...} extraSchemas={...}>` — emits BlogPosting + FAQPage + your extra schemas under one `@graph`
144
+ - `<BlogDetail blog={...} components={{ Header, Footer, blocks }}>` — full article layout with overridable header, footer, and per-block renderers
145
+ - `<BlogRenderer blog={...}>` — body blocks only (skip the article header/footer)
146
+
147
+ ### Customizing the list page `app/blog/page.tsx`
148
+
149
+ ```tsx
150
+ import { listBlogs } from 'chaingrow-blog/next';
151
+ import Link from 'next/link';
152
+
153
+ export default async function Page() {
154
+ const blogs = await listBlogs({ status: 'PUBLISHED' });
56
155
 
57
156
  return (
58
- <article>
59
- <h1>{blog.title}</h1>
60
- {blog.blocks.map((b, i) => (
61
- /* render however you want */
62
- <YourCustomBlock key={i} block={b} />
63
- ))}
64
- </article>
157
+ <main className="max-w-5xl mx-auto px-6 py-24">
158
+ {/* ↑ optional Tailwind: max-width container, vertical padding */}
159
+
160
+ <h1 className="text-4xl font-bold mb-8">Blog</h1>
161
+ {/* ↑ optional Tailwind: large headline */}
162
+
163
+ <div className="grid md:grid-cols-2 lg:grid-cols-3 gap-8">
164
+ {/* ↑ optional Tailwind: 1 col mobile, 2 col tablet, 3 col desktop */}
165
+
166
+ {blogs.map((b) => (
167
+ <Link
168
+ key={b.id}
169
+ href={`/blog/${b.slug}`}
170
+ className="block rounded-xl border border-gray-200 hover:shadow-lg transition p-5"
171
+ // ↑ optional Tailwind: rounded card with hover lift
172
+ >
173
+ {b.og_image && (
174
+ <img
175
+ src={b.og_image}
176
+ alt={b.title}
177
+ className="rounded-lg mb-4 w-full aspect-video object-cover"
178
+ />
179
+ )}
180
+ <h2 className="font-semibold text-lg mb-2">{b.title}</h2>
181
+ {b.description && (
182
+ <p className="text-sm text-gray-600 line-clamp-3">{b.description}</p>
183
+ )}
184
+ </Link>
185
+ ))}
186
+ </div>
187
+ </main>
65
188
  );
66
189
  }
67
190
  ```
68
191
 
69
- **Option B Default renderer with per-block overrides:**
192
+ Every Tailwind className is annotated. Remove all `className` props for bare HTML, or replace with your own CSS / CSS modules / styled-components. You decide what each element looks like.
193
+
194
+ ### Customizing the blog page `app/blog/[slug]/page.tsx`
70
195
 
71
196
  ```tsx
197
+ import { getBlog, generateBlogMetadata } from 'chaingrow-blog/next';
198
+ import { BlogDetail, BlogJsonLd } from 'chaingrow-blog/react';
72
199
  import { notFound } from 'next/navigation';
73
- import { getBlog } from 'chaingrow-blog/next';
74
- import { BlogRenderer } from 'chaingrow-blog/react';
75
200
 
76
- export default async function BlogPage({
201
+ export async function generateMetadata({
202
+ params,
203
+ }: {
204
+ params: Promise<{ slug: string }>;
205
+ }) {
206
+ const { slug } = await params;
207
+ return generateBlogMetadata(slug);
208
+ }
209
+
210
+ export default async function Page({
77
211
  params,
78
212
  }: {
79
- params: { slug: string };
213
+ params: Promise<{ slug: string }>;
80
214
  }) {
81
- const blog = await getBlog(params.slug);
215
+ const { slug } = await params;
216
+ const blog = await getBlog(slug);
82
217
  if (!blog) notFound();
83
218
 
84
219
  return (
85
- <article className="prose">
86
- <h1>{blog.title}</h1>
87
- <BlogRenderer
220
+ <article className="prose max-w-3xl mx-auto py-16 px-6">
221
+ {/* ↑ optional Tailwind Typography: prose typography wrapper */}
222
+
223
+ <BlogJsonLd blog={blog} />
224
+
225
+ <BlogDetail
88
226
  blog={blog}
89
227
  components={{
90
- heading: ({ level, children }) =>
91
- level === 2 ? (
92
- <h2 className="text-3xl font-bold">{children}</h2>
93
- ) : (
94
- <h3 className="text-2xl font-semibold">{children}</h3>
95
- ),
96
- quote: ({ children }) => (
97
- <blockquote className="border-l-4 border-blue-500 pl-4 italic">
98
- {children}
99
- </blockquote>
228
+ // Override the article header (default: og_image + h1 + time + description)
229
+ Header: ({ blog, formatDate }) => (
230
+ <header className="mb-8">
231
+ {blog.og_image && (
232
+ <img
233
+ src={blog.og_image}
234
+ alt={blog.title}
235
+ className="rounded-xl mb-6 w-full aspect-video object-cover"
236
+ />
237
+ )}
238
+ <h1 className="text-4xl font-bold mb-3">{blog.title}</h1>
239
+ {blog.published_at && (
240
+ <time className="text-sm text-gray-500">
241
+ {formatDate(blog.published_at)}
242
+ </time>
243
+ )}
244
+ {blog.description && (
245
+ <p className="text-lg text-gray-600 mt-4">{blog.description}</p>
246
+ )}
247
+ </header>
248
+ ),
249
+
250
+ // Override the article footer (default: nothing). Add share buttons,
251
+ // related posts, comment widgets, etc.
252
+ Footer: ({ blog }) => (
253
+ <footer className="mt-12 pt-8 border-t border-gray-200 text-sm text-gray-500">
254
+ Published {new Date(blog.published_at).toLocaleDateString()}
255
+ </footer>
100
256
  ),
257
+
258
+ // Override individual block renderers — pick any subset, the rest
259
+ // fall through to the bare-HTML defaults.
260
+ blocks: {
261
+ heading: ({ level, children }) => {
262
+ if (level === 1) return <h1 className="text-3xl font-bold mt-12 mb-4">{children}</h1>;
263
+ if (level === 2) return <h2 className="text-2xl font-bold mt-10 mb-3">{children}</h2>;
264
+ return <h3 className="text-xl font-semibold mt-8 mb-2">{children}</h3>;
265
+ },
266
+ paragraph: ({ children }) => (
267
+ <p className="my-4 leading-relaxed">{children}</p>
268
+ ),
269
+ blockquote: ({ children }) => (
270
+ <blockquote className="border-l-4 border-gray-300 pl-4 italic my-6">
271
+ {children}
272
+ </blockquote>
273
+ ),
274
+ codeBlock: ({ code, language }) => (
275
+ <pre className="bg-gray-100 rounded-lg p-4 overflow-x-auto my-6">
276
+ <code className={`language-${language ?? ''}`}>{code}</code>
277
+ </pre>
278
+ ),
279
+ bulletList: ({ items }) => (
280
+ <ul className="list-disc list-inside my-4 space-y-2">
281
+ {items.map((item, i) => <li key={i}>{item}</li>)}
282
+ </ul>
283
+ ),
284
+ orderedList: ({ items }) => (
285
+ <ol className="list-decimal list-inside my-4 space-y-2">
286
+ {items.map((item, i) => <li key={i}>{item}</li>)}
287
+ </ol>
288
+ ),
289
+ image: ({ src, alt, caption }) => (
290
+ <figure className="my-8">
291
+ <img src={src} alt={alt} className="rounded-xl w-full" />
292
+ {caption && (
293
+ <figcaption className="text-sm text-gray-500 text-center mt-2">
294
+ {caption}
295
+ </figcaption>
296
+ )}
297
+ </figure>
298
+ ),
299
+ divider: () => <hr className="my-12 border-gray-200" />,
300
+ },
101
301
  }}
102
302
  />
103
303
  </article>
@@ -105,142 +305,420 @@ export default async function BlogPage({
105
305
  }
106
306
  ```
107
307
 
108
- ## API
308
+ Every Tailwind className is optional — drop it for bare HTML, or swap for your own CSS classes. Override any subset of `Header`, `Footer`, and `blocks.*` — anything you don't override falls back to the bare-HTML defaults.
109
309
 
110
- ### `getBlog(slug, language?, opts?)`
310
+ ### Data shapes
111
311
 
112
- Fetches a blog by slug (server-side, ISR-cached under tag `chaingrow:blog:<slug>`). Returns `null` if not found.
312
+ When you're composing your own pages (or feeding ChainGrow data into your own components), these are the types you'll work with.
313
+
314
+ `listBlogs()` returns `Blog[]`. `getBlog(slug, language?)` returns `Blog | null`. The `Blog` shape:
113
315
 
114
- ### `listBlogs(opts?, clientOpts?)`
316
+ ```ts
317
+ type Blog = {
318
+ id: string;
319
+ slug: string;
320
+ language: string; // 2-letter ISO code (e.g. 'en', 'de')
321
+ title: string;
322
+ description: string | null;
323
+ blocks: Block[]; // structured body (see below)
324
+ faq: { question: string; answer: string }[];
325
+ og_image: string | null; // hero image URL
326
+ image_url: string | null;
327
+ video_url: string | null;
328
+ video_prompt: string | null;
329
+ structured_data: Record<string, unknown> | null; // LLM-generated JSON-LD extras
330
+ published_at: string; // ISO 8601 timestamp
331
+ social_posts: {
332
+ provider: string; // 'X_TWITTER' | 'LINKEDIN' | 'FACEBOOK'
333
+ content: string;
334
+ social_title: string | null;
335
+ }[];
336
+ generatedMedia?: {
337
+ id: string;
338
+ media_url: string;
339
+ media_type: 'IMAGE' | 'VIDEO';
340
+ prompt: string;
341
+ created_at: string;
342
+ }[];
343
+ };
344
+ ```
345
+
346
+ The `Block` union covers the body content and maps one-to-one with TipTap/ProseMirror nodes:
347
+
348
+ ```ts
349
+ type Block =
350
+ | { type: 'heading'; level: 1 | 2 | 3; content: Inline[] }
351
+ | { type: 'paragraph'; content: Inline[] }
352
+ | { type: 'bulletList'; items: ListItem[] }
353
+ | { type: 'orderedList'; items: ListItem[] }
354
+ | { type: 'blockquote'; content: Inline[] }
355
+ | { type: 'codeBlock'; language?: string; code: string }
356
+ | { type: 'image'; src: string; alt: string; caption?: string;
357
+ width?: number; height?: number; mediaId?: string }
358
+ | { type: 'divider' };
359
+
360
+ type Inline = { text: string; marks?: Mark[] };
361
+ type Mark =
362
+ | { type: 'bold' } | { type: 'italic' } | { type: 'strike' }
363
+ | { type: 'code' } | { type: 'link'; href: string };
364
+
365
+ type ListItem = { content: Block[] }; // items can contain nested blocks
366
+ ```
115
367
 
116
- Lists blogs (optionally filtered by `status` or `language`).
368
+ If you don't want to walk the blocks yourself, render the body with `<BlogRenderer blog={blog} />` and override per-block renderers via `components.blocks` (shown above).
117
369
 
118
- ### `createClient({ apiUrl, apiKey })`
370
+ ### Pre-existing blog at `/blog`?
119
371
 
120
- Framework-agnostic client. Use from edge functions, scripts, or non-Next.js environments.
372
+ If you already serve a blog at `/blog/[slug]` from an existing CMS (MDX, WordPress, your own backend) and want to coexist, the cleanest pattern is to mount ChainGrow at a different URL:
121
373
 
122
- ### `<BlogRenderer blog={blog} components={...} />`
374
+ ```
375
+ /blog/[slug] ← legacy (untouched)
376
+ /guides/[slug] ← ChainGrow (new) — set pathPrefix="/guides" on getBlogSitemapEntries
377
+ ```
123
378
 
124
- Renders all `blog.blocks` using unstyled default components. Every block type is overridable via the `components` prop supply only the ones you want to customize.
379
+ If you really need both sources at the same URL space, write the dual lookup yourself in `app/blog/[slug]/page.tsx`: try `getBlog(slug)` first, render via `<BlogJsonLd>` + `<BlogDetail>` if found, fall back to your legacy renderer otherwise. The SDK doesn't provide a built-in for this — the routing logic depends on your legacy data shape and that's not something we can guess.
125
380
 
126
- Block types: `heading`, `paragraph`, `list`, `quote`, `code`, `image_placeholder`.
381
+ ## Sitemaps
127
382
 
128
- ### `createWebhookHandler({ apiKey?, onEvent? })`
383
+ Sitemaps are URL-shape decisions you should own — at root (`/sitemap.xml`), at a custom path, indexed under a master sitemap, split per-locale, etc. The SDK gives you the data; you decide the structure.
129
384
 
130
- Returns a `{ POST }` route handler. Verifies the `X-ChainGrow-Signature` HMAC (signed server-side with `sha256(apiKey)`) and calls `revalidateTag`. Pass `onEvent` to run custom logic after revalidation (e.g. cache warming, analytics). By default reads `CHAINGROW_API_KEY` from the environment.
385
+ ### Pattern A Next.js native `app/sitemap.ts`
131
386
 
132
- ### Rate-limit handling
387
+ Drop ChainGrow blogs into your existing sitemap with one line:
133
388
 
134
- Calls are transparently retried once on HTTP `429 Too Many Requests`, honoring the `Retry-After` header. No configuration needed. Other errors bubble up unchanged.
135
-
136
- ## How publishing works
137
-
138
- ```
139
- ChainGrow dashboard Your Next.js site
140
- ─────────────────── ─────────────────
141
- Press Publish
142
-
143
-
144
- blog.status = PUBLISHED
145
-
146
-
147
- HMAC-signed webhook ──────────────► /api/chaingrow/webhook
148
-
149
-
150
- verify signature
151
-
152
-
153
- revalidateTag('chaingrow:blog:<slug>')
154
-
155
-
156
- Next user request /blog/<slug> cache miss
157
- │ │
158
- ▼ ▼
159
- Fresh HTML getBlog() → GET /api/v1/blogs/by-slug/<slug>
160
-
161
-
162
- cached for next visitors
389
+ ```ts
390
+ // app/sitemap.ts
391
+ import type { MetadataRoute } from 'next';
392
+ import { getBlogSitemapEntries } from 'chaingrow-blog/next';
393
+
394
+ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
395
+ const blogEntries = await getBlogSitemapEntries({
396
+ siteUrl: 'https://example.com',
397
+ pathPrefix: '/blog',
398
+ });
399
+
400
+ return [
401
+ { url: 'https://example.com', lastModified: new Date() },
402
+ { url: 'https://example.com/pricing', lastModified: new Date() },
403
+ ...blogEntries,
404
+ ];
405
+ }
163
406
  ```
164
407
 
165
- ## Releasing new versions (maintainers only)
408
+ `getBlogSitemapEntries` returns `MetadataRoute.Sitemap`-shaped entries (`{ url, lastModified }`), so you spread it in. ISR-cached and webhook-invalidated automatically — same cache tags as `<BlogPage>` and `<BlogIndex>`.
166
409
 
167
- > Consumers installing `chaingrow-blog` can ignore this section it's internal notes for ChainGrow maintainers cutting a new release.
410
+ ### Pattern Bmerge into an existing custom sitemap route
168
411
 
169
- The SDK is published to npm by a GitHub Actions workflow (`.github/workflows/publish-blog-sdk.yml`). The workflow only runs when you push a **git tag** matching `blog-sdk-v*`. Regular commits and branch pushes do **nothing** — npm is never touched unless you explicitly create and push a release tag.
412
+ If you already serve sitemaps from a route file (e.g. `/api/sitemaps/general`), call `listBlogs()` and project to whatever shape you need:
170
413
 
171
- ### Triggers
414
+ ```ts
415
+ import { listBlogs } from 'chaingrow-blog/next';
416
+
417
+ const blogs = await listBlogs({ status: 'PUBLISHED' });
418
+ for (const blog of blogs) {
419
+ yourUrlList.push({
420
+ loc: `https://example.com/blog/${blog.slug}`,
421
+ lastmod: blog.published_at,
422
+ changefreq: 'monthly',
423
+ priority: 0.7,
424
+ });
425
+ }
426
+ ```
172
427
 
173
- ```yaml
174
- on:
175
- push:
176
- tags:
177
- - 'blog-sdk-v*' # only fires on matching tags
178
- workflow_dispatch: # manual "Run workflow" button in GitHub Actions UI
428
+ ### Pattern C — news sitemap (Google News)
429
+
430
+ ```ts
431
+ import { listBlogs, type Blog } from 'chaingrow-blog/next';
432
+ import { filterRecentForNews } from 'chaingrow-blog';
433
+
434
+ const blogs = await listBlogs({ status: 'PUBLISHED' });
435
+ const recent: Blog[] = filterRecentForNews(blogs); // last 48h, max 1000
436
+
437
+ // emit Google News XML using `recent`
179
438
  ```
180
439
 
181
- That's it. Two ways to trigger a publish:
440
+ `filterRecentForNews` is a pure helper that filters to the last 48 hours, sorts newest first, and caps at 1000 entries (Google's spec). You emit the XML with whatever library or hand-rolled XML builder you prefer; the SDK exports `buildNewsSitemapXml()` and `escapeXml()` from the package root if you want the SDK's defaults.
182
441
 
183
- 1. Push a tag named `blog-sdk-v<version>` (the normal flow)
184
- 2. Click "Run workflow" on the Actions page in GitHub (manual escape hatch)
442
+ ## JSON-LD / SEO schemas
185
443
 
186
- No other event publishes. You can push commits to `main` all day without releasing.
444
+ `<BlogJsonLd blog={blog} />` emits a single `<script type="application/ld+json">` containing:
187
445
 
188
- ### Cutting a release
446
+ - **BlogPosting** built from `blog.title`, `blog.description`, `blog.published_at`, `blog.og_image`.
447
+ - **FAQPage** built from `blog.faq` (only when non-empty).
448
+ - Any LLM-generated extras passed through from `blog.structured_data.jsonLd`.
189
449
 
190
- ```bash
191
- # 1. Bump the version in packages/blog-sdk/package.json
192
- # "version": "0.1.0" → "version": "0.1.1"
450
+ All three live under one `@graph` so Google treats them as one graph. Add your own schemas (breadcrumb, `Organization`, `WebSite`) via `extraSchemas`:
193
451
 
194
- # 2. Commit the version bump
195
- git add packages/blog-sdk/package.json
196
- git commit -m "chore(blog-sdk): release v0.1.1"
197
- git push # normal push, nothing publishes yet
452
+ ```tsx
453
+ import { BlogJsonLd } from 'chaingrow-blog/react';
454
+
455
+ <BlogJsonLd
456
+ blog={blog}
457
+ url={`https://mysite.com/blog/${blog.slug}`}
458
+ author={{ name: 'Acme Inc.', type: 'Organization' }}
459
+ publisher={{ name: 'Acme Inc.', logo: 'https://mysite.com/logo.png' }}
460
+ extraSchemas={[
461
+ {
462
+ '@type': 'BreadcrumbList',
463
+ itemListElement: [
464
+ { '@type': 'ListItem', position: 1, name: 'Home', item: 'https://mysite.com/' },
465
+ { '@type': 'ListItem', position: 2, name: 'Blog', item: 'https://mysite.com/blog' },
466
+ ],
467
+ },
468
+ ]}
469
+ />
470
+ ```
198
471
 
199
- # 3. Create the release tag on that commit
200
- git tag blog-sdk-v0.1.1
472
+ Low-level builders are exported from the package root if you want to assemble schemas yourself:
201
473
 
202
- # 4. Push the tag (separate command — git push alone doesn't push tags)
203
- git push origin blog-sdk-v0.1.1 # this is what fires the workflow
474
+ ```ts
475
+ import {
476
+ buildBlogPostingSchema,
477
+ buildFAQPageSchema,
478
+ buildBlogJsonLd,
479
+ } from 'chaingrow-blog';
480
+ import { JsonLdGraph } from 'chaingrow-blog/react';
481
+
482
+ const items = [
483
+ myBreadcrumbSchema,
484
+ ...buildBlogJsonLd(blog, { url: '…', author: { name: '…' } }),
485
+ ];
486
+ // <JsonLdGraph items={items} />
204
487
  ```
205
488
 
206
- About a minute later the new version lands on [npmjs.com/package/chaingrow-blog](https://www.npmjs.com/package/chaingrow-blog).
489
+ ## Media multiple images & videos in the body
490
+
491
+ When your blog has more than one attached image or video, `<BlogRenderer>` distributes them deterministically across the body so the first one lands at the top and the rest spread evenly between blocks. No configuration needed:
492
+
493
+ ```
494
+ 1 attachment → [media] + all blocks
495
+ 2 attachments → [media] + half the blocks + [media] + the other half
496
+ 3 attachments → [media] + 1/3 blocks + [media] + 1/3 blocks + [media] + 1/3 blocks
497
+ N attachments → each media lands at block index floor(i * N / K)
498
+ ```
207
499
 
208
- ### Why tags and not branches?
500
+ The placement is pure function of `(blocks.length, generatedMedia.length)` — same input always produces the same output, so two visitors see the same layout.
209
501
 
210
- Tags are immutable bookmarks pointing at a specific commit, and git treats them as a separate namespace from branches. Splitting the release trigger onto tags means:
502
+ ### Customising the media slot
211
503
 
212
- - Normal development pushes never accidentally publish
213
- - Every published version has a matching tag you can `git checkout` to see exactly what was shipped
214
- - Cutting a release is an explicit, deliberate action (`git tag` + `git push origin <tag>`), not a side effect of merging to main
504
+ Override the `media` component just like any other block-level override:
215
505
 
216
- ### Versioning rules
506
+ ```tsx
507
+ import Image from 'next/image';
508
+ import { BlogRenderer } from 'chaingrow-blog/react';
509
+
510
+ <BlogRenderer
511
+ blog={blog}
512
+ components={{
513
+ media: ({ media }) =>
514
+ media.media_type === 'VIDEO' ? (
515
+ <video
516
+ src={media.media_url}
517
+ controls
518
+ className="rounded-xl my-8 w-full aspect-video"
519
+ />
520
+ ) : (
521
+ <Image
522
+ src={media.media_url}
523
+ alt={media.prompt}
524
+ width={1200}
525
+ height={675}
526
+ className="rounded-xl my-8"
527
+ />
528
+ ),
529
+ }}
530
+ />
531
+ ```
217
532
 
218
- Follow [semver](https://semver.org/):
533
+ ### Getting placement without rendering
219
534
 
220
- | Change | Bump |
535
+ If you need the same distribution logic for a custom layout (e.g. wrapping each section in `<section>`), reuse the utilities directly:
536
+
537
+ ```ts
538
+ import { interleaveMedia, distributeMediaPositions } from 'chaingrow-blog';
539
+
540
+ // Get the insertion index for each media item:
541
+ const positions = distributeMediaPositions(blog.blocks.length, blog.generatedMedia?.length ?? 0);
542
+
543
+ // Or get a flat interleaved list you can map over:
544
+ const slots = interleaveMedia(blog.blocks, blog.generatedMedia);
545
+ // slots: Array<{ kind: 'block', block, index } | { kind: 'media', media, index }>
546
+ ```
547
+
548
+ ## API reference
549
+
550
+ ### `chaingrow-blog/next`
551
+
552
+ #### `BlogPage`
553
+ Default-batteries `app/blog/[slug]/page.tsx` component. Re-export from your route file. Zero configuration.
554
+
555
+ #### `generateBlogPageMetadata(opts?)`
556
+ Default-batteries `generateMetadata` factory. Zero configuration; pass `{ alternates: { siteUrl, pathPrefix?, xDefaultLanguage? } }` to also emit canonical + hreflang `alternates.languages` (see [Localized routes](#localized-routes-i18n)).
557
+
558
+ #### `getBlog(slug, language?, opts?)`
559
+ Fetches a blog by slug (server-side, ISR-cached under tag `chaingrow:blog:<slug>`). Returns `null` if not found.
560
+
561
+ #### `listBlogs(listOpts?, clientOpts?)`
562
+ Lists blogs (optionally filtered by `status` or `language` — filtering happens server-side). Automatically pages through the API until the server-reported `total` is fetched, so large accounts never truncate. Cached under tag `chaingrow:blog:list`.
563
+
564
+ #### `generateBlogMetadata(slug, language?, opts?)`
565
+ Returns a Next.js `Metadata` object (title + description + openGraph + twitter). Shares the ISR cache with `getBlog`. Pass `opts.alternates` for canonical + hreflang output.
566
+
567
+ #### `generateBlogIndexMetadata({ title, description?, canonical?, ogImage? })`
568
+ Synchronous `Metadata` for the blog *index* page — title, description, canonical, openGraph (website) + twitter. Without it, `/blog` falls back to your site-default title.
569
+
570
+ #### `getBlogSitemapEntries({ siteUrl, pathPrefix?, language?, ... })`
571
+ Returns ChainGrow blog entries already shaped for `MetadataRoute.Sitemap`. Spread into your `app/sitemap.ts`.
572
+
573
+ ### `chaingrow-blog/next/webhook`
574
+
575
+ #### `POST` (default route handler)
576
+ Re-export from `app/api/chaingrow/webhook/route.ts` to mount the HMAC-verified handler. Invalidates ISR tags on `blog.published`, `blog.unpublished`, `blog.deleted`, `blog.created`, `blog.updated`.
577
+
578
+ #### `createWebhookHandler({ apiKey?, onEvent? })`
579
+ Returns `{ POST }` with overrides — pass an `onEvent` callback to run custom logic after revalidation.
580
+
581
+ ### `chaingrow-blog/react`
582
+
583
+ #### `<BlogIndex format? language? pathPrefix? hrefFor? formatDate? />`
584
+ Default list page. Calls `listBlogs()` internally and renders one of three preset layouts: `minimalist` (default), `list`, `bare`. On locale-prefixed sites set `pathPrefix` per locale, or pass `hrefFor={(blog) => string}` for full href control (see [Localized routes](#localized-routes-i18n)). For full design control, skip this and write your own page using `listBlogs()`.
585
+
586
+ #### `<BlogDetail blog={} components={} formatDate={} />`
587
+ Article layout: `<article>` → Header → `<BlogRenderer>` → Footer. Default Header: og_image → h1 → time → description. Default Footer: nothing. Override Header, Footer, and per-block renderers via `components`.
588
+
589
+ #### `<BlogRenderer blog={} components={} />`
590
+ Renders `blog.blocks` and distributes `blog.generatedMedia` across them.
591
+
592
+ #### `<BlogJsonLd blog={} url? author? publisher? extraSchemas? />`
593
+ One-liner JSON-LD: BlogPosting + FAQPage + `structured_data.jsonLd` extras, all under one `@graph`.
594
+
595
+ #### `<JsonLdGraph items={} />`
596
+ Low-level: render any array of Schema.org objects as a single `<script type="application/ld+json">`.
597
+
598
+ ### `chaingrow-blog` (root)
599
+
600
+ #### `createClient({ apiUrl, apiKey, fetch? })`
601
+ Framework-agnostic client. Use from edge functions, scripts, Remix, SvelteKit, etc.
602
+
603
+ #### `buildBlogPostingSchema(blog, opts?)` / `buildFAQPageSchema(faq)` / `buildBlogJsonLd(blog, opts?)`
604
+ Pure JSON-LD builders.
605
+
606
+ #### `buildSitemapXml(blogs, { baseUrl, pathPrefix? })` / `buildNewsSitemapXml(blogs, { baseUrl, publicationName, ... })`
607
+ Pure XML builders for hand-rolled sitemap routes (alternative to `app/sitemap.ts`).
608
+
609
+ #### `filterRecentForNews(blogs, now?)`
610
+ Returns blogs published in the last 48 hours, sorted newest first, capped at 1000 — input shape for `buildNewsSitemapXml`.
611
+
612
+ #### `escapeXml(s)`
613
+ Escapes the five XML special characters.
614
+
615
+ #### `distributeMediaPositions(blockCount, mediaCount)` / `interleaveMedia(blocks, media)`
616
+ Pure media-placement utilities matching `<BlogRenderer>`'s deterministic distribution.
617
+
618
+ ### Rate-limit handling
619
+ Calls are transparently retried once on HTTP `429 Too Many Requests`, honoring the `Retry-After` header. No configuration needed.
620
+
621
+ ## Caching & revalidation
622
+
623
+ `getBlog()` and `listBlogs()` run on the server and plug into Next's ISR cache. Every response is tagged (`chaingrow:blog`, `chaingrow:blog:<slug>`, `chaingrow:blog:list`) so the webhook can invalidate by slug.
624
+
625
+ **Default:** `revalidate: 604800` (7 days). Blog posts are effectively immutable once published, and the webhook (if mounted) invalidates the cache the moment ChainGrow publishes — so the window is a safety net, not a freshness promise.
626
+
627
+ Override per call when you need different behaviour:
628
+
629
+ ```ts
630
+ // Staging / debugging — hit ChainGrow every request
631
+ await getBlog(slug, lang, { revalidate: 0 });
632
+
633
+ // Pair with the webhook route for cache-forever + instant invalidation
634
+ await getBlog(slug, lang, { revalidate: false });
635
+
636
+ // Custom window
637
+ await getBlog(slug, lang, { revalidate: 3600 }); // 1 hour
638
+ ```
639
+
640
+ | `revalidate` | Background fetches / day / slug | When this makes sense |
641
+ |---|---|---|
642
+ | `0` | one per request | staging, debugging |
643
+ | `3600` (1 h) | 24 | content updates hourly, no webhook |
644
+ | `86400` (1 day) | 1 | content updates daily, no webhook |
645
+ | **`604800` (1 week, default)** | **0.14** | blog posts, with or without webhook |
646
+ | `false` (forever) | 0 | paired with the webhook — invalidation only on publish |
647
+
648
+ ## Webhook details
649
+
650
+ The webhook handler at `chaingrow-blog/next/webhook` exports a `POST` route that:
651
+
652
+ 1. Reads the raw request body
653
+ 2. Verifies the `X-ChainGrow-Signature` HMAC against `sha256(CHAINGROW_API_KEY)`
654
+ 3. Parses the `WebhookPayload` (type-narrowed by event)
655
+ 4. Calls `revalidateTag('chaingrow:blog')`, `revalidateTag('chaingrow:blog:list')`, and `revalidateTag('chaingrow:blog:<slug>')` for the affected blog
656
+ 5. Optionally invokes your `onEvent` callback for side effects (cache warming, analytics, etc.)
657
+ 6. Returns `200 { ok: true, revalidated: <slug> }`
658
+
659
+ Events emitted by ChainGrow:
660
+
661
+ | Event | When |
221
662
  |---|---|
222
- | Adding a new field to `Blog` (backwards-compatible) | patch (`0.1.0` `0.1.1`) |
223
- | Adding a new exported function or component | minor (`0.1.0` → `0.2.0`) |
224
- | **Changing the `Block` type union** | **major (`0.1.0` `1.0.0`)** — customer override maps will break |
225
- | Removing or renaming any exported symbol | major |
663
+ | `blog.created` | A new blog row is created (typically before publishing) |
664
+ | `blog.updated` | Title/body/metadata changes on an existing blog |
665
+ | `blog.published` | Status flips to `PUBLISHED` |
666
+ | `blog.unpublished` | Status flips back to `DRAFT` |
667
+ | `blog.deleted` | Blog is deleted |
226
668
 
227
- ### Fixing a mistake
669
+ The handler invalidates cache tags on every event; that's why publish, unpublish, and delete all become visible to your visitors on the next request rather than waiting out the ISR window.
228
670
 
229
- If you tagged the wrong commit and it hasn't published yet:
671
+ To run custom logic after revalidation, use the factory:
230
672
 
231
- ```bash
232
- git tag -d blog-sdk-v0.1.1 # delete local tag
233
- git push origin :refs/tags/blog-sdk-v0.1.1 # delete remote tag
234
- # then re-tag correctly
673
+ ```ts
674
+ // app/api/chaingrow/webhook/route.ts
675
+ import { createWebhookHandler } from 'chaingrow-blog/next/webhook';
676
+
677
+ export const { POST } = createWebhookHandler({
678
+ onEvent: async ({ event, blog }) => {
679
+ console.log('chaingrow:', event, blog.slug);
680
+ // push to analytics, warm caches, etc.
681
+ },
682
+ });
235
683
  ```
236
684
 
237
- If it already published: npm blocks unpublishing after 72 hours. The recipe is to deprecate the bad version and bump:
685
+ ## Releasing new versions (maintainers only)
686
+
687
+ > Consumers installing `chaingrow-blog` can ignore this section — it's internal notes for ChainGrow maintainers cutting a new release.
688
+
689
+ The SDK is published to npm by a GitHub Actions workflow (`.github/workflows/publish-blog-sdk.yml`). The workflow only runs when you push a **git tag** matching `blog-sdk-v*`.
690
+
691
+ ### Triggers
692
+
693
+ ```yaml
694
+ on:
695
+ push:
696
+ tags:
697
+ - 'blog-sdk-v*' # only fires on matching tags
698
+ workflow_dispatch: # manual "Run workflow" button in GitHub Actions UI
699
+ ```
700
+
701
+ ### Cutting a release
238
702
 
239
703
  ```bash
240
- npm deprecate chaingrow-blog@0.1.1 "published in error use 0.1.2"
241
- # then cut a fresh release with the fix
704
+ # 1. Bump the version in packages/blog-sdk/package.json
705
+ # 2. Commit the version bump and push
706
+ # 3. Create + push the release tag:
707
+ git tag blog-sdk-v0.6.0
708
+ git push origin blog-sdk-v0.6.0
242
709
  ```
243
710
 
711
+ About a minute later the new version lands on [npmjs.com/package/chaingrow-blog](https://www.npmjs.com/package/chaingrow-blog).
712
+
713
+ ### Versioning rules
714
+
715
+ | Change | Bump |
716
+ |---|---|
717
+ | Adding a new field to `Blog` (backwards-compatible) | patch |
718
+ | Adding a new exported function or component | minor |
719
+ | Removing or renaming any exported symbol | minor (pre-1.0) / major (post-1.0) |
720
+ | Changing the `Block` type union | major |
721
+
244
722
  ## License
245
723
 
246
724
  MIT