@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/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.