Skip to content
Merged
Show file tree
Hide file tree
Changes from 35 commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
da7144d
Add changelog rss feed api function and page
aim4ik11 Nov 19, 2025
664ad49
Merge branch 'main' into subscribe-via-rss
aim4ik11 Nov 19, 2025
86dcdb0
add query params to api
aim4ik11 Nov 19, 2025
596c818
implement ui
aim4ik11 Nov 20, 2025
90456dc
add blog rss feed
aim4ik11 Nov 20, 2025
6ace28f
avoid third-party libraries
aim4ik11 Nov 20, 2025
173a45f
add more descriptive error message
aim4ik11 Nov 24, 2025
fb369ec
fix routing problem
aim4ik11 Nov 24, 2025
2ceec17
check various locations
aim4ik11 Nov 24, 2025
0e46521
try access page-data
aim4ik11 Nov 24, 2025
be39ec2
get client folder
aim4ik11 Nov 24, 2025
d181eca
clean code from unworking solutions
aim4ik11 Nov 24, 2025
df68927
add some test changelog
aim4ik11 Nov 24, 2025
2eb8520
add author to blog feed
aim4ik11 Nov 24, 2025
64a026b
change api routes
aim4ik11 Nov 24, 2025
ff03cca
make api routes
aim4ik11 Nov 24, 2025
265332b
add links, restyle to anchors
aim4ik11 Nov 24, 2025
8e15c88
improve feed styles
aim4ik11 Nov 24, 2025
ea1072b
add test feed item
aim4ik11 Nov 24, 2025
f103b7a
add new test changelog
aim4ik11 Nov 24, 2025
cf24f57
try to improve styles for slack feed
aim4ik11 Nov 24, 2025
2ddade1
new changelog to test
aim4ik11 Nov 24, 2025
e86787c
fix changelog
aim4ik11 Nov 24, 2025
c5a3132
add new test changelog
aim4ik11 Nov 25, 2025
94d26b8
update rss feed to new routes
aim4ik11 Nov 25, 2025
94911a0
update paths in ui
aim4ik11 Nov 25, 2025
e99dbc8
no-cache + dummy test blog
aim4ik11 Nov 25, 2025
63aefe9
change ui for blog rss
aim4ik11 Nov 25, 2025
8fb511f
add new dummy blog
aim4ik11 Nov 25, 2025
3eeec20
restyle blog rss
aim4ik11 Nov 25, 2025
2e5c2a8
added second rss button to blog
aim4ik11 Nov 25, 2025
3337549
add more margin, remove test changelogs, blogs
aim4ik11 Nov 25, 2025
c44b499
Merge branch 'main' into subscribe-via-rss
aim4ik11 Nov 25, 2025
d8f3ff9
Merge branch 'main' into subscribe-via-rss
aim4ik11 Nov 26, 2025
8dbe402
refactor blog feed, use shared data
aim4ik11 Nov 26, 2025
3bf21bc
chore: simplify
RomanHotsiy Nov 26, 2025
8740581
chore: fix links
RomanHotsiy Nov 26, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 95 additions & 1 deletion @theme/blog.page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { Breadcrumbs } from '@redocly/theme/components/Breadcrumbs/Breadcrumbs';
import { BreadcrumbItem } from '@redocly/theme/core/types';
import { H2 } from '@redocly/theme/components/Typography/H2';
import styled from 'styled-components';
import { BlogRssSubscription } from './components/Blog/BlogRssSubscription';

export default function BlogRoutes() {
return (
Expand All @@ -33,12 +34,31 @@ export default function BlogRoutes() {
function BlogMain() {
return (
<PageWrapper>
<FirstThreePosts />
<FirstThreePostsWrapper>
<RssButtonContainer>
<BlogRssSubscription />
</RssButtonContainer>
<FirstThreePosts />
</FirstThreePostsWrapper>

<FeaturedClassics />

<LatestPosts />

<SubscribeCardSection>
<SubscribeCard>
<CardEyebrow>Stay in the loop</CardEyebrow>
<CardTitle>Subscribe to the Redocly blog</CardTitle>
<CardDescription>
Get notified about new releases, API best practices, and company updates the moment they ship.
Plug the RSS feed into your reader or workflow automation.
</CardDescription>
<CardActions>
<BlogRssSubscription />
</CardActions>
</SubscribeCard>
</SubscribeCardSection>

<ContactUs />

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

const FirstThreePostsWrapper = styled.div`
position: relative;
`;

const RssButtonContainer = styled.div`
position: absolute;
top: -64px;
right: 0;
z-index: 10;

@media screen and (min-width: 1400px) {
right: calc((100% - 1240px) / 2);
}

@media screen and (max-width: 1399px) {
right: calc((100% - 90vw) / 2);
}

@media (max-width: 640px) {
position: static;
display: flex;
justify-content: center;
margin-bottom: 16px;
}
`;

const SubscribeCardSection = styled.section`
display: flex;
justify-content: center;
padding: 64px 24px 16px;
`;

const SubscribeCard = styled.div`
width: 100%;
max-width: 960px;
border-radius: 16px;
padding: 48px;
background: linear-gradient(135deg, rgba(32, 125, 255, 0.08), rgba(148, 97, 255, 0.08));
border: 1px solid rgba(10, 28, 43, 0.08);
display: flex;
flex-direction: column;
gap: 16px;

@media (max-width: 640px) {
padding: 32px 24px;
}
`;

const CardEyebrow = styled.span`
font-size: 14px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: rgba(10, 28, 43, 0.72);
`;

const CardTitle = styled.h2`
margin: 0;
font-size: 32px;
line-height: 1.2;
color: #0a1c2b;
`;

const CardDescription = styled.p`
margin: 0;
font-size: 18px;
line-height: 1.5;
color: rgba(10, 28, 43, 0.8);
`;

const CardActions = styled.div`
margin-top: 12px;
display: inline-flex;
`;

// Category page component
function CategoryPage() {
const { category, subcategory } = useParams();
Expand Down
51 changes: 51 additions & 0 deletions @theme/components/Blog/BlogRssSubscription.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import * as React from 'react';
import styled from 'styled-components';
import { CDNIcon } from '@redocly/theme/icons/CDNIcon/CDNIcon';

const BLOG_RSS_PATH = '/blog/feed.xml';

interface BlogRssSubscriptionProps {
className?: string;
}

export function BlogRssSubscription({ className }: BlogRssSubscriptionProps) {
const rssFeedUrl = React.useMemo(() => {
if (typeof window === 'undefined') return BLOG_RSS_PATH;
return `${window.location.origin}${BLOG_RSS_PATH}`;
}, []);

return (
<SubscribeButton
href={rssFeedUrl}
target="_blank"
rel="noopener noreferrer"
className={className}
aria-label="Open Redocly blog RSS feed in a new tab"
>
<CDNIcon name="rss" size="1em" color="currentColor" />
Subscribe via RSS
</SubscribeButton>
);
}

const SubscribeButton = styled.a`
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 20px;
border-radius: 999px;
border: 1px solid var(--border-color-primary);
background: var(--bg-color-tonal);
color: var(--text-color-primary);
font-weight: 600;
text-decoration: none;
transition: background 0.2s ease, border-color 0.2s ease, color 0.2s ease;

&:hover {
background: var(--bg-color-hover);
color: var(--text-color-primary);
}
`;

export default BlogRssSubscription;

213 changes: 213 additions & 0 deletions blog/feed.xml.get.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
import type { ApiFunctionsContext, PageStaticData } from '@redocly/config';
import { readSharedData } from '@redocly/realm/dist/server/utils/shared-data.js';

const BLOG_SLUG = '/blog/';
const RSS_ITEMS_LIMIT = 50;
const ALL_POSTS_SHARED_DATA_ID = 'blog-posts';

type BlogCategory = { label: string };
type BlogAuthor = { name?: string };
type BlogPost = {
slug: string;
title: string;
description?: string;
date: string;
author?: BlogAuthor;
categories: BlogCategory[];
};

function escapeXml(unsafe: string): string {
return unsafe
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}

function escapeXmlForCategories(unsafe: string): string {
return unsafe
.replace(/&(?!(amp|lt|gt|quot|apos);)/g, '&amp;')
.replace(/</g, '&lt;');
}

function formatRssDate(timestamp: string): string {
return new Date(timestamp).toUTCString();
}

function buildRssItem(post: BlogPost, origin: string): string {
const link = `${origin}${post.slug}`;
const pubDate = formatRssDate(post.date);

const categoriesArray = Array.isArray(post.categories) ? post.categories : [];
const categoryLabels = categoriesArray
.map((cat) => (typeof cat?.label === 'string' ? cat.label : null))
.filter((label): label is string => Boolean(label));

const categoriesXml = categoryLabels
.map(label => `<category>${escapeXmlForCategories(label)}</category>`)
.join('');

const author = escapeXml(post.author?.name || 'Redocly Team');
const description = `<p>${(post.description || '').replace(/]]>/g, ']]&gt;')}</p>`;

return `
<item>
<title>${escapeXml(post.title)}</title>
<link>${escapeXml(link)}</link>
<guid isPermaLink="true">${escapeXml(link)}</guid>
<pubDate>${pubDate}</pubDate>
${categoriesXml}
<description><![CDATA[${description}]]></description>
<author>${author}</author>
</item>
`;
}

function mapCategory(category: any): BlogCategory | null {
if (!category || typeof category !== 'object') {
return null;
}

const mainLabel = typeof category.category?.label === 'string' ? category.category.label : '';
const subLabel = typeof category.subcategory?.label === 'string' ? category.subcategory.label : '';

if (mainLabel && subLabel) {
return { label: `${mainLabel} > ${subLabel}` };
}

if (mainLabel) {
return { label: mainLabel };
}

return null;
}

function mapToBlogPost(post: any): BlogPost | null {
if (!post) {
return null;
}

const postDate = post.publishedDate || post.date;
const slug = typeof post.slug === 'string' ? post.slug : '';
const title = typeof post.title === 'string' ? post.title : '';

if (!slug || !title || !postDate) {
return null;
}

const categoriesList = Array.isArray(post.categories) ? post.categories : [];
const categories: BlogCategory[] = categoriesList
.map(mapCategory)
.filter((cat): cat is BlogCategory => cat !== null);

let authorName: string | undefined;
if (typeof post.author === 'object' && typeof post.author?.name === 'string') {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if (typeof post.author === 'object' && typeof post.author?.name === 'string') {
if (typeof post.author?.name === 'string') {

authorName = post.author.name;
} else if (typeof post.author === 'string') {
authorName = post.author;
}

const author: BlogAuthor = { name: authorName || 'Redocly Team' };

return {
slug,
title,
description: typeof post.description === 'string' ? post.description : '',
date: postDate,
author,
categories,
};
}

async function readBlogPosts(outdir?: string): Promise<BlogPost[]> {
if (!outdir) {
console.warn('[Blog RSS] Missing outdir. Cannot read shared blog posts data.');
return [];
}

try {
const sharedData = await readSharedData(ALL_POSTS_SHARED_DATA_ID, outdir);
if (!sharedData) {
console.warn('[Blog RSS] No shared data found for blog posts.');
return [];
}

const postsSource = Array.isArray((sharedData as any).posts)
? (sharedData as any).posts
: Array.isArray(sharedData)
? sharedData
: [];

if (!Array.isArray(postsSource)) {
console.warn('[Blog RSS] Shared data does not contain a posts array.');
return [];
}

return postsSource
.map(mapToBlogPost)
.filter((post): post is BlogPost => Boolean(post));
} catch (error) {
console.error('[Blog RSS] Failed to read shared blog posts data:', error);
return [];
}
}

export default async function blogRssHandler(
request: Request,
context: ApiFunctionsContext,
staticData: PageStaticData
) {
try {
const outdir =
typeof staticData?.props?.outdir === 'string' ? staticData.props.outdir : undefined;
const posts = await readBlogPosts(outdir);

const sortedPosts = posts
.filter((post) => Boolean(post.date))
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
.slice(0, RSS_ITEMS_LIMIT);

const url = new URL(request.url);
const origin = `${url.protocol}//${url.host}`;
const rssItems = sortedPosts.map((post) => buildRssItem(post, origin)).join('');

const rssXml = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<title>Redocly Blog</title>
<link>${escapeXml(origin + BLOG_SLUG)}</link>
<description>Latest posts from the Redocly blog.</description>
<language>en-us</language>
<lastBuildDate>${formatRssDate(new Date().toISOString())}</lastBuildDate>
<atom:link href="${escapeXml(request.url)}" rel="self" type="application/rss+xml"/>
${rssItems}
</channel>
</rss>`;

return new Response(rssXml, {
status: 200,
headers: {
'Content-Type': 'application/rss+xml; charset=utf-8',
'Cache-Control': 'no-store, no-cache, must-revalidate',
Pragma: 'no-cache',
Expires: '0',
},
});
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
const errorStack = error instanceof Error ? error.stack : 'No stack trace';

console.error('[Blog RSS] Error generating RSS feed:', {
message: errorMessage,
stack: errorStack,
});

return context.status(500).json({
error: 'Internal server error',
message: 'Failed to generate blog RSS feed',
details: errorMessage,
});
}
}

Loading