@xexr/blog-platform 0.2.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/CHANGELOG.md +61 -0
- package/LICENSE +21 -0
- package/README.md +1175 -0
- package/dist/app/api/blog/publish/route.ts +360 -0
- package/dist/app/api/blog/slugs/[slug]/route.ts +166 -0
- package/dist/app/api/blog/unpublish/route.ts +301 -0
- package/dist/app/api/blog/version/route.ts +259 -0
- package/dist/app/blog/[slug]/page.tsx +304 -0
- package/dist/app/blog/page.tsx +316 -0
- package/dist/index.d.mts +1799 -0
- package/dist/index.d.ts +1799 -0
- package/dist/index.js +31 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +31 -0
- package/dist/index.mjs.map +1 -0
- package/dist/types/index.d.mts +142 -0
- package/dist/types/index.d.ts +142 -0
- package/dist/types/index.js +2 -0
- package/dist/types/index.js.map +1 -0
- package/dist/types/index.mjs +2 -0
- package/dist/types/index.mjs.map +1 -0
- package/package.json +60 -0
package/README.md
ADDED
|
@@ -0,0 +1,1175 @@
|
|
|
1
|
+
# @xexr/blog-platform
|
|
2
|
+
|
|
3
|
+
SEO-friendly blog platform for Next.js 15 with Bloggy integration.
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
This package provides a complete blog platform solution that integrates with Bloggy's AI-generated content. It includes:
|
|
8
|
+
|
|
9
|
+
- 📝 **Blog pages** with full article rendering and MDX support
|
|
10
|
+
- 👤 **Author management** with rich bylines and JSON-LD schema
|
|
11
|
+
- 🔍 **SEO optimization** with meta tags, OpenGraph, and structured data
|
|
12
|
+
- 📊 **Publishing API** with idempotency and rate limiting
|
|
13
|
+
- 🛡️ **Security** with authentication middleware and CORS support
|
|
14
|
+
|
|
15
|
+
## Requirements
|
|
16
|
+
|
|
17
|
+
- Next.js 15.x
|
|
18
|
+
- React 19.x
|
|
19
|
+
- Node.js 18+ or 20+
|
|
20
|
+
|
|
21
|
+
## Installation
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
npm install @xexr/blog-platform
|
|
25
|
+
# or
|
|
26
|
+
pnpm add @xexr/blog-platform
|
|
27
|
+
# or
|
|
28
|
+
yarn add @xexr/blog-platform
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Tailwind CSS Setup (Required)
|
|
32
|
+
|
|
33
|
+
**This package uses Tailwind CSS utility classes that must be processed by your application's Tailwind configuration.** The blog components will automatically inherit your site's theme colors, fonts, and design system.
|
|
34
|
+
|
|
35
|
+
### Tailwind v4 Configuration (Recommended)
|
|
36
|
+
|
|
37
|
+
If you're using Tailwind v4, the blog platform works with an existing tailwind install.
|
|
38
|
+
|
|
39
|
+
You will need to install the tailwind typography plugin and point tailwind at the installed blog-platform package.
|
|
40
|
+
|
|
41
|
+
Be sure your globals.css file is configured similarly to the below:
|
|
42
|
+
|
|
43
|
+
```css
|
|
44
|
+
@import "tailwindcss";
|
|
45
|
+
@source "../../node_modules/@xexr/blog-platform";
|
|
46
|
+
@plugin "@tailwindcss/typography";
|
|
47
|
+
|
|
48
|
+
@theme inline {
|
|
49
|
+
--color-background: var(--background);
|
|
50
|
+
--color-foreground: var(--foreground);
|
|
51
|
+
--color-primary: var(--primary);
|
|
52
|
+
--color-muted: var(--muted);
|
|
53
|
+
--color-muted-foreground: var(--muted-foreground);
|
|
54
|
+
--color-border: var(--border);
|
|
55
|
+
/* ... other colors */
|
|
56
|
+
}
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
The blog will automatically use these theme variables! ✨
|
|
60
|
+
|
|
61
|
+
### Theme Inheritance
|
|
62
|
+
|
|
63
|
+
The blog components use **semantic Tailwind classes** that automatically adapt to your site's theme:
|
|
64
|
+
|
|
65
|
+
- `text-foreground` - Uses your site's primary text color
|
|
66
|
+
- `text-muted-foreground` - Uses your site's muted text color
|
|
67
|
+
- `bg-card` - Uses your site's card background color
|
|
68
|
+
- `text-primary` - Uses your site's primary brand color
|
|
69
|
+
- `dark:prose-invert` - Automatically supports dark mode
|
|
70
|
+
|
|
71
|
+
**This means:**
|
|
72
|
+
|
|
73
|
+
- Blue primary → blog links are blue
|
|
74
|
+
- Dark mode → blog automatically adapts
|
|
75
|
+
- Custom fonts → blog inherits them
|
|
76
|
+
- Custom spacing → blog matches it
|
|
77
|
+
|
|
78
|
+
**No additional CSS needed** - the blog seamlessly blends with your design system!
|
|
79
|
+
|
|
80
|
+
## Quick Start
|
|
81
|
+
|
|
82
|
+
### 1. Initialize the Platform Once
|
|
83
|
+
|
|
84
|
+
Create a small server-only module that configures authentication, storage, and rate limiting exactly once. Import it anywhere you need the platform (layouts, API routes, server components).
|
|
85
|
+
|
|
86
|
+
**`src/lib/blog-platform.ts`**:
|
|
87
|
+
|
|
88
|
+
```ts
|
|
89
|
+
import {
|
|
90
|
+
initBlogPlatformOnce,
|
|
91
|
+
DrizzleStorage,
|
|
92
|
+
EnvAuth,
|
|
93
|
+
} from "@xexr/blog-platform";
|
|
94
|
+
import { createClient } from "@libsql/client";
|
|
95
|
+
import { drizzle } from "drizzle-orm/libsql";
|
|
96
|
+
|
|
97
|
+
const url = process.env.BLOG_DATABASE_URL;
|
|
98
|
+
const authToken = process.env.BLOG_DATABASE_AUTH_TOKEN;
|
|
99
|
+
|
|
100
|
+
if (!url) {
|
|
101
|
+
throw new Error("BLOG_DATABASE_URL must be set before initializing the blog platform");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
initBlogPlatformOnce({
|
|
105
|
+
auth: new EnvAuth(), // Reads from BLOG_PLATFORM_API_KEYS
|
|
106
|
+
storage: new DrizzleStorage(drizzle(createClient({ url, authToken }))),
|
|
107
|
+
// optional: override rate limiting here
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
declare global {
|
|
111
|
+
// eslint-disable-next-line no-var
|
|
112
|
+
var __BLOG_PLATFORM_INITIALISED__?: boolean;
|
|
113
|
+
}
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
> Import `"@/lib/blog-platform"` as a side effect in any file that re-exports the platform handlers (API routes) or renders the blog pages. This ensures the same storage instance is used everywhere.
|
|
117
|
+
|
|
118
|
+
### 2. Add Blog Pages to Your Next.js App
|
|
119
|
+
|
|
120
|
+
Create the following files in your Next.js application:
|
|
121
|
+
|
|
122
|
+
**`app/blog/page.tsx`** - Blog home page:
|
|
123
|
+
|
|
124
|
+
```tsx
|
|
125
|
+
import "@/lib/blog-platform";
|
|
126
|
+
import { BlogHomePage, getStorage } from "@xexr/blog-platform";
|
|
127
|
+
|
|
128
|
+
const ITEMS_PER_PAGE = 10;
|
|
129
|
+
|
|
130
|
+
export default async function BlogPage({
|
|
131
|
+
searchParams,
|
|
132
|
+
}: {
|
|
133
|
+
searchParams: Promise<{ page?: string }>;
|
|
134
|
+
}) {
|
|
135
|
+
const params = await searchParams;
|
|
136
|
+
const storage = getStorage();
|
|
137
|
+
|
|
138
|
+
// Parse current page from URL (default to 1)
|
|
139
|
+
const currentPage = Math.max(1, parseInt(params.page ?? "1", 10));
|
|
140
|
+
const offset = (currentPage - 1) * ITEMS_PER_PAGE;
|
|
141
|
+
|
|
142
|
+
// Fetch articles for current page
|
|
143
|
+
const { articles, total } = await storage.listArticles({
|
|
144
|
+
sortBy: "publishedAt",
|
|
145
|
+
order: "desc",
|
|
146
|
+
limit: ITEMS_PER_PAGE,
|
|
147
|
+
offset,
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// Calculate total pages
|
|
151
|
+
const totalPages = Math.ceil(total / ITEMS_PER_PAGE);
|
|
152
|
+
|
|
153
|
+
return (
|
|
154
|
+
<BlogHomePage
|
|
155
|
+
articles={articles}
|
|
156
|
+
siteName={articles[0]?.site.name ?? "Blog"}
|
|
157
|
+
siteTagline={articles[0]?.site.tagline ?? "Insights and updates"}
|
|
158
|
+
includeStructuredData
|
|
159
|
+
pagination={{
|
|
160
|
+
currentPage,
|
|
161
|
+
totalPages,
|
|
162
|
+
baseUrl: "/blog",
|
|
163
|
+
}}
|
|
164
|
+
/>
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
**`app/blog/[slug]/page.tsx`** - Individual article pages:
|
|
170
|
+
|
|
171
|
+
```tsx
|
|
172
|
+
import "@/lib/blog-platform";
|
|
173
|
+
import { BlogArticlePage, getStorage } from "@xexr/blog-platform";
|
|
174
|
+
import { notFound } from "next/navigation";
|
|
175
|
+
|
|
176
|
+
export default async function ArticlePage({
|
|
177
|
+
params,
|
|
178
|
+
}: {
|
|
179
|
+
params: Promise<{ slug: string }>;
|
|
180
|
+
}) {
|
|
181
|
+
const { slug } = await params;
|
|
182
|
+
const storage = getStorage();
|
|
183
|
+
|
|
184
|
+
// Fetch the article
|
|
185
|
+
const article = await storage.getArticle(slug);
|
|
186
|
+
if (!article) {
|
|
187
|
+
notFound();
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Fetch all articles to find previous/next
|
|
191
|
+
const { articles } = await storage.listArticles({
|
|
192
|
+
sortBy: "publishedAt",
|
|
193
|
+
order: "desc",
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
// Find current article index
|
|
197
|
+
const currentIndex = articles.findIndex((a) => a.slug === slug);
|
|
198
|
+
const previousArticle = currentIndex > 0 ? articles[currentIndex - 1] : undefined;
|
|
199
|
+
const nextArticle = currentIndex < articles.length - 1 ? articles[currentIndex + 1] : undefined;
|
|
200
|
+
|
|
201
|
+
return (
|
|
202
|
+
<BlogArticlePage
|
|
203
|
+
article={article}
|
|
204
|
+
navigation={{
|
|
205
|
+
backToHome: { url: "/blog" },
|
|
206
|
+
...(previousArticle && {
|
|
207
|
+
previousArticle: {
|
|
208
|
+
url: `/blog/${previousArticle.slug}`,
|
|
209
|
+
title: previousArticle.title,
|
|
210
|
+
},
|
|
211
|
+
}),
|
|
212
|
+
...(nextArticle && {
|
|
213
|
+
nextArticle: {
|
|
214
|
+
url: `/blog/${nextArticle.slug}`,
|
|
215
|
+
title: nextArticle.title,
|
|
216
|
+
},
|
|
217
|
+
}),
|
|
218
|
+
}}
|
|
219
|
+
/>
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
> **Note**: The `navigation` prop is optional. You can omit `previousArticle` and `nextArticle` if you don't want article navigation links.
|
|
225
|
+
|
|
226
|
+
### 2. Set Up Publishing API
|
|
227
|
+
|
|
228
|
+
**`app/api/blog/publish/route.ts`**:
|
|
229
|
+
|
|
230
|
+
```tsx
|
|
231
|
+
import "@/lib/blog-platform";
|
|
232
|
+
import { publishHandler } from "@xexr/blog-platform";
|
|
233
|
+
|
|
234
|
+
export const POST = publishHandler;
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
**`app/api/blog/unpublish/route.ts`**:
|
|
238
|
+
|
|
239
|
+
```tsx
|
|
240
|
+
import "@/lib/blog-platform";
|
|
241
|
+
import { unpublishHandler } from "@xexr/blog-platform";
|
|
242
|
+
|
|
243
|
+
export const POST = unpublishHandler;
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
**`app/api/blog/version/route.ts`**:
|
|
247
|
+
|
|
248
|
+
```tsx
|
|
249
|
+
import "@/lib/blog-platform";
|
|
250
|
+
import { versionHandler } from "@xexr/blog-platform";
|
|
251
|
+
|
|
252
|
+
export const GET = versionHandler;
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
**`app/api/blog/slugs/[slug]/route.ts`** - Dry-run slug availability check used during validation:
|
|
256
|
+
|
|
257
|
+
```tsx
|
|
258
|
+
import "@/lib/blog-platform";
|
|
259
|
+
import {
|
|
260
|
+
slugExistsHandler,
|
|
261
|
+
slugExistsOptionsHandler,
|
|
262
|
+
} from "@xexr/blog-platform";
|
|
263
|
+
|
|
264
|
+
export const GET = slugExistsHandler;
|
|
265
|
+
export const OPTIONS = slugExistsOptionsHandler;
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
> 🛈 When this route is missing, Bloggy’s dry-run validation falls back to a warning and your platform logs will show `GET /api/blog/slugs/... 404`. Adding the handler keeps the validation flow clean.
|
|
269
|
+
|
|
270
|
+
### 3. Configure Durable Storage (Drizzle + Turso)
|
|
271
|
+
|
|
272
|
+
By default, the package uses in-memory storage (not persistent). To persist published articles, plug in the Drizzle adapter.
|
|
273
|
+
|
|
274
|
+
1. Install dependencies (host app):
|
|
275
|
+
|
|
276
|
+
```bash
|
|
277
|
+
pnpm add drizzle-orm @libsql/client
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
2. Create the tables (run once via your migrations):
|
|
281
|
+
|
|
282
|
+
```ts
|
|
283
|
+
// e.g., scripts/createBlogPlatformTables.ts or inside your migration
|
|
284
|
+
import { getDrizzleAdapterSql } from "@xexr/blog-platform";
|
|
285
|
+
import { createClient } from "@libsql/client";
|
|
286
|
+
import { drizzle } from "drizzle-orm/libsql";
|
|
287
|
+
|
|
288
|
+
const client = createClient({
|
|
289
|
+
url: process.env.BLOG_DATABASE_URL!,
|
|
290
|
+
authToken: process.env.BLOG_DATABASE_AUTH_TOKEN!,
|
|
291
|
+
});
|
|
292
|
+
const db = drizzle(client);
|
|
293
|
+
|
|
294
|
+
const { createArticles, createOperations } = getDrizzleAdapterSql();
|
|
295
|
+
await db.run(createArticles);
|
|
296
|
+
await db.run(createOperations);
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
3. Wire the adapter at app startup:
|
|
300
|
+
|
|
301
|
+
```ts
|
|
302
|
+
// app/(root)/layout.tsx or middleware.ts (server-only)
|
|
303
|
+
import { initBlogPlatform, DrizzleStorage, EnvAuth } from "@xexr/blog-platform";
|
|
304
|
+
import { createClient } from "@libsql/client";
|
|
305
|
+
import { drizzle } from "drizzle-orm/libsql";
|
|
306
|
+
|
|
307
|
+
const client = createClient({
|
|
308
|
+
url: process.env.BLOG_DATABASE_URL!,
|
|
309
|
+
authToken: process.env.BLOG_DATABASE_AUTH_TOKEN!,
|
|
310
|
+
});
|
|
311
|
+
const db = drizzle(client);
|
|
312
|
+
|
|
313
|
+
initBlogPlatform({
|
|
314
|
+
auth: new EnvAuth(), // Reads from BLOG_PLATFORM_API_KEYS
|
|
315
|
+
storage: new DrizzleStorage(db),
|
|
316
|
+
// optional: pass rateLimit config here
|
|
317
|
+
});
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
Environment:
|
|
321
|
+
|
|
322
|
+
```env
|
|
323
|
+
BLOG_DATABASE_URL=libsql://your-turso-url
|
|
324
|
+
BLOG_DATABASE_AUTH_TOKEN=your-turso-token
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
### 4. Environment Variables
|
|
328
|
+
|
|
329
|
+
Add these to your `.env.local`:
|
|
330
|
+
|
|
331
|
+
```env
|
|
332
|
+
# Required for rate limiting (if enabled)
|
|
333
|
+
UPSTASH_REDIS_REST_URL=your_redis_url
|
|
334
|
+
UPSTASH_REDIS_REST_TOKEN=your_redis_token
|
|
335
|
+
|
|
336
|
+
# Required for authentication (set either BLOG_PLATFORM_API_KEYS or BLOG_AUTH_TOKEN)
|
|
337
|
+
BLOG_PLATFORM_API_KEYS=your_secure_token
|
|
338
|
+
# BLOG_AUTH_TOKEN=your_secure_token
|
|
339
|
+
|
|
340
|
+
# Required for Drizzle adapter
|
|
341
|
+
BLOG_DATABASE_URL=libsql://your-turso-url
|
|
342
|
+
BLOG_DATABASE_AUTH_TOKEN=your-turso-token
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
### 5. Image Handling with Cloudflare CDN
|
|
346
|
+
|
|
347
|
+
The blog platform uses Cloudflare-optimized images hosted on Bloggy's central domain. When Bloggy publishes articles, it provides fully-qualified CDN URLs that your blog platform displays.
|
|
348
|
+
|
|
349
|
+
**How it works:**
|
|
350
|
+
|
|
351
|
+
1. Images are stored centrally on Bloggy's R2 storage
|
|
352
|
+
2. Bloggy sends optimized CDN URLs (e.g., `https://bloggy.poyzer.download/cdn-cgi/image/width=1200,height=630,fit=cover,quality=85,format=webp/sites/3/images/photo.webp`)
|
|
353
|
+
3. Your blog displays these images using Next.js `<Image>` component
|
|
354
|
+
|
|
355
|
+
**Next.js Configuration (Required):**
|
|
356
|
+
|
|
357
|
+
You **must** configure your `next.config.js` to allow images from the Bloggy domain:
|
|
358
|
+
|
|
359
|
+
```js
|
|
360
|
+
/** @type {import('next').NextConfig} */
|
|
361
|
+
const nextConfig = {
|
|
362
|
+
images: {
|
|
363
|
+
remotePatterns: [
|
|
364
|
+
{
|
|
365
|
+
protocol: 'https',
|
|
366
|
+
hostname: 'bloggy.poyzer.download', // Replace with your Bloggy instance domain
|
|
367
|
+
},
|
|
368
|
+
],
|
|
369
|
+
},
|
|
370
|
+
};
|
|
371
|
+
|
|
372
|
+
export default nextConfig;
|
|
373
|
+
```
|
|
374
|
+
|
|
375
|
+
**Important:** Replace `bloggy.poyzer.download` with your actual Bloggy instance domain. Without this configuration, Next.js will throw an error about unconfigured hostnames.
|
|
376
|
+
|
|
377
|
+
### 6. Configure Sitemap (Optional)
|
|
378
|
+
|
|
379
|
+
Add blog content to your sitemap for better SEO:
|
|
380
|
+
|
|
381
|
+
**`app/sitemap.ts`**:
|
|
382
|
+
|
|
383
|
+
```tsx
|
|
384
|
+
import { generateBlogSitemapEntries } from "@xexr/blog-platform";
|
|
385
|
+
import type { MetadataRoute } from "next";
|
|
386
|
+
import { getBlogData } from "@/lib/blog"; // Your blog data fetching
|
|
387
|
+
|
|
388
|
+
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
|
389
|
+
// Your existing static routes
|
|
390
|
+
const staticRoutes: MetadataRoute.Sitemap = [
|
|
391
|
+
{
|
|
392
|
+
url: "https://yoursite.com",
|
|
393
|
+
lastModified: new Date(),
|
|
394
|
+
changeFrequency: "yearly",
|
|
395
|
+
priority: 1,
|
|
396
|
+
},
|
|
397
|
+
// ... other routes
|
|
398
|
+
];
|
|
399
|
+
|
|
400
|
+
// Generate blog entries
|
|
401
|
+
const { site, articles, authors } = await getBlogData();
|
|
402
|
+
const blogEntries = await generateBlogSitemapEntries(
|
|
403
|
+
site,
|
|
404
|
+
articles,
|
|
405
|
+
authors,
|
|
406
|
+
{
|
|
407
|
+
includeAuthorPages: true,
|
|
408
|
+
changeFrequency: { articles: "monthly" },
|
|
409
|
+
priority: { articles: 0.8 },
|
|
410
|
+
},
|
|
411
|
+
);
|
|
412
|
+
|
|
413
|
+
return [...staticRoutes, ...blogEntries];
|
|
414
|
+
}
|
|
415
|
+
```
|
|
416
|
+
|
|
417
|
+
> 📖 **For detailed sitemap configuration options**, see [SITEMAP-INTEGRATION.md](./SITEMAP-INTEGRATION.md)
|
|
418
|
+
|
|
419
|
+
## Publishing Workflow Guide
|
|
420
|
+
|
|
421
|
+
This guide explains how to publish articles from Bloggy to your blog using the `@xexr/blog-platform` package.
|
|
422
|
+
|
|
423
|
+
### Prerequisites
|
|
424
|
+
|
|
425
|
+
Before you can publish articles, ensure you have:
|
|
426
|
+
|
|
427
|
+
1. ✅ Installed and configured the `@xexr/blog-platform` package (see [Quick Start](#quick-start))
|
|
428
|
+
2. ✅ Set up your blog pages (`/blog` and `/blog/[slug]`)
|
|
429
|
+
3. ✅ Configured API routes (`/api/blog/publish`, `/api/blog/version`, etc.)
|
|
430
|
+
4. ✅ Set environment variables (`BLOG_PLATFORM_API_KEYS`, database credentials)
|
|
431
|
+
5. ✅ Configured Tailwind CSS to process blog components
|
|
432
|
+
|
|
433
|
+
### Step 1: Configure Publishing Platform in Bloggy
|
|
434
|
+
|
|
435
|
+
1. **Navigate to Platform Settings**:
|
|
436
|
+
- In Bloggy, go to **Blogs** → Select your blog → **Edit** → **Publishing** tab
|
|
437
|
+
|
|
438
|
+
2. **Enter Platform Details**:
|
|
439
|
+
- **Blog Platform API URL**: Your blog's API endpoint (e.g., `https://yourblog.com/api/blog`)
|
|
440
|
+
- **Public Blog URL**: Your blog's public URL (e.g., `https://yourblog.com/blog`)
|
|
441
|
+
- **Publishing Token**: The authentication token (matches `BLOG_PLATFORM_API_KEYS` in your .env)
|
|
442
|
+
|
|
443
|
+
3. **Test Connection**:
|
|
444
|
+
- Click **Test Connection** to verify:
|
|
445
|
+
- Authentication is working
|
|
446
|
+
- Blog platform is accessible
|
|
447
|
+
- Versions are compatible (semantic versioning check)
|
|
448
|
+
- You should see: ✅ **Compatible** (green badge) or ⚠️ **Minor warning** (yellow badge)
|
|
449
|
+
|
|
450
|
+
4. **Save Configuration**:
|
|
451
|
+
- Click **Save Configuration**
|
|
452
|
+
- Token is encrypted and stored securely
|
|
453
|
+
- Publishing is now enabled for all articles
|
|
454
|
+
|
|
455
|
+
**Troubleshooting Connection Test**:
|
|
456
|
+
|
|
457
|
+
| Issue | Solution |
|
|
458
|
+
|-------|----------|
|
|
459
|
+
| ❌ Invalid credentials | Check that `BLOG_PLATFORM_API_KEYS` in your .env matches the token you entered |
|
|
460
|
+
| ❌ Connection failed | Verify your Blog Platform API URL is correct and accessible |
|
|
461
|
+
| ❌ Version incompatible | Upgrade your blog platform package to match Bloggy's version |
|
|
462
|
+
|
|
463
|
+
### Step 2: Publish an Article
|
|
464
|
+
|
|
465
|
+
1. **Navigate to Article**:
|
|
466
|
+
- Go to **Blogs** → Select your blog → **Headlines**
|
|
467
|
+
- Click on a headline to open the consolidated headline details page
|
|
468
|
+
- The article must be in **Accepted** status to publish
|
|
469
|
+
|
|
470
|
+
2. **Initiate Publishing**:
|
|
471
|
+
- In the top action bar, click the **Publish** button
|
|
472
|
+
- If no platform is configured, the button will be disabled with a helpful message
|
|
473
|
+
|
|
474
|
+
3. **Configure Publication Settings**:
|
|
475
|
+
The publish modal opens with pre-filled fields:
|
|
476
|
+
|
|
477
|
+
- **URL Slug**: Auto-generated from the title (lowercase, hyphens)
|
|
478
|
+
- Preview: `https://yourblog.com/blog/your-article-slug`
|
|
479
|
+
- Editable: Change to your preferred slug
|
|
480
|
+
- Validation: Must be lowercase letters, numbers, and hyphens only
|
|
481
|
+
|
|
482
|
+
- **Meta Description**: Article excerpt (max 160 characters)
|
|
483
|
+
- Shows character count (e.g., "142/160 characters")
|
|
484
|
+
- Editable: Customize for search engines
|
|
485
|
+
|
|
486
|
+
4. **Optional: Validate Before Publishing**:
|
|
487
|
+
- Click **Run Validation** to perform a dry-run check
|
|
488
|
+
- System validates:
|
|
489
|
+
- Content meets requirements (min 50 words)
|
|
490
|
+
- Slug format is valid
|
|
491
|
+
- All required fields present
|
|
492
|
+
- Issues are displayed inline if found
|
|
493
|
+
|
|
494
|
+
5. **Publish to Blog**:
|
|
495
|
+
- Click **Publish Article** button
|
|
496
|
+
- Publishing typically completes in 2-5 seconds
|
|
497
|
+
- Success notification shows: "Article published successfully"
|
|
498
|
+
- Toast includes **View Article** link to the live blog
|
|
499
|
+
|
|
500
|
+
6. **Post-Publish State**:
|
|
501
|
+
- Status badge changes to **Published** (green)
|
|
502
|
+
- **View Live** link appears with publish date
|
|
503
|
+
- Article is accessible at: `https://yourblog.com/blog/your-article-slug`
|
|
504
|
+
|
|
505
|
+
### Step 3: Republish an Article (Updates)
|
|
506
|
+
|
|
507
|
+
1. **Modify Article Content**:
|
|
508
|
+
- Click **Edit Article** in the action bar
|
|
509
|
+
- Make your changes in the article editor
|
|
510
|
+
- Save the changes
|
|
511
|
+
|
|
512
|
+
2. **Republish**:
|
|
513
|
+
- Click the **Publish** button again
|
|
514
|
+
- A confirmation dialog appears:
|
|
515
|
+
> ⚠️ **Republish Article**
|
|
516
|
+
>
|
|
517
|
+
> This will update the live article on your external blog.
|
|
518
|
+
|
|
519
|
+
3. **Confirm Update**:
|
|
520
|
+
- Click **Confirm Republish**
|
|
521
|
+
- Updated content pushes to blog (typically < 2 seconds)
|
|
522
|
+
- Blog article refreshes with new content
|
|
523
|
+
- `dateModified` updates in JSON-LD, `datePublished` remains unchanged
|
|
524
|
+
|
|
525
|
+
**What Gets Updated**:
|
|
526
|
+
- ✅ Article content (title, body, images)
|
|
527
|
+
- ✅ Meta description
|
|
528
|
+
- ✅ `dateModified` timestamp
|
|
529
|
+
- ❌ URL slug (remains unchanged to preserve SEO)
|
|
530
|
+
- ❌ `datePublished` (original publish date preserved)
|
|
531
|
+
|
|
532
|
+
### Step 4: Unpublish an Article
|
|
533
|
+
|
|
534
|
+
1. **Initiate Unpublish**:
|
|
535
|
+
- Click **Unpublish** button (visible when article is published)
|
|
536
|
+
- Confirmation modal explains consequences:
|
|
537
|
+
> ⚠️ **Unpublish Article**
|
|
538
|
+
>
|
|
539
|
+
> This will remove the article from your blog. The URL will return a 404 error, and analytics data may be affected.
|
|
540
|
+
|
|
541
|
+
2. **Confirm Removal**:
|
|
542
|
+
- Click **Confirm Unpublish**
|
|
543
|
+
- Article removed from blog (returns 404)
|
|
544
|
+
- Status changes to **Never Published**
|
|
545
|
+
- **Publish** button becomes available again
|
|
546
|
+
|
|
547
|
+
3. **Re-Publishing After Unpublish**:
|
|
548
|
+
- You can republish at any time
|
|
549
|
+
- Same URL slug can be reused
|
|
550
|
+
- Treated as a new publish with new publish date
|
|
551
|
+
|
|
552
|
+
### Publishing Status Reference
|
|
553
|
+
|
|
554
|
+
| Status | Description | Available Actions |
|
|
555
|
+
|--------|-------------|-------------------|
|
|
556
|
+
| **Never Published** | Article has never been published | ✅ Publish |
|
|
557
|
+
| **Published** | Article is live on blog | ✅ Republish (update)<br>✅ Unpublish |
|
|
558
|
+
| **Failed** | Publishing failed due to error | ✅ Retry publish |
|
|
559
|
+
| **Publishing** | Publish in progress | ⏳ Wait (typically < 5s) |
|
|
560
|
+
|
|
561
|
+
### Error Handling & Troubleshooting
|
|
562
|
+
|
|
563
|
+
#### Common Publishing Errors
|
|
564
|
+
|
|
565
|
+
**PUB003: Slug Already Exists**
|
|
566
|
+
- **Cause**: Another article is already using this slug
|
|
567
|
+
- **Solution**: Choose a different slug (add date, number, or variation)
|
|
568
|
+
|
|
569
|
+
**PUB005: Invalid Credentials**
|
|
570
|
+
- **Cause**: Authentication token doesn't match
|
|
571
|
+
- **Solution**: Update token in platform settings and re-test connection
|
|
572
|
+
|
|
573
|
+
**PUB006: Content Validation Failed**
|
|
574
|
+
- **Cause**: Article doesn't meet publishing requirements
|
|
575
|
+
- **Solution**: Check error details (usually min 50 words required)
|
|
576
|
+
|
|
577
|
+
**PUB007: Platform Unavailable**
|
|
578
|
+
- **Cause**: Blog platform is down or unreachable
|
|
579
|
+
- **Solution**: Check your blog is running, then retry publish
|
|
580
|
+
|
|
581
|
+
**PUB008: No Platform Configured**
|
|
582
|
+
- **Cause**: Publishing platform not set up for this site
|
|
583
|
+
- **Solution**: Go to Blog → Edit → Publishing and configure platform
|
|
584
|
+
|
|
585
|
+
**PUB009: Version Incompatible**
|
|
586
|
+
- **Cause**: Bloggy version doesn't match blog platform version (major mismatch)
|
|
587
|
+
- **Solution**: Upgrade your `@xexr/blog-platform` package
|
|
588
|
+
|
|
589
|
+
#### Retry Failed Publishes
|
|
590
|
+
|
|
591
|
+
If publishing fails:
|
|
592
|
+
|
|
593
|
+
1. Error message shows specific error code (PUB001-PUB010)
|
|
594
|
+
2. Toast includes **Retry** button for network/timeout errors
|
|
595
|
+
3. Fix the underlying issue based on error code
|
|
596
|
+
4. Click **Retry** or open publish modal again
|
|
597
|
+
5. Idempotency protection prevents duplicate publishes
|
|
598
|
+
|
|
599
|
+
### Author Handling
|
|
600
|
+
|
|
601
|
+
The blog platform displays author information based on Bloggy's author settings:
|
|
602
|
+
|
|
603
|
+
**Author Resolution Order**:
|
|
604
|
+
1. **Article Author**: If article has an assigned author, use that author
|
|
605
|
+
2. **Site Default Author**: If no article author, use site's default author
|
|
606
|
+
3. **No Author**: If neither available, publish without author byline
|
|
607
|
+
|
|
608
|
+
**Guest Author Display**:
|
|
609
|
+
- Guest authors (marked with `isGuest: true`) show **Guest Author** badge
|
|
610
|
+
- Author bio card displays if `author.bio` is present
|
|
611
|
+
- Author profile links are not included (post-MVP feature)
|
|
612
|
+
|
|
613
|
+
**Example Byline Displays**:
|
|
614
|
+
|
|
615
|
+
```
|
|
616
|
+
✅ With Author:
|
|
617
|
+
"by Alex Chen • Oct 4, 2025 • 4 min read • From Indie Web Growth"
|
|
618
|
+
|
|
619
|
+
✅ Guest Author:
|
|
620
|
+
"by Maya Patel (Guest Author) • Oct 4, 2025 • 3 min read • From Tech Insights"
|
|
621
|
+
|
|
622
|
+
✅ No Author:
|
|
623
|
+
"Oct 4, 2025 • 4 min read • From Indie Web Growth"
|
|
624
|
+
```
|
|
625
|
+
|
|
626
|
+
### SEO Best Practices
|
|
627
|
+
|
|
628
|
+
When publishing articles, the blog platform automatically handles:
|
|
629
|
+
|
|
630
|
+
**Meta Tags**:
|
|
631
|
+
- `<title>`: Article title
|
|
632
|
+
- `<meta name="description">`: Your custom meta description
|
|
633
|
+
- `<link rel="canonical">`: Self-canonical URL
|
|
634
|
+
|
|
635
|
+
**Structured Data (JSON-LD)**:
|
|
636
|
+
- BlogPosting schema with full article metadata
|
|
637
|
+
- Author Person schema (if author present)
|
|
638
|
+
- Organization schema for publisher
|
|
639
|
+
- BreadcrumbList for navigation
|
|
640
|
+
|
|
641
|
+
**OpenGraph Tags**:
|
|
642
|
+
- `og:title`, `og:description`, `og:url`
|
|
643
|
+
- `og:type="article"`
|
|
644
|
+
- Featured image as `og:image` (if available)
|
|
645
|
+
|
|
646
|
+
**Image Optimization**:
|
|
647
|
+
- Images served via Cloudflare CDN
|
|
648
|
+
- Automatic WebP conversion
|
|
649
|
+
- Responsive sizing
|
|
650
|
+
- Descriptive alt text
|
|
651
|
+
|
|
652
|
+
### Version Compatibility
|
|
653
|
+
|
|
654
|
+
The blog platform uses **semantic versioning** to ensure compatibility:
|
|
655
|
+
|
|
656
|
+
**Version Format**: `MAJOR.MINOR.PATCH`
|
|
657
|
+
|
|
658
|
+
**Compatibility Rules**:
|
|
659
|
+
- ✅ **Patch Differences** (1.0.0 ↔ 1.0.3): Always compatible
|
|
660
|
+
- ⚠️ **Minor Differences** (1.0.0 ↔ 1.1.0): Compatible with warnings
|
|
661
|
+
- ❌ **Major Differences** (1.0.0 ↔ 2.0.0): Incompatible, blocks publishing
|
|
662
|
+
|
|
663
|
+
**Checking Compatibility**:
|
|
664
|
+
1. Go to Blog → Edit → Publishing
|
|
665
|
+
2. Click **Test Connection**
|
|
666
|
+
3. View compatibility status in test results
|
|
667
|
+
4. Upgrade blog platform if incompatible:
|
|
668
|
+
```bash
|
|
669
|
+
pnpm add @xexr/blog-platform@latest
|
|
670
|
+
```
|
|
671
|
+
|
|
672
|
+
### Rate Limiting
|
|
673
|
+
|
|
674
|
+
Publishing API is rate-limited to **5 requests per minute per authentication token**.
|
|
675
|
+
|
|
676
|
+
**Rate Limit Behavior**:
|
|
677
|
+
- Each publish/unpublish counts as 1 request
|
|
678
|
+
- Version check counts as 1 request
|
|
679
|
+
- Limit resets every 60 seconds
|
|
680
|
+
- Exceeded limits return HTTP 429 with `Retry-After` header
|
|
681
|
+
|
|
682
|
+
**If Rate Limited**:
|
|
683
|
+
1. Wait for reset (shown in error message)
|
|
684
|
+
2. System automatically retries after delay
|
|
685
|
+
3. Avoid rapid republishing during testing
|
|
686
|
+
|
|
687
|
+
### Security & Privacy
|
|
688
|
+
|
|
689
|
+
**Credential Security**:
|
|
690
|
+
- All tokens encrypted with AES-256-GCM before storage
|
|
691
|
+
- Tokens never displayed after save (shown as "••••")
|
|
692
|
+
- HTTPS required for all API communication
|
|
693
|
+
- Authentication required on all blog platform endpoints
|
|
694
|
+
|
|
695
|
+
**Content Privacy**:
|
|
696
|
+
- Only accepted articles can be published
|
|
697
|
+
- Draft articles remain private in Bloggy
|
|
698
|
+
- Unpublished articles return 404 on blog
|
|
699
|
+
- No content indexing until published
|
|
700
|
+
|
|
701
|
+
### Best Practices
|
|
702
|
+
|
|
703
|
+
1. **Test Configuration First**: Always test connection before publishing
|
|
704
|
+
2. **Use Descriptive Slugs**: Make URLs readable and SEO-friendly
|
|
705
|
+
3. **Optimize Meta Descriptions**: Keep under 160 characters, include keywords
|
|
706
|
+
4. **Assign Authors**: Include author info for better engagement and SEO
|
|
707
|
+
5. **Preview Before Publishing**: Review article in Bloggy's article tab
|
|
708
|
+
6. **Update Responsibly**: Use republish for corrections, not complete rewrites
|
|
709
|
+
7. **Monitor Published URLs**: Check "View Live" link after publishing
|
|
710
|
+
8. **Keep Versions Aligned**: Update blog platform when Bloggy updates
|
|
711
|
+
|
|
712
|
+
## API Endpoints
|
|
713
|
+
|
|
714
|
+
### Publishing API
|
|
715
|
+
|
|
716
|
+
#### `POST /api/blog/publish`
|
|
717
|
+
|
|
718
|
+
Publishes an article to your blog.
|
|
719
|
+
|
|
720
|
+
**Headers:**
|
|
721
|
+
|
|
722
|
+
```
|
|
723
|
+
Authorization: Bearer your_blog_auth_token
|
|
724
|
+
Content-Type: application/json
|
|
725
|
+
X-Idempotency-Key: unique_request_id
|
|
726
|
+
```
|
|
727
|
+
|
|
728
|
+
**Request Body:**
|
|
729
|
+
|
|
730
|
+
```json
|
|
731
|
+
{
|
|
732
|
+
"article": {
|
|
733
|
+
"id": "article-123",
|
|
734
|
+
"title": "Article Title",
|
|
735
|
+
"content": "# Article Content\n\nYour markdown content here...",
|
|
736
|
+
"slug": "article-title",
|
|
737
|
+
"excerpt": "Brief description...",
|
|
738
|
+
"publishedAt": "2024-01-01T00:00:00Z",
|
|
739
|
+
"featuredImage": {
|
|
740
|
+
"url": "https://example.com/image.jpg",
|
|
741
|
+
"alt": "Image description"
|
|
742
|
+
}
|
|
743
|
+
},
|
|
744
|
+
"author": {
|
|
745
|
+
"id": "author-123",
|
|
746
|
+
"name": "Author Name",
|
|
747
|
+
"bio": "Author biography...",
|
|
748
|
+
"image": "https://example.com/author.jpg"
|
|
749
|
+
},
|
|
750
|
+
"site": {
|
|
751
|
+
"id": "site-123",
|
|
752
|
+
"name": "Site Name",
|
|
753
|
+
"url": "https://example.com",
|
|
754
|
+
"description": "Site description..."
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
```
|
|
758
|
+
|
|
759
|
+
**Response:**
|
|
760
|
+
|
|
761
|
+
```json
|
|
762
|
+
{
|
|
763
|
+
"success": true,
|
|
764
|
+
"publishedUrl": "https://yoursite.com/blog/article-title",
|
|
765
|
+
"publishedAt": "2024-01-01T00:00:00Z"
|
|
766
|
+
}
|
|
767
|
+
```
|
|
768
|
+
|
|
769
|
+
#### `POST /api/blog/unpublish`
|
|
770
|
+
|
|
771
|
+
Unpublishes an article from your blog.
|
|
772
|
+
|
|
773
|
+
**Headers:**
|
|
774
|
+
|
|
775
|
+
```
|
|
776
|
+
Authorization: Bearer your_blog_auth_token
|
|
777
|
+
Content-Type: application/json
|
|
778
|
+
X-Idempotency-Key: unique_request_id
|
|
779
|
+
```
|
|
780
|
+
|
|
781
|
+
**Request Body:**
|
|
782
|
+
|
|
783
|
+
```json
|
|
784
|
+
{
|
|
785
|
+
"articleId": "article-123"
|
|
786
|
+
}
|
|
787
|
+
```
|
|
788
|
+
|
|
789
|
+
**Response:**
|
|
790
|
+
|
|
791
|
+
```json
|
|
792
|
+
{
|
|
793
|
+
"success": true,
|
|
794
|
+
"unpublishedAt": "2024-01-01T12:00:00Z"
|
|
795
|
+
}
|
|
796
|
+
```
|
|
797
|
+
|
|
798
|
+
#### `GET /api/blog/version`
|
|
799
|
+
|
|
800
|
+
Returns version compatibility information.
|
|
801
|
+
|
|
802
|
+
**Response:**
|
|
803
|
+
|
|
804
|
+
```json
|
|
805
|
+
{
|
|
806
|
+
"version": "0.1.0",
|
|
807
|
+
"compatible": true
|
|
808
|
+
}
|
|
809
|
+
```
|
|
810
|
+
|
|
811
|
+
## Rate Limiting
|
|
812
|
+
|
|
813
|
+
The publishing API is rate-limited to **5 requests per minute per authentication token** using Upstash Redis.
|
|
814
|
+
|
|
815
|
+
Rate limit headers are included in responses:
|
|
816
|
+
|
|
817
|
+
- `X-RateLimit-Limit`: Maximum requests per window
|
|
818
|
+
- `X-RateLimit-Remaining`: Remaining requests in current window
|
|
819
|
+
- `X-RateLimit-Reset`: Time when the rate limit resets
|
|
820
|
+
|
|
821
|
+
## Error Handling
|
|
822
|
+
|
|
823
|
+
The API returns standardized error codes:
|
|
824
|
+
|
|
825
|
+
| Code | Description | HTTP Status |
|
|
826
|
+
| ------ | ------------------------- | ----------- |
|
|
827
|
+
| PUB001 | Missing required field | 400 |
|
|
828
|
+
| PUB002 | Invalid slug format | 400 |
|
|
829
|
+
| PUB003 | Slug already exists | 409 |
|
|
830
|
+
| PUB004 | Network timeout | 408 |
|
|
831
|
+
| PUB005 | Invalid credentials | 401 |
|
|
832
|
+
| PUB006 | Content validation failed | 400 |
|
|
833
|
+
| PUB007 | Platform unavailable | 503 |
|
|
834
|
+
| PUB008 | No platform configured | 400 |
|
|
835
|
+
|
|
836
|
+
## Type Definitions
|
|
837
|
+
|
|
838
|
+
The package exports TypeScript types for all data structures:
|
|
839
|
+
|
|
840
|
+
```tsx
|
|
841
|
+
import type {
|
|
842
|
+
PublishedArticle,
|
|
843
|
+
PublishedAuthor,
|
|
844
|
+
PublishedSite,
|
|
845
|
+
PublishPayload,
|
|
846
|
+
PublishResult,
|
|
847
|
+
} from "@xexr/blog-platform/types";
|
|
848
|
+
```
|
|
849
|
+
|
|
850
|
+
## Customization & Styling
|
|
851
|
+
|
|
852
|
+
### Theme Customization
|
|
853
|
+
|
|
854
|
+
The blog platform is designed to seamlessly blend with your existing design system. All components use **semantic Tailwind classes** that automatically inherit your site's theme.
|
|
855
|
+
|
|
856
|
+
**No CSS Compilation Required**: Unlike traditional component libraries, this package doesn't ship pre-compiled CSS. Instead, it uses Tailwind utility classes that your application's Tailwind configuration processes. This ensures perfect theme alignment with zero configuration.
|
|
857
|
+
|
|
858
|
+
**What Gets Inherited Automatically**:
|
|
859
|
+
|
|
860
|
+
- ✅ **Colors**: Primary, foreground, background, muted colors
|
|
861
|
+
- ✅ **Typography**: Font families, sizes, and weights
|
|
862
|
+
- ✅ **Spacing**: Consistent padding and margins
|
|
863
|
+
- ✅ **Border Radius**: Matches your design system
|
|
864
|
+
- ✅ **Dark Mode**: Automatic dark mode support via `dark:` variants
|
|
865
|
+
|
|
866
|
+
### Customization Points
|
|
867
|
+
|
|
868
|
+
#### 1. Color Scheme
|
|
869
|
+
|
|
870
|
+
The blog uses these semantic color tokens from your theme:
|
|
871
|
+
|
|
872
|
+
```css
|
|
873
|
+
/* Blog components automatically use: */
|
|
874
|
+
text-foreground /* Primary text color */
|
|
875
|
+
text-muted-foreground /* Secondary/muted text */
|
|
876
|
+
bg-card /* Card backgrounds */
|
|
877
|
+
bg-background /* Page backgrounds */
|
|
878
|
+
text-primary /* Brand color (links, CTAs) */
|
|
879
|
+
border-border /* Border colors */
|
|
880
|
+
```
|
|
881
|
+
|
|
882
|
+
**To customize**: Simply adjust these colors in your Tailwind theme configuration:
|
|
883
|
+
|
|
884
|
+
```css
|
|
885
|
+
/* globals.css - Tailwind v4 */
|
|
886
|
+
@theme inline {
|
|
887
|
+
--color-primary: oklch(0.6 0.2 250); /* Custom blue */
|
|
888
|
+
--color-foreground: oklch(0.2 0.02 250); /* Dark text */
|
|
889
|
+
/* ... other theme variables */
|
|
890
|
+
}
|
|
891
|
+
```
|
|
892
|
+
|
|
893
|
+
#### 2. Typography
|
|
894
|
+
|
|
895
|
+
The blog uses Tailwind's typography plugin (`@tailwindcss/typography`) with automatic theme inheritance:
|
|
896
|
+
|
|
897
|
+
```tsx
|
|
898
|
+
/* Article content uses: */
|
|
899
|
+
<div className="prose dark:prose-invert">
|
|
900
|
+
{/* Inherits your prose styles */}
|
|
901
|
+
</div>
|
|
902
|
+
```
|
|
903
|
+
|
|
904
|
+
**To customize typography**:
|
|
905
|
+
|
|
906
|
+
```js
|
|
907
|
+
// tailwind.config.js
|
|
908
|
+
module.exports = {
|
|
909
|
+
theme: {
|
|
910
|
+
extend: {
|
|
911
|
+
typography: {
|
|
912
|
+
DEFAULT: {
|
|
913
|
+
css: {
|
|
914
|
+
maxWidth: '80ch', // Wider content
|
|
915
|
+
h1: {
|
|
916
|
+
fontSize: '3rem', // Larger headings
|
|
917
|
+
},
|
|
918
|
+
// ... custom prose styles
|
|
919
|
+
},
|
|
920
|
+
},
|
|
921
|
+
},
|
|
922
|
+
},
|
|
923
|
+
},
|
|
924
|
+
};
|
|
925
|
+
```
|
|
926
|
+
|
|
927
|
+
#### 3. Component Overrides
|
|
928
|
+
|
|
929
|
+
For advanced customization, you can override blog components:
|
|
930
|
+
|
|
931
|
+
**Option A: Wrap and Extend**
|
|
932
|
+
|
|
933
|
+
```tsx
|
|
934
|
+
// app/blog/page.tsx
|
|
935
|
+
import { BlogHomePage } from "@xexr/blog-platform";
|
|
936
|
+
|
|
937
|
+
export default function CustomBlogPage() {
|
|
938
|
+
return (
|
|
939
|
+
<div className="my-custom-wrapper">
|
|
940
|
+
{/* Add custom header */}
|
|
941
|
+
<MyCustomHeader />
|
|
942
|
+
|
|
943
|
+
<BlogHomePage {...props} />
|
|
944
|
+
|
|
945
|
+
{/* Add custom footer */}
|
|
946
|
+
<MyCustomFooter />
|
|
947
|
+
</div>
|
|
948
|
+
);
|
|
949
|
+
}
|
|
950
|
+
```
|
|
951
|
+
|
|
952
|
+
**Option B: Component Replacement**
|
|
953
|
+
|
|
954
|
+
```tsx
|
|
955
|
+
// Create your own article page using the same data structure
|
|
956
|
+
import { getStorage } from "@xexr/blog-platform";
|
|
957
|
+
|
|
958
|
+
export default async function MyArticlePage({ params }) {
|
|
959
|
+
const storage = getStorage();
|
|
960
|
+
const article = await storage.getArticle(params.slug);
|
|
961
|
+
|
|
962
|
+
// Use your own components
|
|
963
|
+
return <MyCustomArticleLayout article={article} />;
|
|
964
|
+
}
|
|
965
|
+
```
|
|
966
|
+
|
|
967
|
+
#### 4. Layout Structure
|
|
968
|
+
|
|
969
|
+
The blog components provide clean, semantic HTML that's easy to style:
|
|
970
|
+
|
|
971
|
+
```html
|
|
972
|
+
<!-- Blog Home Structure -->
|
|
973
|
+
<div className="container mx-auto px-4 py-8">
|
|
974
|
+
<header className="mb-12">
|
|
975
|
+
<h1 className="text-foreground">Site Name</h1>
|
|
976
|
+
<p className="text-muted-foreground">Tagline</p>
|
|
977
|
+
</header>
|
|
978
|
+
|
|
979
|
+
<article className="space-y-8">
|
|
980
|
+
<!-- Article cards -->
|
|
981
|
+
</article>
|
|
982
|
+
|
|
983
|
+
<footer className="mt-16">
|
|
984
|
+
<!-- Pagination -->
|
|
985
|
+
</footer>
|
|
986
|
+
</div>
|
|
987
|
+
```
|
|
988
|
+
|
|
989
|
+
**Customize via Tailwind**:
|
|
990
|
+
|
|
991
|
+
```tsx
|
|
992
|
+
// Adjust container width, padding, etc.
|
|
993
|
+
<BlogHomePage
|
|
994
|
+
articles={articles}
|
|
995
|
+
siteName="My Blog"
|
|
996
|
+
// Components respect Tailwind utility classes
|
|
997
|
+
/>
|
|
998
|
+
```
|
|
999
|
+
|
|
1000
|
+
#### 5. Dark Mode
|
|
1001
|
+
|
|
1002
|
+
Dark mode is automatically supported via Tailwind's `dark:` variant:
|
|
1003
|
+
|
|
1004
|
+
```tsx
|
|
1005
|
+
/* Components include dark mode styles: */
|
|
1006
|
+
<div className="bg-card dark:bg-card text-foreground dark:text-foreground">
|
|
1007
|
+
{/* Automatically adapts to dark mode */}
|
|
1008
|
+
</div>
|
|
1009
|
+
```
|
|
1010
|
+
|
|
1011
|
+
**To enable dark mode** in your Next.js app:
|
|
1012
|
+
|
|
1013
|
+
```tsx
|
|
1014
|
+
// app/layout.tsx
|
|
1015
|
+
export default function RootLayout({ children }) {
|
|
1016
|
+
return (
|
|
1017
|
+
<html lang="en" className="dark"> {/* Add 'dark' class */}
|
|
1018
|
+
<body>{children}</body>
|
|
1019
|
+
</html>
|
|
1020
|
+
);
|
|
1021
|
+
}
|
|
1022
|
+
```
|
|
1023
|
+
|
|
1024
|
+
Or use `next-themes` for dynamic toggling:
|
|
1025
|
+
|
|
1026
|
+
```tsx
|
|
1027
|
+
import { ThemeProvider } from "next-themes";
|
|
1028
|
+
|
|
1029
|
+
export default function RootLayout({ children }) {
|
|
1030
|
+
return (
|
|
1031
|
+
<html suppressHydrationWarning>
|
|
1032
|
+
<body>
|
|
1033
|
+
<ThemeProvider attribute="class" defaultTheme="system">
|
|
1034
|
+
{children}
|
|
1035
|
+
</ThemeProvider>
|
|
1036
|
+
</body>
|
|
1037
|
+
</html>
|
|
1038
|
+
);
|
|
1039
|
+
}
|
|
1040
|
+
```
|
|
1041
|
+
|
|
1042
|
+
### Design Philosophy
|
|
1043
|
+
|
|
1044
|
+
The blog platform follows these design principles:
|
|
1045
|
+
|
|
1046
|
+
1. **Zero Configuration**: Works out of the box with your existing theme
|
|
1047
|
+
2. **Semantic Classes**: Uses meaning-based tokens, not hardcoded colors
|
|
1048
|
+
3. **Progressive Enhancement**: Core functionality works without JavaScript
|
|
1049
|
+
4. **Accessibility First**: WCAG 2.1 AA compliant markup
|
|
1050
|
+
5. **Performance Optimized**: Minimal CSS footprint, no runtime styles
|
|
1051
|
+
|
|
1052
|
+
### Common Customization Examples
|
|
1053
|
+
|
|
1054
|
+
**Example 1: Custom Brand Colors**
|
|
1055
|
+
|
|
1056
|
+
```css
|
|
1057
|
+
/* globals.css */
|
|
1058
|
+
@theme inline {
|
|
1059
|
+
--color-primary: oklch(0.6 0.25 140); /* Green primary */
|
|
1060
|
+
--color-primary-foreground: oklch(1 0 0); /* White text on primary */
|
|
1061
|
+
}
|
|
1062
|
+
```
|
|
1063
|
+
|
|
1064
|
+
Result: All blog links, CTAs, and accent colors become green.
|
|
1065
|
+
|
|
1066
|
+
**Example 2: Wider Content Layout**
|
|
1067
|
+
|
|
1068
|
+
```tsx
|
|
1069
|
+
// app/blog/[slug]/page.tsx
|
|
1070
|
+
export default function ArticlePage({ params }) {
|
|
1071
|
+
return (
|
|
1072
|
+
<div className="container mx-auto max-w-5xl"> {/* Wider than default */}
|
|
1073
|
+
<BlogArticlePage article={article} />
|
|
1074
|
+
</div>
|
|
1075
|
+
);
|
|
1076
|
+
}
|
|
1077
|
+
```
|
|
1078
|
+
|
|
1079
|
+
**Example 3: Custom Author Byline**
|
|
1080
|
+
|
|
1081
|
+
```tsx
|
|
1082
|
+
// Create custom author component
|
|
1083
|
+
import { AuthorByline as DefaultByline } from "@xexr/blog-platform";
|
|
1084
|
+
|
|
1085
|
+
function CustomAuthorByline({ author }) {
|
|
1086
|
+
return (
|
|
1087
|
+
<div className="flex items-center gap-4">
|
|
1088
|
+
<img src={author.avatar} className="w-16 h-16 rounded-full" />
|
|
1089
|
+
<div>
|
|
1090
|
+
<DefaultByline author={author} />
|
|
1091
|
+
<p className="text-sm text-muted-foreground">
|
|
1092
|
+
@{author.twitter}
|
|
1093
|
+
</p>
|
|
1094
|
+
</div>
|
|
1095
|
+
</div>
|
|
1096
|
+
);
|
|
1097
|
+
}
|
|
1098
|
+
```
|
|
1099
|
+
|
|
1100
|
+
## SEO Features
|
|
1101
|
+
|
|
1102
|
+
- **Meta tags**: Automatic title, description, and Open Graph tags
|
|
1103
|
+
- **JSON-LD schema**: Rich snippets for articles and authors
|
|
1104
|
+
- **Canonical URLs**: Proper URL canonicalization
|
|
1105
|
+
- **Sitemap support**: Built-in sitemap generation utilities
|
|
1106
|
+
|
|
1107
|
+
## Security
|
|
1108
|
+
|
|
1109
|
+
- **Authentication**: Bearer token authentication required
|
|
1110
|
+
- **Rate limiting**: Prevents abuse with Redis-based limiting
|
|
1111
|
+
- **CORS**: Configurable CORS headers
|
|
1112
|
+
- **Input validation**: Comprehensive input sanitization
|
|
1113
|
+
|
|
1114
|
+
## Development
|
|
1115
|
+
|
|
1116
|
+
To work on this package locally:
|
|
1117
|
+
|
|
1118
|
+
```bash
|
|
1119
|
+
# Install dependencies
|
|
1120
|
+
pnpm install
|
|
1121
|
+
|
|
1122
|
+
# Build the package
|
|
1123
|
+
pnpm build
|
|
1124
|
+
|
|
1125
|
+
# Run tests
|
|
1126
|
+
pnpm test
|
|
1127
|
+
|
|
1128
|
+
# Type check
|
|
1129
|
+
pnpm type
|
|
1130
|
+
|
|
1131
|
+
# Lint code
|
|
1132
|
+
pnpm lint
|
|
1133
|
+
```
|
|
1134
|
+
|
|
1135
|
+
## Changelog
|
|
1136
|
+
|
|
1137
|
+
### 0.1.2
|
|
1138
|
+
|
|
1139
|
+
- **SECURITY**: Changed default authentication to fail-closed (was fail-open) - all requests now rejected until `initBlogPlatform()` called
|
|
1140
|
+
- **SECURITY**: Added authentication requirement to `/api/blog/version` endpoint (was unprotected)
|
|
1141
|
+
- **SECURITY**: Added comprehensive security headers to all API endpoints (CORS on 401/OPTIONS responses)
|
|
1142
|
+
- **SECURITY**: Added page-level security headers via middleware (CSP, X-Frame-Options, X-Content-Type-Options, Referrer-Policy)
|
|
1143
|
+
- **Enhancement**: Theme inheritance via semantic Tailwind classes - blog now automatically inherits consumer's colors, fonts, and dark mode
|
|
1144
|
+
- **Enhancement**: Guest author badges and bio display on article pages
|
|
1145
|
+
- **Enhancement**: Improved version compatibility checking with proper semantic versioning
|
|
1146
|
+
- **Enhancement**: Author bio passthrough in publish payload for rich author cards
|
|
1147
|
+
- **Fix**: Version endpoint now correctly reports both Bloggy and platform versions
|
|
1148
|
+
- **Fix**: Incompatible versions now return HTTP 200 with compatibility details (was 409)
|
|
1149
|
+
- **Fix**: Removed tone adjectives from UI and JSON-LD to avoid exposing writing style data
|
|
1150
|
+
- **Docs**: Added comprehensive publishing workflow guide
|
|
1151
|
+
- **Docs**: Added theme customization and styling documentation
|
|
1152
|
+
|
|
1153
|
+
### 0.1.1
|
|
1154
|
+
|
|
1155
|
+
- **BREAKING**: Replaced `next-mdx-remote` with `next-mdx-remote-client` for React 19 compatibility
|
|
1156
|
+
- Fixed property mangling issue in build configuration that was breaking `dangerouslySetInnerHTML`
|
|
1157
|
+
- Improved React 19 fiber reconciliation compatibility
|
|
1158
|
+
- Enhanced error handling for MDX rendering
|
|
1159
|
+
|
|
1160
|
+
### 0.1.0
|
|
1161
|
+
|
|
1162
|
+
- Initial release
|
|
1163
|
+
- Blog page components with MDX rendering
|
|
1164
|
+
- Publishing API with idempotency support
|
|
1165
|
+
- Rate limiting with Upstash Redis
|
|
1166
|
+
- SEO optimization features
|
|
1167
|
+
- TypeScript declarations
|
|
1168
|
+
|
|
1169
|
+
## License
|
|
1170
|
+
|
|
1171
|
+
MIT
|
|
1172
|
+
|
|
1173
|
+
## Support
|
|
1174
|
+
|
|
1175
|
+
For issues and questions, please refer to the [Bloggy documentation](https://docs.bloggy.com) or create an issue in the project repository.
|