Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 4 additions & 0 deletions data/onCreateNode/create-graphql-schema-customization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ export const createSchemaCustomization: GatsbyNode['createSchemaCustomization']
meta_description: String
meta_keywords: String
redirect_from: [String]
date: Date @dateformat
products: [String]
meta_image: String
meta_image_alt: String
}
type Error implements Node {
message: String
Expand Down
10 changes: 9 additions & 1 deletion data/onCreatePage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ const pageLayoutOptions: Record<string, LayoutOptions> = {
},
'/docs/sdks': { leftSidebar: false, rightSidebar: false, template: 'sdk', mdx: false },
'/examples': { leftSidebar: false, rightSidebar: false, template: 'examples', mdx: false },
'/docs/changelog': { leftSidebar: false, rightSidebar: false, template: 'changelog', mdx: false },
'/docs/404': { leftSidebar: false, rightSidebar: false, template: '404', mdx: false },
};

Expand Down Expand Up @@ -56,14 +57,21 @@ export const onCreatePage: GatsbyNode['onCreatePage'] = async ({ page, actions }
const { createPage } = actions;
const pathOptions = Object.entries(pageLayoutOptions).find(([path]) => page.path === path);
const isMDX = page.component.endsWith('.mdx');
// Changelog entry pages are MDX but use a dedicated layout (no product left-nav,
// a changelog-specific header) instead of the standard doc chrome.
const isChangelogEntry = isMDX && page.path.startsWith('/docs/changelog/');
const detectedLanguages = isMDX ? await extractCodeLanguages(page.component) : new Set();

const defaultLayout: LayoutOptions = isChangelogEntry
? { leftSidebar: false, rightSidebar: true, template: 'changelog-entry', mdx: true }
: { leftSidebar: true, rightSidebar: true, template: 'base', mdx: isMDX };

if (pathOptions || isMDX) {
createPage({
...page,
context: {
...page.context,
layout: pathOptions ? pathOptions[1] : { leftSidebar: true, rightSidebar: true, template: 'base', mdx: isMDX },
layout: pathOptions ? pathOptions[1] : defaultLayout,
...(isMDX ? { languages: Array.from(detectedLanguages) } : {}),
},
component: isMDX ? `${mdxWrapper}?__contentFilePath=${page.component}` : page.component,
Expand Down
65 changes: 65 additions & 0 deletions data/onPostBuild/changelogFeed.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { buildFeedXml, FeedItem } from './changelogFeed';

const OPTS = {
changelogUrl: 'https://ably.com/docs/changelog',
feedUrl: 'https://ably.com/docs/changelog/rss.xml',
};

const item = (over: Partial<FeedItem> = {}): FeedItem => ({
title: 'JS SDK v2.23.0',
description: 'A short blurb.',
date: '2026-06-19',
products: ['pub-sub'],
url: 'https://ably.com/docs/changelog/2026/06/js-sdk-2-23-0',
...over,
});

describe('buildFeedXml', () => {
it('produces a well-formed RSS 2.0 channel with a self link', () => {
const xml = buildFeedXml([], OPTS);
const doc = new DOMParser().parseFromString(xml, 'application/xml');
expect(doc.querySelector('parsererror')).toBeNull();
expect(xml).toContain('<rss version="2.0"');
expect(xml).toContain('<title>Ably Changelog</title>');
expect(xml).toContain('<link>https://ably.com/docs/changelog</link>');
expect(xml).toContain(
'<atom:link href="https://ably.com/docs/changelog/rss.xml" rel="self" type="application/rss+xml" />',
);
});

it('renders one item per entry with title, link, guid, pubDate, and description', () => {
const xml = buildFeedXml([item({ title: 'Hello', url: 'https://ably.com/docs/changelog/a' })], OPTS);
expect(xml).toContain('<title>Hello</title>');
expect(xml).toContain('<link>https://ably.com/docs/changelog/a</link>');
expect(xml).toContain('<guid isPermaLink="true">https://ably.com/docs/changelog/a</guid>');
expect(xml).toContain(`<pubDate>${new Date('2026-06-19').toUTCString()}</pubDate>`);
expect(xml).toContain('<description>A short blurb.</description>');
});

it('is a summary feed — no full-content block', () => {
const xml = buildFeedXml([item()], OPTS);
expect(xml).not.toContain('content:encoded');
expect(xml).not.toContain('CDATA');
expect(xml).not.toContain('xmlns:content');
});

it('emits a category per product', () => {
const xml = buildFeedXml([item({ products: ['pub-sub', 'chat'] })], OPTS);
expect(xml).toContain('<category>pub-sub</category>');
expect(xml).toContain('<category>chat</category>');
});

it('escapes XML metacharacters in text content', () => {
const xml = buildFeedXml([item({ title: `A & B <c> "d" 'e'` })], OPTS);
expect(xml).toContain('A &amp; B &lt;c&gt; &quot;d&quot; &apos;e&apos;');
expect(xml).not.toContain('<c>');
// Still well-formed after escaping.
expect(new DOMParser().parseFromString(xml, 'application/xml').querySelector('parsererror')).toBeNull();
});

it('renders an item per entry in the order given', () => {
const xml = buildFeedXml([item({ title: 'First' }), item({ title: 'Second' })], OPTS);
expect(xml.match(/<item>/g) ?? []).toHaveLength(2);
expect(xml.indexOf('First')).toBeLessThan(xml.indexOf('Second'));
});
});
184 changes: 184 additions & 0 deletions data/onPostBuild/changelogFeed.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
import { GatsbyNode } from 'gatsby';
import * as path from 'path';
import * as fs from 'fs';
import { ChangelogFileNode, nodesToEntries } from '../../src/components/Changelog/entries';
import { sortByDateDesc } from '../../src/components/Changelog/filter-changelog';
import { CHANGELOG_PATH, CHANGELOG_RSS_PATH } from '../../src/components/Changelog/constants';
import { isKnownProductSlug, productTags } from '../../src/components/Changelog/tags';
import { absolutizeUrl } from '../../src/components/Changelog/absolutize-url';

const REPORTER_PREFIX = 'onPostBuild:changelogFeed';

const FEED_TITLE = 'Ably Changelog';
const FEED_DESCRIPTION = 'New features, improvements, and fixes across the Ably platform and SDKs.';

// Cap the feed to the most recent entries, matching the previous
// changelog.ably.com feed and standard RSS practice (readers only surface recent
// items, and an unbounded feed grows without limit). The full history remains
// browsable on the changelog index page.
const FEED_MAX_ITEMS = 20;

interface ChangelogFeedQueryResult {
site: {
siteMetadata: {
siteUrl: string | null;
};
};
entries: {
nodes: ChangelogFileNode[];
};
}

// A single feed item, already shaped (absolute URL, blurb) for rendering.
export interface FeedItem {
title: string;
description: string;
date: string;
products: string[];
url: string;
}

// Minimal XML escaping for text placed inside elements/attributes.
const escapeXml = (value: string): string =>
value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');

// Build a summary RSS 2.0 feed: one item per entry with a title, the
// meta_description as the blurb, a link/guid back to the entry, a pubDate, and a
// category per product. Deliberately summary-only (no <content:encoded>) — the
// canonical, full entry lives on the changelog page, matching how peer changelogs
// (Supabase, Neon, GitHub) publish. Pure and synchronous so it can be unit-tested.
export const buildFeedXml = (items: FeedItem[], opts: { changelogUrl: string; feedUrl: string }): string => {
const renderedItems = items
.map((item) => {
const categories = item.products.map((product) => ` <category>${escapeXml(product)}</category>`).join('\n');
return [
' <item>',
` <title>${escapeXml(item.title)}</title>`,
` <link>${escapeXml(item.url)}</link>`,
` <guid isPermaLink="true">${escapeXml(item.url)}</guid>`,
` <pubDate>${new Date(item.date).toUTCString()}</pubDate>`,
` <description>${escapeXml(item.description)}</description>`,
categories,
' </item>',
]
.filter(Boolean)
.join('\n');
})
.join('\n');

return `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<title>${escapeXml(FEED_TITLE)}</title>
<link>${escapeXml(opts.changelogUrl)}</link>
<description>${escapeXml(FEED_DESCRIPTION)}</description>
<atom:link href="${escapeXml(opts.feedUrl)}" rel="self" type="application/rss+xml" />
${renderedItems}
</channel>
</rss>
`;
};

// Generates a summary RSS 2.0 feed from the changelog MDX entries and writes it to
// public/docs/changelog/rss.xml. Mirrors the query/siteUrl handling used by the
// llms.txt and markdown post-build steps; no extra plugin dependency is needed.
export const onPostBuild: GatsbyNode['onPostBuild'] = async ({ graphql, reporter, basePath }) => {
const query = `
query {
site {
siteMetadata {
siteUrl
}
}
entries: allFile(
filter: {
sourceInstanceName: { eq: "pages" }
extension: { eq: "mdx" }
relativeDirectory: { regex: "/^docs/changelog(/|$)/" }
}
) {
nodes {
name
relativeDirectory
childMdx {
frontmatter {
title
meta_description
date
products
}
}
}
}
}
`;

const { data, errors } = await graphql<ChangelogFeedQueryResult>(query);

if (errors) {
reporter.panicOnBuild(`${REPORTER_PREFIX} GraphQL query failed: ${JSON.stringify(errors, null, 2)}`);
throw errors;
}

if (!data) {
reporter.warn(`${REPORTER_PREFIX} No data returned; skipping changelog feed.`);
return;
}

// All sourced changelog entries (the query is unbounded). Validate product slugs
// here — before the feed is filtered/capped — so a typo'd or unregistered product
// in any entry's frontmatter fails the build loudly rather than silently shipping
// an un-filterable grey badge on the index.
const allEntries = nodesToEntries(data.entries.nodes);
const invalidProducts = allEntries.flatMap((entry) =>
entry.products.filter((product) => !isKnownProductSlug(product)).map((product) => `${entry.link} → "${product}"`),
);
if (invalidProducts.length > 0) {
reporter.panicOnBuild(
`${REPORTER_PREFIX} Unknown product slug(s) in changelog frontmatter (expected one of: ` +
`${Object.keys(productTags).join(', ')}):\n ${invalidProducts.join('\n ')}`,
);
return;
}

const siteUrl = data.site.siteMetadata.siteUrl;
if (!siteUrl) {
reporter.warn(`${REPORTER_PREFIX} Site URL not found; skipping changelog feed.`);
return;
}

const prefix = `${siteUrl}${basePath ?? ''}`;

// Shared identification/mapping with the on-page changelog, then feed-specific
// shaping: drop entries without a date (RSS items need a pubDate), cap to the
// most recent, and turn each entry's site path into an absolute, prefixed URL.
const items: FeedItem[] = sortByDateDesc(allEntries)
.filter((entry) => Boolean(entry.date))
.slice(0, FEED_MAX_ITEMS)
.map((entry) => ({
title: entry.title,
description: entry.description,
date: entry.date,
products: entry.products,
url: absolutizeUrl(entry.link, prefix),
}));

const xml = buildFeedXml(items, {
changelogUrl: absolutizeUrl(CHANGELOG_PATH, prefix),
feedUrl: absolutizeUrl(CHANGELOG_RSS_PATH, prefix),
});

const outputDir = path.join(process.cwd(), 'public', 'docs', 'changelog');
try {
fs.mkdirSync(outputDir, { recursive: true });
fs.writeFileSync(path.join(outputDir, 'rss.xml'), xml);
reporter.info(`${REPORTER_PREFIX} Wrote changelog feed: ${items.length} items`);
} catch (err) {
reporter.panic(`${REPORTER_PREFIX} Error writing changelog feed`, err as Error);
}
};
2 changes: 2 additions & 0 deletions data/onPostBuild/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { GatsbyNode, Reporter } from 'gatsby';
import { onPostBuild as llmstxt } from './llmstxt';
import { onPostBuild as transpileMdxToMarkdown } from './transpileMdxToMarkdown';
import { onPostBuild as changelogFeed } from './changelogFeed';
import { onPostBuild as compressAssets } from './compressAssets';
import { validateRedirectFile, REDIRECT_FILE_PATH } from '../utils/validateRedirectFile';

Expand Down Expand Up @@ -35,5 +36,6 @@ export const onPostBuild: GatsbyNode['onPostBuild'] = async (args) => {
// Run all onPostBuild functions in sequence
await llmstxt(args);
await transpileMdxToMarkdown(args);
await changelogFeed(args);
await compressAssets(args);
};
7 changes: 0 additions & 7 deletions gatsby-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,13 +86,6 @@ export const plugins = [
},
},
'gatsby-plugin-react-helmet',
{
resolve: `gatsby-source-rss-feed`,
options: {
url: `https://changelog.ably.com/rss`,
name: `AblyChangelog`,
},
},
'gatsby-plugin-root-import',
{
resolve: 'gatsby-plugin-mdx',
Expand Down
1 change: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,6 @@
"gatsby-remark-gifs": "^1.2.0",
"gatsby-remark-images": "7.16.0",
"gatsby-source-filesystem": "5.16.0",
"gatsby-source-rss-feed": "^1.2.2",
"gatsby-transformer-remark": "6.16.0",
"gatsby-transformer-sharp": "5.16.0",
"gatsby-transformer-yaml": "5.16.0",
Expand Down
Loading