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 +617 -139
- package/dist/blog-cards-D1j7ZqHK.d.ts +123 -0
- package/dist/blog-cards-DPfcmUUD.d.cts +123 -0
- package/dist/chunk-4EKET2DQ.js +34 -0
- package/dist/chunk-4EKET2DQ.js.map +1 -0
- package/dist/chunk-BXEQ6QKM.js +636 -0
- package/dist/chunk-BXEQ6QKM.js.map +1 -0
- package/dist/chunk-K3HNYPEP.js +176 -0
- package/dist/chunk-K3HNYPEP.js.map +1 -0
- package/dist/index.cjs +237 -12
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +20 -3
- package/dist/index.d.ts +20 -3
- package/dist/index.js +101 -3
- package/dist/index.js.map +1 -1
- package/dist/next/index.cjs +682 -11
- package/dist/next/index.cjs.map +1 -1
- package/dist/next/index.d.cts +168 -2
- package/dist/next/index.d.ts +168 -2
- package/dist/next/index.js +14 -42
- package/dist/next/index.js.map +1 -1
- package/dist/next/webhook.d.cts +1 -1
- package/dist/next/webhook.d.ts +1 -1
- package/dist/react/index.cjs +721 -37
- package/dist/react/index.cjs.map +1 -1
- package/dist/react/index.d.cts +189 -31
- package/dist/react/index.d.ts +189 -31
- package/dist/react/index.js +265 -65
- package/dist/react/index.js.map +1 -1
- package/dist/types-DvkCm1rh.d.cts +114 -0
- package/dist/types-DvkCm1rh.d.ts +114 -0
- package/package.json +1 -1
- package/dist/chunk-OTLGKSYD.js +0 -74
- package/dist/chunk-OTLGKSYD.js.map +0 -1
- package/dist/types-CoPD0ejB.d.cts +0 -53
- package/dist/types-CoPD0ejB.d.ts +0 -53
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
|
-
- **
|
|
7
|
-
- **Headless
|
|
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
|
-
|
|
26
|
+
yarn add chaingrow-blog
|
|
15
27
|
```
|
|
16
28
|
|
|
17
29
|
Requires Next.js ≥ 14 (App Router) and React ≥ 18.
|
|
18
30
|
|
|
19
|
-
##
|
|
31
|
+
## Quickstart
|
|
20
32
|
|
|
21
|
-
### 1. Environment
|
|
33
|
+
### 1. Environment
|
|
22
34
|
|
|
23
35
|
```env
|
|
24
|
-
|
|
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
|
-
|
|
53
|
+
```tsx
|
|
54
|
+
import { BlogPage, generateBlogPageMetadata } from 'chaingrow-blog/next';
|
|
28
55
|
|
|
29
|
-
|
|
56
|
+
export const generateMetadata = generateBlogPageMetadata();
|
|
57
|
+
export default BlogPage;
|
|
58
|
+
```
|
|
30
59
|
|
|
31
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
80
|
+
`<BlogIndex>` defaults to `/blog/<slug>` hrefs regardless of `language`. Derive `pathPrefix` from the active locale:
|
|
43
81
|
|
|
44
82
|
```tsx
|
|
45
|
-
// app/
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
<
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
87
|
-
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
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
|
-
###
|
|
310
|
+
### Data shapes
|
|
111
311
|
|
|
112
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
###
|
|
370
|
+
### Pre-existing blog at `/blog`?
|
|
119
371
|
|
|
120
|
-
|
|
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
|
-
|
|
374
|
+
```
|
|
375
|
+
/blog/[slug] ← legacy (untouched)
|
|
376
|
+
/guides/[slug] ← ChainGrow (new) — set pathPrefix="/guides" on getBlogSitemapEntries
|
|
377
|
+
```
|
|
123
378
|
|
|
124
|
-
|
|
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
|
-
|
|
381
|
+
## Sitemaps
|
|
127
382
|
|
|
128
|
-
|
|
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
|
-
|
|
385
|
+
### Pattern A — Next.js native `app/sitemap.ts`
|
|
131
386
|
|
|
132
|
-
|
|
387
|
+
Drop ChainGrow blogs into your existing sitemap with one line:
|
|
133
388
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
|
|
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
|
-
|
|
410
|
+
### Pattern B — merge into an existing custom sitemap route
|
|
168
411
|
|
|
169
|
-
|
|
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
|
-
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
-
|
|
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
|
-
|
|
184
|
-
2. Click "Run workflow" on the Actions page in GitHub (manual escape hatch)
|
|
442
|
+
## JSON-LD / SEO schemas
|
|
185
443
|
|
|
186
|
-
|
|
444
|
+
`<BlogJsonLd blog={blog} />` emits a single `<script type="application/ld+json">` containing:
|
|
187
445
|
|
|
188
|
-
|
|
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
|
-
|
|
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
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
|
|
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
|
-
|
|
203
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
502
|
+
### Customising the media slot
|
|
211
503
|
|
|
212
|
-
|
|
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
|
-
|
|
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
|
-
|
|
533
|
+
### Getting placement without rendering
|
|
219
534
|
|
|
220
|
-
|
|
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
|
-
|
|
|
223
|
-
|
|
|
224
|
-
|
|
|
225
|
-
|
|
|
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
|
-
|
|
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
|
-
|
|
671
|
+
To run custom logic after revalidation, use the factory:
|
|
230
672
|
|
|
231
|
-
```
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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
|
-
|
|
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
|
-
|
|
241
|
-
#
|
|
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
|