@xexr/blog-platform 1.0.15 → 1.0.17

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.
@@ -1,6 +1,7 @@
1
1
  import type { ReactElement } from "react";
2
2
  import type {
3
3
  PublishedArticle,
4
+ PublishedArticleCta,
4
5
  PublishedNewsletterSettings,
5
6
  } from "../../../types/contracts";
6
7
  import type { BlogTheme } from "../../../lib/article-ui";
@@ -28,14 +29,10 @@ export interface ArticlePageProps {
28
29
  };
29
30
  basePath?: string;
30
31
  theme?: BlogTheme;
31
- postArticleCta?: {
32
- title: string;
33
- description?: string;
34
- href: string;
35
- label: string;
36
- };
32
+ postArticleCta?: PublishedArticleCta;
37
33
  newsletter?: PublishedNewsletterSettings;
38
34
  newsletterPreviewMode?: boolean;
35
+ articles?: PublishedArticle[];
39
36
  relatedArticles?: PublishedArticle[];
40
37
  showShareLinks?: boolean;
41
38
  }
@@ -0,0 +1,135 @@
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 ArticlePage from "./page";
7
+ import type { PublishedArticle } from "../../../types/contracts";
8
+
9
+ const article: PublishedArticle = {
10
+ id: "article-security",
11
+ slug: "mdx-security",
12
+ title: "MDX security",
13
+ excerpt: "Renderer hardening fixtures.",
14
+ content: "Safe content.",
15
+ readingSeconds: 60,
16
+ wordCount: 120,
17
+ publishedAt: "2026-05-13T09:00:00.000Z",
18
+ updatedAt: "2026-05-13T09: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
+ topics: [],
30
+ meta: {
31
+ canonicalUrl: "https://example.com/blog/mdx-security",
32
+ keywords: [],
33
+ },
34
+ version: 1,
35
+ };
36
+
37
+ describe("ArticlePage MDX security", () => {
38
+ it("renders expected markdown and figure HTML through the real article renderer", async () => {
39
+ const html = await renderToHtml(
40
+ React.createElement(ArticlePage, {
41
+ article: {
42
+ ...article,
43
+ content: [
44
+ "## Safe section",
45
+ "",
46
+ "<figure>",
47
+ "",
48
+ "![Dashboard](https://example.com/dashboard.png)",
49
+ "",
50
+ '<figcaption>Dashboard caption <span class="blog-image-credit">Credit: Example</span></figcaption>',
51
+ "",
52
+ "</figure>",
53
+ "",
54
+ "[Read more](https://example.com/read-more)",
55
+ ].join("\n"),
56
+ },
57
+ showShareLinks: false,
58
+ }),
59
+ );
60
+
61
+ expect(html).toContain("Safe section");
62
+ expect(html).toContain("<figure>");
63
+ expect(html).toContain("<figcaption>");
64
+ expect(html).toContain("Dashboard caption");
65
+ expect(html).toContain('src="https://example.com/dashboard.png"');
66
+ expect(html).toContain('href="https://example.com/read-more"');
67
+ });
68
+
69
+ it("removes executable raw HTML and unsafe URL protocols from rendered articles", async () => {
70
+ const html = await renderToHtml(
71
+ React.createElement(ArticlePage, {
72
+ article: {
73
+ ...article,
74
+ content: [
75
+ "Safe paragraph.",
76
+ "",
77
+ "<script>alert('script')</script>",
78
+ '<img src="https://example.com/safe.png" onerror="alert(1)" alt="event handler" />',
79
+ '<a href="javascript:alert(2)">unsafe link</a>',
80
+ '<img src="data:text/html,<script>alert(3)</script>" alt="data url" />',
81
+ '<iframe src="https://example.com/embed"></iframe>',
82
+ '<object data="https://example.com/payload"></object>',
83
+ '<embed src="https://example.com/payload" />',
84
+ ].join("\n"),
85
+ },
86
+ showShareLinks: false,
87
+ }),
88
+ );
89
+
90
+ const articleBodyHtml = extractArticleBodyHtml(html);
91
+
92
+ expect(articleBodyHtml).toContain("Safe paragraph.");
93
+ expect(articleBodyHtml).toContain("unsafe link");
94
+ expect(articleBodyHtml).not.toContain("<script");
95
+ expect(articleBodyHtml).not.toContain("onerror");
96
+ expect(articleBodyHtml).not.toContain("javascript:");
97
+ expect(articleBodyHtml).not.toContain("data:text/html");
98
+ expect(articleBodyHtml).not.toContain("<iframe");
99
+ expect(articleBodyHtml).not.toContain("<object");
100
+ expect(articleBodyHtml).not.toContain("<embed");
101
+ });
102
+ });
103
+
104
+ function extractArticleBodyHtml(html: string): string {
105
+ const start = html.indexOf('id="article-body"');
106
+ const end = html.indexOf('<script type="application/ld+json"', start);
107
+
108
+ if (start === -1) {
109
+ throw new Error("Article body not found in rendered HTML");
110
+ }
111
+
112
+ return end === -1 ? html.slice(start) : html.slice(start, end);
113
+ }
114
+
115
+ function renderToHtml(element: React.ReactElement): Promise<string> {
116
+ return new Promise((resolve, reject) => {
117
+ let html = "";
118
+ const stream = new PassThrough();
119
+ stream.setEncoding("utf8");
120
+ stream.on("data", (chunk: string) => {
121
+ html += chunk;
122
+ });
123
+ stream.on("end", () => resolve(html));
124
+ stream.on("error", reject);
125
+
126
+ const { pipe } = renderToPipeableStream(element, {
127
+ onAllReady() {
128
+ pipe(stream);
129
+ },
130
+ onError(error) {
131
+ reject(error instanceof Error ? error : new Error(String(error)));
132
+ },
133
+ });
134
+ });
135
+ }
@@ -4,6 +4,7 @@ import Link from "next/link";
4
4
  import { MDXRemote } from "next-mdx-remote-client/rsc";
5
5
  import {
6
6
  type PublishedArticle,
7
+ type PublishedArticleCta,
7
8
  type PublishedAuthor,
8
9
  type PublishedNewsletterSettings,
9
10
  } from "../../../types/contracts";
@@ -34,6 +35,7 @@ import {
34
35
  formatArticleDateMeta,
35
36
  getBlogThemeStyle,
36
37
  } from "../../../lib/article-ui";
38
+ import { getRelatedFeaturedArticles } from "../../../lib/related-articles";
37
39
 
38
40
  export interface ArticlePageProps {
39
41
  article: PublishedArticle;
@@ -62,17 +64,14 @@ export interface ArticlePageProps {
62
64
  /** Optional theme values for host-site brand integration. */
63
65
  theme?: BlogTheme;
64
66
  /** Optional post-article CTA supplied by the host site. */
65
- postArticleCta?: {
66
- title: string;
67
- description?: string;
68
- href: string;
69
- label: string;
70
- };
67
+ postArticleCta?: PublishedArticleCta;
71
68
  /** Optional Bloggy-owned newsletter capture configuration. */
72
69
  newsletter?: PublishedNewsletterSettings;
73
70
  /** Render interactive preview success without persisting subscribers. */
74
71
  newsletterPreviewMode?: boolean;
75
- /** Optional related articles supplied by the host site. */
72
+ /** All available articles used to derive topic-first related articles. */
73
+ articles?: PublishedArticle[];
74
+ /** Optional pre-ranked related articles supplied by the host site. */
76
75
  relatedArticles?: PublishedArticle[];
77
76
  /** Whether to render static article share links. */
78
77
  showShareLinks?: boolean;
@@ -142,6 +141,7 @@ export default function ArticlePage({
142
141
  postArticleCta,
143
142
  newsletter,
144
143
  newsletterPreviewMode = false,
144
+ articles = [],
145
145
  relatedArticles = [],
146
146
  showShareLinks = true,
147
147
  }: ArticlePageProps) {
@@ -156,6 +156,7 @@ export default function ArticlePage({
156
156
  article.meta.canonicalUrl ??
157
157
  buildBlogPath(normalizedBasePath, article.slug);
158
158
  const newsletterSettings = newsletter ?? article.site.newsletter;
159
+ const articleCta = postArticleCta ?? article.cta;
159
160
 
160
161
  // Use the featured image URL provided by Bloggy (hosted on Bloggy's domain)
161
162
  const imageUrl = article.featuredImage;
@@ -164,6 +165,10 @@ export default function ArticlePage({
164
165
  article.featuredImageCreditText ??
165
166
  article.featuredImageCreditUrl,
166
167
  );
168
+ const resolvedRelatedArticles =
169
+ relatedArticles.length > 0
170
+ ? relatedArticles.slice(0, 3)
171
+ : getRelatedFeaturedArticles(article, articles);
167
172
 
168
173
  return (
169
174
  <article
@@ -445,13 +450,13 @@ export default function ArticlePage({
445
450
  </div>
446
451
  )}
447
452
 
448
- {((postArticleCta ?? false) ||
453
+ {((articleCta ?? false) ||
449
454
  (newsletterSettings?.enabled ?? false) ||
450
455
  showShareLinks ||
451
- relatedArticles.length > 0) && (
456
+ resolvedRelatedArticles.length > 0) && (
452
457
  <div className="mx-auto mt-12 max-w-4xl space-y-8">
453
458
  <Separator />
454
- {postArticleCta && <PostArticleCta cta={postArticleCta} />}
459
+ {articleCta && <PostArticleCta cta={articleCta} />}
455
460
  {newsletterSettings?.enabled && (
456
461
  <NewsletterSignup
457
462
  siteId={article.site.id}
@@ -465,9 +470,9 @@ export default function ArticlePage({
465
470
  {showShareLinks && (
466
471
  <ArticleShareLinks title={article.title} url={articleUrl} />
467
472
  )}
468
- {relatedArticles.length > 0 && (
473
+ {resolvedRelatedArticles.length > 0 && (
469
474
  <RelatedArticles
470
- articles={relatedArticles.slice(0, 3)}
475
+ articles={resolvedRelatedArticles}
471
476
  basePath={normalizedBasePath}
472
477
  />
473
478
  )}
@@ -707,13 +712,26 @@ function PostArticleCta({
707
712
  </p>
708
713
  )}
709
714
  </div>
710
- <Link
711
- href={cta.href}
712
- className="inline-flex shrink-0 items-center justify-center gap-2 rounded-full bg-[var(--blog-accent)] px-4 py-2 text-sm font-semibold text-white transition-opacity hover:opacity-90"
713
- >
714
- {cta.label}
715
- <ArrowUpRight className="size-4" aria-hidden="true" />
716
- </Link>
715
+ <div className="flex shrink-0 flex-col gap-2 sm:flex-row">
716
+ {cta.primary && (
717
+ <Link
718
+ href={cta.primary.href}
719
+ className="inline-flex items-center justify-center gap-2 rounded-full bg-[var(--blog-accent)] px-4 py-2 text-sm font-semibold text-white transition-opacity hover:opacity-90"
720
+ >
721
+ {cta.primary.label}
722
+ <ArrowUpRight className="size-4" aria-hidden="true" />
723
+ </Link>
724
+ )}
725
+ {cta.secondary && (
726
+ <Link
727
+ href={cta.secondary.href}
728
+ className="border-border bg-background text-foreground hover:bg-muted inline-flex items-center justify-center gap-2 rounded-full border px-4 py-2 text-sm font-semibold transition-colors"
729
+ >
730
+ {cta.secondary.label}
731
+ <ArrowUpRight className="size-4" aria-hidden="true" />
732
+ </Link>
733
+ )}
734
+ </div>
717
735
  </div>
718
736
  </div>
719
737
  );
@@ -0,0 +1,229 @@
1
+ /**
2
+ * Public API contracts for the @xexr/blog-platform package.
3
+ *
4
+ * These types define the public interface between Bloggy and external blog platforms.
5
+ * They must remain stable and backward-compatible to ensure smooth upgrades.
6
+ *
7
+ * IMPORTANT: These contracts should never import from the main Bloggy application
8
+ * to maintain clean separation of concerns and prevent coupling.
9
+ */
10
+ /**
11
+ * Public profile and social links for an author.
12
+ */
13
+ interface PublishedAuthorSocialLinks {
14
+ /** Author website or profile URL */
15
+ readonly website?: string;
16
+ /** X/Twitter profile URL */
17
+ readonly x?: string;
18
+ /** Legacy Twitter profile URL */
19
+ readonly twitter?: string;
20
+ /** LinkedIn profile URL */
21
+ readonly linkedin?: string;
22
+ /** GitHub profile URL */
23
+ readonly github?: string;
24
+ /** Public contact email address */
25
+ readonly email?: string;
26
+ }
27
+ /**
28
+ * Represents a published author with essential information for bylines and attribution.
29
+ */
30
+ interface PublishedAuthor {
31
+ /** Unique identifier for the author */
32
+ readonly id: string;
33
+ /** Author's full name */
34
+ readonly name: string;
35
+ /** URL-friendly slug for author pages */
36
+ readonly slug: string;
37
+ /** Author's bio/description (optional) */
38
+ readonly bio?: string;
39
+ /** Author avatar image URL (optional) */
40
+ readonly avatarUrl?: string;
41
+ /** Public profile and social links (optional) */
42
+ readonly socialLinks?: PublishedAuthorSocialLinks;
43
+ /** Pronouns for proper attribution */
44
+ readonly pronouns?: string;
45
+ /** Whether this is a guest author */
46
+ readonly isGuest: boolean;
47
+ /** Tone adjectives that describe this author's writing style */
48
+ readonly toneAdjectives: readonly string[];
49
+ }
50
+ /**
51
+ * Represents a published site with essential metadata for blog rendering.
52
+ */
53
+ interface PublishedSite {
54
+ /** Unique identifier for the site */
55
+ readonly id: string;
56
+ /** Site name/title */
57
+ readonly name: string;
58
+ /** Site tagline/subtitle */
59
+ readonly tagline: string;
60
+ /** Site description */
61
+ readonly description: string;
62
+ /** Primary domain for the site */
63
+ readonly domain: string;
64
+ /** Site-wide tone adjectives for content context */
65
+ readonly toneAdjectives: readonly string[];
66
+ /** Default locale/language code */
67
+ readonly locale: string;
68
+ /** Optional Bloggy-owned newsletter capture configuration */
69
+ readonly newsletter?: PublishedNewsletterSettings;
70
+ }
71
+ /**
72
+ * Public configuration for Bloggy-owned newsletter capture.
73
+ */
74
+ interface PublishedNewsletterSettings {
75
+ /** Whether the capture form should render */
76
+ readonly enabled: boolean;
77
+ /** Form heading */
78
+ readonly title: string;
79
+ /** Form supporting copy */
80
+ readonly description: string;
81
+ /** Submit button label */
82
+ readonly buttonLabel: string;
83
+ /** Base URL for the Bloggy instance that receives subscriptions */
84
+ readonly captureBaseUrl: string;
85
+ /** Site-scoped public token used to validate public submissions */
86
+ readonly publicSubscribeToken: string;
87
+ }
88
+ /**
89
+ * Public post-article CTA configuration.
90
+ */
91
+ interface PublishedArticleCta {
92
+ /** CTA heading */
93
+ readonly title: string;
94
+ /** Optional supporting copy */
95
+ readonly description?: string;
96
+ /** Primary sign-up action */
97
+ readonly primary?: PublishedArticleCtaAction;
98
+ /** Secondary learn-more action */
99
+ readonly secondary?: PublishedArticleCtaAction;
100
+ }
101
+ /**
102
+ * Public post-article CTA link.
103
+ */
104
+ interface PublishedArticleCtaAction {
105
+ /** Button label */
106
+ readonly label: string;
107
+ /** Safe http(s) URL or same-site relative path */
108
+ readonly href: string;
109
+ }
110
+ /**
111
+ * Public topic/category taxonomy assigned to published articles.
112
+ */
113
+ interface PublishedTopic {
114
+ /** Unique identifier for the topic */
115
+ readonly id: string;
116
+ /** Display name */
117
+ readonly name: string;
118
+ /** URL-friendly slug for topic archive routing */
119
+ readonly slug: string;
120
+ /** Optional public description */
121
+ readonly description?: string;
122
+ /** Whether this is the article's primary topic */
123
+ readonly isPrimary: boolean;
124
+ }
125
+ /**
126
+ * Represents a published article with all necessary metadata for rendering
127
+ * on external blog platforms.
128
+ */
129
+ interface PublishedArticle {
130
+ /** Unique identifier for the article */
131
+ readonly id: string;
132
+ /** URL-friendly slug for routing */
133
+ readonly slug: string;
134
+ /** Article title/headline */
135
+ readonly title: string;
136
+ /** Brief excerpt/summary for meta descriptions and previews */
137
+ readonly excerpt: string;
138
+ /** Full article content in markdown format */
139
+ readonly content: string;
140
+ /** Estimated reading time in seconds */
141
+ readonly readingSeconds: number;
142
+ /** Word count for analytics */
143
+ readonly wordCount: number;
144
+ /** Publication date as ISO 8601 string */
145
+ readonly publishedAt: string;
146
+ /** Last updated date as ISO 8601 string */
147
+ readonly updatedAt: string;
148
+ /** Author information (can be null for anonymous posts) */
149
+ readonly author: PublishedAuthor | null;
150
+ /** Site information */
151
+ readonly site: PublishedSite;
152
+ /** Optional post-article call-to-action */
153
+ readonly cta?: PublishedArticleCta;
154
+ /** Public topic taxonomy assigned to the article */
155
+ readonly topics?: readonly PublishedTopic[];
156
+ /** Featured image URL (optional) */
157
+ readonly featuredImage?: string;
158
+ /** Alt text for featured image */
159
+ readonly featuredImageAlt?: string;
160
+ /** Caption displayed with the featured image */
161
+ readonly featuredImageCaption?: string;
162
+ /** Credit text displayed with the featured image */
163
+ readonly featuredImageCreditText?: string;
164
+ /** Optional URL for the featured image credit */
165
+ readonly featuredImageCreditUrl?: string;
166
+ /** Featured image R2 key for CDN optimization (optional) */
167
+ readonly featuredImageKey?: string;
168
+ /** SEO meta tags */
169
+ readonly meta: {
170
+ /** Meta title (defaults to article title if not provided) */
171
+ readonly title?: string;
172
+ /** Meta description (defaults to excerpt if not provided) */
173
+ readonly description?: string;
174
+ /** Canonical URL for SEO */
175
+ readonly canonicalUrl?: string;
176
+ /** Keywords for SEO */
177
+ readonly keywords: readonly string[];
178
+ /** Open Graph image URL */
179
+ readonly ogImage?: string;
180
+ };
181
+ /** Article version for optimistic locking */
182
+ readonly version: number;
183
+ }
184
+ /**
185
+ * Payload structure for publishing operations.
186
+ * Used when sending publish/unpublish requests to blog platforms.
187
+ */
188
+ interface PublishPayload {
189
+ /** The article to publish */
190
+ readonly article: PublishedArticle;
191
+ /** Idempotency key to prevent duplicate operations */
192
+ readonly idempotencyKey: string;
193
+ /** Optional slug override (defaults to article.slug) */
194
+ readonly slug?: string;
195
+ /** Optional meta description override (defaults to article.excerpt) */
196
+ readonly metaDescription?: string;
197
+ /** Platform-specific configuration */
198
+ readonly platformConfig?: Record<string, unknown>;
199
+ /** Whether this is a dry run (validation only) */
200
+ readonly dryRun?: boolean;
201
+ }
202
+ /**
203
+ * Result structure returned from publishing operations.
204
+ */
205
+ interface PublishResult {
206
+ /** Whether the operation was successful */
207
+ readonly success: boolean;
208
+ /** The published article URL if successful */
209
+ readonly url?: string;
210
+ /** Platform-specific post ID */
211
+ readonly platformPostId?: string;
212
+ /** Publication timestamp as ISO 8601 string */
213
+ readonly publishedAt?: string;
214
+ /** Error information if unsuccessful */
215
+ readonly error?: {
216
+ /** Error code (PUB001-PUB011) */
217
+ readonly code: string;
218
+ /** Human-readable error message */
219
+ readonly message: string;
220
+ /** Additional error details */
221
+ readonly details?: Record<string, unknown>;
222
+ };
223
+ /** Validation warnings (for dry runs or minor issues) */
224
+ readonly warnings?: readonly string[];
225
+ /** Idempotency key used for this operation */
226
+ readonly idempotencyKey: string;
227
+ }
228
+
229
+ export type { PublishedTopic as P, PublishedArticle as a, PublishedSite as b, PublishedAuthor as c, PublishedNewsletterSettings as d, PublishedArticleCta as e, PublishResult as f, PublishPayload as g, PublishedAuthorSocialLinks as h };