Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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;

6 changes: 0 additions & 6 deletions @theme/utils/dates.ts

This file was deleted.

12 changes: 12 additions & 0 deletions @theme/utils/rss.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
export function escapeXml(unsafe: string): string {
return unsafe
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}

export function formatRssDate(timestamp: number | string): string {
return new Date(timestamp).toUTCString();
}
127 changes: 127 additions & 0 deletions blog/feed.xml.get.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import type { ApiFunctionsContext, PageStaticData } from '@redocly/config';
import { readSharedData } from '@redocly/realm/dist/server/utils/shared-data.js';
import { formatRssDate, escapeXml } from '../@theme/utils/rss';

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

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


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


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

const categoryLabels = post.categories.map(formatCategory);
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 formatCategory(category: BlogCategory): string {
return category.category.label + (category.subcategory ? ` > ${category.subcategory.label}` : '');
}

async function readBlogPosts(outdir: string): Promise<BlogPost[]> {
try {
const sharedData = (await readSharedData(ALL_POSTS_SHARED_DATA_ID, outdir)) as {
posts: BlogPost[];
};
if (!sharedData) {
console.warn('[Blog RSS] No shared data found for blog posts.');
return [];
}

return sharedData.posts;
} 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 = String(staticData.props?.outdir || '');
const posts = await readBlogPosts(outdir);

const sortedPosts = posts
.filter((post) => Boolean(post.publishedDate))
.sort((a, b) => new Date(b.publishedDate).getTime() - new Date(a.publishedDate).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(Date.now())}</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