@xexr/blog-platform 1.0.17 → 1.1.1

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 CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.1.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 4c880fb: Escape JSON-LD structured data for safe embedding in script tags.
8
+
9
+ ## 1.1.0
10
+
11
+ ### Minor Changes
12
+
13
+ - Add authenticated config sync and public IndexNow key route support for Bloggy-managed search engine submission settings. Expose package-owned Drizzle schema exports plus setup/check helpers so host apps can generate or apply Bloggy table upgrades without copying table definitions.
14
+
3
15
  ## 1.0.15
4
16
 
5
17
  ### Patch Changes
package/README.md CHANGED
@@ -328,6 +328,27 @@ export const OPTIONS = slugExistsOptionsHandler;
328
328
 
329
329
  > 🛈 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.
330
330
 
331
+ **`app/api/blog/config/route.ts`** - Authenticated config sync used for search submission settings:
332
+
333
+ ```tsx
334
+ import "@/lib/blog-platform";
335
+ import { configHandler, configOptionsHandler } from "@xexr/blog-platform";
336
+
337
+ export const POST = configHandler;
338
+ export const OPTIONS = configOptionsHandler;
339
+ ```
340
+
341
+ **`app/bloggy-indexnow-key.txt/route.ts`** - Public IndexNow key file route:
342
+
343
+ ```tsx
344
+ import "@/lib/blog-platform";
345
+ import { indexNowKeyRoute } from "@xexr/blog-platform";
346
+
347
+ export const GET = indexNowKeyRoute;
348
+ ```
349
+
350
+ Bloggy generates the IndexNow key, stores it against the site, and syncs it to this package through `/api/blog/config` using the same publishing token. The downstream app does not need an IndexNow env var.
351
+
331
352
  ### 3. Configure Durable Storage (Drizzle + Turso)
332
353
 
333
354
  By default, the package uses in-memory storage (not persistent). To persist published articles, plug in the Drizzle adapter.
@@ -338,12 +359,76 @@ By default, the package uses in-memory storage (not persistent). To persist publ
338
359
  pnpm add drizzle-orm @libsql/client
339
360
  ```
340
361
 
341
- 2. Create the tables (run once via your migrations):
362
+ 2. Add the package-owned schema to your Drizzle schema exports.
363
+
364
+ This is the recommended path for apps that use Drizzle Kit. The host app still owns generated migration files, but the table definitions stay package-owned so you do not copy/fork `bp_*` table shapes.
365
+
366
+ **`src/db/schema/blogPlatform.ts`**:
367
+
368
+ ```ts
369
+ export {
370
+ bpArticles,
371
+ bpMetadata,
372
+ bpOperations,
373
+ } from "@xexr/blog-platform";
374
+ ```
375
+
376
+ Then make sure your Drizzle config includes that schema file. For example:
377
+
378
+ ```ts
379
+ // drizzle.config.ts
380
+ import { defineConfig } from "drizzle-kit";
381
+
382
+ export default defineConfig({
383
+ schema: "./src/db/schema/!(*.test).ts",
384
+ out: "drizzle",
385
+ dialect: "turso",
386
+ dbCredentials: {
387
+ url: process.env.BLOG_DATABASE_URL!,
388
+ authToken: process.env.BLOG_DATABASE_AUTH_TOKEN!,
389
+ },
390
+ });
391
+ ```
392
+
393
+ Run your normal migration flow after upgrading the package:
394
+
395
+ ```bash
396
+ pnpm drizzle-kit generate
397
+ pnpm drizzle-kit migrate
398
+ ```
399
+
400
+ 3. Manual setup or upgrade path.
401
+
402
+ For apps that do not generate Drizzle migrations from schema files, use the ordered SQL helper. It is safe to run repeatedly because each statement uses `IF NOT EXISTS`.
342
403
 
343
404
  ```ts
344
405
  // e.g., scripts/createBlogPlatformTables.ts or inside your migration
345
406
  import { getDrizzleAdapterSql } from "@xexr/blog-platform";
346
407
  import { createClient } from "@libsql/client";
408
+
409
+ const client = createClient({
410
+ url: process.env.BLOG_DATABASE_URL!,
411
+ authToken: process.env.BLOG_DATABASE_AUTH_TOKEN!,
412
+ });
413
+
414
+ const { createAll } = getDrizzleAdapterSql();
415
+ for (const statement of createAll) {
416
+ await client.execute(statement);
417
+ }
418
+ ```
419
+
420
+ Existing installs upgrading from a package version before IndexNow/config sync can run the same `createAll` loop, or just `getDrizzleAdapterSql().createMetadata`, to add the `bp_metadata` table.
421
+
422
+ 4. Optional startup/schema health check.
423
+
424
+ Use `ensureDrizzleAdapterSchema` during setup checks or deployment smoke tests when you want a clear package-level error for missing Bloggy tables.
425
+
426
+ ```ts
427
+ import {
428
+ ensureDrizzleAdapterSchema,
429
+ getDrizzleAdapterSql,
430
+ } from "@xexr/blog-platform";
431
+ import { createClient } from "@libsql/client";
347
432
  import { drizzle } from "drizzle-orm/libsql";
348
433
 
349
434
  const client = createClient({
@@ -352,12 +437,16 @@ const client = createClient({
352
437
  });
353
438
  const db = drizzle(client);
354
439
 
355
- const { createArticles, createOperations } = getDrizzleAdapterSql();
356
- await db.run(createArticles);
357
- await db.run(createOperations);
440
+ try {
441
+ await ensureDrizzleAdapterSchema(db);
442
+ } catch (error) {
443
+ console.error(error);
444
+ console.info(getDrizzleAdapterSql().createAll.join("\n\n"));
445
+ throw error;
446
+ }
358
447
  ```
359
448
 
360
- 3. Wire the adapter at app startup:
449
+ 5. Wire the adapter at app startup:
361
450
 
362
451
  ```ts
363
452
  // app/(root)/layout.tsx or middleware.ts (server-only)
@@ -0,0 +1,259 @@
1
+ import type { NextRequest, NextResponse } from "next/server.js";
2
+
3
+ import { getStorage } from "@/lib/database";
4
+ import {
5
+ authenticateRequest,
6
+ createTokenFingerprint,
7
+ requireAuth,
8
+ } from "@/lib/auth";
9
+ import { createCorsResponse, handleCorsOptions } from "@/lib/cors";
10
+ import { enforceRateLimit, getRateLimitHeaders } from "@/lib/ratelimit";
11
+ import type { BlogPlatformConfigPayload } from "@/types/contracts";
12
+
13
+ interface ConfigResponse {
14
+ success: boolean;
15
+ updated?: string[];
16
+ error?: {
17
+ code: string;
18
+ message: string;
19
+ details?: Record<string, unknown>;
20
+ };
21
+ }
22
+
23
+ const INDEXNOW_KEY_PATTERN = /^[A-Za-z0-9-]{8,128}$/;
24
+
25
+ export async function POST(
26
+ request: NextRequest,
27
+ ): Promise<NextResponse<ConfigResponse>> {
28
+ const auth = await authenticateRequest(request);
29
+ if (!auth.authenticated) {
30
+ const authCheck = await requireAuth(request);
31
+ return createCorsResponse(
32
+ request,
33
+ {
34
+ success: false,
35
+ error: {
36
+ code: authCheck.error?.error?.code ?? "PUB005",
37
+ message: authCheck.error?.error?.message ?? "Authentication required",
38
+ },
39
+ },
40
+ { status: 401 },
41
+ );
42
+ }
43
+
44
+ const tokenFingerprint = createTokenFingerprint(auth.token ?? "anonymous");
45
+ const rateLimitCheck = await enforceRateLimit(tokenFingerprint);
46
+ if (!rateLimitCheck.allowed) {
47
+ const rateLimitHeaders = getRateLimitHeaders({
48
+ success: false,
49
+ remaining: 0,
50
+ reset: Date.now() + 60_000,
51
+ limit: 5,
52
+ count: 5,
53
+ });
54
+ return createCorsResponse(
55
+ request,
56
+ {
57
+ success: false,
58
+ error: rateLimitCheck.errorResponse.error ?? {
59
+ code: "PUB011",
60
+ message: "Rate limit exceeded",
61
+ },
62
+ },
63
+ { status: 429, headers: rateLimitHeaders },
64
+ );
65
+ }
66
+
67
+ const contentType = request.headers.get("content-type");
68
+ if (!contentType?.includes("application/json")) {
69
+ return createCorsResponse(request, invalidConfig("Expected JSON body"), {
70
+ status: 400,
71
+ });
72
+ }
73
+
74
+ let payload: unknown;
75
+ try {
76
+ payload = await request.json();
77
+ } catch {
78
+ return createCorsResponse(request, invalidConfig("Invalid JSON body"), {
79
+ status: 400,
80
+ });
81
+ }
82
+
83
+ const parsed = parseConfigPayload(payload);
84
+ if (!parsed.success) {
85
+ return createCorsResponse(request, invalidConfig(parsed.message), {
86
+ status: 400,
87
+ });
88
+ }
89
+
90
+ const updated: string[] = [];
91
+ if (parsed.config.searchSubmission) {
92
+ await getStorage().setMetadata(
93
+ "searchSubmission",
94
+ parsed.config.searchSubmission,
95
+ );
96
+ updated.push("searchSubmission");
97
+ }
98
+
99
+ return createCorsResponse(
100
+ request,
101
+ {
102
+ success: true,
103
+ updated,
104
+ },
105
+ {
106
+ status: 200,
107
+ headers: getRateLimitHeaders(rateLimitCheck.rateLimitResult),
108
+ },
109
+ );
110
+ }
111
+
112
+ export async function OPTIONS(request: NextRequest): Promise<NextResponse> {
113
+ return handleCorsOptions(request, ["POST"], {
114
+ allowedHeaders: [
115
+ "Content-Type",
116
+ "X-Bloggy-Version",
117
+ "Authorization",
118
+ "X-Requested-With",
119
+ ],
120
+ });
121
+ }
122
+
123
+ function invalidConfig(message: string): ConfigResponse {
124
+ return {
125
+ success: false,
126
+ error: {
127
+ code: "PUB001",
128
+ message,
129
+ },
130
+ };
131
+ }
132
+
133
+ function parseConfigPayload(payload: unknown):
134
+ | { success: true; config: BlogPlatformConfigPayload }
135
+ | {
136
+ success: false;
137
+ message: string;
138
+ } {
139
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
140
+ return { success: false, message: "Request body must be an object" };
141
+ }
142
+
143
+ const record = payload as Record<string, unknown>;
144
+ const allowedKeys = new Set(["version", "searchSubmission"]);
145
+ const unknownKeys = Object.keys(record).filter(
146
+ (key) => !allowedKeys.has(key),
147
+ );
148
+ if (unknownKeys.length > 0) {
149
+ return {
150
+ success: false,
151
+ message: `Unsupported config section: ${unknownKeys.join(", ")}`,
152
+ };
153
+ }
154
+
155
+ if (record.version !== 1) {
156
+ return { success: false, message: "Config version must be 1" };
157
+ }
158
+
159
+ const config: BlogPlatformConfigPayload = { version: 1 };
160
+ if (record.searchSubmission !== undefined) {
161
+ const searchSubmission = parseSearchSubmission(record.searchSubmission);
162
+ if (!searchSubmission.success) {
163
+ return searchSubmission;
164
+ }
165
+ return {
166
+ success: true,
167
+ config: {
168
+ ...config,
169
+ searchSubmission: searchSubmission.config,
170
+ },
171
+ };
172
+ }
173
+
174
+ return {
175
+ success: false,
176
+ message: "At least one supported config section is required",
177
+ };
178
+ }
179
+
180
+ function parseSearchSubmission(value: unknown):
181
+ | {
182
+ success: true;
183
+ config: NonNullable<BlogPlatformConfigPayload["searchSubmission"]>;
184
+ }
185
+ | {
186
+ success: false;
187
+ message: string;
188
+ } {
189
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
190
+ return { success: false, message: "searchSubmission must be an object" };
191
+ }
192
+
193
+ const record = value as Record<string, unknown>;
194
+ const allowedKeys = new Set(["indexNow"]);
195
+ const unknownKeys = Object.keys(record).filter(
196
+ (key) => !allowedKeys.has(key),
197
+ );
198
+ if (unknownKeys.length > 0) {
199
+ return {
200
+ success: false,
201
+ message: `Unsupported searchSubmission section: ${unknownKeys.join(", ")}`,
202
+ };
203
+ }
204
+
205
+ if (record.indexNow === undefined) {
206
+ return {
207
+ success: false,
208
+ message: "searchSubmission.indexNow is required",
209
+ };
210
+ }
211
+
212
+ const indexNow = record.indexNow;
213
+ if (!indexNow || typeof indexNow !== "object" || Array.isArray(indexNow)) {
214
+ return {
215
+ success: false,
216
+ message: "searchSubmission.indexNow must be an object",
217
+ };
218
+ }
219
+
220
+ const indexNowRecord = indexNow as Record<string, unknown>;
221
+ if (typeof indexNowRecord.enabled !== "boolean") {
222
+ return { success: false, message: "indexNow.enabled must be a boolean" };
223
+ }
224
+ if (
225
+ typeof indexNowRecord.key !== "string" ||
226
+ !INDEXNOW_KEY_PATTERN.test(indexNowRecord.key)
227
+ ) {
228
+ return {
229
+ success: false,
230
+ message:
231
+ "indexNow.key must be 8 to 128 characters using letters, numbers, or dashes",
232
+ };
233
+ }
234
+ if (typeof indexNowRecord.keyLocation !== "string") {
235
+ return { success: false, message: "indexNow.keyLocation must be a URL" };
236
+ }
237
+ try {
238
+ const keyLocation = new URL(indexNowRecord.keyLocation);
239
+ if (keyLocation.protocol !== "https:" && keyLocation.protocol !== "http:") {
240
+ return {
241
+ success: false,
242
+ message: "indexNow.keyLocation must use HTTP or HTTPS",
243
+ };
244
+ }
245
+ } catch {
246
+ return { success: false, message: "indexNow.keyLocation must be a URL" };
247
+ }
248
+
249
+ return {
250
+ success: true,
251
+ config: {
252
+ indexNow: {
253
+ enabled: indexNowRecord.enabled,
254
+ key: indexNowRecord.key,
255
+ keyLocation: indexNowRecord.keyLocation,
256
+ },
257
+ },
258
+ };
259
+ }
@@ -1,4 +1,4 @@
1
- import type { NextRequest, NextResponse } from "next/server";
1
+ import type { NextRequest, NextResponse } from "next/server.js";
2
2
  import { getStorage } from "@/lib/database";
3
3
  import { getBlogPlatformVersion, parseVersionHeader } from "@/lib/version";
4
4
  import {
@@ -1,4 +1,4 @@
1
- import { type NextRequest, NextResponse } from "next/server";
1
+ import { type NextRequest, NextResponse } from "next/server.js";
2
2
  import { getStorage } from "@/lib/database";
3
3
  import { getBlogPlatformVersion, parseVersionHeader } from "@/lib/version";
4
4
  import {
@@ -1,4 +1,4 @@
1
- import { type NextRequest, NextResponse } from "next/server";
1
+ import { type NextRequest, NextResponse } from "next/server.js";
2
2
  import { getStorage } from "@/lib/database";
3
3
  import { getBlogPlatformVersion, parseVersionHeader } from "@/lib/version";
4
4
  import {
@@ -1,4 +1,4 @@
1
- import type { NextRequest, NextResponse } from "next/server";
1
+ import type { NextRequest, NextResponse } from "next/server.js";
2
2
  import {
3
3
  getBlogPlatformVersion,
4
4
  parseVersionHeader,
@@ -99,6 +99,29 @@ describe("ArticlePage MDX security", () => {
99
99
  expect(articleBodyHtml).not.toContain("<object");
100
100
  expect(articleBodyHtml).not.toContain("<embed");
101
101
  });
102
+
103
+ it("escapes script-breaking sequences in article structured data", async () => {
104
+ const breakout = '</script><script>window.__bloggyXss = true</script>';
105
+ const html = await renderToHtml(
106
+ React.createElement(ArticlePage, {
107
+ article: {
108
+ ...article,
109
+ title: breakout,
110
+ excerpt: `Structured data payload ${breakout}`,
111
+ site: {
112
+ ...article.site,
113
+ name: `Example ${breakout}`,
114
+ description: `Site description ${breakout}`,
115
+ },
116
+ },
117
+ showShareLinks: false,
118
+ }),
119
+ );
120
+
121
+ expect(html).not.toContain(breakout);
122
+ expect(html).toContain("\\u003c/script\\u003e");
123
+ expect(html).toContain("\\u003cscript\\u003ewindow.__bloggyXss");
124
+ });
102
125
  });
103
126
 
104
127
  function extractArticleBodyHtml(html: string): string {
@@ -1,6 +1,6 @@
1
1
  import React from "react";
2
- import Image from "next/image";
3
- import Link from "next/link";
2
+ import Image from "next/image.js";
3
+ import Link from "next/link.js";
4
4
  import { MDXRemote } from "next-mdx-remote-client/rsc";
5
5
  import {
6
6
  type PublishedArticle,
@@ -1,6 +1,6 @@
1
1
  import React from "react";
2
- import Image from "next/image";
3
- import Link from "next/link";
2
+ import Image from "next/image.js";
3
+ import Link from "next/link.js";
4
4
  import { ArrowLeft, Globe, Mail } from "lucide-react";
5
5
  import { formatDistanceToNow } from "date-fns";
6
6
 
@@ -1,6 +1,6 @@
1
1
  import React from "react";
2
- import Link from "next/link";
3
- import Image from "next/image";
2
+ import Link from "next/link.js";
3
+ import Image from "next/image.js";
4
4
  import {
5
5
  type PublishedArticle,
6
6
  type PublishedAuthor,
@@ -0,0 +1,22 @@
1
+ import { NextResponse, type NextRequest } from "next/server.js";
2
+
3
+ import { getStorage } from "@/lib/database";
4
+ import type { SearchSubmissionConfig } from "@/types/contracts";
5
+
6
+ export async function GET(_request: NextRequest): Promise<NextResponse> {
7
+ const searchSubmission =
8
+ await getStorage().getMetadata<SearchSubmissionConfig>("searchSubmission");
9
+ const indexNow = searchSubmission?.indexNow;
10
+
11
+ if (!indexNow?.enabled || !indexNow.key) {
12
+ return new NextResponse("Not found", { status: 404 });
13
+ }
14
+
15
+ return new NextResponse(indexNow.key, {
16
+ status: 200,
17
+ headers: {
18
+ "Content-Type": "text/plain; charset=utf-8",
19
+ "Cache-Control": "public, max-age=300, stale-while-revalidate=60",
20
+ },
21
+ });
22
+ }
@@ -1,5 +1,5 @@
1
1
  import React from "react";
2
- import Image from "next/image";
2
+ import Image from "next/image.js";
3
3
  import { Globe, Mail } from "lucide-react";
4
4
  import { FaGithub, FaLinkedinIn, FaXTwitter } from "react-icons/fa6";
5
5
  import { type PublishedAuthor } from "../types/contracts";
@@ -1,6 +1,6 @@
1
1
  import React from "react";
2
- import Image from "next/image";
3
- import Link from "next/link";
2
+ import Image from "next/image.js";
3
+ import Link from "next/link.js";
4
4
 
5
5
  import type { PublishedArticle, PublishedAuthor } from "../types/contracts";
6
6
  import type { BlogTheme } from "../lib/article-ui";
@@ -1,6 +1,6 @@
1
1
  import React from "react";
2
- import Link from "next/link";
3
- import Image from "next/image";
2
+ import Link from "next/link.js";
3
+ import Image from "next/image.js";
4
4
  import { formatDistanceToNow } from "date-fns";
5
5
 
6
6
  import type { PublishedArticle, PublishedTopic } from "../types/contracts";