@xexr/blog-platform 1.0.9 → 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. */
@@ -86,6 +92,46 @@ function formatReadingTime(seconds: number): string {
86
92
  return `${minutes} min read`;
87
93
  }
88
94
 
95
+ function formatCreditUrl(url: string): string {
96
+ try {
97
+ return new URL(url).hostname.replace(/^www\./, "");
98
+ } catch {
99
+ return url;
100
+ }
101
+ }
102
+
103
+ function FeaturedImageCredit({
104
+ creditText,
105
+ creditUrl,
106
+ }: {
107
+ creditText?: string;
108
+ creditUrl?: string;
109
+ }) {
110
+ if (!creditText && !creditUrl) {
111
+ return null;
112
+ }
113
+
114
+ const label = creditText ?? formatCreditUrl(creditUrl ?? "");
115
+
116
+ return (
117
+ <span>
118
+ Credit:{" "}
119
+ {creditUrl ? (
120
+ <a
121
+ href={creditUrl}
122
+ target="_blank"
123
+ rel="noreferrer"
124
+ className="underline underline-offset-2 transition-colors hover:text-[var(--blog-text-accent)]"
125
+ >
126
+ {label}
127
+ </a>
128
+ ) : (
129
+ label
130
+ )}
131
+ </span>
132
+ );
133
+ }
134
+
89
135
  export default function ArticlePage({
90
136
  article,
91
137
  className,
@@ -93,6 +139,8 @@ export default function ArticlePage({
93
139
  basePath = "/blog",
94
140
  theme,
95
141
  postArticleCta,
142
+ newsletter,
143
+ newsletterPreviewMode = false,
96
144
  relatedArticles = [],
97
145
  showShareLinks = true,
98
146
  }: ArticlePageProps) {
@@ -106,9 +154,15 @@ export default function ArticlePage({
106
154
  const articleUrl =
107
155
  article.meta.canonicalUrl ??
108
156
  buildBlogPath(normalizedBasePath, article.slug);
157
+ const newsletterSettings = newsletter ?? article.site.newsletter;
109
158
 
110
159
  // Use the featured image URL provided by Bloggy (hosted on Bloggy's domain)
111
160
  const imageUrl = article.featuredImage;
161
+ const hasFeaturedImageMeta = Boolean(
162
+ article.featuredImageCaption ??
163
+ article.featuredImageCreditText ??
164
+ article.featuredImageCreditUrl,
165
+ );
112
166
 
113
167
  return (
114
168
  <article
@@ -140,6 +194,45 @@ export default function ArticlePage({
140
194
  </header>
141
195
  )}
142
196
 
197
+ <style>{`
198
+ .article-content figure > p {
199
+ margin-top: 0 !important;
200
+ margin-bottom: 0 !important;
201
+ }
202
+
203
+ .article-content figure figcaption {
204
+ margin-top: 0.5rem !important;
205
+ text-align: center;
206
+ font-size: 0.875rem !important;
207
+ line-height: 1.35 !important;
208
+ color: hsl(var(--muted-foreground));
209
+ }
210
+
211
+ .article-content figure figcaption .blog-image-credit {
212
+ display: block;
213
+ margin-top: 0.25rem;
214
+ font-size: 0.75rem;
215
+ line-height: 1.25;
216
+ letter-spacing: 0.025em;
217
+ opacity: 0.8;
218
+ }
219
+
220
+ .article-content figure figcaption > span:not(.blog-image-credit) {
221
+ margin-left: 0.5rem;
222
+ padding-left: 0.5rem;
223
+ border-left: 1px solid var(--blog-accent-border);
224
+ font-size: 0.75rem;
225
+ letter-spacing: 0.025em;
226
+ opacity: 0.8;
227
+ }
228
+
229
+ .article-content figure figcaption a {
230
+ color: inherit;
231
+ text-decoration-line: underline;
232
+ text-underline-offset: 0.18em;
233
+ }
234
+ `}</style>
235
+
143
236
  {/* Article Header */}
144
237
  <header className="article-header mx-auto mb-6 max-w-4xl space-y-4 text-center sm:mb-8 sm:space-y-6 lg:mb-12 lg:space-y-8">
145
238
  {/* Title */}
@@ -237,18 +330,38 @@ export default function ArticlePage({
237
330
 
238
331
  {/* Featured Image */}
239
332
  {imageUrl && (
240
- <div className="article-featured-image mx-auto my-8 aspect-[16/9] w-full max-w-4xl overflow-hidden rounded-3xl border border-[var(--blog-accent-border)] shadow-[0_24px_80px_rgb(168_85_247_/_0.14)] sm:my-12 lg:my-16">
241
- <Image
242
- src={imageUrl}
243
- alt={
244
- article.featuredImageAlt ?? `Featured image for ${article.title}`
245
- }
246
- className="h-full w-full object-cover"
247
- width={800}
248
- height={450}
249
- priority
250
- />
251
- </div>
333
+ <figure className="article-featured-image mx-auto my-8 w-full max-w-4xl sm:my-12 lg:my-16">
334
+ <div className="aspect-[16/9] overflow-hidden rounded-3xl border border-[var(--blog-accent-border)] shadow-[0_24px_80px_rgb(168_85_247_/_0.14)]">
335
+ <Image
336
+ src={imageUrl}
337
+ alt={
338
+ article.featuredImageAlt ??
339
+ `Featured image for ${article.title}`
340
+ }
341
+ className="h-full w-full object-cover"
342
+ width={800}
343
+ height={450}
344
+ priority
345
+ />
346
+ </div>
347
+
348
+ {hasFeaturedImageMeta && (
349
+ <figcaption className="text-muted-foreground mt-3 space-y-1 px-2 text-center text-sm leading-relaxed sm:px-0">
350
+ {article.featuredImageCaption && (
351
+ <p>{article.featuredImageCaption}</p>
352
+ )}
353
+ {(article.featuredImageCreditText ??
354
+ article.featuredImageCreditUrl) && (
355
+ <p className="text-xs">
356
+ <FeaturedImageCredit
357
+ creditText={article.featuredImageCreditText}
358
+ creditUrl={article.featuredImageCreditUrl}
359
+ />
360
+ </p>
361
+ )}
362
+ </figcaption>
363
+ )}
364
+ </figure>
252
365
  )}
253
366
 
254
367
  {/* Article Content */}
@@ -265,7 +378,7 @@ export default function ArticlePage({
265
378
 
266
379
  <div
267
380
  id="article-body"
268
- className="prose prose-sm sm:prose-base lg:prose-lg xl:prose-xl prose-gray dark:prose-invert prose-headings:scroll-mt-20 prose-headings:font-light prose-headings:text-balance prose-headings:text-[var(--blog-text-accent)] prose-h1:text-2xl sm:prose-h1:text-3xl lg:prose-h1:text-4xl prose-h2:text-xl sm:prose-h2:text-2xl lg:prose-h2:text-3xl prose-h3:text-lg sm:prose-h3:text-xl lg:prose-h3:text-2xl prose-p:leading-relaxed prose-p:text-pretty prose-a:text-[var(--blog-text-accent)] prose-a:transition-colors hover:prose-a:text-[var(--blog-text-accent-hover)] max-w-none"
381
+ className="prose prose-sm sm:prose-base lg:prose-lg xl:prose-xl prose-gray dark:prose-invert prose-headings:scroll-mt-20 prose-headings:font-light prose-headings:text-balance prose-headings:text-[var(--blog-text-accent)] prose-h1:text-2xl sm:prose-h1:text-3xl lg:prose-h1:text-4xl prose-h2:text-xl sm:prose-h2:text-2xl lg:prose-h2:text-3xl prose-h3:text-lg sm:prose-h3:text-xl lg:prose-h3:text-2xl prose-p:leading-relaxed prose-p:text-pretty prose-a:text-[var(--blog-text-accent)] prose-a:transition-colors hover:prose-a:text-[var(--blog-text-accent-hover)] [&_figure>p]:!my-0 [&_figcaption]:!mt-2 [&_figcaption]:text-center [&_figcaption]:!text-sm [&_figcaption]:!leading-snug [&_figcaption]:text-muted-foreground [&_figcaption_.blog-image-credit]:mt-1 [&_figcaption_.blog-image-credit]:block [&_figcaption_.blog-image-credit]:text-xs [&_figcaption_.blog-image-credit]:tracking-wide [&_figcaption_.blog-image-credit]:text-muted-foreground/80 [&_figcaption>span:not(.blog-image-credit)]:ml-2 [&_figcaption>span:not(.blog-image-credit)]:border-l [&_figcaption>span:not(.blog-image-credit)]:border-[var(--blog-accent-border)] [&_figcaption>span:not(.blog-image-credit)]:pl-2 [&_figcaption>span:not(.blog-image-credit)]:text-xs [&_figcaption>span:not(.blog-image-credit)]:tracking-wide [&_figcaption>span:not(.blog-image-credit)]:text-muted-foreground/80 max-w-none"
269
382
  >
270
383
  <MDXRemote
271
384
  source={article.content}
@@ -327,11 +440,22 @@ export default function ArticlePage({
327
440
  )}
328
441
 
329
442
  {((postArticleCta ?? false) ||
443
+ (newsletterSettings?.enabled ?? false) ||
330
444
  showShareLinks ||
331
445
  relatedArticles.length > 0) && (
332
446
  <div className="mx-auto mt-12 max-w-4xl space-y-8">
333
447
  <Separator />
334
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
+ )}
335
459
  {showShareLinks && (
336
460
  <ArticleShareLinks title={article.title} url={articleUrl} />
337
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 &&