-
Notifications
You must be signed in to change notification settings - Fork 6
chore: RSS feed for changelog and blog #181
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
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 664ad49
Merge branch 'main' into subscribe-via-rss
aim4ik11 86dcdb0
add query params to api
aim4ik11 596c818
implement ui
aim4ik11 90456dc
add blog rss feed
aim4ik11 6ace28f
avoid third-party libraries
aim4ik11 173a45f
add more descriptive error message
aim4ik11 fb369ec
fix routing problem
aim4ik11 2ceec17
check various locations
aim4ik11 0e46521
try access page-data
aim4ik11 be39ec2
get client folder
aim4ik11 d181eca
clean code from unworking solutions
aim4ik11 df68927
add some test changelog
aim4ik11 2eb8520
add author to blog feed
aim4ik11 64a026b
change api routes
aim4ik11 ff03cca
make api routes
aim4ik11 265332b
add links, restyle to anchors
aim4ik11 8e15c88
improve feed styles
aim4ik11 ea1072b
add test feed item
aim4ik11 f103b7a
add new test changelog
aim4ik11 cf24f57
try to improve styles for slack feed
aim4ik11 2ddade1
new changelog to test
aim4ik11 e86787c
fix changelog
aim4ik11 c5a3132
add new test changelog
aim4ik11 94d26b8
update rss feed to new routes
aim4ik11 94911a0
update paths in ui
aim4ik11 e99dbc8
no-cache + dummy test blog
aim4ik11 63aefe9
change ui for blog rss
aim4ik11 8fb511f
add new dummy blog
aim4ik11 3eeec20
restyle blog rss
aim4ik11 2e5c2a8
added second rss button to blog
aim4ik11 3337549
add more margin, remove test changelogs, blogs
aim4ik11 c44b499
Merge branch 'main' into subscribe-via-rss
aim4ik11 d8f3ff9
Merge branch 'main' into subscribe-via-rss
aim4ik11 8dbe402
refactor blog feed, use shared data
aim4ik11 3bf21bc
chore: simplify
RomanHotsiy 8740581
chore: fix links
RomanHotsiy File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; | ||
|
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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, '&') | ||
| .replace(/</g, '<') | ||
| .replace(/>/g, '>') | ||
| .replace(/"/g, '"') | ||
| .replace(/'/g, '''); | ||
| } | ||
|
|
||
| function escapeXmlForCategories(unsafe: string): string { | ||
| return unsafe | ||
| .replace(/&(?!(amp|lt|gt|quot|apos);)/g, '&') | ||
| .replace(/</g, '<'); | ||
| } | ||
|
|
||
| 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, ']]>')}</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') { | ||
| 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, | ||
| }); | ||
| } | ||
| } | ||
|
|
||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.