@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.
Files changed (43) 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 +224 -98
  4. package/dist/app/blog/page.tsx +83 -2
  5. package/dist/components/ArticleReadingProgress.tsx +208 -0
  6. package/dist/components/ArticleTopActions.tsx +179 -0
  7. package/dist/components/AuthorByline.tsx +569 -0
  8. package/dist/components/ExpandableArticleImage.tsx +79 -0
  9. package/dist/components/Pagination.tsx +236 -0
  10. package/dist/components/TopicArchivePages.tsx +204 -0
  11. package/dist/components/ui/badge.tsx +36 -0
  12. package/dist/components/ui/button.tsx +59 -0
  13. package/dist/components/ui/card.tsx +92 -0
  14. package/dist/components/ui/pagination.tsx +127 -0
  15. package/dist/components/ui/separator.tsx +29 -0
  16. package/dist/index.d.mts +88 -71
  17. package/dist/index.d.ts +88 -71
  18. package/dist/index.js +83 -8
  19. package/dist/index.js.map +1 -1
  20. package/dist/index.mjs +83 -8
  21. package/dist/index.mjs.map +1 -1
  22. package/dist/lib/article-ui.ts +158 -0
  23. package/dist/lib/auth.ts +354 -0
  24. package/dist/lib/cors.ts +243 -0
  25. package/dist/lib/database.ts +277 -0
  26. package/dist/lib/init.ts +186 -0
  27. package/dist/lib/mdx.ts +73 -0
  28. package/dist/lib/metadata.ts +558 -0
  29. package/dist/lib/ratelimit.ts +224 -0
  30. package/dist/lib/security-headers.ts +322 -0
  31. package/dist/lib/seo.ts +295 -0
  32. package/dist/lib/sitemap.ts +719 -0
  33. package/dist/lib/storage/drizzle.ts +400 -0
  34. package/dist/lib/structured-data.ts +608 -0
  35. package/dist/lib/topics.ts +56 -0
  36. package/dist/lib/urls.ts +29 -0
  37. package/dist/lib/utils.ts +6 -0
  38. package/dist/lib/version.ts +154 -0
  39. package/dist/types/contracts.ts +256 -0
  40. package/dist/types/index.d.mts +37 -1
  41. package/dist/types/index.d.ts +37 -1
  42. package/dist/types/index.ts +16 -0
  43. package/package.json +14 -9
@@ -3,6 +3,7 @@ import Link from "next/link";
3
3
  import Image from "next/image";
4
4
  import {
5
5
  type PublishedArticle,
6
+ type PublishedAuthor,
6
7
  type PublishedSite,
7
8
  } from "../../types/contracts";
8
9
  import { cn } from "../../lib/utils";
@@ -198,7 +199,10 @@ function ArticlePreview({
198
199
  <div className="text-muted-foreground flex flex-wrap items-center gap-x-3 gap-y-1 text-sm">
199
200
  {article.author && (
200
201
  <>
201
- <span className="font-medium">{article.author.name}</span>
202
+ <span className="inline-flex items-center gap-2 font-medium">
203
+ <AuthorAvatar author={article.author} size="sm" />
204
+ {article.author.name}
205
+ </span>
202
206
  <span className="text-border">•</span>
203
207
  </>
204
208
  )}
@@ -218,6 +222,19 @@ function ArticlePreview({
218
222
  {article.excerpt}
219
223
  </p>
220
224
  )}
225
+
226
+ {(article.topics ?? []).length > 0 && (
227
+ <div className="flex flex-wrap gap-2">
228
+ {(article.topics ?? []).map((topic) => (
229
+ <span
230
+ key={topic.id}
231
+ className="rounded-full border border-[var(--blog-accent-border)] px-2.5 py-1 text-xs font-medium text-[var(--blog-text-accent)]"
232
+ >
233
+ {topic.name}
234
+ </span>
235
+ ))}
236
+ </div>
237
+ )}
221
238
  </div>
222
239
  </div>
223
240
 
@@ -249,12 +266,28 @@ function ArticlePreview({
249
266
  {article.excerpt}
250
267
  </p>
251
268
  )}
269
+
270
+ {(article.topics ?? []).length > 0 && (
271
+ <div className="flex flex-wrap gap-2">
272
+ {(article.topics ?? []).map((topic) => (
273
+ <span
274
+ key={topic.id}
275
+ className="rounded-full border border-[var(--blog-accent-border)] px-2.5 py-1 text-xs font-medium text-[var(--blog-text-accent)]"
276
+ >
277
+ {topic.name}
278
+ </span>
279
+ ))}
280
+ </div>
281
+ )}
252
282
  </div>
253
283
 
254
284
  <div className="text-muted-foreground flex flex-wrap items-center gap-x-3 gap-y-1 text-sm">
255
285
  {article.author && (
256
286
  <>
257
- <span className="font-medium">{article.author.name}</span>
287
+ <span className="inline-flex items-center gap-2 font-medium">
288
+ <AuthorAvatar author={article.author} size="sm" />
289
+ {article.author.name}
290
+ </span>
258
291
  <span className="text-border">•</span>
259
292
  </>
260
293
  )}
@@ -275,6 +308,54 @@ function ArticlePreview({
275
308
  );
276
309
  }
277
310
 
311
+ function AuthorAvatar({
312
+ author,
313
+ size,
314
+ }: {
315
+ author: PublishedAuthor;
316
+ size: "sm" | "md";
317
+ }) {
318
+ const sizeClass = size === "md" ? "h-9 w-9 text-sm" : "h-6 w-6 text-xs";
319
+ const initials = getAuthorInitials(author.name);
320
+
321
+ if (author.avatarUrl) {
322
+ return (
323
+ <Image
324
+ src={author.avatarUrl}
325
+ alt={author.name}
326
+ width={size === "md" ? 36 : 24}
327
+ height={size === "md" ? 36 : 24}
328
+ className={cn("shrink-0 rounded-full object-cover", sizeClass)}
329
+ loading="lazy"
330
+ />
331
+ );
332
+ }
333
+
334
+ return (
335
+ <span
336
+ className={cn(
337
+ "bg-muted inline-flex shrink-0 items-center justify-center rounded-full font-medium",
338
+ sizeClass,
339
+ )}
340
+ aria-hidden="true"
341
+ >
342
+ {initials}
343
+ </span>
344
+ );
345
+ }
346
+
347
+ function getAuthorInitials(name: string): string {
348
+ const initials = name
349
+ .trim()
350
+ .split(/\s+/)
351
+ .filter(Boolean)
352
+ .slice(0, 2)
353
+ .map((part) => part[0]?.toUpperCase())
354
+ .join("");
355
+
356
+ return initials || "?";
357
+ }
358
+
278
359
  /**
279
360
  * Generate metadata for the blog home page for SEO optimization.
280
361
  * This function should be used in the Next.js metadata API.
@@ -0,0 +1,208 @@
1
+ import {
2
+ formatTableOfContentsSummary,
3
+ getTocItemState,
4
+ type TocItem,
5
+ } from "../lib/article-ui";
6
+
7
+ interface ArticleReadingProgressProps {
8
+ items: TocItem[];
9
+ contentId: string;
10
+ variant: "mobile" | "desktop";
11
+ showProgress?: boolean;
12
+ }
13
+
14
+ export function ArticleReadingProgress({
15
+ items,
16
+ contentId,
17
+ variant,
18
+ showProgress = true,
19
+ }: ArticleReadingProgressProps) {
20
+ const activeItemId = items[0]?.id ?? null;
21
+
22
+ return (
23
+ <>
24
+ {showProgress && (
25
+ <div
26
+ data-blog-reading-progress={contentId}
27
+ className="fixed inset-x-0 top-0 z-50 h-1 origin-left bg-[var(--blog-accent)] motion-safe:transition-transform"
28
+ style={{ transform: "scaleX(0)" }}
29
+ role="progressbar"
30
+ aria-label="Reading progress"
31
+ aria-valuemin={0}
32
+ aria-valuemax={100}
33
+ aria-valuenow={0}
34
+ suppressHydrationWarning
35
+ />
36
+ )}
37
+
38
+ <ArticleTableOfContents
39
+ items={items}
40
+ activeItemId={activeItemId}
41
+ variant={variant}
42
+ />
43
+
44
+ {showProgress && (
45
+ <script
46
+ dangerouslySetInnerHTML={{
47
+ __html: getReadingProgressScript(contentId, items),
48
+ }}
49
+ />
50
+ )}
51
+ </>
52
+ );
53
+ }
54
+
55
+ function ArticleTableOfContents({
56
+ items,
57
+ activeItemId,
58
+ variant,
59
+ }: {
60
+ items: TocItem[];
61
+ activeItemId: string | null;
62
+ variant: "mobile" | "desktop";
63
+ }) {
64
+ if (items.length === 0) {
65
+ return null;
66
+ }
67
+
68
+ const list = (
69
+ <ul className="space-y-2 text-sm">
70
+ {items.map((item) => {
71
+ const itemState = getTocItemState(item.id, activeItemId);
72
+
73
+ return (
74
+ <li key={item.id} className={item.level === 3 ? "pl-4" : undefined}>
75
+ <a
76
+ href={`#${item.id}`}
77
+ aria-current={itemState.ariaCurrent}
78
+ data-blog-toc-link={item.id}
79
+ data-active={itemState.isActive ? "true" : "false"}
80
+ suppressHydrationWarning
81
+ className="text-muted-foreground hover:text-[var(--blog-text-accent)] data-[active=true]:border-[var(--blog-accent)] data-[active=true]:text-[var(--blog-text-accent)] block border-l-2 border-transparent pl-3 leading-snug transition-colors"
82
+ >
83
+ {item.text}
84
+ </a>
85
+ </li>
86
+ );
87
+ })}
88
+ </ul>
89
+ );
90
+
91
+ if (variant === "mobile") {
92
+ return (
93
+ <details className="group mb-6 rounded-2xl border border-[var(--blog-accent-border)] bg-[var(--blog-accent-bg)] lg:hidden">
94
+ <summary className="text-foreground flex cursor-pointer list-none items-center justify-between gap-3 px-4 py-3 text-sm font-semibold [&::-webkit-details-marker]:hidden">
95
+ <span>On this page</span>
96
+ <span className="text-muted-foreground flex items-center gap-2 text-xs font-medium">
97
+ {formatTableOfContentsSummary(items.length)}
98
+ <span
99
+ className="text-[var(--blog-text-accent)] transition-transform group-open:rotate-180"
100
+ aria-hidden="true"
101
+ >
102
+ ↓
103
+ </span>
104
+ </span>
105
+ </summary>
106
+ <nav
107
+ aria-label="Table of contents"
108
+ className="border-t border-[var(--blog-accent-border)] px-4 py-4"
109
+ >
110
+ {list}
111
+ </nav>
112
+ </details>
113
+ );
114
+ }
115
+
116
+ return (
117
+ <aside className="hidden lg:block">
118
+ <nav
119
+ aria-label="Table of contents"
120
+ className="sticky top-24 max-h-[calc(100vh-8rem)] overflow-y-auto border-l border-border pl-5"
121
+ >
122
+ <p className="text-muted-foreground mb-3 text-xs font-semibold tracking-wider uppercase">
123
+ On this page
124
+ </p>
125
+ {list}
126
+ </nav>
127
+ </aside>
128
+ );
129
+ }
130
+
131
+ function getReadingProgressScript(contentId: string, items: TocItem[]): string {
132
+ return `
133
+ (() => {
134
+ const contentId = ${JSON.stringify(contentId)};
135
+ const itemIds = ${JSON.stringify(items.map((item) => item.id))};
136
+ const progressElement = document.querySelector('[data-blog-reading-progress="' + contentId + '"]');
137
+ let animationFrameId = 0;
138
+ let pendingHashItemId = decodeURIComponent(window.location.hash.replace(/^#/, ''));
139
+
140
+ const normalizeProgress = (value) => {
141
+ if (!Number.isFinite(value)) return 0;
142
+ return Math.min(100, Math.max(0, value));
143
+ };
144
+
145
+ const setActiveItem = (activeItemId) => {
146
+ const tocLinks = Array.from(document.querySelectorAll('[data-blog-toc-link]'));
147
+
148
+ for (const link of tocLinks) {
149
+ const isActive = link.getAttribute('data-blog-toc-link') === activeItemId;
150
+ link.setAttribute('data-active', isActive ? 'true' : 'false');
151
+ if (isActive) {
152
+ link.setAttribute('aria-current', 'location');
153
+ } else {
154
+ link.removeAttribute('aria-current');
155
+ }
156
+ }
157
+ };
158
+
159
+ const updateScrollState = () => {
160
+ const contentElement = document.getElementById(contentId);
161
+ if (!contentElement) return;
162
+
163
+ const contentRect = contentElement.getBoundingClientRect();
164
+ const scrollTop = window.scrollY;
165
+ const contentTop = contentRect.top + scrollTop;
166
+ const readableDistance = contentElement.offsetHeight - window.innerHeight;
167
+ const nextProgress = readableDistance <= 0
168
+ ? scrollTop >= contentTop ? 100 : 0
169
+ : ((scrollTop - contentTop) / readableDistance) * 100;
170
+ const progress = normalizeProgress(nextProgress);
171
+
172
+ if (progressElement) {
173
+ progressElement.style.transform = 'scaleX(' + progress / 100 + ')';
174
+ progressElement.setAttribute('aria-valuenow', String(Math.round(progress)));
175
+ }
176
+
177
+ const headings = itemIds
178
+ .map((id) => document.getElementById(id))
179
+ .filter(Boolean);
180
+ if (pendingHashItemId && itemIds.includes(pendingHashItemId)) {
181
+ setActiveItem(pendingHashItemId);
182
+ pendingHashItemId = '';
183
+ return;
184
+ }
185
+
186
+ const activeOffset = Math.max(160, Math.round(window.innerHeight * 0.33));
187
+ const activeHeading = headings
188
+ .filter((heading) => heading.getBoundingClientRect().top <= activeOffset)
189
+ .at(-1) || headings[0] || null;
190
+
191
+ setActiveItem(activeHeading ? activeHeading.id : null);
192
+ };
193
+
194
+ const scheduleUpdate = () => {
195
+ window.cancelAnimationFrame(animationFrameId);
196
+ animationFrameId = window.requestAnimationFrame(updateScrollState);
197
+ };
198
+
199
+ scheduleUpdate();
200
+ window.addEventListener('scroll', scheduleUpdate, { passive: true });
201
+ window.addEventListener('resize', scheduleUpdate);
202
+ window.addEventListener('hashchange', () => {
203
+ pendingHashItemId = decodeURIComponent(window.location.hash.replace(/^#/, ''));
204
+ scheduleUpdate();
205
+ });
206
+ })();
207
+ `;
208
+ }
@@ -0,0 +1,179 @@
1
+ "use client";
2
+
3
+ import React from "react";
4
+ import { Check, Copy, Share2 } from "lucide-react";
5
+
6
+ import { cn } from "../lib/utils";
7
+
8
+ type ClipboardLike = Pick<Clipboard, "writeText">;
9
+
10
+ interface CopyFallbackDocument {
11
+ body: Pick<HTMLElement, "appendChild">;
12
+ createElement: (tagName: "textarea") => HTMLTextAreaElement;
13
+ execCommand: (commandId: "copy") => boolean;
14
+ }
15
+
16
+ interface NavigatorLike {
17
+ clipboard?: ClipboardLike;
18
+ share?: (data: ShareData) => Promise<void>;
19
+ }
20
+
21
+ interface ArticleTopActionsProps {
22
+ title: string;
23
+ url: string;
24
+ className?: string;
25
+ size?: "sm" | "md";
26
+ }
27
+
28
+ export function resolveShareUrl(url: string, currentHref?: string): string {
29
+ try {
30
+ return new URL(url).href;
31
+ } catch {
32
+ if (!currentHref) return url;
33
+ }
34
+
35
+ try {
36
+ return new URL(url, currentHref).href;
37
+ } catch {
38
+ return url;
39
+ }
40
+ }
41
+
42
+ export async function copyArticleLink(
43
+ url: string,
44
+ clipboard: ClipboardLike | undefined = globalThis.navigator?.clipboard,
45
+ fallbackDocument: CopyFallbackDocument | undefined =
46
+ typeof document === "undefined" ? undefined : document,
47
+ ): Promise<void> {
48
+ if (clipboard) {
49
+ try {
50
+ await clipboard.writeText(url);
51
+ return;
52
+ } catch {
53
+ // Fall back below for browser contexts that expose clipboard but deny it.
54
+ }
55
+ }
56
+
57
+ if (!fallbackDocument) {
58
+ throw new Error("Clipboard API is unavailable");
59
+ }
60
+
61
+ const textarea = fallbackDocument.createElement("textarea");
62
+ textarea.value = url;
63
+ textarea.setAttribute("readonly", "");
64
+ textarea.style.position = "fixed";
65
+ textarea.style.left = "-9999px";
66
+
67
+ fallbackDocument.body.appendChild(textarea);
68
+ textarea.select();
69
+
70
+ try {
71
+ const copied = fallbackDocument.execCommand("copy");
72
+ if (!copied) {
73
+ throw new Error("Copy command failed");
74
+ }
75
+ } finally {
76
+ textarea.remove();
77
+ }
78
+ }
79
+
80
+ export async function shareArticle(
81
+ article: { title: string; url: string },
82
+ navigatorLike: NavigatorLike | undefined = globalThis.navigator,
83
+ ): Promise<"shared" | "copied"> {
84
+ if (navigatorLike?.share) {
85
+ await navigatorLike.share({
86
+ title: article.title,
87
+ url: article.url,
88
+ });
89
+ return "shared";
90
+ }
91
+
92
+ await copyArticleLink(article.url, navigatorLike?.clipboard);
93
+ return "copied";
94
+ }
95
+
96
+ export default function ArticleTopActions({
97
+ title,
98
+ url,
99
+ className,
100
+ size = "md",
101
+ }: ArticleTopActionsProps) {
102
+ const [copied, setCopied] = React.useState(false);
103
+ const [sharing, setSharing] = React.useState(false);
104
+ const resolvedUrl = React.useMemo(
105
+ () =>
106
+ resolveShareUrl(
107
+ url,
108
+ typeof window === "undefined" ? undefined : window.location.href,
109
+ ),
110
+ [url],
111
+ );
112
+
113
+ React.useEffect(() => {
114
+ if (!copied) return;
115
+
116
+ const timeout = window.setTimeout(() => setCopied(false), 2000);
117
+ return () => window.clearTimeout(timeout);
118
+ }, [copied]);
119
+
120
+ async function handleCopy() {
121
+ await copyArticleLink(resolvedUrl);
122
+ setCopied(true);
123
+ }
124
+
125
+ async function handleShare() {
126
+ setSharing(true);
127
+ try {
128
+ const result = await shareArticle({ title, url: resolvedUrl });
129
+ if (result === "copied") setCopied(true);
130
+ } finally {
131
+ setSharing(false);
132
+ }
133
+ }
134
+
135
+ const buttonClassName =
136
+ size === "sm"
137
+ ? "h-8 gap-1.5 px-2.5 text-xs"
138
+ : "h-9 gap-2 px-3 text-sm";
139
+ const iconClassName = size === "sm" ? "size-3.5" : "size-4";
140
+
141
+ return (
142
+ <div
143
+ className={cn(
144
+ "article-top-actions flex flex-wrap items-center justify-center gap-2",
145
+ className,
146
+ )}
147
+ >
148
+ <button
149
+ type="button"
150
+ aria-label="Copy article link"
151
+ onClick={handleCopy}
152
+ className={cn(
153
+ "text-muted-foreground hover:text-foreground border-border inline-flex items-center justify-center rounded-full border font-medium transition-colors hover:border-[var(--blog-accent-border)] hover:bg-[var(--blog-accent-bg)]",
154
+ buttonClassName,
155
+ )}
156
+ >
157
+ {copied ? (
158
+ <Check className={iconClassName} aria-hidden="true" />
159
+ ) : (
160
+ <Copy className={iconClassName} aria-hidden="true" />
161
+ )}
162
+ <span>{copied ? "Copied" : "Copy link"}</span>
163
+ </button>
164
+ <button
165
+ type="button"
166
+ aria-label="Share article"
167
+ onClick={handleShare}
168
+ disabled={sharing}
169
+ className={cn(
170
+ "text-muted-foreground hover:text-foreground border-border inline-flex items-center justify-center rounded-full border font-medium transition-colors hover:border-[var(--blog-accent-border)] hover:bg-[var(--blog-accent-bg)] disabled:cursor-wait disabled:opacity-60",
171
+ buttonClassName,
172
+ )}
173
+ >
174
+ <Share2 className={iconClassName} aria-hidden="true" />
175
+ <span>Share</span>
176
+ </button>
177
+ </div>
178
+ );
179
+ }