@xexr/blog-platform 1.0.7 → 1.0.9

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 (49) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/README.md +110 -29
  3. package/dist/app/blog/[slug]/page.d.ts +68 -0
  4. package/dist/app/blog/[slug]/page.tsx +175 -116
  5. package/dist/app/blog/authors/[slug]/page.test.ts +108 -0
  6. package/dist/app/blog/authors/[slug]/page.tsx +387 -0
  7. package/dist/app/blog/page.tsx +138 -113
  8. package/dist/components/ArticleReadingProgress.tsx +208 -0
  9. package/dist/components/ArticleTopActions.tsx +179 -0
  10. package/dist/components/AuthorByline.tsx +569 -0
  11. package/dist/components/AuthorPages.test.ts +87 -0
  12. package/dist/components/AuthorPages.tsx +129 -0
  13. package/dist/components/ExpandableArticleImage.tsx +79 -0
  14. package/dist/components/Pagination.tsx +236 -0
  15. package/dist/components/TopicArchivePages.tsx +204 -0
  16. package/dist/components/ui/badge.tsx +36 -0
  17. package/dist/components/ui/button.tsx +59 -0
  18. package/dist/components/ui/card.tsx +92 -0
  19. package/dist/components/ui/pagination.tsx +127 -0
  20. package/dist/components/ui/separator.tsx +29 -0
  21. package/dist/index.d.mts +174 -112
  22. package/dist/index.d.ts +174 -112
  23. package/dist/index.js +83 -8
  24. package/dist/index.js.map +1 -1
  25. package/dist/index.mjs +83 -8
  26. package/dist/index.mjs.map +1 -1
  27. package/dist/lib/article-ui.ts +158 -0
  28. package/dist/lib/auth.ts +354 -0
  29. package/dist/lib/authors.ts +100 -0
  30. package/dist/lib/cors.ts +243 -0
  31. package/dist/lib/database.ts +277 -0
  32. package/dist/lib/init.ts +186 -0
  33. package/dist/lib/mdx.ts +73 -0
  34. package/dist/lib/metadata.ts +558 -0
  35. package/dist/lib/ratelimit.ts +224 -0
  36. package/dist/lib/security-headers.ts +322 -0
  37. package/dist/lib/seo.ts +295 -0
  38. package/dist/lib/sitemap.ts +738 -0
  39. package/dist/lib/storage/drizzle.ts +400 -0
  40. package/dist/lib/structured-data.ts +609 -0
  41. package/dist/lib/topics.ts +56 -0
  42. package/dist/lib/urls.ts +36 -0
  43. package/dist/lib/utils.ts +6 -0
  44. package/dist/lib/version.ts +154 -0
  45. package/dist/types/contracts.ts +259 -0
  46. package/dist/types/index.d.mts +20 -1
  47. package/dist/types/index.d.ts +20 -1
  48. package/dist/types/index.ts +16 -0
  49. 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,87 @@
1
+ import React from "react";
2
+ import { renderToStaticMarkup } from "react-dom/server";
3
+ import { describe, expect, it } from "vitest";
4
+
5
+ import { BlogAuthorsIndexPage } from "./AuthorPages";
6
+ import type {
7
+ PublishedArticle,
8
+ PublishedAuthor,
9
+ PublishedSite,
10
+ } from "../types/contracts";
11
+
12
+ const site: PublishedSite = {
13
+ id: "site-1",
14
+ name: "Test Blog",
15
+ tagline: "Testing author pages",
16
+ description: "A test blog",
17
+ domain: "example.com",
18
+ toneAdjectives: [],
19
+ locale: "en",
20
+ };
21
+
22
+ const jane: PublishedAuthor = {
23
+ id: "author-1",
24
+ name: "Jane Doe",
25
+ slug: "jane-doe",
26
+ bio: "Writes about product systems.",
27
+ isGuest: false,
28
+ toneAdjectives: [],
29
+ };
30
+
31
+ const alex: PublishedAuthor = {
32
+ id: "author-2",
33
+ name: "Alex Stone",
34
+ slug: "alex-stone",
35
+ isGuest: false,
36
+ toneAdjectives: [],
37
+ };
38
+
39
+ function article(
40
+ slug: string,
41
+ title: string,
42
+ author: PublishedAuthor,
43
+ ): PublishedArticle {
44
+ return {
45
+ id: slug,
46
+ slug,
47
+ title,
48
+ excerpt: `${title} excerpt.`,
49
+ content: "# Article",
50
+ readingSeconds: 180,
51
+ wordCount: 500,
52
+ publishedAt: "2026-05-01T10:00:00.000Z",
53
+ updatedAt: "2026-05-01T10:00:00.000Z",
54
+ author,
55
+ site,
56
+ topics: [],
57
+ meta: {
58
+ keywords: ["test"],
59
+ canonicalUrl: `https://example.com/blog/${slug}`,
60
+ },
61
+ version: 1,
62
+ };
63
+ }
64
+
65
+ describe("BlogAuthorsIndexPage", () => {
66
+ it("lists unique published authors with article counts", () => {
67
+ const html = renderToStaticMarkup(
68
+ React.createElement(BlogAuthorsIndexPage, {
69
+ articles: [
70
+ article("first", "First", jane),
71
+ article("second", "Second", jane),
72
+ article("third", "Third", alex),
73
+ ],
74
+ basePath: "/new-blog",
75
+ title: "dypt authors",
76
+ }),
77
+ );
78
+
79
+ expect(html).toContain("dypt authors");
80
+ expect(html).toContain("Jane Doe");
81
+ expect(html).toContain("Alex Stone");
82
+ expect(html).toContain("/new-blog/authors/jane-doe");
83
+ expect(html).toContain("/new-blog/authors/alex-stone");
84
+ expect(html).toContain("2 articles");
85
+ expect(html).toContain("1 article");
86
+ });
87
+ });