@xexr/blog-platform 1.0.10 → 1.0.11

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/README.md CHANGED
@@ -237,7 +237,7 @@ export default async function ArticlePage({
237
237
  }
238
238
  ```
239
239
 
240
- > **Note**: The `navigation` prop is optional. You can omit `previousArticle` and `nextArticle` if you don't want article navigation links.
240
+ > **Note**: The `navigation` prop is optional. You can omit `previousArticle` and `nextArticle` if you don't want article navigation links. Bloggy can also publish a site-scoped `newsletter` configuration; pass it through to `BlogHomePage` or `BlogArticlePage` if you are not using the article/site objects returned by Bloggy storage directly. The newsletter form posts back to Bloggy's `/api/newsletter/subscribe` endpoint so subscribers are captured consistently across host apps.
241
241
 
242
242
  **`app/blog/authors/[slug]/page.tsx`** - Author profile pages:
243
243
 
@@ -1,8 +1,11 @@
1
1
  import type { ReactElement } from "react";
2
- import type { PublishedArticle } from "../../../types/contracts";
2
+ import type {
3
+ PublishedArticle,
4
+ PublishedNewsletterSettings,
5
+ } from "../../../types/contracts";
3
6
  import type { BlogTheme } from "../../../lib/article-ui";
4
7
 
5
- interface ArticlePageProps {
8
+ export interface ArticlePageProps {
6
9
  article: PublishedArticle;
7
10
  className?: string;
8
11
  navigation?: {
@@ -31,6 +34,8 @@ interface ArticlePageProps {
31
34
  href: string;
32
35
  label: string;
33
36
  };
37
+ newsletter?: PublishedNewsletterSettings;
38
+ newsletterPreviewMode?: boolean;
34
39
  relatedArticles?: PublishedArticle[];
35
40
  showShareLinks?: boolean;
36
41
  }
@@ -5,6 +5,7 @@ import { MDXRemote } from "next-mdx-remote-client/rsc";
5
5
  import {
6
6
  type PublishedArticle,
7
7
  type PublishedAuthor,
8
+ type PublishedNewsletterSettings,
8
9
  } from "../../../types/contracts";
9
10
  import { cn } from "../../../lib/utils";
10
11
  import { ArrowLeft, ArrowUpRight, Globe, Mail } from "lucide-react";
@@ -25,6 +26,7 @@ import {
25
26
  buildBlogPath,
26
27
  normalizeBlogBasePath,
27
28
  } from "../../../lib/urls";
29
+ import { NewsletterSignup } from "../../../components/NewsletterSignup";
28
30
  import {
29
31
  type BlogTheme,
30
32
  extractTableOfContents,
@@ -32,7 +34,7 @@ import {
32
34
  getBlogThemeStyle,
33
35
  } from "../../../lib/article-ui";
34
36
 
35
- interface ArticlePageProps {
37
+ export interface ArticlePageProps {
36
38
  article: PublishedArticle;
37
39
  className?: string;
38
40
  /** Optional navigation links */
@@ -65,6 +67,10 @@ interface ArticlePageProps {
65
67
  href: string;
66
68
  label: string;
67
69
  };
70
+ /** Optional Bloggy-owned newsletter capture configuration. */
71
+ newsletter?: PublishedNewsletterSettings;
72
+ /** Render interactive preview success without persisting subscribers. */
73
+ newsletterPreviewMode?: boolean;
68
74
  /** Optional related articles supplied by the host site. */
69
75
  relatedArticles?: PublishedArticle[];
70
76
  /** Whether to render static article share links. */
@@ -133,6 +139,8 @@ export default function ArticlePage({
133
139
  basePath = "/blog",
134
140
  theme,
135
141
  postArticleCta,
142
+ newsletter,
143
+ newsletterPreviewMode = false,
136
144
  relatedArticles = [],
137
145
  showShareLinks = true,
138
146
  }: ArticlePageProps) {
@@ -146,6 +154,7 @@ export default function ArticlePage({
146
154
  const articleUrl =
147
155
  article.meta.canonicalUrl ??
148
156
  buildBlogPath(normalizedBasePath, article.slug);
157
+ const newsletterSettings = newsletter ?? article.site.newsletter;
149
158
 
150
159
  // Use the featured image URL provided by Bloggy (hosted on Bloggy's domain)
151
160
  const imageUrl = article.featuredImage;
@@ -431,11 +440,22 @@ export default function ArticlePage({
431
440
  )}
432
441
 
433
442
  {((postArticleCta ?? false) ||
443
+ (newsletterSettings?.enabled ?? false) ||
434
444
  showShareLinks ||
435
445
  relatedArticles.length > 0) && (
436
446
  <div className="mx-auto mt-12 max-w-4xl space-y-8">
437
447
  <Separator />
438
448
  {postArticleCta && <PostArticleCta cta={postArticleCta} />}
449
+ {newsletterSettings?.enabled && (
450
+ <NewsletterSignup
451
+ siteId={article.site.id}
452
+ settings={newsletterSettings}
453
+ sourceSurface="article"
454
+ sourceArticleSlug={article.slug}
455
+ sourceUrl={articleUrl}
456
+ previewMode={newsletterPreviewMode}
457
+ />
458
+ )}
439
459
  {showShareLinks && (
440
460
  <ArticleShareLinks title={article.title} url={articleUrl} />
441
461
  )}
@@ -0,0 +1,59 @@
1
+ import type { ReactElement } from "react";
2
+ import type {
3
+ PublishedArticle,
4
+ PublishedNewsletterSettings,
5
+ PublishedSite,
6
+ } from "../../types/contracts";
7
+ import type { BlogTheme } from "../../lib/article-ui";
8
+
9
+ export interface BlogHomePageProps {
10
+ articles: PublishedArticle[];
11
+ siteName?: string;
12
+ siteTagline?: string;
13
+ className?: string;
14
+ site?: PublishedSite;
15
+ includeStructuredData?: boolean;
16
+ basePath?: string;
17
+ pagination?: {
18
+ currentPage: number;
19
+ totalPages: number;
20
+ baseUrl?: string;
21
+ };
22
+ theme?: BlogTheme;
23
+ newsletter?: PublishedNewsletterSettings;
24
+ newsletterPreviewMode?: boolean;
25
+ }
26
+
27
+ export default function BlogHomePage(props: BlogHomePageProps): ReactElement;
28
+
29
+ export function generateBlogHomeMetadata(
30
+ siteName?: string,
31
+ siteTagline?: string,
32
+ siteUrl?: string,
33
+ pagination?: {
34
+ currentPage: number;
35
+ totalPages: number;
36
+ },
37
+ basePath?: string,
38
+ ): {
39
+ title: string;
40
+ description: string;
41
+ openGraph: {
42
+ title: string;
43
+ description: string;
44
+ type: string;
45
+ url?: string;
46
+ };
47
+ twitter: {
48
+ card: string;
49
+ title: string;
50
+ description: string;
51
+ };
52
+ alternates: {
53
+ canonical?: string;
54
+ };
55
+ other: {
56
+ prev?: string;
57
+ next?: string;
58
+ };
59
+ };
@@ -0,0 +1,83 @@
1
+ import React from "react";
2
+ import { renderToPipeableStream } from "react-dom/server";
3
+ import { PassThrough } from "stream";
4
+ import { describe, expect, it } from "vitest";
5
+
6
+ import BlogHomePage from "./page";
7
+ import type { PublishedArticle } from "../../types/contracts";
8
+
9
+ const article: PublishedArticle = {
10
+ id: "article-1",
11
+ slug: "useful-routines",
12
+ title: "Useful routines",
13
+ excerpt: "A practical guide to keeping routines useful.",
14
+ content: "Routines work best when they are easy to maintain.",
15
+ readingSeconds: 360,
16
+ wordCount: 900,
17
+ publishedAt: "2026-05-06T12:00:00.000Z",
18
+ updatedAt: "2026-05-07T12:00:00.000Z",
19
+ author: null,
20
+ site: {
21
+ id: "site-1",
22
+ name: "Example Blog",
23
+ tagline: "Useful writing",
24
+ description: "Articles about useful systems.",
25
+ domain: "example.com",
26
+ toneAdjectives: [],
27
+ locale: "en",
28
+ },
29
+ meta: {
30
+ canonicalUrl: "https://example.com/blog/useful-routines",
31
+ keywords: [],
32
+ },
33
+ version: 1,
34
+ };
35
+
36
+ describe("BlogHomePage", () => {
37
+ it("renders the newsletter form when configured", async () => {
38
+ const html = await renderToHtml(
39
+ React.createElement(BlogHomePage, {
40
+ articles: [article],
41
+ siteName: "Example Blog",
42
+ newsletter: {
43
+ enabled: true,
44
+ title: "Get product updates",
45
+ description: "Follow new articles and product notes.",
46
+ buttonLabel: "Get updates",
47
+ captureBaseUrl: "https://bloggy.example.com",
48
+ publicSubscribeToken: "public_subscribe_token",
49
+ },
50
+ }),
51
+ );
52
+
53
+ expect(html).toContain("Get product updates");
54
+ expect(html).toContain("Follow new articles and product notes.");
55
+ expect(html).toContain('type="email"');
56
+ expect(html).toContain('name="name"');
57
+ expect(html.indexOf("Get product updates")).toBeGreaterThan(
58
+ html.indexOf("Useful routines"),
59
+ );
60
+ });
61
+ });
62
+
63
+ function renderToHtml(element: React.ReactElement): Promise<string> {
64
+ return new Promise((resolve, reject) => {
65
+ let html = "";
66
+ const stream = new PassThrough();
67
+ stream.setEncoding("utf8");
68
+ stream.on("data", (chunk: string) => {
69
+ html += chunk;
70
+ });
71
+ stream.on("end", () => resolve(html));
72
+ stream.on("error", reject);
73
+
74
+ const { pipe } = renderToPipeableStream(element, {
75
+ onAllReady() {
76
+ pipe(stream);
77
+ },
78
+ onError(error) {
79
+ reject(error instanceof Error ? error : new Error(String(error)));
80
+ },
81
+ });
82
+ });
83
+ }
@@ -4,6 +4,7 @@ import Image from "next/image";
4
4
  import {
5
5
  type PublishedArticle,
6
6
  type PublishedAuthor,
7
+ type PublishedNewsletterSettings,
7
8
  type PublishedSite,
8
9
  } from "../../types/contracts";
9
10
  import { cn } from "../../lib/utils";
@@ -19,6 +20,7 @@ import {
19
20
  } from "../../lib/urls";
20
21
  import { formatDistanceToNow } from "date-fns";
21
22
  import { type BlogTheme, getBlogThemeStyle } from "../../lib/article-ui";
23
+ import { NewsletterSignup } from "../../components/NewsletterSignup";
22
24
 
23
25
  interface BlogHomePageProps {
24
26
  articles: PublishedArticle[];
@@ -39,6 +41,10 @@ interface BlogHomePageProps {
39
41
  };
40
42
  /** Optional theme values for host-site brand integration. */
41
43
  theme?: BlogTheme;
44
+ /** Optional Bloggy-owned newsletter capture configuration. */
45
+ newsletter?: PublishedNewsletterSettings;
46
+ /** Render interactive preview success without persisting subscribers. */
47
+ newsletterPreviewMode?: boolean;
42
48
  }
43
49
 
44
50
  /**
@@ -58,8 +64,12 @@ export default function BlogHomePage({
58
64
  basePath = "/blog",
59
65
  pagination,
60
66
  theme,
67
+ newsletter,
68
+ newsletterPreviewMode = false,
61
69
  }: BlogHomePageProps) {
62
70
  const normalizedBasePath = normalizeBlogBasePath(basePath) || "/";
71
+ const newsletterSettings = newsletter ?? site?.newsletter;
72
+ const newsletterSiteId = site?.id ?? articles[0]?.site.id;
63
73
 
64
74
  return (
65
75
  <div
@@ -119,6 +129,16 @@ export default function BlogHomePage({
119
129
  )}
120
130
  </main>
121
131
 
132
+ {newsletterSettings?.enabled && newsletterSiteId && (
133
+ <NewsletterSignup
134
+ siteId={newsletterSiteId}
135
+ settings={newsletterSettings}
136
+ sourceSurface="blog_home"
137
+ previewMode={newsletterPreviewMode}
138
+ className="mt-10 sm:mt-12"
139
+ />
140
+ )}
141
+
122
142
  {/* JSON-LD Structured Data */}
123
143
  {includeStructuredData &&
124
144
  site &&
@@ -0,0 +1,279 @@
1
+ "use client";
2
+
3
+ import { useState, useSyncExternalStore, type FormEvent } from "react";
4
+ import { ArrowUpRight, Check } from "lucide-react";
5
+
6
+ import { cn } from "../lib/utils";
7
+ import type { PublishedNewsletterSettings } from "../types/contracts";
8
+
9
+ export const NEWSLETTER_SUCCESS_MESSAGE =
10
+ "Thanks, you're on the list. We'll confirm before sending updates.";
11
+
12
+ export interface NewsletterSignupProps {
13
+ siteId: string;
14
+ settings: PublishedNewsletterSettings;
15
+ sourceSurface: "blog_home" | "article";
16
+ sourceArticleSlug?: string;
17
+ sourceUrl?: string;
18
+ previewMode?: boolean;
19
+ className?: string;
20
+ }
21
+
22
+ export function NewsletterSignup({
23
+ siteId,
24
+ settings,
25
+ sourceSurface,
26
+ sourceArticleSlug,
27
+ sourceUrl,
28
+ previewMode = false,
29
+ className,
30
+ }: NewsletterSignupProps) {
31
+ const [message, setMessage] = useState<string | null>(null);
32
+ const [error, setError] = useState<string | null>(null);
33
+ const [hasSubmitted, setHasSubmitted] = useState(false);
34
+ const [isSubmitting, setIsSubmitting] = useState(false);
35
+ const subscriptionKey = getSubscriptionStorageKey(siteId);
36
+ const hasStoredSubscribed = useSyncExternalStore(
37
+ subscribeToStoredSubscription,
38
+ () => hasStoredSubscription(subscriptionKey),
39
+ () => false,
40
+ );
41
+ const hasSubscribed = hasSubmitted || (!previewMode && hasStoredSubscribed);
42
+
43
+ async function handleSubmit(event: FormEvent<HTMLFormElement>) {
44
+ event.preventDefault();
45
+ setError(null);
46
+ setMessage(null);
47
+
48
+ const formData = new FormData(event.currentTarget);
49
+ const honeypot = getFormString(formData, "company");
50
+
51
+ if (previewMode) {
52
+ setMessage(NEWSLETTER_SUCCESS_MESSAGE);
53
+ setHasSubmitted(true);
54
+ event.currentTarget.reset();
55
+ return;
56
+ }
57
+
58
+ setIsSubmitting(true);
59
+ try {
60
+ const response = await fetch(
61
+ `${settings.captureBaseUrl.replace(/\/$/, "")}/api/newsletter/subscribe`,
62
+ {
63
+ method: "POST",
64
+ headers: {
65
+ "Content-Type": "application/json",
66
+ },
67
+ body: JSON.stringify({
68
+ siteId: Number(siteId),
69
+ publicSubscribeToken: settings.publicSubscribeToken,
70
+ email: getFormString(formData, "email"),
71
+ name: getOptionalFormString(formData, "name"),
72
+ sourceSurface,
73
+ sourceUrl: resolveSourceUrl(sourceUrl),
74
+ sourceArticleSlug: sourceArticleSlug ?? null,
75
+ honeypot,
76
+ }),
77
+ },
78
+ );
79
+
80
+ if (!response.ok) {
81
+ throw new Error("Newsletter signup failed.");
82
+ }
83
+
84
+ const result: unknown = await response.json().catch(() => null);
85
+ const successMessage =
86
+ getNewsletterResponseMessage(result) ?? NEWSLETTER_SUCCESS_MESSAGE;
87
+
88
+ storeSubscription(subscriptionKey);
89
+ setMessage(successMessage);
90
+ setHasSubmitted(true);
91
+ event.currentTarget.reset();
92
+ } catch {
93
+ setMessage(null);
94
+ setError("Newsletter signup failed. Please try again.");
95
+ } finally {
96
+ setIsSubmitting(false);
97
+ }
98
+ }
99
+
100
+ return (
101
+ <section
102
+ className={cn(
103
+ "border-y border-[var(--blog-accent-border)] py-5 sm:py-6",
104
+ className,
105
+ )}
106
+ aria-labelledby={`newsletter-${sourceSurface}-title`}
107
+ >
108
+ <div className="grid gap-4 lg:grid-cols-[minmax(0,0.9fr)_minmax(0,1.4fr)] lg:items-end">
109
+ <div className="max-w-xl space-y-1">
110
+ <h2
111
+ id={`newsletter-${sourceSurface}-title`}
112
+ className="text-base leading-6 font-semibold"
113
+ >
114
+ {settings.title}
115
+ </h2>
116
+ <p className="text-muted-foreground text-sm leading-6">
117
+ {settings.description}
118
+ </p>
119
+ </div>
120
+
121
+ {hasSubscribed ? (
122
+ <div
123
+ className="flex items-start gap-3 text-sm leading-6 text-emerald-500"
124
+ role="status"
125
+ aria-live="polite"
126
+ >
127
+ <span className="mt-0.5 inline-flex size-5 shrink-0 items-center justify-center rounded-full bg-emerald-500/15">
128
+ <Check className="size-3.5" aria-hidden="true" />
129
+ </span>
130
+ <p>{message ?? NEWSLETTER_SUCCESS_MESSAGE}</p>
131
+ </div>
132
+ ) : (
133
+ <form onSubmit={handleSubmit} className="space-y-3">
134
+ <div className="grid gap-2 sm:grid-cols-[minmax(0,0.8fr)_minmax(0,1fr)_auto]">
135
+ <label
136
+ className="sr-only"
137
+ htmlFor={`newsletter-name-${sourceSurface}`}
138
+ >
139
+ Name
140
+ </label>
141
+ <input
142
+ id={`newsletter-name-${sourceSurface}`}
143
+ name="name"
144
+ type="text"
145
+ autoComplete="name"
146
+ placeholder="Name"
147
+ className="border-input bg-background text-foreground placeholder:text-muted-foreground focus-visible:ring-ring min-h-10 rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
148
+ />
149
+
150
+ <label
151
+ className="sr-only"
152
+ htmlFor={`newsletter-email-${sourceSurface}`}
153
+ >
154
+ Email
155
+ </label>
156
+ <input
157
+ id={`newsletter-email-${sourceSurface}`}
158
+ name="email"
159
+ type="email"
160
+ autoComplete="email"
161
+ placeholder="Email address"
162
+ required
163
+ className="border-input bg-background text-foreground placeholder:text-muted-foreground focus-visible:ring-ring min-h-10 rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
164
+ />
165
+
166
+ <input
167
+ type="text"
168
+ name="company"
169
+ tabIndex={-1}
170
+ autoComplete="off"
171
+ aria-hidden="true"
172
+ className="hidden"
173
+ />
174
+
175
+ <button
176
+ type="submit"
177
+ disabled={isSubmitting}
178
+ className="inline-flex min-h-10 w-full shrink-0 items-center justify-center gap-2 rounded-full bg-[var(--blog-accent)] px-5 py-2 text-sm font-semibold text-white transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-60 sm:w-auto"
179
+ >
180
+ {isSubmitting ? "Submitting..." : settings.buttonLabel}
181
+ <ArrowUpRight className="size-4" aria-hidden="true" />
182
+ </button>
183
+ </div>
184
+
185
+ {error && <p className="text-sm text-red-600">{error}</p>}
186
+ </form>
187
+ )}
188
+ </div>
189
+ </section>
190
+ );
191
+ }
192
+
193
+ function resolveSourceUrl(sourceUrl: string | undefined): string | null {
194
+ if (sourceUrl?.startsWith("http://") || sourceUrl?.startsWith("https://")) {
195
+ return sourceUrl;
196
+ }
197
+
198
+ if (typeof window !== "undefined") {
199
+ return window.location.href;
200
+ }
201
+
202
+ return null;
203
+ }
204
+
205
+ function getFormString(formData: FormData, key: string): string {
206
+ const value = formData.get(key);
207
+ return typeof value === "string" ? value : "";
208
+ }
209
+
210
+ function getOptionalFormString(formData: FormData, key: string): string | null {
211
+ const value = getFormString(formData, key).trim();
212
+ if (value.length === 0) {
213
+ return null;
214
+ }
215
+
216
+ return value;
217
+ }
218
+
219
+ function getSubscriptionStorageKey(siteId: string): string {
220
+ return `bloggy_newsletter_subscribed_${siteId}`;
221
+ }
222
+
223
+ function hasStoredSubscription(key: string): boolean {
224
+ if (typeof window === "undefined") {
225
+ return false;
226
+ }
227
+
228
+ try {
229
+ if (window.localStorage.getItem(key) === "true") {
230
+ return true;
231
+ }
232
+ } catch {
233
+ // Ignore storage failures; the subscription API remains the source of truth.
234
+ }
235
+
236
+ if (typeof document === "undefined") {
237
+ return false;
238
+ }
239
+
240
+ return document.cookie
241
+ .split(";")
242
+ .some((cookie) => cookie.trim().startsWith(`${key}=true`));
243
+ }
244
+
245
+ function storeSubscription(key: string): void {
246
+ if (typeof window !== "undefined") {
247
+ try {
248
+ window.localStorage.setItem(key, "true");
249
+ } catch {
250
+ // Ignore storage failures; users can still submit normally.
251
+ }
252
+ }
253
+
254
+ if (typeof document !== "undefined") {
255
+ document.cookie = `${key}=true; Max-Age=31536000; Path=/; SameSite=Lax`;
256
+ }
257
+ }
258
+
259
+ function subscribeToStoredSubscription(callback: () => void): () => void {
260
+ if (typeof window === "undefined") {
261
+ return () => undefined;
262
+ }
263
+
264
+ window.addEventListener("storage", callback);
265
+ return () => window.removeEventListener("storage", callback);
266
+ }
267
+
268
+ function getNewsletterResponseMessage(value: unknown): string | null {
269
+ if (
270
+ value &&
271
+ typeof value === "object" &&
272
+ "message" in value &&
273
+ typeof value.message === "string"
274
+ ) {
275
+ return value.message;
276
+ }
277
+
278
+ return null;
279
+ }