Skip to content

Commit 6c6a918

Browse files
chore: RSS feed for changelog and blog (#181)
* Add changelog rss feed api function and page * add query params to api * implement ui * add blog rss feed * avoid third-party libraries * add more descriptive error message * fix routing problem * check various locations * try access page-data * get client folder * clean code from unworking solutions * add some test changelog * add author to blog feed * change api routes * make api routes * add links, restyle to anchors * improve feed styles * add test feed item * add new test changelog * try to improve styles for slack feed * new changelog to test * fix changelog * add new test changelog * update rss feed to new routes * update paths in ui * no-cache + dummy test blog * change ui for blog rss * add new dummy blog * restyle blog rss * added second rss button to blog * add more margin, remove test changelogs, blogs * refactor blog feed, use shared data * chore: simplify * chore: fix links --------- Co-authored-by: Roman Hotsiy <[email protected]>
1 parent 3dda0a8 commit 6c6a918

File tree

11 files changed

+1114
-76
lines changed

11 files changed

+1114
-76
lines changed

@theme/blog.page.tsx

Lines changed: 95 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { Breadcrumbs } from '@redocly/theme/components/Breadcrumbs/Breadcrumbs';
1616
import { BreadcrumbItem } from '@redocly/theme/core/types';
1717
import { H2 } from '@redocly/theme/components/Typography/H2';
1818
import styled from 'styled-components';
19+
import { BlogRssSubscription } from './components/Blog/BlogRssSubscription';
1920

2021
export default function BlogRoutes() {
2122
return (
@@ -33,12 +34,31 @@ export default function BlogRoutes() {
3334
function BlogMain() {
3435
return (
3536
<PageWrapper>
36-
<FirstThreePosts />
37+
<FirstThreePostsWrapper>
38+
<RssButtonContainer>
39+
<BlogRssSubscription />
40+
</RssButtonContainer>
41+
<FirstThreePosts />
42+
</FirstThreePostsWrapper>
3743

3844
<FeaturedClassics />
3945

4046
<LatestPosts />
4147

48+
<SubscribeCardSection>
49+
<SubscribeCard>
50+
<CardEyebrow>Stay in the loop</CardEyebrow>
51+
<CardTitle>Subscribe to the Redocly blog</CardTitle>
52+
<CardDescription>
53+
Get notified about new releases, API best practices, and company updates the moment they ship.
54+
Plug the RSS feed into your reader or workflow automation.
55+
</CardDescription>
56+
<CardActions>
57+
<BlogRssSubscription />
58+
</CardActions>
59+
</SubscribeCard>
60+
</SubscribeCardSection>
61+
4262
<ContactUs />
4363

4464
<CallToAction title="Launch API docs you'll be proud of">
@@ -59,6 +79,80 @@ const PageWrapper = styled.div`
5979
overflow: hidden;
6080
`;
6181

82+
const FirstThreePostsWrapper = styled.div`
83+
position: relative;
84+
`;
85+
86+
const RssButtonContainer = styled.div`
87+
position: absolute;
88+
top: -64px;
89+
right: 0;
90+
z-index: 10;
91+
92+
@media screen and (min-width: 1400px) {
93+
right: calc((100% - 1240px) / 2);
94+
}
95+
96+
@media screen and (max-width: 1399px) {
97+
right: calc((100% - 90vw) / 2);
98+
}
99+
100+
@media (max-width: 640px) {
101+
position: static;
102+
display: flex;
103+
justify-content: center;
104+
margin-bottom: 16px;
105+
}
106+
`;
107+
108+
const SubscribeCardSection = styled.section`
109+
display: flex;
110+
justify-content: center;
111+
padding: 64px 24px 16px;
112+
`;
113+
114+
const SubscribeCard = styled.div`
115+
width: 100%;
116+
max-width: 960px;
117+
border-radius: 16px;
118+
padding: 48px;
119+
background: linear-gradient(135deg, rgba(32, 125, 255, 0.08), rgba(148, 97, 255, 0.08));
120+
border: 1px solid rgba(10, 28, 43, 0.08);
121+
display: flex;
122+
flex-direction: column;
123+
gap: 16px;
124+
125+
@media (max-width: 640px) {
126+
padding: 32px 24px;
127+
}
128+
`;
129+
130+
const CardEyebrow = styled.span`
131+
font-size: 14px;
132+
text-transform: uppercase;
133+
letter-spacing: 0.08em;
134+
color: rgba(10, 28, 43, 0.72);
135+
`;
136+
137+
const CardTitle = styled.h2`
138+
margin: 0;
139+
font-size: 32px;
140+
line-height: 1.2;
141+
color: #0a1c2b;
142+
`;
143+
144+
const CardDescription = styled.p`
145+
margin: 0;
146+
font-size: 18px;
147+
line-height: 1.5;
148+
color: rgba(10, 28, 43, 0.8);
149+
`;
150+
151+
const CardActions = styled.div`
152+
margin-top: 12px;
153+
display: inline-flex;
154+
`;
155+
62156
// Category page component
63157
function CategoryPage() {
64158
const { category, subcategory } = useParams();
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import * as React from 'react';
2+
import styled from 'styled-components';
3+
import { CDNIcon } from '@redocly/theme/icons/CDNIcon/CDNIcon';
4+
5+
const BLOG_RSS_PATH = '/blog/feed.xml';
6+
7+
interface BlogRssSubscriptionProps {
8+
className?: string;
9+
}
10+
11+
export function BlogRssSubscription({ className }: BlogRssSubscriptionProps) {
12+
const rssFeedUrl = React.useMemo(() => {
13+
if (typeof window === 'undefined') return BLOG_RSS_PATH;
14+
return `${window.location.origin}${BLOG_RSS_PATH}`;
15+
}, []);
16+
17+
return (
18+
<SubscribeButton
19+
href={rssFeedUrl}
20+
target="_blank"
21+
rel="noopener noreferrer"
22+
className={className}
23+
aria-label="Open Redocly blog RSS feed in a new tab"
24+
>
25+
<CDNIcon name="rss" size="1em" color="currentColor" />
26+
Subscribe via RSS
27+
</SubscribeButton>
28+
);
29+
}
30+
31+
const SubscribeButton = styled.a`
32+
display: inline-flex;
33+
align-items: center;
34+
gap: 8px;
35+
padding: 10px 20px;
36+
border-radius: 999px;
37+
border: 1px solid var(--border-color-primary);
38+
background: var(--bg-color-tonal);
39+
color: var(--text-color-primary);
40+
font-weight: 600;
41+
text-decoration: none;
42+
transition: background 0.2s ease, border-color 0.2s ease, color 0.2s ease;
43+
44+
&:hover {
45+
background: var(--bg-color-hover);
46+
color: var(--text-color-primary);
47+
}
48+
`;
49+
50+
export default BlogRssSubscription;
51+

@theme/utils/dates.ts

Lines changed: 0 additions & 6 deletions
This file was deleted.

@theme/utils/rss.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
export function escapeXml(unsafe: string): string {
2+
return unsafe
3+
.replace(/&/g, '&amp;')
4+
.replace(/</g, '&lt;')
5+
.replace(/>/g, '&gt;')
6+
.replace(/"/g, '&quot;')
7+
.replace(/'/g, '&apos;');
8+
}
9+
10+
export function formatRssDate(timestamp: number | string): string {
11+
return new Date(timestamp).toUTCString();
12+
}

blog/feed.xml.get.ts

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import type { ApiFunctionsContext, PageStaticData } from '@redocly/config';
2+
import { readSharedData } from '@redocly/realm/dist/server/utils/shared-data.js';
3+
import { formatRssDate, escapeXml } from '../@theme/utils/rss';
4+
5+
const BLOG_SLUG = '/blog/';
6+
const RSS_ITEMS_LIMIT = 50;
7+
const ALL_POSTS_SHARED_DATA_ID = 'blog-posts';
8+
9+
type BlogCategory = { category: { label: string }; subcategory?: { label: string } };
10+
type BlogAuthor = { name?: string };
11+
type BlogPost = {
12+
slug: string;
13+
title: string;
14+
description?: string;
15+
publishedDate: string;
16+
author?: BlogAuthor;
17+
categories: BlogCategory[];
18+
};
19+
20+
21+
function escapeXmlForCategories(unsafe: string): string {
22+
return unsafe.replace(/&(?!(amp|lt|gt|quot|apos);)/g, '&amp;').replace(/</g, '&lt;');
23+
}
24+
25+
26+
function buildRssItem(post: BlogPost, origin: string): string {
27+
const link = `${origin}${post.slug}`;
28+
const pubDate = formatRssDate(post.publishedDate);
29+
30+
const categoryLabels = post.categories.map(formatCategory);
31+
const categoriesXml = categoryLabels
32+
.map((label) => `<category>${escapeXmlForCategories(label)}</category>`)
33+
.join('');
34+
35+
const author = escapeXml(post.author?.name || 'Redocly Team');
36+
const description = `<p>${(post.description || '').replace(/]]>/g, ']]&gt;')}</p>`;
37+
38+
return `
39+
<item>
40+
<title>${escapeXml(post.title)}</title>
41+
<link>${escapeXml(link)}</link>
42+
<guid isPermaLink="true">${escapeXml(link)}</guid>
43+
<pubDate>${pubDate}</pubDate>
44+
${categoriesXml}
45+
<description><![CDATA[${description}]]></description>
46+
<author>${author}</author>
47+
</item>
48+
`;
49+
}
50+
51+
function formatCategory(category: BlogCategory): string {
52+
return category.category.label + (category.subcategory ? ` > ${category.subcategory.label}` : '');
53+
}
54+
55+
async function readBlogPosts(outdir: string): Promise<BlogPost[]> {
56+
try {
57+
const sharedData = (await readSharedData(ALL_POSTS_SHARED_DATA_ID, outdir)) as {
58+
posts: BlogPost[];
59+
};
60+
if (!sharedData) {
61+
console.warn('[Blog RSS] No shared data found for blog posts.');
62+
return [];
63+
}
64+
65+
return sharedData.posts;
66+
} catch (error) {
67+
console.error('[Blog RSS] Failed to read shared blog posts data:', error);
68+
return [];
69+
}
70+
}
71+
72+
export default async function blogRssHandler(
73+
request: Request,
74+
context: ApiFunctionsContext,
75+
staticData: PageStaticData,
76+
) {
77+
try {
78+
const outdir = String(staticData.props?.outdir || '');
79+
const posts = await readBlogPosts(outdir);
80+
81+
const sortedPosts = posts
82+
.filter((post) => Boolean(post.publishedDate))
83+
.sort((a, b) => new Date(b.publishedDate).getTime() - new Date(a.publishedDate).getTime())
84+
.slice(0, RSS_ITEMS_LIMIT);
85+
86+
const url = new URL(request.url);
87+
const origin = `${url.protocol}//${url.host}`;
88+
const rssItems = sortedPosts.map((post) => buildRssItem(post, origin)).join('');
89+
90+
const rssXml = `<?xml version="1.0" encoding="UTF-8"?>
91+
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
92+
<channel>
93+
<title>Redocly Blog</title>
94+
<link>${escapeXml(origin + BLOG_SLUG)}</link>
95+
<description>Latest posts from the Redocly blog.</description>
96+
<language>en-us</language>
97+
<lastBuildDate>${formatRssDate(Date.now())}</lastBuildDate>
98+
<atom:link href="${escapeXml(request.url)}" rel="self" type="application/rss+xml"/>
99+
${rssItems}
100+
</channel>
101+
</rss>`;
102+
103+
return new Response(rssXml, {
104+
status: 200,
105+
headers: {
106+
'Content-Type': 'application/rss+xml; charset=utf-8',
107+
'Cache-Control': 'no-store, no-cache, must-revalidate',
108+
Pragma: 'no-cache',
109+
Expires: '0',
110+
},
111+
});
112+
} catch (error) {
113+
const errorMessage = error instanceof Error ? error.message : String(error);
114+
const errorStack = error instanceof Error ? error.stack : 'No stack trace';
115+
116+
console.error('[Blog RSS] Error generating RSS feed:', {
117+
message: errorMessage,
118+
stack: errorStack,
119+
});
120+
121+
return context.status(500).json({
122+
error: 'Internal server error',
123+
message: 'Failed to generate blog RSS feed',
124+
details: errorMessage,
125+
});
126+
}
127+
}

0 commit comments

Comments
 (0)