@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,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
+ }