@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.
- package/CHANGELOG.md +7 -0
- package/dist/app/blog/[slug]/page.d.ts +68 -0
- package/dist/app/blog/[slug]/page.tsx +155 -113
- package/dist/components/ArticleReadingProgress.tsx +208 -0
- package/dist/components/ArticleTopActions.tsx +179 -0
- package/dist/components/AuthorByline.tsx +569 -0
- package/dist/components/ExpandableArticleImage.tsx +79 -0
- package/dist/components/Pagination.tsx +236 -0
- package/dist/components/TopicArchivePages.tsx +204 -0
- package/dist/components/ui/badge.tsx +36 -0
- package/dist/components/ui/button.tsx +59 -0
- package/dist/components/ui/card.tsx +92 -0
- package/dist/components/ui/pagination.tsx +127 -0
- package/dist/components/ui/separator.tsx +29 -0
- package/dist/index.d.mts +59 -69
- package/dist/index.d.ts +59 -69
- package/dist/index.js +83 -8
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +83 -8
- package/dist/index.mjs.map +1 -1
- package/dist/lib/article-ui.ts +158 -0
- package/dist/lib/auth.ts +354 -0
- package/dist/lib/cors.ts +243 -0
- package/dist/lib/database.ts +277 -0
- package/dist/lib/init.ts +186 -0
- package/dist/lib/mdx.ts +73 -0
- package/dist/lib/metadata.ts +558 -0
- package/dist/lib/ratelimit.ts +224 -0
- package/dist/lib/security-headers.ts +322 -0
- package/dist/lib/seo.ts +295 -0
- package/dist/lib/sitemap.ts +719 -0
- package/dist/lib/storage/drizzle.ts +400 -0
- package/dist/lib/structured-data.ts +608 -0
- package/dist/lib/topics.ts +56 -0
- package/dist/lib/urls.ts +29 -0
- package/dist/lib/utils.ts +6 -0
- package/dist/lib/version.ts +154 -0
- package/dist/types/contracts.ts +256 -0
- package/dist/types/index.d.mts +18 -1
- package/dist/types/index.d.ts +18 -1
- package/dist/types/index.ts +16 -0
- 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
|
+
}
|