@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,569 @@
1
+ import React from "react";
2
+ import Image from "next/image";
3
+ import { Globe, Mail } from "lucide-react";
4
+ import { FaGithub, FaLinkedinIn, FaXTwitter } from "react-icons/fa6";
5
+ import { type PublishedAuthor } from "../types/contracts";
6
+ import { cn } from "../lib/utils";
7
+ import { Badge } from "./ui/badge";
8
+ import {
9
+ generateAuthorStructuredData,
10
+ createStructuredDataScript,
11
+ } from "../lib/structured-data";
12
+
13
+ interface AuthorSocialLinkView {
14
+ key: string;
15
+ href: string;
16
+ label: string;
17
+ external: boolean;
18
+ icon: React.ReactNode;
19
+ }
20
+
21
+ interface AuthorBylineProps {
22
+ /** The author to display */
23
+ author: PublishedAuthor | null;
24
+ /** Optional publication date */
25
+ publishedAt?: string;
26
+ /** Optional reading time in minutes */
27
+ readingTime?: number;
28
+ /** Optional word count */
29
+ wordCount?: number;
30
+ /** Whether to show the full bio */
31
+ showBio?: boolean;
32
+ /** Whether to include JSON-LD structured data */
33
+ includeStructuredData?: boolean;
34
+ /** Custom className for styling */
35
+ className?: string;
36
+ /** Layout variant */
37
+ variant?: "inline" | "stacked" | "minimal";
38
+ /** Size variant */
39
+ size?: "sm" | "md" | "lg";
40
+ /** Whether to show tone adjectives */
41
+ showToneAdjectives?: boolean;
42
+ }
43
+
44
+ /**
45
+ * AuthorByline component for displaying author information with optional structured data.
46
+ *
47
+ * This component provides a consistent way to display author information across
48
+ * different contexts (article listings, article pages, etc.) with proper
49
+ * semantic markup and SEO optimization through JSON-LD structured data.
50
+ */
51
+ export default function AuthorByline({
52
+ author,
53
+ publishedAt,
54
+ readingTime,
55
+ wordCount,
56
+ showBio = false,
57
+ includeStructuredData = false,
58
+ className,
59
+ variant = "inline",
60
+ size = "md",
61
+ showToneAdjectives = false,
62
+ }: AuthorBylineProps) {
63
+ // If no author, show minimal byline
64
+ if (!author) {
65
+ return (
66
+ <div
67
+ className={cn(
68
+ "author-byline text-muted-foreground",
69
+ getVariantStyles(variant, size),
70
+ className,
71
+ )}
72
+ >
73
+ {publishedAt && (
74
+ <time dateTime={publishedAt} className="published-date">
75
+ {formatDate(publishedAt)}
76
+ </time>
77
+ )}
78
+ {readingTime && (
79
+ <span className="reading-time">{readingTime} min read</span>
80
+ )}
81
+ {wordCount && (
82
+ <span className="word-count">{wordCount.toLocaleString()} words</span>
83
+ )}
84
+ </div>
85
+ );
86
+ }
87
+
88
+ const structuredDataProps = includeStructuredData
89
+ ? createStructuredDataScript(generateAuthorStructuredData(author))
90
+ : null;
91
+
92
+ return (
93
+ <>
94
+ <div
95
+ className={cn(
96
+ "author-byline",
97
+ getVariantStyles(variant, size),
98
+ className,
99
+ )}
100
+ >
101
+ {variant === "stacked" ? (
102
+ <StackedByline
103
+ author={author}
104
+ publishedAt={publishedAt}
105
+ readingTime={readingTime}
106
+ wordCount={wordCount}
107
+ showBio={showBio}
108
+ showToneAdjectives={showToneAdjectives}
109
+ size={size}
110
+ />
111
+ ) : variant === "minimal" ? (
112
+ <MinimalByline
113
+ author={author}
114
+ publishedAt={publishedAt}
115
+ readingTime={readingTime}
116
+ wordCount={wordCount}
117
+ size={size}
118
+ />
119
+ ) : (
120
+ <InlineByline
121
+ author={author}
122
+ publishedAt={publishedAt}
123
+ readingTime={readingTime}
124
+ wordCount={wordCount}
125
+ showBio={showBio}
126
+ showToneAdjectives={showToneAdjectives}
127
+ size={size}
128
+ />
129
+ )}
130
+ </div>
131
+
132
+ {/* JSON-LD Structured Data */}
133
+ {includeStructuredData && structuredDataProps && (
134
+ <script
135
+ type={structuredDataProps.type}
136
+ dangerouslySetInnerHTML={structuredDataProps.dangerouslySetInnerHTML}
137
+ />
138
+ )}
139
+ </>
140
+ );
141
+ }
142
+
143
+ /**
144
+ * Inline layout (default) - author info in a single line
145
+ */
146
+ function InlineByline({
147
+ author,
148
+ publishedAt,
149
+ readingTime,
150
+ wordCount,
151
+ showBio,
152
+ showToneAdjectives,
153
+ size,
154
+ }: {
155
+ author: PublishedAuthor;
156
+ publishedAt?: string;
157
+ readingTime?: number;
158
+ wordCount?: number;
159
+ showBio: boolean;
160
+ showToneAdjectives: boolean;
161
+ size: "sm" | "md" | "lg";
162
+ }) {
163
+ const textSize = getTextSizeClass(size);
164
+
165
+ return (
166
+ <div className="inline-byline">
167
+ {/* Author info line */}
168
+ <div
169
+ className={cn(
170
+ "flex flex-wrap items-center gap-3",
171
+ textSize,
172
+ "text-muted-foreground",
173
+ )}
174
+ >
175
+ <div className="author-info flex items-center gap-2">
176
+ <AuthorAvatar author={author} size={size} />
177
+ <span className="author-prefix">By</span>
178
+ <span className="author-name text-foreground font-medium">
179
+ {author.name}
180
+ </span>
181
+ {author.isGuest && (
182
+ <Badge
183
+ variant="secondary"
184
+ className={cn(size === "sm" && "px-1.5 py-0.5 text-xs")}
185
+ >
186
+ Guest
187
+ </Badge>
188
+ )}
189
+ </div>
190
+
191
+ <AuthorSocialLinks author={author} />
192
+
193
+ {publishedAt && (
194
+ <time dateTime={publishedAt} className="published-date">
195
+ {formatDate(publishedAt)}
196
+ </time>
197
+ )}
198
+
199
+ {readingTime && (
200
+ <span className="reading-time">{readingTime} min read</span>
201
+ )}
202
+
203
+ {wordCount && (
204
+ <span className="word-count">{wordCount.toLocaleString()} words</span>
205
+ )}
206
+ </div>
207
+
208
+ {/* Optional bio */}
209
+ {showBio && author.bio && (
210
+ <div className={cn("author-bio mt-2", textSize)}>
211
+ <p className="text-muted-foreground">{author.bio}</p>
212
+ </div>
213
+ )}
214
+
215
+ {/* Optional tone adjectives */}
216
+ {showToneAdjectives && author.toneAdjectives.length > 0 && (
217
+ <div
218
+ className={cn(
219
+ "author-tone mt-1",
220
+ size === "sm" ? "text-xs" : "text-sm",
221
+ )}
222
+ >
223
+ <span className="text-muted-foreground">
224
+ Writing style: {author.toneAdjectives.join(", ")}
225
+ </span>
226
+ </div>
227
+ )}
228
+ </div>
229
+ );
230
+ }
231
+
232
+ /**
233
+ * Stacked layout - author info in multiple lines for better readability
234
+ */
235
+ function StackedByline({
236
+ author,
237
+ publishedAt,
238
+ readingTime,
239
+ wordCount,
240
+ showBio,
241
+ showToneAdjectives,
242
+ size,
243
+ }: {
244
+ author: PublishedAuthor;
245
+ publishedAt?: string;
246
+ readingTime?: number;
247
+ wordCount?: number;
248
+ showBio: boolean;
249
+ showToneAdjectives: boolean;
250
+ size: "sm" | "md" | "lg";
251
+ }) {
252
+ const textSize = getTextSizeClass(size);
253
+
254
+ return (
255
+ <div className="stacked-byline space-y-2">
256
+ {/* Author name and guest indicator */}
257
+ <div className="author-info flex items-center gap-2">
258
+ <AuthorAvatar author={author} size={size} />
259
+ <span
260
+ className={cn(
261
+ "author-name text-foreground font-medium",
262
+ size === "lg" ? "text-lg" : size === "md" ? "text-base" : "text-sm",
263
+ )}
264
+ >
265
+ {author.name}
266
+ </span>
267
+ {author.isGuest && (
268
+ <Badge
269
+ variant="secondary"
270
+ className={cn(size === "sm" && "px-1.5 py-0.5 text-xs")}
271
+ >
272
+ Guest Author
273
+ </Badge>
274
+ )}
275
+ <AuthorSocialLinks author={author} />
276
+ </div>
277
+
278
+ {/* Publication metadata */}
279
+ {(Boolean(publishedAt) || Boolean(readingTime) || Boolean(wordCount)) && (
280
+ <div
281
+ className={cn(
282
+ "metadata flex flex-wrap items-center gap-3",
283
+ textSize,
284
+ "text-muted-foreground",
285
+ )}
286
+ >
287
+ {publishedAt && (
288
+ <time dateTime={publishedAt} className="published-date">
289
+ {formatDate(publishedAt)}
290
+ </time>
291
+ )}
292
+ {readingTime && (
293
+ <span className="reading-time">{readingTime} min read</span>
294
+ )}
295
+ {wordCount && (
296
+ <span className="word-count">
297
+ {wordCount.toLocaleString()} words
298
+ </span>
299
+ )}
300
+ </div>
301
+ )}
302
+
303
+ {/* Optional bio */}
304
+ {showBio && author.bio && (
305
+ <div className={cn("author-bio", textSize)}>
306
+ <p className="text-muted-foreground">{author.bio}</p>
307
+ </div>
308
+ )}
309
+
310
+ {/* Optional tone adjectives */}
311
+ {showToneAdjectives && author.toneAdjectives.length > 0 && (
312
+ <div
313
+ className={cn("author-tone", size === "sm" ? "text-xs" : "text-sm")}
314
+ >
315
+ <span className="text-muted-foreground">
316
+ Writing style: {author.toneAdjectives.join(", ")}
317
+ </span>
318
+ </div>
319
+ )}
320
+ </div>
321
+ );
322
+ }
323
+
324
+ /**
325
+ * Minimal layout - just author name and essential info
326
+ */
327
+ function MinimalByline({
328
+ author,
329
+ publishedAt,
330
+ readingTime,
331
+ wordCount: _wordCount,
332
+ size,
333
+ }: {
334
+ author: PublishedAuthor;
335
+ publishedAt?: string;
336
+ readingTime?: number;
337
+ wordCount?: number;
338
+ size: "sm" | "md" | "lg";
339
+ }) {
340
+ const textSize = getTextSizeClass(size);
341
+
342
+ return (
343
+ <div
344
+ className={cn(
345
+ "minimal-byline flex items-center gap-2",
346
+ textSize,
347
+ "text-muted-foreground",
348
+ )}
349
+ >
350
+ <AuthorAvatar author={author} size={size} />
351
+ <span className="author-name text-foreground font-medium">
352
+ {author.name}
353
+ </span>
354
+ {author.isGuest && (
355
+ <Badge
356
+ variant="outline"
357
+ className={cn(size === "sm" && "px-1.5 py-0.5 text-xs")}
358
+ >
359
+ Guest
360
+ </Badge>
361
+ )}
362
+ {publishedAt && (
363
+ <>
364
+ <span>•</span>
365
+ <time dateTime={publishedAt}>{formatDate(publishedAt, "short")}</time>
366
+ </>
367
+ )}
368
+ {readingTime && (
369
+ <>
370
+ <span>•</span>
371
+ <span>{readingTime} min</span>
372
+ </>
373
+ )}
374
+ </div>
375
+ );
376
+ }
377
+
378
+ function AuthorAvatar({
379
+ author,
380
+ size,
381
+ }: {
382
+ author: PublishedAuthor;
383
+ size: "sm" | "md" | "lg";
384
+ }) {
385
+ const sizeClass =
386
+ size === "lg"
387
+ ? "h-12 w-12 text-base"
388
+ : size === "md"
389
+ ? "h-9 w-9 text-sm"
390
+ : "h-7 w-7 text-xs";
391
+ const initials = getAuthorInitials(author.name);
392
+
393
+ if (author.avatarUrl) {
394
+ return (
395
+ <Image
396
+ src={author.avatarUrl}
397
+ alt={author.name}
398
+ width={size === "lg" ? 48 : size === "md" ? 36 : 28}
399
+ height={size === "lg" ? 48 : size === "md" ? 36 : 28}
400
+ className={cn(
401
+ "author-avatar shrink-0 rounded-full object-cover",
402
+ sizeClass,
403
+ )}
404
+ loading="lazy"
405
+ />
406
+ );
407
+ }
408
+
409
+ return (
410
+ <span
411
+ className={cn(
412
+ "author-avatar-fallback bg-muted text-muted-foreground inline-flex shrink-0 items-center justify-center rounded-full font-medium",
413
+ sizeClass,
414
+ )}
415
+ aria-hidden="true"
416
+ >
417
+ {initials}
418
+ </span>
419
+ );
420
+ }
421
+
422
+ function AuthorSocialLinks({ author }: { author: PublishedAuthor }) {
423
+ const links = getAuthorSocialLinks(author);
424
+ if (links.length === 0) return null;
425
+
426
+ return (
427
+ <span className="author-social-links inline-flex items-center gap-1">
428
+ {links.map((link) => (
429
+ <a
430
+ key={link.key}
431
+ href={link.href}
432
+ target={link.external ? "_blank" : undefined}
433
+ rel={link.external ? "noopener noreferrer" : undefined}
434
+ aria-label={`${author.name} ${link.label}`}
435
+ className="text-muted-foreground hover:text-foreground hover:bg-muted inline-flex size-6 items-center justify-center rounded-full transition-colors"
436
+ >
437
+ {link.icon}
438
+ </a>
439
+ ))}
440
+ </span>
441
+ );
442
+ }
443
+
444
+ function getAuthorSocialLinks(author: PublishedAuthor): AuthorSocialLinkView[] {
445
+ const links = author.socialLinks;
446
+ if (!links) return [];
447
+
448
+ const socialLinks: Array<AuthorSocialLinkView | false | undefined> = [
449
+ links.website
450
+ ? {
451
+ key: "website",
452
+ href: links.website,
453
+ label: "website",
454
+ external: true,
455
+ icon: <Globe className="size-3.5" aria-hidden="true" />,
456
+ }
457
+ : undefined,
458
+ links.x
459
+ ? {
460
+ key: "x",
461
+ href: links.x,
462
+ label: "on X",
463
+ external: true,
464
+ icon: <FaXTwitter className="size-3.5" aria-hidden="true" />,
465
+ }
466
+ : undefined,
467
+ links.linkedin
468
+ ? {
469
+ key: "linkedin",
470
+ href: links.linkedin,
471
+ label: "on LinkedIn",
472
+ external: true,
473
+ icon: <FaLinkedinIn className="size-3.5" aria-hidden="true" />,
474
+ }
475
+ : undefined,
476
+ links.github
477
+ ? {
478
+ key: "github",
479
+ href: links.github,
480
+ label: "on GitHub",
481
+ external: true,
482
+ icon: <FaGithub className="size-3.5" aria-hidden="true" />,
483
+ }
484
+ : undefined,
485
+ links.email
486
+ ? {
487
+ key: "email",
488
+ href: `mailto:${links.email}`,
489
+ label: "email",
490
+ external: false,
491
+ icon: <Mail className="size-3.5" aria-hidden="true" />,
492
+ }
493
+ : undefined,
494
+ ];
495
+
496
+ return socialLinks.filter((link): link is AuthorSocialLinkView =>
497
+ Boolean(link),
498
+ );
499
+ }
500
+
501
+ function getAuthorInitials(name: string): string {
502
+ const initials = name
503
+ .trim()
504
+ .split(/\s+/)
505
+ .filter(Boolean)
506
+ .slice(0, 2)
507
+ .map((part) => part[0]?.toUpperCase())
508
+ .join("");
509
+
510
+ return initials || "?";
511
+ }
512
+
513
+ /**
514
+ * Get variant-specific styles
515
+ */
516
+ function getVariantStyles(
517
+ variant: "inline" | "stacked" | "minimal",
518
+ size: "sm" | "md" | "lg",
519
+ ): string {
520
+ const baseStyles = "author-byline";
521
+
522
+ const sizeStyles = {
523
+ sm: "text-sm",
524
+ md: "text-base",
525
+ lg: "text-lg",
526
+ };
527
+
528
+ return cn(baseStyles, sizeStyles[size]);
529
+ }
530
+
531
+ /**
532
+ * Get text size class for content
533
+ */
534
+ function getTextSizeClass(size: "sm" | "md" | "lg"): string {
535
+ switch (size) {
536
+ case "sm":
537
+ return "text-xs";
538
+ case "md":
539
+ return "text-sm";
540
+ case "lg":
541
+ return "text-base";
542
+ default:
543
+ return "text-sm";
544
+ }
545
+ }
546
+
547
+ /**
548
+ * Format date for display
549
+ */
550
+ function formatDate(
551
+ dateString: string,
552
+ format: "long" | "short" = "long",
553
+ ): string {
554
+ const date = new Date(dateString);
555
+
556
+ if (format === "short") {
557
+ return date.toLocaleDateString("en-US", {
558
+ month: "short",
559
+ day: "numeric",
560
+ year: "numeric",
561
+ });
562
+ }
563
+
564
+ return date.toLocaleDateString("en-US", {
565
+ month: "long",
566
+ day: "numeric",
567
+ year: "numeric",
568
+ });
569
+ }
@@ -0,0 +1,79 @@
1
+ /* eslint-disable @next/next/no-img-element */
2
+
3
+ import type React from "react";
4
+
5
+ import { cn } from "../lib/utils";
6
+
7
+ type ArticleImageProps = React.ImgHTMLAttributes<HTMLImageElement>;
8
+
9
+ export function ExpandableArticleImage({
10
+ alt = "",
11
+ className,
12
+ src,
13
+ ...props
14
+ }: ArticleImageProps) {
15
+ if (typeof src !== "string" || src.length === 0) {
16
+ return <img alt={alt} className={className} {...props} />;
17
+ }
18
+
19
+ const previewId = `blog-image-preview-${hashImageSrc(src)}`;
20
+
21
+ return (
22
+ <>
23
+ <a
24
+ href={`#${previewId}`}
25
+ className="group mx-auto block cursor-zoom-in rounded-2xl focus-visible:ring-2 focus-visible:ring-[var(--blog-text-accent)] focus-visible:ring-offset-2 focus-visible:outline-none"
26
+ aria-label={alt ? `Expand image: ${alt}` : "Expand article image"}
27
+ >
28
+ <img
29
+ src={src}
30
+ alt={alt}
31
+ className={cn(
32
+ "mx-auto rounded-2xl border border-[var(--blog-accent-border)] shadow-[0_18px_60px_rgb(168_85_247_/_0.12)] transition-opacity group-hover:opacity-95",
33
+ className,
34
+ )}
35
+ {...props}
36
+ />
37
+ </a>
38
+
39
+ <div
40
+ id={previewId}
41
+ className="invisible fixed inset-0 z-50 flex opacity-0 transition-opacity target:visible target:opacity-100"
42
+ role="dialog"
43
+ aria-modal="true"
44
+ aria-label={alt ? `Image preview: ${alt}` : "Image preview"}
45
+ >
46
+ <a
47
+ href="#article-content"
48
+ className="absolute inset-0 bg-black/85 backdrop-blur-sm"
49
+ aria-label="Close image preview"
50
+ />
51
+ <div className="pointer-events-none relative z-10 flex min-h-screen w-full items-center justify-center p-4 sm:p-6">
52
+ <img
53
+ src={src}
54
+ alt={alt}
55
+ className="pointer-events-auto h-[88vh] w-[94vw] rounded-3xl object-contain shadow-2xl"
56
+ />
57
+ </div>
58
+ <a
59
+ href="#article-content"
60
+ className="absolute top-4 right-4 z-20 inline-flex size-10 items-center justify-center rounded-full border border-white/20 bg-black/50 text-xl leading-none text-white no-underline transition-colors hover:bg-white/10 focus-visible:ring-2 focus-visible:ring-white focus-visible:outline-none"
61
+ aria-label="Close image preview"
62
+ >
63
+ ×
64
+ </a>
65
+ </div>
66
+ </>
67
+ );
68
+ }
69
+
70
+ function hashImageSrc(src: string): string {
71
+ let hash = 0;
72
+
73
+ for (let index = 0; index < src.length; index += 1) {
74
+ hash = (hash << 5) - hash + src.charCodeAt(index);
75
+ hash |= 0;
76
+ }
77
+
78
+ return Math.abs(hash).toString(36);
79
+ }