@xexr/blog-platform 1.0.7 → 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.
Files changed (42) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/dist/app/blog/[slug]/page.d.ts +68 -0
  3. package/dist/app/blog/[slug]/page.tsx +155 -113
  4. package/dist/components/ArticleReadingProgress.tsx +208 -0
  5. package/dist/components/ArticleTopActions.tsx +179 -0
  6. package/dist/components/AuthorByline.tsx +569 -0
  7. package/dist/components/ExpandableArticleImage.tsx +79 -0
  8. package/dist/components/Pagination.tsx +236 -0
  9. package/dist/components/TopicArchivePages.tsx +204 -0
  10. package/dist/components/ui/badge.tsx +36 -0
  11. package/dist/components/ui/button.tsx +59 -0
  12. package/dist/components/ui/card.tsx +92 -0
  13. package/dist/components/ui/pagination.tsx +127 -0
  14. package/dist/components/ui/separator.tsx +29 -0
  15. package/dist/index.d.mts +59 -69
  16. package/dist/index.d.ts +59 -69
  17. package/dist/index.js +83 -8
  18. package/dist/index.js.map +1 -1
  19. package/dist/index.mjs +83 -8
  20. package/dist/index.mjs.map +1 -1
  21. package/dist/lib/article-ui.ts +158 -0
  22. package/dist/lib/auth.ts +354 -0
  23. package/dist/lib/cors.ts +243 -0
  24. package/dist/lib/database.ts +277 -0
  25. package/dist/lib/init.ts +186 -0
  26. package/dist/lib/mdx.ts +73 -0
  27. package/dist/lib/metadata.ts +558 -0
  28. package/dist/lib/ratelimit.ts +224 -0
  29. package/dist/lib/security-headers.ts +322 -0
  30. package/dist/lib/seo.ts +295 -0
  31. package/dist/lib/sitemap.ts +719 -0
  32. package/dist/lib/storage/drizzle.ts +400 -0
  33. package/dist/lib/structured-data.ts +608 -0
  34. package/dist/lib/topics.ts +56 -0
  35. package/dist/lib/urls.ts +29 -0
  36. package/dist/lib/utils.ts +6 -0
  37. package/dist/lib/version.ts +154 -0
  38. package/dist/types/contracts.ts +256 -0
  39. package/dist/types/index.d.mts +18 -1
  40. package/dist/types/index.d.ts +18 -1
  41. package/dist/types/index.ts +16 -0
  42. package/package.json +14 -9
@@ -0,0 +1,158 @@
1
+ import { format, formatDistanceToNow } from "date-fns";
2
+ import type { CSSProperties } from "react";
3
+
4
+ export interface BlogTheme {
5
+ accentColor?: string;
6
+ accentHoverColor?: string;
7
+ textAccentColor?: string;
8
+ textAccentHoverColor?: string;
9
+ accentBackgroundColor?: string;
10
+ accentBorderColor?: string;
11
+ }
12
+
13
+ export interface TocItem {
14
+ id: string;
15
+ text: string;
16
+ level: 2 | 3;
17
+ }
18
+
19
+ export interface ArticleDateMeta {
20
+ publishedDateLabel: string;
21
+ publishedRelativeLabel: string;
22
+ updatedDateLabel: string;
23
+ isUpdated: boolean;
24
+ }
25
+
26
+ export interface TocItemState {
27
+ isActive: boolean;
28
+ ariaCurrent: "location" | undefined;
29
+ }
30
+
31
+ export type BlogThemeStyle = CSSProperties & {
32
+ "--blog-accent": string;
33
+ "--blog-accent-hover": string;
34
+ "--blog-text-accent": string;
35
+ "--blog-text-accent-hover": string;
36
+ "--blog-accent-bg": string;
37
+ "--blog-accent-border": string;
38
+ };
39
+
40
+ const DEFAULT_THEME: Required<BlogTheme> = {
41
+ accentColor: "hsl(var(--primary))",
42
+ accentHoverColor: "hsl(var(--primary))",
43
+ textAccentColor: "hsl(var(--primary))",
44
+ textAccentHoverColor: "hsl(var(--primary))",
45
+ accentBackgroundColor: "hsl(var(--primary) / 0.08)",
46
+ accentBorderColor: "hsl(var(--primary) / 0.25)",
47
+ };
48
+
49
+ export function getBlogThemeStyle(theme: BlogTheme = {}): BlogThemeStyle {
50
+ return {
51
+ "--blog-accent": theme.accentColor ?? DEFAULT_THEME.accentColor,
52
+ "--blog-accent-hover":
53
+ theme.accentHoverColor ?? DEFAULT_THEME.accentHoverColor,
54
+ "--blog-text-accent":
55
+ theme.textAccentColor ?? theme.accentColor ?? DEFAULT_THEME.textAccentColor,
56
+ "--blog-text-accent-hover":
57
+ theme.textAccentHoverColor ??
58
+ theme.accentHoverColor ??
59
+ DEFAULT_THEME.textAccentHoverColor,
60
+ "--blog-accent-bg":
61
+ theme.accentBackgroundColor ?? DEFAULT_THEME.accentBackgroundColor,
62
+ "--blog-accent-border":
63
+ theme.accentBorderColor ?? DEFAULT_THEME.accentBorderColor,
64
+ };
65
+ }
66
+
67
+ export function formatArticleDateMeta(
68
+ publishedAt: string,
69
+ updatedAt: string,
70
+ ): ArticleDateMeta {
71
+ const publishedDate = new Date(publishedAt);
72
+ const updatedDate = new Date(updatedAt);
73
+
74
+ return {
75
+ publishedDateLabel: format(publishedDate, "d MMMM yyyy"),
76
+ publishedRelativeLabel: formatDistanceToNow(publishedDate, {
77
+ addSuffix: true,
78
+ }),
79
+ updatedDateLabel: format(updatedDate, "d MMMM yyyy"),
80
+ isUpdated: updatedDate.getTime() !== publishedDate.getTime(),
81
+ };
82
+ }
83
+
84
+ export function extractTableOfContents(markdown: string): TocItem[] {
85
+ const items: TocItem[] = [];
86
+ let inFence = false;
87
+
88
+ for (const line of markdown.split("\n")) {
89
+ if (/^\s*```/.test(line)) {
90
+ inFence = !inFence;
91
+ continue;
92
+ }
93
+
94
+ if (inFence) {
95
+ continue;
96
+ }
97
+
98
+ const match = /^(#{2,3})\s+(.+?)\s*#*\s*$/.exec(line);
99
+ if (!match?.[1] || !match[2]) {
100
+ continue;
101
+ }
102
+
103
+ const text = stripMarkdownInline(match[2]);
104
+ if (!text) {
105
+ continue;
106
+ }
107
+
108
+ items.push({
109
+ id: slugifyHeading(text),
110
+ text,
111
+ level: match[1].length as 2 | 3,
112
+ });
113
+ }
114
+
115
+ return items;
116
+ }
117
+
118
+ export function formatTableOfContentsSummary(count: number): string {
119
+ return `${count} ${count === 1 ? "section" : "sections"}`;
120
+ }
121
+
122
+ export function normalizeReadingProgress(progress: number): number {
123
+ if (!Number.isFinite(progress)) {
124
+ return 0;
125
+ }
126
+
127
+ return Math.min(100, Math.max(0, progress));
128
+ }
129
+
130
+ export function getTocItemState(
131
+ itemId: string,
132
+ activeItemId: string | null,
133
+ ): TocItemState {
134
+ const isActive = itemId === activeItemId;
135
+
136
+ return {
137
+ isActive,
138
+ ariaCurrent: isActive ? "location" : undefined,
139
+ };
140
+ }
141
+
142
+ function stripMarkdownInline(value: string): string {
143
+ return value
144
+ .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
145
+ .replace(/[*_`~]/g, "")
146
+ .replace(/<[^>]+>/g, "")
147
+ .replace(/\s+/g, " ")
148
+ .trim();
149
+ }
150
+
151
+ function slugifyHeading(value: string): string {
152
+ return value
153
+ .toLowerCase()
154
+ .replace(/[^a-z0-9\s-]/g, "")
155
+ .trim()
156
+ .replace(/\s+/g, "-")
157
+ .replace(/-+/g, "-");
158
+ }
@@ -0,0 +1,354 @@
1
+ import { type NextRequest } from "next/server";
2
+ import type { PublishResult } from "@/types/contracts";
3
+
4
+ /**
5
+ * Authentication configuration interface
6
+ * Customers can implement this to customize authentication behavior
7
+ */
8
+ export interface AuthConfig {
9
+ /**
10
+ * Validate an authentication token
11
+ * @param token - The token to validate
12
+ * @returns Promise that resolves to validation result
13
+ */
14
+ validateToken(token: string): Promise<AuthResult>;
15
+
16
+ /**
17
+ * Extract token from request
18
+ * @param request - The incoming request
19
+ * @returns The extracted token or null if not found
20
+ */
21
+ extractToken?(request: NextRequest): string | null;
22
+
23
+ /**
24
+ * Custom error messages for authentication failures
25
+ */
26
+ errorMessages?: {
27
+ missing?: string;
28
+ invalid?: string;
29
+ expired?: string;
30
+ };
31
+ }
32
+
33
+ /**
34
+ * Authentication result from token validation
35
+ */
36
+ export interface AuthResult {
37
+ /** Whether the token is valid */
38
+ valid: boolean;
39
+
40
+ /** Error code if invalid */
41
+ errorCode?: "missing" | "invalid" | "expired";
42
+
43
+ /** Additional user/context information */
44
+ context?: Record<string, unknown>;
45
+ }
46
+
47
+ /**
48
+ * Default token extraction from Authorization header
49
+ * Supports both "Bearer <token>" and "Token <token>" formats
50
+ */
51
+ export function extractTokenFromHeader(request: NextRequest): string | null {
52
+ const authHeader = request.headers.get("Authorization");
53
+ if (!authHeader) {
54
+ return null;
55
+ }
56
+
57
+ // Support both "Bearer <token>" and "Token <token>" formats
58
+ const bearerRegex = /^Bearer\s+(.+)$/i;
59
+ const bearerMatch = bearerRegex.exec(authHeader);
60
+ if (bearerMatch?.[1]) {
61
+ return bearerMatch[1];
62
+ }
63
+
64
+ const tokenRegex = /^Token\s+(.+)$/i;
65
+ const tokenMatch = tokenRegex.exec(authHeader);
66
+ if (tokenMatch?.[1]) {
67
+ return tokenMatch[1];
68
+ }
69
+
70
+ return null;
71
+ }
72
+
73
+ /**
74
+ * Simple API key based authentication implementation
75
+ * Customers can extend this or provide their own AuthConfig
76
+ */
77
+ export class ApiKeyAuth implements AuthConfig {
78
+ private validApiKeys: Set<string>;
79
+
80
+ constructor(apiKeys: string[]) {
81
+ this.validApiKeys = new Set(apiKeys);
82
+ }
83
+
84
+ async validateToken(token: string): Promise<AuthResult> {
85
+ if (!token?.trim()) {
86
+ return {
87
+ valid: false,
88
+ errorCode: "missing",
89
+ };
90
+ }
91
+
92
+ if (this.validApiKeys.has(token)) {
93
+ return {
94
+ valid: true,
95
+ context: {
96
+ tokenHash: this.hashToken(token),
97
+ },
98
+ };
99
+ }
100
+
101
+ return {
102
+ valid: false,
103
+ errorCode: "invalid",
104
+ };
105
+ }
106
+
107
+ extractToken = (request: NextRequest): string | null => {
108
+ return extractTokenFromHeader(request);
109
+ };
110
+
111
+ private hashToken(token: string): string {
112
+ // Simple hash for logging/debugging (first 8 chars)
113
+ return token.substring(0, 8) + "...";
114
+ }
115
+ }
116
+
117
+ /**
118
+ * Environment variable based authentication
119
+ * Reads API keys from environment variables
120
+ */
121
+ export class EnvAuth extends ApiKeyAuth {
122
+ constructor(envVarName = "BLOG_PLATFORM_API_KEYS") {
123
+ let apiKeysEnv = process.env[envVarName];
124
+
125
+ // Backwards compatibility: fall back to BLOG_AUTH_TOKEN when using default env var
126
+ if (!apiKeysEnv && envVarName === "BLOG_PLATFORM_API_KEYS") {
127
+ apiKeysEnv = process.env.BLOG_AUTH_TOKEN;
128
+ }
129
+
130
+ const apiKeys = apiKeysEnv ? apiKeysEnv.split(",").map((key) => key.trim()).filter(Boolean) : [];
131
+
132
+ if (apiKeys.length === 0) {
133
+ const hint = envVarName === "BLOG_PLATFORM_API_KEYS"
134
+ ? " (set BLOG_PLATFORM_API_KEYS or BLOG_AUTH_TOKEN)"
135
+ : "";
136
+ console.warn(
137
+ `⚠️ No API keys found in environment variable ${envVarName}${hint}. Authentication will fail for all requests.`,
138
+ );
139
+ }
140
+
141
+ super(apiKeys);
142
+ }
143
+ }
144
+
145
+ /**
146
+ * No-op authentication for development/testing
147
+ * WARNING: Never use this in production!
148
+ */
149
+ export class NoAuth implements AuthConfig {
150
+ async validateToken(): Promise<AuthResult> {
151
+ return {
152
+ valid: true,
153
+ context: {
154
+ development: true,
155
+ },
156
+ };
157
+ }
158
+
159
+ extractToken(): string | null {
160
+ return "development-mode";
161
+ }
162
+ }
163
+
164
+ /**
165
+ * Uninitialized authentication - rejects all requests
166
+ * This is the secure default before initialization
167
+ */
168
+ export class UninitializedAuth implements AuthConfig {
169
+ private hasWarned = false;
170
+
171
+ async validateToken(_token: string): Promise<AuthResult> {
172
+ if (!this.hasWarned) {
173
+ console.error(
174
+ "❌ SECURITY WARNING: Blog platform authentication not initialized! " +
175
+ "All requests will be rejected. Call initBlogPlatform() to configure authentication."
176
+ );
177
+ this.hasWarned = true;
178
+ }
179
+
180
+ return {
181
+ valid: false,
182
+ errorCode: "invalid",
183
+ };
184
+ }
185
+
186
+ // Don't override extractToken - use default header extraction
187
+ // Don't override error messages - use defaults so users see
188
+ // "Invalid authentication credentials" instead of confusing
189
+ // "platform not initialized" messages
190
+ }
191
+
192
+ declare global {
193
+ // Using `var` here intentionally to declare a global on Node's globalThis
194
+ // across module boundaries and hot reloads
195
+ var __BLOG_PLATFORM_AUTH_CONFIG__: AuthConfig | undefined;
196
+ }
197
+
198
+ /**
199
+ * Global authentication configuration
200
+ * Defaults to UninitializedAuth for security (fail closed)
201
+ * Stored in globalThis to persist across Next.js dev mode hot reloads
202
+ */
203
+ function getDefaultAuthConfig(): AuthConfig {
204
+ globalThis.__BLOG_PLATFORM_AUTH_CONFIG__ ??= new UninitializedAuth();
205
+ return globalThis.__BLOG_PLATFORM_AUTH_CONFIG__;
206
+ }
207
+
208
+ /**
209
+ * Set the authentication configuration
210
+ * Customers should call this in their app initialization
211
+ */
212
+ export function setAuthConfig(config: AuthConfig): void {
213
+ globalThis.__BLOG_PLATFORM_AUTH_CONFIG__ = config;
214
+ }
215
+
216
+ /**
217
+ * Get the current authentication configuration
218
+ */
219
+ export function getAuthConfig(): AuthConfig {
220
+ return globalThis.__BLOG_PLATFORM_AUTH_CONFIG__ ?? getDefaultAuthConfig();
221
+ }
222
+
223
+ /**
224
+ * Initialize authentication with environment-based API keys
225
+ * This is a convenience function for common setups
226
+ */
227
+ export function initEnvAuth(envVarName?: string): void {
228
+ setAuthConfig(new EnvAuth(envVarName));
229
+ }
230
+
231
+ /**
232
+ * Authenticate an incoming request
233
+ * @param request - The Next.js request object
234
+ * @returns Authentication result with token validation
235
+ */
236
+ export async function authenticateRequest(request: NextRequest): Promise<{
237
+ authenticated: boolean;
238
+ result: AuthResult;
239
+ token: string | null;
240
+ }> {
241
+ const config = getAuthConfig();
242
+
243
+ // Extract token using configured method or default
244
+ const token = config.extractToken ? config.extractToken(request) : extractTokenFromHeader(request);
245
+
246
+ if (!token) {
247
+ return {
248
+ authenticated: false,
249
+ result: {
250
+ valid: false,
251
+ errorCode: "missing",
252
+ },
253
+ token: null,
254
+ };
255
+ }
256
+
257
+ // Validate the token
258
+ const result = await config.validateToken(token);
259
+
260
+ return {
261
+ authenticated: result.valid,
262
+ result,
263
+ token,
264
+ };
265
+ }
266
+
267
+ /**
268
+ * Create an authentication error response
269
+ * @param authResult - The failed authentication result
270
+ * @param idempotencyKey - Optional idempotency key from request
271
+ * @returns NextResponse with appropriate error
272
+ */
273
+ export function createAuthErrorResponse(
274
+ authResult: AuthResult,
275
+ idempotencyKey = ""
276
+ ): PublishResult {
277
+ const config = getAuthConfig();
278
+ const customMessages = config.errorMessages ?? {};
279
+
280
+ let message: string;
281
+ let code: string;
282
+
283
+ switch (authResult.errorCode) {
284
+ case "missing":
285
+ message = customMessages.missing ?? "Authentication token is required";
286
+ code = "PUB005";
287
+ break;
288
+ case "expired":
289
+ message = customMessages.expired ?? "Authentication token has expired";
290
+ code = "PUB005";
291
+ break;
292
+ case "invalid":
293
+ default:
294
+ message = customMessages.invalid ?? "Invalid authentication credentials";
295
+ code = "PUB005";
296
+ break;
297
+ }
298
+
299
+ return {
300
+ success: false,
301
+ error: {
302
+ code,
303
+ message,
304
+ details: {
305
+ authError: authResult.errorCode ?? "invalid",
306
+ },
307
+ },
308
+ idempotencyKey,
309
+ };
310
+ }
311
+
312
+ /**
313
+ * Middleware function to protect API endpoints
314
+ * Usage in route handlers:
315
+ *
316
+ * ```typescript
317
+ * export async function POST(request: NextRequest) {
318
+ * const authCheck = await requireAuth(request);
319
+ * if (!authCheck.authenticated) {
320
+ * return NextResponse.json(authCheck.error, { status: 401 });
321
+ * }
322
+ *
323
+ * // Continue with authenticated request...
324
+ * }
325
+ * ```
326
+ */
327
+ export async function requireAuth(request: NextRequest): Promise<{
328
+ authenticated: boolean;
329
+ error?: PublishResult;
330
+ context?: Record<string, unknown>;
331
+ }> {
332
+ const auth = await authenticateRequest(request);
333
+
334
+ if (!auth.authenticated) {
335
+ return {
336
+ authenticated: false,
337
+ error: createAuthErrorResponse(auth.result),
338
+ };
339
+ }
340
+
341
+ return {
342
+ authenticated: true,
343
+ context: auth.result.context,
344
+ };
345
+ }
346
+
347
+ /**
348
+ * Utility to check if current environment should skip authentication
349
+ * Useful for development and testing
350
+ */
351
+ export function shouldSkipAuth(): boolean {
352
+ const skipAuth = process.env.SKIP_BLOG_PLATFORM_AUTH;
353
+ return skipAuth === "true" || skipAuth === "1" || process.env.NODE_ENV === "test";
354
+ }