@xexr/blog-platform 1.0.6 → 1.0.8
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 +7 -0
- package/dist/app/blog/[slug]/page.d.ts +68 -0
- package/dist/app/blog/[slug]/page.tsx +224 -98
- package/dist/app/blog/page.tsx +83 -2
- package/dist/components/ArticleReadingProgress.tsx +208 -0
- package/dist/components/ArticleTopActions.tsx +179 -0
- package/dist/components/AuthorByline.tsx +569 -0
- package/dist/components/ExpandableArticleImage.tsx +79 -0
- package/dist/components/Pagination.tsx +236 -0
- package/dist/components/TopicArchivePages.tsx +204 -0
- package/dist/components/ui/badge.tsx +36 -0
- package/dist/components/ui/button.tsx +59 -0
- package/dist/components/ui/card.tsx +92 -0
- package/dist/components/ui/pagination.tsx +127 -0
- package/dist/components/ui/separator.tsx +29 -0
- package/dist/index.d.mts +88 -71
- package/dist/index.d.ts +88 -71
- package/dist/index.js +83 -8
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +83 -8
- package/dist/index.mjs.map +1 -1
- package/dist/lib/article-ui.ts +158 -0
- package/dist/lib/auth.ts +354 -0
- package/dist/lib/cors.ts +243 -0
- package/dist/lib/database.ts +277 -0
- package/dist/lib/init.ts +186 -0
- package/dist/lib/mdx.ts +73 -0
- package/dist/lib/metadata.ts +558 -0
- package/dist/lib/ratelimit.ts +224 -0
- package/dist/lib/security-headers.ts +322 -0
- package/dist/lib/seo.ts +295 -0
- package/dist/lib/sitemap.ts +719 -0
- package/dist/lib/storage/drizzle.ts +400 -0
- package/dist/lib/structured-data.ts +608 -0
- package/dist/lib/topics.ts +56 -0
- package/dist/lib/urls.ts +29 -0
- package/dist/lib/utils.ts +6 -0
- package/dist/lib/version.ts +154 -0
- package/dist/types/contracts.ts +256 -0
- package/dist/types/index.d.mts +37 -1
- package/dist/types/index.d.ts +37 -1
- package/dist/types/index.ts +16 -0
- package/package.json +14 -9
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
import type { InferSelectModel } from "drizzle-orm";
|
|
2
|
+
import { and, desc, eq, sql } from "drizzle-orm";
|
|
3
|
+
import type { LibSQLDatabase } from "drizzle-orm/libsql";
|
|
4
|
+
import {
|
|
5
|
+
sqliteTable,
|
|
6
|
+
integer,
|
|
7
|
+
text,
|
|
8
|
+
uniqueIndex,
|
|
9
|
+
} from "drizzle-orm/sqlite-core";
|
|
10
|
+
import type {
|
|
11
|
+
PublishedArticle,
|
|
12
|
+
PublishResult,
|
|
13
|
+
PublishedAuthor,
|
|
14
|
+
PublishedSite,
|
|
15
|
+
} from "../../types/contracts";
|
|
16
|
+
import type { BlogPlatformStorage } from "../database";
|
|
17
|
+
|
|
18
|
+
// Schema definitions for durable storage
|
|
19
|
+
export const bpArticles = sqliteTable(
|
|
20
|
+
"bp_articles",
|
|
21
|
+
{
|
|
22
|
+
id: text("id").primaryKey(),
|
|
23
|
+
slug: text("slug").notNull(),
|
|
24
|
+
title: text("title").notNull(),
|
|
25
|
+
excerpt: text("excerpt").notNull(),
|
|
26
|
+
content: text("content").notNull(),
|
|
27
|
+
readingSeconds: integer("reading_seconds").notNull(),
|
|
28
|
+
wordCount: integer("word_count").notNull(),
|
|
29
|
+
publishedAt: text("published_at").notNull(),
|
|
30
|
+
updatedAt: text("updated_at").notNull(),
|
|
31
|
+
featuredImage: text("featured_image"),
|
|
32
|
+
featuredImageAlt: text("featured_image_alt"),
|
|
33
|
+
// Store complex objects as JSON strings
|
|
34
|
+
authorJson: text("author_json"),
|
|
35
|
+
siteJson: text("site_json").notNull(),
|
|
36
|
+
metaJson: text("meta_json").notNull(),
|
|
37
|
+
version: integer("version").notNull(),
|
|
38
|
+
},
|
|
39
|
+
(table) => ({
|
|
40
|
+
slugUniqueIdx: uniqueIndex("bp_articles_slug_unique").on(table.slug),
|
|
41
|
+
}),
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
export const bpOperations = sqliteTable("bp_operations", {
|
|
45
|
+
key: text("key").primaryKey(),
|
|
46
|
+
// JSON-encoded PublishResult
|
|
47
|
+
resultJson: text("result_json").notNull(),
|
|
48
|
+
createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(),
|
|
49
|
+
// Optional TTL field (not currently used by adapter logic)
|
|
50
|
+
expiresAt: integer("expires_at", { mode: "timestamp_ms" }),
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
type ArticleRow = InferSelectModel<typeof bpArticles>;
|
|
54
|
+
|
|
55
|
+
function toRow(article: PublishedArticle): ArticleRow {
|
|
56
|
+
return {
|
|
57
|
+
id: article.id,
|
|
58
|
+
slug: article.slug,
|
|
59
|
+
title: article.title,
|
|
60
|
+
excerpt: article.excerpt,
|
|
61
|
+
content: article.content,
|
|
62
|
+
readingSeconds: article.readingSeconds,
|
|
63
|
+
wordCount: article.wordCount,
|
|
64
|
+
publishedAt: article.publishedAt,
|
|
65
|
+
updatedAt: article.updatedAt,
|
|
66
|
+
featuredImage: article.featuredImage ?? null,
|
|
67
|
+
featuredImageAlt: article.featuredImageAlt ?? null,
|
|
68
|
+
authorJson: article.author ? JSON.stringify(article.author) : null,
|
|
69
|
+
siteJson: JSON.stringify(article.site),
|
|
70
|
+
metaJson: JSON.stringify({
|
|
71
|
+
...article.meta,
|
|
72
|
+
__bloggyTopics: article.topics ?? [],
|
|
73
|
+
}),
|
|
74
|
+
version: article.version,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function fromRow(row: ArticleRow): PublishedArticle {
|
|
79
|
+
return {
|
|
80
|
+
id: row.id,
|
|
81
|
+
slug: row.slug,
|
|
82
|
+
title: row.title,
|
|
83
|
+
excerpt: row.excerpt,
|
|
84
|
+
content: row.content,
|
|
85
|
+
readingSeconds: row.readingSeconds,
|
|
86
|
+
wordCount: row.wordCount,
|
|
87
|
+
publishedAt: row.publishedAt,
|
|
88
|
+
updatedAt: row.updatedAt,
|
|
89
|
+
featuredImage: row.featuredImage ?? undefined,
|
|
90
|
+
featuredImageAlt: row.featuredImageAlt ?? undefined,
|
|
91
|
+
author: row.authorJson
|
|
92
|
+
? (JSON.parse(row.authorJson) as PublishedAuthor)
|
|
93
|
+
: null,
|
|
94
|
+
site: JSON.parse(row.siteJson) as PublishedSite,
|
|
95
|
+
topics: extractTopics(row.metaJson),
|
|
96
|
+
meta: extractMeta(row.metaJson),
|
|
97
|
+
version: row.version,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function extractTopics(metaJson: string): PublishedArticle["topics"] {
|
|
102
|
+
const parsed = JSON.parse(metaJson) as PublishedArticle["meta"] & {
|
|
103
|
+
__bloggyTopics?: PublishedArticle["topics"];
|
|
104
|
+
};
|
|
105
|
+
return parsed.__bloggyTopics ?? [];
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function extractMeta(metaJson: string): PublishedArticle["meta"] {
|
|
109
|
+
const parsed = JSON.parse(metaJson) as PublishedArticle["meta"] & {
|
|
110
|
+
__bloggyTopics?: PublishedArticle["topics"];
|
|
111
|
+
};
|
|
112
|
+
delete parsed.__bloggyTopics;
|
|
113
|
+
const meta = parsed;
|
|
114
|
+
return meta;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export interface DrizzleStorageOptions {
|
|
118
|
+
// Optional: limit retention for operations table (ms)
|
|
119
|
+
operationTtlMs?: number;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Drizzle-backed durable storage adapter.
|
|
124
|
+
* Pass a Drizzle Database instance created with drizzle-orm/libsql.
|
|
125
|
+
*/
|
|
126
|
+
export class DrizzleStorage implements BlogPlatformStorage {
|
|
127
|
+
private readonly db: LibSQLDatabase;
|
|
128
|
+
private readonly options: DrizzleStorageOptions;
|
|
129
|
+
|
|
130
|
+
constructor(db: LibSQLDatabase, options: DrizzleStorageOptions = {}) {
|
|
131
|
+
this.db = db;
|
|
132
|
+
this.options = options;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async saveArticle(
|
|
136
|
+
article: PublishedArticle,
|
|
137
|
+
idempotencyKey: string,
|
|
138
|
+
): Promise<PublishResult> {
|
|
139
|
+
const existing = await this.checkIdempotency(idempotencyKey);
|
|
140
|
+
|
|
141
|
+
// CRITICAL FIX: Allow republish after unpublish
|
|
142
|
+
// If we found a cached operation result AND it was successful,
|
|
143
|
+
// check if the article still exists. If it was removed (unpublished),
|
|
144
|
+
// proceed with the save to allow republishing.
|
|
145
|
+
if (existing) {
|
|
146
|
+
if (existing.success) {
|
|
147
|
+
// Check if article still exists in database
|
|
148
|
+
const articleExists = await this.db
|
|
149
|
+
.select({ id: bpArticles.id })
|
|
150
|
+
.from(bpArticles)
|
|
151
|
+
.where(eq(bpArticles.id, article.id))
|
|
152
|
+
.limit(1);
|
|
153
|
+
|
|
154
|
+
// If article was removed, allow republish by not returning cached result
|
|
155
|
+
if (articleExists.length === 0) {
|
|
156
|
+
// Article was unpublished - proceed with save
|
|
157
|
+
} else {
|
|
158
|
+
// Article still exists - this is a true duplicate
|
|
159
|
+
return existing;
|
|
160
|
+
}
|
|
161
|
+
} else {
|
|
162
|
+
// Failed operations are always returned as-is
|
|
163
|
+
return existing;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Enforce slug uniqueness: if slug exists with a different article id → conflict
|
|
168
|
+
const bySlug = await this.db
|
|
169
|
+
.select()
|
|
170
|
+
.from(bpArticles)
|
|
171
|
+
.where(eq(bpArticles.slug, article.slug))
|
|
172
|
+
.limit(1);
|
|
173
|
+
|
|
174
|
+
const existingRow = bySlug[0];
|
|
175
|
+
if (existingRow && existingRow.id !== article.id) {
|
|
176
|
+
const conflict: PublishResult = {
|
|
177
|
+
success: false,
|
|
178
|
+
error: {
|
|
179
|
+
code: "PUB003",
|
|
180
|
+
message: "This URL is already in use. Please choose another.",
|
|
181
|
+
},
|
|
182
|
+
idempotencyKey,
|
|
183
|
+
};
|
|
184
|
+
await this.storeOperationResult(idempotencyKey, conflict);
|
|
185
|
+
return conflict;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Upsert by id; slug is immutable once set for a given id
|
|
189
|
+
const row = toRow(article);
|
|
190
|
+
|
|
191
|
+
if (bySlug.length === 0) {
|
|
192
|
+
// Insert new
|
|
193
|
+
await this.db.insert(bpArticles).values(row).run();
|
|
194
|
+
} else {
|
|
195
|
+
// Update existing row for same article id
|
|
196
|
+
await this.db
|
|
197
|
+
.update(bpArticles)
|
|
198
|
+
.set({
|
|
199
|
+
title: row.title,
|
|
200
|
+
excerpt: row.excerpt,
|
|
201
|
+
content: row.content,
|
|
202
|
+
readingSeconds: row.readingSeconds,
|
|
203
|
+
wordCount: row.wordCount,
|
|
204
|
+
updatedAt: row.updatedAt,
|
|
205
|
+
featuredImage: row.featuredImage,
|
|
206
|
+
featuredImageAlt: row.featuredImageAlt,
|
|
207
|
+
authorJson: row.authorJson,
|
|
208
|
+
siteJson: row.siteJson,
|
|
209
|
+
metaJson: row.metaJson,
|
|
210
|
+
version: row.version,
|
|
211
|
+
})
|
|
212
|
+
.where(and(eq(bpArticles.id, row.id), eq(bpArticles.slug, row.slug)))
|
|
213
|
+
.run();
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const success: PublishResult = {
|
|
217
|
+
success: true,
|
|
218
|
+
url: `/${article.slug}`,
|
|
219
|
+
platformPostId: article.id,
|
|
220
|
+
publishedAt: article.publishedAt,
|
|
221
|
+
idempotencyKey,
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
await this.storeOperationResult(idempotencyKey, success);
|
|
225
|
+
return success;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async getArticle(slug: string): Promise<PublishedArticle | null> {
|
|
229
|
+
const rows = await this.db
|
|
230
|
+
.select()
|
|
231
|
+
.from(bpArticles)
|
|
232
|
+
.where(eq(bpArticles.slug, slug))
|
|
233
|
+
.limit(1);
|
|
234
|
+
if (!rows[0]) return null;
|
|
235
|
+
return fromRow(rows[0]);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
async listArticles(
|
|
239
|
+
options: {
|
|
240
|
+
limit?: number;
|
|
241
|
+
offset?: number;
|
|
242
|
+
sortBy?: "publishedAt" | "updatedAt";
|
|
243
|
+
order?: "asc" | "desc";
|
|
244
|
+
} = {},
|
|
245
|
+
): Promise<{ articles: PublishedArticle[]; total: number }> {
|
|
246
|
+
const {
|
|
247
|
+
limit = 10,
|
|
248
|
+
offset = 0,
|
|
249
|
+
sortBy = "publishedAt",
|
|
250
|
+
order = "desc",
|
|
251
|
+
} = options;
|
|
252
|
+
const orderColumn =
|
|
253
|
+
sortBy === "publishedAt" ? bpArticles.publishedAt : bpArticles.updatedAt;
|
|
254
|
+
const orderBy = order === "desc" ? desc(orderColumn) : orderColumn;
|
|
255
|
+
|
|
256
|
+
const items = await this.db
|
|
257
|
+
.select()
|
|
258
|
+
.from(bpArticles)
|
|
259
|
+
.orderBy(orderBy)
|
|
260
|
+
.limit(limit)
|
|
261
|
+
.offset(offset);
|
|
262
|
+
|
|
263
|
+
// Total count (approx via window-free query)
|
|
264
|
+
const totalRow = await this.db
|
|
265
|
+
.select({ c: sql<number>`count(*)` })
|
|
266
|
+
.from(bpArticles);
|
|
267
|
+
const total = Number(totalRow?.[0]?.c ?? 0);
|
|
268
|
+
|
|
269
|
+
return { articles: items.map(fromRow), total };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
async removeArticle(
|
|
273
|
+
slug: string,
|
|
274
|
+
idempotencyKey: string,
|
|
275
|
+
): Promise<PublishResult> {
|
|
276
|
+
const existing = await this.checkIdempotency(idempotencyKey);
|
|
277
|
+
|
|
278
|
+
// CRITICAL FIX: Allow re-unpublish after republish
|
|
279
|
+
// If we found a cached operation result AND it was successful,
|
|
280
|
+
// check if the article NOW exists. If it was republished,
|
|
281
|
+
// proceed with the removal to allow re-unpublishing.
|
|
282
|
+
if (existing) {
|
|
283
|
+
if (existing.success) {
|
|
284
|
+
// Check if article exists NOW (might have been republished)
|
|
285
|
+
const articleExists = await this.db
|
|
286
|
+
.select({ id: bpArticles.id })
|
|
287
|
+
.from(bpArticles)
|
|
288
|
+
.where(eq(bpArticles.slug, slug))
|
|
289
|
+
.limit(1);
|
|
290
|
+
|
|
291
|
+
// If article EXISTS, it was republished - allow re-unpublish
|
|
292
|
+
if (articleExists.length > 0) {
|
|
293
|
+
// Article was republished - proceed with removal
|
|
294
|
+
} else {
|
|
295
|
+
// Article still removed - this is a true duplicate
|
|
296
|
+
return existing;
|
|
297
|
+
}
|
|
298
|
+
} else {
|
|
299
|
+
// Failed operations are always returned as-is
|
|
300
|
+
return existing;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const found = await this.db
|
|
305
|
+
.select()
|
|
306
|
+
.from(bpArticles)
|
|
307
|
+
.where(eq(bpArticles.slug, slug))
|
|
308
|
+
.limit(1);
|
|
309
|
+
if (!found[0]) {
|
|
310
|
+
const notFound: PublishResult = {
|
|
311
|
+
success: false,
|
|
312
|
+
error: { code: "PUB007", message: "Article not found" },
|
|
313
|
+
idempotencyKey,
|
|
314
|
+
};
|
|
315
|
+
await this.storeOperationResult(idempotencyKey, notFound);
|
|
316
|
+
return notFound;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
await this.db.delete(bpArticles).where(eq(bpArticles.slug, slug)).run();
|
|
320
|
+
|
|
321
|
+
const result: PublishResult = { success: true, idempotencyKey };
|
|
322
|
+
await this.storeOperationResult(idempotencyKey, result);
|
|
323
|
+
return result;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
async checkIdempotency(
|
|
327
|
+
idempotencyKey: string,
|
|
328
|
+
): Promise<PublishResult | null> {
|
|
329
|
+
const rows = await this.db
|
|
330
|
+
.select()
|
|
331
|
+
.from(bpOperations)
|
|
332
|
+
.where(eq(bpOperations.key, idempotencyKey))
|
|
333
|
+
.limit(1);
|
|
334
|
+
if (!rows[0]) return null;
|
|
335
|
+
try {
|
|
336
|
+
return JSON.parse(rows[0].resultJson) as PublishResult;
|
|
337
|
+
} catch {
|
|
338
|
+
return null;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
async storeOperationResult(
|
|
343
|
+
idempotencyKey: string,
|
|
344
|
+
result: PublishResult,
|
|
345
|
+
): Promise<void> {
|
|
346
|
+
const now = Date.now();
|
|
347
|
+
const expiresAt = this.options.operationTtlMs
|
|
348
|
+
? now + this.options.operationTtlMs
|
|
349
|
+
: null;
|
|
350
|
+
// Upsert behavior using ON CONFLICT is not portable via builder; rely on delete+insert
|
|
351
|
+
await this.db
|
|
352
|
+
.delete(bpOperations)
|
|
353
|
+
.where(eq(bpOperations.key, idempotencyKey))
|
|
354
|
+
.run();
|
|
355
|
+
await this.db
|
|
356
|
+
.insert(bpOperations)
|
|
357
|
+
.values({
|
|
358
|
+
key: idempotencyKey,
|
|
359
|
+
resultJson: JSON.stringify(result),
|
|
360
|
+
createdAt: new Date(now),
|
|
361
|
+
expiresAt: expiresAt ? new Date(expiresAt) : null,
|
|
362
|
+
})
|
|
363
|
+
.run();
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Helper: Return SQL strings for creating required tables.
|
|
369
|
+
* Consumers can embed these in their migrations.
|
|
370
|
+
*/
|
|
371
|
+
export function getDrizzleAdapterSql(): {
|
|
372
|
+
createArticles: string;
|
|
373
|
+
createOperations: string;
|
|
374
|
+
} {
|
|
375
|
+
return {
|
|
376
|
+
createArticles: `CREATE TABLE IF NOT EXISTS bp_articles (
|
|
377
|
+
id TEXT PRIMARY KEY,
|
|
378
|
+
slug TEXT NOT NULL UNIQUE,
|
|
379
|
+
title TEXT NOT NULL,
|
|
380
|
+
excerpt TEXT NOT NULL,
|
|
381
|
+
content TEXT NOT NULL,
|
|
382
|
+
reading_seconds INTEGER NOT NULL,
|
|
383
|
+
word_count INTEGER NOT NULL,
|
|
384
|
+
published_at TEXT NOT NULL,
|
|
385
|
+
updated_at TEXT NOT NULL,
|
|
386
|
+
featured_image TEXT,
|
|
387
|
+
featured_image_alt TEXT,
|
|
388
|
+
author_json TEXT,
|
|
389
|
+
site_json TEXT NOT NULL,
|
|
390
|
+
meta_json TEXT NOT NULL,
|
|
391
|
+
version INTEGER NOT NULL
|
|
392
|
+
);`,
|
|
393
|
+
createOperations: `CREATE TABLE IF NOT EXISTS bp_operations (
|
|
394
|
+
key TEXT PRIMARY KEY,
|
|
395
|
+
result_json TEXT NOT NULL,
|
|
396
|
+
created_at INTEGER NOT NULL,
|
|
397
|
+
expires_at INTEGER
|
|
398
|
+
);`,
|
|
399
|
+
};
|
|
400
|
+
}
|