+
+
+
+`;
+
+describe('extractEntryBodyHtml', () => {
+ it('extracts the body region and excludes the header chrome', () => {
+ const html = extractEntryBodyHtml(page('
Version 2.23.0 has been released.
'), SITE);
+ expect(html).toBe('
Version 2.23.0 has been released.
');
+ });
+
+ it('does not leak the back-link or "All updates" header text', () => {
+ const html = extractEntryBodyHtml(page('
';
+ expect(extractEntryBodyHtml(html, SITE)).toBeNull();
+ });
+
+ it('returns null when the body region is empty', () => {
+ expect(extractEntryBodyHtml(page(' '), SITE)).toBeNull();
+ });
+
+ it('absolutizes relative href and src, leaving absolute ones untouched', () => {
+ const html =
+ extractEntryBodyHtml(
+ page('chatgh'),
+ SITE,
+ ) ?? '';
+ expect(html).toContain('href="https://ably.com/docs/chat"');
+ expect(html).toContain('src="https://ably.com/static/x.png"');
+ expect(html).toContain('href="https://github.com/ably"');
+ });
+
+ it('strips script and style tags', () => {
+ const html = extractEntryBodyHtml(page('
ok
'), SITE) ?? '';
+ expect(html).toContain('
ok
');
+ expect(html).not.toContain('evil()');
+ expect(html).not.toContain('.x{}');
+ });
+
+ it('absolutizes every URL in a srcset', () => {
+ const html = extractEntryBodyHtml(page(''), SITE) ?? '';
+ expect(html).toContain('https://ably.com/a.png 1x');
+ expect(html).toContain('https://ably.com/b.png 2x');
+ });
+});
+
+describe('wrapCdata', () => {
+ it('wraps content in a CDATA section', () => {
+ expect(wrapCdata('
hi
')).toBe('hi
]]>');
+ });
+
+ it('splits an embedded ]]> using the standard CDATA-escape so it round-trips', () => {
+ // `]]>` becomes `]]` + (close)`]]>` + (reopen)``, which a parser
+ // reassembles back into the original `]]>` rather than terminating early.
+ const wrapped = wrapCdata('a]]>b');
+ expect(wrapped).toBe('b]]>');
+ expect(wrapped.startsWith('')).toBe(true);
+ });
+});
diff --git a/data/onPostBuild/changelog-content.ts b/data/onPostBuild/changelog-content.ts
new file mode 100644
index 0000000000..2ef07357de
--- /dev/null
+++ b/data/onPostBuild/changelog-content.ts
@@ -0,0 +1,68 @@
+// Use the slim build: it provides `load` without cheerio's `fromURL` helper, which
+// pulls in `undici` (unnecessary here, and it fails to load under the Jest runtime).
+import { load } from 'cheerio/slim';
+import { absolutizeUrl } from '../../src/components/Changelog/absolutize-url';
+
+// Pure helpers for building the RSS feed's bodies from the
+// already-built changelog entry pages. Kept free of `fs`/Gatsby so they can be
+// unit-tested with HTML fixtures; the file reading lives in changelogFeed.ts.
+
+// Selector for the changelog entry body region. This is a deliberate contract
+// with src/components/Layout/MDXWrapper.tsx, which wraps each entry's MDX body in
+// `
`. Keep the two in sync — if the marker is removed the
+// feed build fails its minimum-coverage assertion rather than silently regressing.
+export const CHANGELOG_BODY_SELECTOR = '[data-changelog-body]';
+
+// Absolutize every URL in a srcset attribute ("url1 1x, url2 2x").
+const absolutizeSrcset = (srcset: string, siteUrl: string): string =>
+ srcset
+ .split(',')
+ .map((candidate) => {
+ const [url, ...descriptors] = candidate.trim().split(/\s+/);
+ if (!url) {
+ return candidate.trim();
+ }
+ return [absolutizeUrl(url, siteUrl), ...descriptors].join(' ');
+ })
+ .filter(Boolean)
+ .join(', ');
+
+// Extract the inner HTML of a built changelog entry's body region, with scripts/
+// styles stripped and all URLs absolutized. Returns null when the region is absent
+// or empty so the caller can fall back to a summary-only feed item.
+export const extractEntryBodyHtml = (pageHtml: string, siteUrl: string): string | null => {
+ const $ = load(pageHtml);
+ const body = $(CHANGELOG_BODY_SELECTOR).first();
+ if (body.length === 0) {
+ return null;
+ }
+
+ // Defensive: a feed item should never carry executable/style content.
+ body.find('script, style').remove();
+
+ body.find('[href]').each((_, el) => {
+ const value = $(el).attr('href');
+ if (value) {
+ $(el).attr('href', absolutizeUrl(value, siteUrl));
+ }
+ });
+ body.find('[src]').each((_, el) => {
+ const value = $(el).attr('src');
+ if (value) {
+ $(el).attr('src', absolutizeUrl(value, siteUrl));
+ }
+ });
+ body.find('[srcset]').each((_, el) => {
+ const value = $(el).attr('srcset');
+ if (value) {
+ $(el).attr('srcset', absolutizeSrcset(value, siteUrl));
+ }
+ });
+
+ const html = body.html();
+ return html && html.trim() ? html.trim() : null;
+};
+
+// Wrap HTML for inclusion in a CDATA section, splitting any `]]>` sequence so the
+// content can't terminate the section early and break the XML.
+export const wrapCdata = (content: string): string => `/g, ']]]]>')}]]>`;
diff --git a/data/onPostBuild/changelogFeed.ts b/data/onPostBuild/changelogFeed.ts
new file mode 100644
index 0000000000..27c8a23a52
--- /dev/null
+++ b/data/onPostBuild/changelogFeed.ts
@@ -0,0 +1,221 @@
+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';
+import { extractEntryBodyHtml, wrapCdata } from './changelog-content';
+
+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[];
+ };
+}
+
+// Minimal XML escaping for text placed inside elements/attributes.
+const escapeXml = (value: string): string =>
+ value
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+
+// Generates an 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(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 ?? ''}`;
+ const feedUrl = absolutizeUrl(CHANGELOG_RSS_PATH, prefix);
+ const changelogUrl = absolutizeUrl(CHANGELOG_PATH, prefix);
+
+ // Read the body of an entry's already-built page so we can include it as full
+ // content. Gatsby writes pages to public//index.html (no pathPrefix here);
+ // the `.html` fallback guards against a future trailingSlash/layout change.
+ const readEntryHtml = (link: string): string | null => {
+ const segments = link.split('/').filter(Boolean);
+ const candidates = [
+ path.join(process.cwd(), 'public', ...segments, 'index.html'),
+ `${path.join(process.cwd(), 'public', ...segments)}.html`,
+ ];
+ const found = candidates.find((candidate) => fs.existsSync(candidate));
+ return found ? fs.readFileSync(found, 'utf-8') : null;
+ };
+
+ // Shared identification/mapping with the on-page changelog, then feed-specific
+ // shaping: drop entries without a date (RSS items need a pubDate) and turn each
+ // entry's site path into an absolute, prefixed URL.
+ const entries = 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,
+ link: entry.link,
+ url: absolutizeUrl(entry.link, prefix),
+ }));
+
+ // Build full-content () for each entry from its built HTML.
+ // Any failure degrades that single item to summary-only and logs a warning, so
+ // one bad page never breaks the feed; total failure is caught by the guard below.
+ let fullContentCount = 0;
+ const itemsData = entries.map((entry) => {
+ let contentEncoded: string | null = null;
+ try {
+ const pageHtml = readEntryHtml(entry.link);
+ if (!pageHtml) {
+ reporter.warn(`${REPORTER_PREFIX} Built HTML not found for ${entry.link}; emitting summary only.`);
+ } else {
+ const body = extractEntryBodyHtml(pageHtml, prefix);
+ if (body) {
+ contentEncoded = wrapCdata(body);
+ fullContentCount += 1;
+ } else {
+ reporter.warn(
+ `${REPORTER_PREFIX} No [data-changelog-body] content for ${entry.link}; emitting summary only.`,
+ );
+ }
+ }
+ } catch (err) {
+ reporter.warn(`${REPORTER_PREFIX} Failed to extract content for ${entry.link}: ${(err as Error).message}`);
+ }
+ return { ...entry, contentEncoded };
+ });
+
+ // Guard against a silent, site-wide regression: if there are entries but not one
+ // produced full content, the [data-changelog-body] contract has almost certainly
+ // been broken upstream. Fail the build loudly rather than ship a degraded feed.
+ if (entries.length > 0 && fullContentCount === 0) {
+ reporter.panicOnBuild(
+ `${REPORTER_PREFIX} Produced full content for 0 of ${entries.length} entries. The ` +
+ `[data-changelog-body] marker (src/components/Layout/MDXWrapper.tsx) may have been removed or renamed.`,
+ );
+ return;
+ }
+
+ const items = itemsData
+ .map((entry) => {
+ const categories = entry.products.map((product) => ` ${escapeXml(product)}`).join('\n');
+ return [
+ ' ',
+ ` ${escapeXml(entry.title)}`,
+ ` ${escapeXml(entry.url)}`,
+ ` ${escapeXml(entry.url)}`,
+ ` ${new Date(entry.date).toUTCString()}`,
+ ` ${escapeXml(entry.description)}`,
+ entry.contentEncoded ? ` ${entry.contentEncoded}` : '',
+ categories,
+ ' ',
+ ]
+ .filter(Boolean)
+ .join('\n');
+ })
+ .join('\n');
+
+ const xml = `
+
+
+ ${escapeXml(FEED_TITLE)}
+ ${escapeXml(changelogUrl)}
+ ${escapeXml(FEED_DESCRIPTION)}
+
+${items}
+
+
+`;
+
+ 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: ${entries.length} items, ${fullContentCount} with full content`,
+ );
+ } catch (err) {
+ reporter.panic(`${REPORTER_PREFIX} Error writing changelog feed`, err as Error);
+ }
+};
diff --git a/data/onPostBuild/index.ts b/data/onPostBuild/index.ts
index e8bc0eaad1..3bdf9b6b38 100644
--- a/data/onPostBuild/index.ts
+++ b/data/onPostBuild/index.ts
@@ -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';
@@ -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);
};
diff --git a/gatsby-config.ts b/gatsby-config.ts
index a6a5597171..e70285bb49 100644
--- a/gatsby-config.ts
+++ b/gatsby-config.ts
@@ -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',
diff --git a/package.json b/package.json
index 20287c8f0f..7207893438 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/src/components/Changelog/ChangelogContent.test.tsx b/src/components/Changelog/ChangelogContent.test.tsx
new file mode 100644
index 0000000000..954e02258d
--- /dev/null
+++ b/src/components/Changelog/ChangelogContent.test.tsx
@@ -0,0 +1,143 @@
+import React from 'react';
+import { render, screen, fireEvent } from '@testing-library/react';
+import { useLocation } from '@reach/router';
+import ChangelogContent from './ChangelogContent';
+import { ChangelogEntry } from './types';
+import { ImageProps } from '../Image';
+
+// ChangelogContent reads the shared `?product=` filter from the router and renders
+// a client-paginated timeline; override only useLocation so each test controls the
+// URL, keeping the rest of reach-router intact (Gatsby's relies on it).
+jest.mock('@reach/router', () => ({
+ ...jest.requireActual('@reach/router'),
+ useLocation: jest.fn(),
+}));
+
+// The decorative background uses gatsby-plugin-image transitively via src/Image.
+jest.mock('gatsby-plugin-image', () => ({
+ GatsbyImage: jest.fn(() => null),
+ StaticImage: jest.fn(() => null),
+ getImage: jest.fn(),
+}));
+
+const mockLocation = (search = '') =>
+ (useLocation as jest.Mock).mockReturnValue({
+ pathname: '/docs/changelog',
+ search,
+ hash: '',
+ state: null,
+ key: 'test',
+ });
+
+// 12 chat entries + 3 spaces entries = 15 total, all in June 2026 (one month group).
+const CHAT_COUNT = 12;
+const SPACES_COUNT = 3;
+const makeEntries = (): ChangelogEntry[] => [
+ ...Array.from({ length: CHAT_COUNT }, (_, i) => ({
+ link: `/docs/changelog/2026/06/chat-${i + 1}`,
+ title: `Chat entry ${i + 1}`,
+ description: `Chat description ${i + 1}`,
+ // Newest first; dates 2026-06-28 down to 2026-06-17.
+ date: `2026-06-${String(28 - i).padStart(2, '0')}`,
+ products: ['chat'],
+ })),
+ ...Array.from({ length: SPACES_COUNT }, (_, i) => ({
+ link: `/docs/changelog/2026/06/spaces-${i + 1}`,
+ title: `Spaces entry ${i + 1}`,
+ description: `Spaces description ${i + 1}`,
+ date: `2026-06-0${i + 1}`,
+ products: ['spaces'],
+ })),
+];
+
+// Decorative background SVGs the component looks up by name; stubbing them keeps
+// getImageFromList from warning about missing images during the test.
+const IMAGES = [
+ { base: 'mobile-grid.svg', extension: 'svg', publicURL: '/mobile-grid.svg' },
+ { base: 'pattern-grid.svg', extension: 'svg', publicURL: '/pattern-grid.svg' },
+] as unknown as ImageProps[];
+
+const renderContent = (entries: ChangelogEntry[]) => render();
+
+// Each entry card is rendered as a list item; counting them is the cleanest
+// proxy for "how many entries are currently visible".
+const visibleCount = () => screen.getAllByRole('listitem').length;
+
+describe('ChangelogContent', () => {
+ beforeEach(() => {
+ mockLocation('');
+ });
+
+ describe('pagination', () => {
+ it('caps the initial render at one page (10) and reports the total', () => {
+ renderContent(makeEntries());
+
+ expect(visibleCount()).toBe(10);
+ expect(screen.getByText('Showing 10 of 15')).toBeInTheDocument();
+ expect(screen.getByText('Show more')).toBeInTheDocument();
+ });
+
+ it('reveals another page on "Show more" and hides the button once exhausted', () => {
+ renderContent(makeEntries());
+
+ fireEvent.click(screen.getByText('Show more'));
+
+ expect(visibleCount()).toBe(15);
+ expect(screen.queryByText('Show more')).not.toBeInTheDocument();
+ });
+
+ it('does not paginate when there are fewer entries than a page', () => {
+ renderContent(makeEntries().slice(0, 5));
+
+ expect(visibleCount()).toBe(5);
+ expect(screen.queryByText('Show more')).not.toBeInTheDocument();
+ });
+ });
+
+ describe('product filter', () => {
+ it('narrows the timeline to the selected product', () => {
+ renderContent(makeEntries());
+
+ fireEvent.click(screen.getByTestId('product-spaces'));
+
+ expect(visibleCount()).toBe(SPACES_COUNT);
+ expect(screen.getByText('Spaces entry 1')).toBeInTheDocument();
+ expect(screen.queryByText('Chat entry 1')).not.toBeInTheDocument();
+ });
+
+ it('resets pagination to the first page when the filter changes', () => {
+ renderContent(makeEntries());
+
+ // Expand to show every entry...
+ fireEvent.click(screen.getByText('Show more'));
+ expect(visibleCount()).toBe(15);
+
+ // ...then apply a filter whose result set still exceeds one page. The view
+ // should collapse back to 10 rather than inherit the expanded count.
+ fireEvent.click(screen.getByTestId('product-chat'));
+
+ expect(visibleCount()).toBe(10);
+ expect(screen.getByText('Showing 10 of 12')).toBeInTheDocument();
+ });
+
+ it('hydrates the filter from the ?product= query string on mount', () => {
+ mockLocation('?product=spaces');
+
+ renderContent(makeEntries());
+
+ expect(visibleCount()).toBe(SPACES_COUNT);
+ expect(screen.getByText('Spaces entry 1')).toBeInTheDocument();
+ expect(screen.queryByText('Chat entry 1')).not.toBeInTheDocument();
+ });
+
+ it('ignores unknown product slugs in the query string', () => {
+ mockLocation('?product=not-a-product');
+
+ renderContent(makeEntries());
+
+ // Unknown slug is dropped, so the filter is empty and all entries show.
+ expect(visibleCount()).toBe(10);
+ expect(screen.getByText('Showing 10 of 15')).toBeInTheDocument();
+ });
+ });
+});
diff --git a/src/components/Changelog/ChangelogContent.tsx b/src/components/Changelog/ChangelogContent.tsx
new file mode 100644
index 0000000000..5afdf04101
--- /dev/null
+++ b/src/components/Changelog/ChangelogContent.tsx
@@ -0,0 +1,140 @@
+import React, { useEffect, useMemo, useState } from 'react';
+import { useLocation } from '@reach/router';
+import Button from '@ably/ui/core/Button';
+import ChangelogFilter from './ChangelogFilter';
+import ChangelogTimeline from './ChangelogTimeline';
+import { filterChangelog, sortByDateDesc } from './filter-changelog';
+import { productTags } from './tags';
+import { ChangelogEntry } from './types';
+import { CHANGELOG_RSS_PATH } from './constants';
+import { Image, ImageProps, getImageFromList } from 'src/components/Image';
+
+// Number of entries shown initially and revealed per "Show more" click.
+const PAGE_SIZE = 10;
+
+// Top-level client component for the changelog index page. Owns the product filter
+// state, hydrates it from the URL, and renders the hero, filter rail, and timeline.
+// Free-text search is intentionally left to the site-wide (Inkeep) search; the
+// index offers product tag filtering plus a paginated "Show more" timeline.
+const ChangelogContent = ({ entries, images = [] }: { entries: ChangelogEntry[]; images?: ImageProps[] }) => {
+ const location = useLocation();
+
+ // Decorative top-right background pattern (changelog-specific assets). It is
+ // offset by the fixed site header height (top-[3.9375rem] = 63px) so it sits
+ // directly below the header rather than underneath it.
+ const mobileBackground = getImageFromList(images, 'mobile-grid.svg');
+ const desktopBackground = getImageFromList(images, 'pattern-grid.svg');
+
+ const getInitialProducts = (): string[] => {
+ const params = new URLSearchParams(location.search);
+ const productParam = params.get('product');
+ if (!productParam) {
+ return [];
+ }
+ const validSlugs = Object.keys(productTags);
+ return productParam
+ .split(',')
+ .map((slug) => slug.trim())
+ .filter((slug) => validSlugs.includes(slug));
+ };
+
+ const [selected, setSelected] = useState([]);
+ const [visibleCount, setVisibleCount] = useState(PAGE_SIZE);
+
+ // Hydrate the filter from a shared `?product=` URL after mount rather than in the
+ // useState initializer. The server renders with no query string, so seeding state
+ // from the URL up-front would mismatch the first client render; applying it in an
+ // effect keeps hydration consistent and then reflects the shared filter.
+ useEffect(() => {
+ setSelected(getInitialProducts());
+ // Mount-only: later URL changes are driven by the filter itself, not read back.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ // Filtering happens over the full dataset; pagination is only a display cap
+ // applied afterwards.
+ const filteredEntries = useMemo(() => sortByDateDesc(filterChangelog(entries, selected)), [entries, selected]);
+ const visibleEntries = filteredEntries.slice(0, visibleCount);
+ const hasMore = filteredEntries.length > visibleCount;
+
+ // Reset the page size whenever the filter changes so a new result set starts
+ // from the top rather than inheriting a previously expanded count.
+ useEffect(() => {
+ setVisibleCount(PAGE_SIZE);
+ }, [selected]);
+
+ const rssUrl = CHANGELOG_RSS_PATH;
+
+ return (
+ <>
+ {mobileBackground && (
+
+ )}
+ {desktopBackground && (
+
+ )}
+
+
+
+ );
+};
+
+export default ChangelogFilter;
diff --git a/src/components/Changelog/ChangelogHeader.tsx b/src/components/Changelog/ChangelogHeader.tsx
new file mode 100644
index 0000000000..d417520e3b
--- /dev/null
+++ b/src/components/Changelog/ChangelogHeader.tsx
@@ -0,0 +1,23 @@
+import React from 'react';
+import Icon from '@ably/ui/core/Icon';
+import Link from '../Link';
+import { formatFullDate, toISODate } from './format-date';
+
+// Header rendered at the top of an individual changelog entry page, in place of
+// the standard doc PageHeader. Shows a back-link, the title, and the publish date.
+const ChangelogHeader = ({ title, date }: { title: string; date?: string }) => (
+
+
+
+ All updates
+
+
{title}
+ {date && (
+
+ )}
+
+);
+
+export default ChangelogHeader;
diff --git a/src/components/Changelog/ChangelogTag.tsx b/src/components/Changelog/ChangelogTag.tsx
new file mode 100644
index 0000000000..3990f2aa4b
--- /dev/null
+++ b/src/components/Changelog/ChangelogTag.tsx
@@ -0,0 +1,13 @@
+import React from 'react';
+import Badge from '@ably/ui/core/Badge';
+import cn from '@ably/ui/core/utils/cn';
+import { getProductTag } from './tags';
+
+// Renders a single product tag, matching the Examples grid: a default (neutral)
+// Badge with an uppercase, product-coloured label.
+const ChangelogTag = ({ product }: { product: string }) => {
+ const { label, colorClass } = getProductTag(product);
+ return {label};
+};
+
+export default ChangelogTag;
diff --git a/src/components/Changelog/ChangelogTimeline.tsx b/src/components/Changelog/ChangelogTimeline.tsx
new file mode 100644
index 0000000000..43bf8713ba
--- /dev/null
+++ b/src/components/Changelog/ChangelogTimeline.tsx
@@ -0,0 +1,57 @@
+import React, { useMemo } from 'react';
+import ChangelogEntryCard from './ChangelogEntryCard';
+import { ChangelogEntry } from './types';
+import { toMonthKey, formatMonth } from './format-date';
+
+type MonthGroup = {
+ monthKey: string;
+ // The date of the first (newest) entry in the month, used to format the heading.
+ date: string;
+ entries: ChangelogEntry[];
+};
+
+// Groups entries (already newest-first) by calendar month, preserving order.
+const groupByMonth = (entries: ChangelogEntry[]): MonthGroup[] => {
+ const groups: MonthGroup[] = [];
+ entries.forEach((entry) => {
+ const monthKey = toMonthKey(entry.date);
+ const lastGroup = groups[groups.length - 1];
+ if (lastGroup && lastGroup.monthKey === monthKey) {
+ lastGroup.entries.push(entry);
+ } else {
+ groups.push({ monthKey, date: entry.date, entries: [entry] });
+ }
+ });
+ return groups;
+};
+
+// Reverse-chronological timeline with a left-hand month rail. Each month heading
+// groups the entries published that month; each entry card shows its own date.
+// Entries are expected newest-first (the index page sorts before slicing); month
+// grouping relies on that ordering rather than re-sorting here.
+const ChangelogTimeline = ({ entries }: { entries: ChangelogEntry[] }) => {
+ const groups = useMemo(() => groupByMonth(entries), [entries]);
+
+ if (groups.length === 0) {
+ return
No updates match your filters.
;
+ }
+
+ return (
+
+ {groups.map((group) => (
+
+
+ {formatMonth(group.date)}
+
+
+ {group.entries.map((entry) => (
+
+ ))}
+
+
+ ))}
+
+ );
+};
+
+export default ChangelogTimeline;
diff --git a/src/components/Changelog/absolutize-url.test.ts b/src/components/Changelog/absolutize-url.test.ts
new file mode 100644
index 0000000000..aba6b87ec3
--- /dev/null
+++ b/src/components/Changelog/absolutize-url.test.ts
@@ -0,0 +1,25 @@
+import { absolutizeUrl } from './absolutize-url';
+
+const SITE = 'https://ably.com';
+
+describe('absolutizeUrl', () => {
+ it('prefixes site-relative and bare paths', () => {
+ expect(absolutizeUrl('/docs/chat', SITE)).toBe('https://ably.com/docs/chat');
+ expect(absolutizeUrl('images/x.png', SITE)).toBe('https://ably.com/images/x.png');
+ });
+
+ it('leaves absolute, protocol-relative, anchor, and non-http schemes untouched', () => {
+ expect(absolutizeUrl('https://x.com/y', SITE)).toBe('https://x.com/y');
+ expect(absolutizeUrl('//cdn.com/y', SITE)).toBe('//cdn.com/y');
+ expect(absolutizeUrl('#section', SITE)).toBe('#section');
+ expect(absolutizeUrl('mailto:a@b.com', SITE)).toBe('mailto:a@b.com');
+ });
+
+ it('tolerates a trailing slash on the base URL', () => {
+ expect(absolutizeUrl('/docs', 'https://ably.com/')).toBe('https://ably.com/docs');
+ });
+
+ it('returns empty input unchanged', () => {
+ expect(absolutizeUrl('', SITE)).toBe('');
+ });
+});
diff --git a/src/components/Changelog/absolutize-url.ts b/src/components/Changelog/absolutize-url.ts
new file mode 100644
index 0000000000..122407863f
--- /dev/null
+++ b/src/components/Changelog/absolutize-url.ts
@@ -0,0 +1,19 @@
+// Single source of truth for turning a possibly-relative URL into an absolute one
+// against a site origin. Used by the OG-image meta (MDXWrapper), the RSS feed's
+// item/link URLs (changelogFeed), and the feed body's href/src/srcset rewriting
+// (changelog-content), so all changelog URL handling shares one set of rules.
+
+// True for values that already resolve on their own: absolute URLs of any scheme,
+// protocol-relative URLs, in-page anchors, and non-navigational schemes (mailto:, …).
+export const isSelfResolving = (value: string): boolean =>
+ value.startsWith('#') || value.startsWith('//') || /^[a-z][a-z0-9+.-]*:/i.test(value);
+
+// Resolve a possibly-relative URL against a base origin. Self-resolving and empty
+// values are returned unchanged; a trailing slash on the base is tolerated.
+export const absolutizeUrl = (value: string, base: string): string => {
+ if (!value || isSelfResolving(value)) {
+ return value;
+ }
+ const origin = base.replace(/\/$/, '');
+ return `${origin}${value.startsWith('/') ? '' : '/'}${value}`;
+};
diff --git a/src/components/Changelog/constants.ts b/src/components/Changelog/constants.ts
new file mode 100644
index 0000000000..2dc31b5413
--- /dev/null
+++ b/src/components/Changelog/constants.ts
@@ -0,0 +1,5 @@
+// Canonical changelog routes, defined once and shared by the index page, the
+// on-page RSS link, the page , and the RSS feed builder so the route and
+// its feed URL can't drift apart.
+export const CHANGELOG_PATH = '/docs/changelog';
+export const CHANGELOG_RSS_PATH = '/docs/changelog/rss.xml';
diff --git a/src/components/Changelog/entries.test.ts b/src/components/Changelog/entries.test.ts
new file mode 100644
index 0000000000..4e45dce3aa
--- /dev/null
+++ b/src/components/Changelog/entries.test.ts
@@ -0,0 +1,80 @@
+import { isChangelogEntryNode, nodesToEntries, ChangelogFileNode } from './entries';
+
+const node = (
+ overrides: Partial & { name: string; relativeDirectory: string },
+): ChangelogFileNode => ({
+ childMdx: {
+ frontmatter: { title: 'Some entry', meta_description: 'A description', date: '2026-06-19', products: ['pub-sub'] },
+ },
+ ...overrides,
+});
+
+describe('isChangelogEntryNode', () => {
+ it('accepts a nested entry with a title', () => {
+ expect(isChangelogEntryNode(node({ name: 'js-sdk-2-23-0', relativeDirectory: 'docs/changelog/2026/06' }))).toBe(
+ true,
+ );
+ });
+
+ it('accepts an entry placed directly under docs/changelog', () => {
+ expect(isChangelogEntryNode(node({ name: 'an-entry', relativeDirectory: 'docs/changelog' }))).toBe(true);
+ });
+
+ it('rejects a directory index', () => {
+ expect(isChangelogEntryNode(node({ name: 'index', relativeDirectory: 'docs/changelog' }))).toBe(false);
+ });
+
+ it('rejects files outside docs/changelog', () => {
+ expect(isChangelogEntryNode(node({ name: 'messages', relativeDirectory: 'docs/chat/rooms' }))).toBe(false);
+ expect(isChangelogEntryNode(node({ name: 'x', relativeDirectory: 'docs/changelog-archive' }))).toBe(false);
+ });
+
+ it('rejects a node without a childMdx or title', () => {
+ expect(isChangelogEntryNode({ name: 'x', relativeDirectory: 'docs/changelog', childMdx: null })).toBe(false);
+ expect(
+ isChangelogEntryNode(
+ node({
+ name: 'x',
+ relativeDirectory: 'docs/changelog',
+ childMdx: { frontmatter: { title: null, meta_description: null, date: null, products: null } },
+ }),
+ ),
+ ).toBe(false);
+ });
+});
+
+describe('nodesToEntries', () => {
+ it('filters non-entries and builds the site path from the file location', () => {
+ const entries = nodesToEntries([
+ node({ name: 'js-sdk-2-23-0', relativeDirectory: 'docs/changelog/2026/06' }),
+ node({ name: 'index', relativeDirectory: 'docs/changelog' }),
+ node({ name: 'messages', relativeDirectory: 'docs/chat/rooms' }),
+ ]);
+ expect(entries).toEqual([
+ {
+ link: '/docs/changelog/2026/06/js-sdk-2-23-0',
+ title: 'Some entry',
+ description: 'A description',
+ date: '2026-06-19',
+ products: ['pub-sub'],
+ },
+ ]);
+ });
+
+ it('defaults missing optional frontmatter', () => {
+ const [entry] = nodesToEntries([
+ node({
+ name: 'minimal',
+ relativeDirectory: 'docs/changelog/2026/06',
+ childMdx: { frontmatter: { title: 'Minimal', meta_description: null, date: null, products: null } },
+ }),
+ ]);
+ expect(entry).toEqual({
+ link: '/docs/changelog/2026/06/minimal',
+ title: 'Minimal',
+ description: '',
+ date: '',
+ products: [],
+ });
+ });
+});
diff --git a/src/components/Changelog/entries.ts b/src/components/Changelog/entries.ts
new file mode 100644
index 0000000000..a96593d64e
--- /dev/null
+++ b/src/components/Changelog/entries.ts
@@ -0,0 +1,67 @@
+import { ChangelogEntry } from './types';
+
+// Shared mapping from sourced MDX file nodes to changelog entries. Used by the
+// index page, the homepage widget, and the RSS post-build step so the three stay
+// in lock-step (same identification rules, same link construction).
+
+// Root directory (relative to the `pages` filesystem source) that holds changelog
+// entry MDX files. Entries are nested by year/month, e.g. `docs/changelog/2026/06`.
+export const CHANGELOG_ROOT = 'docs/changelog';
+
+// The `allFile` queries that back the changelog should scope to changelog MDX files
+// only — rather than pulling every MDX node site-wide and filtering in JS — using
+// this filter (inlined per query, as `graphql` requires a static literal):
+//
+// filter: {
+// sourceInstanceName: { eq: "pages" }
+// extension: { eq: "mdx" }
+// relativeDirectory: { regex: "/^docs\\/changelog(\\/|$)/" }
+// }
+//
+// `isChangelogEntryNode` still re-checks the directory below so the consumers can't
+// drift from the query.
+
+// Minimal shape of a sourced changelog file node, as returned by the `allFile` →
+// `childMdx` queries. Kept deliberately loose so each consumer can request only
+// the frontmatter fields it needs on top of this.
+export type ChangelogFileNode = {
+ name: string;
+ relativeDirectory: string;
+ childMdx: {
+ frontmatter: {
+ title: string | null;
+ meta_description: string | null;
+ date: string | null;
+ products: string[] | null;
+ };
+ } | null;
+};
+
+// True for files that are changelog entries: under `docs/changelog/` (at any depth),
+// not a directory `index`, and carrying a title. The directory check is redundant
+// with `CHANGELOG_FILE_FILTER` but guards against a consumer querying a wider set.
+export const isChangelogEntryNode = (node: ChangelogFileNode): boolean =>
+ node.childMdx != null &&
+ node.name !== 'index' &&
+ (node.relativeDirectory === CHANGELOG_ROOT || node.relativeDirectory.startsWith(`${CHANGELOG_ROOT}/`)) &&
+ Boolean(node.childMdx.frontmatter.title);
+
+// Filters a list of sourced file nodes to changelog entries and maps each to a
+// `ChangelogEntry`. The site path is derived from the file's location, e.g.
+// `docs/changelog/2026/06` + `js-sdk-2-23-0` → `/docs/changelog/2026/06/js-sdk-2-23-0`.
+export const nodesToEntries = (nodes: ChangelogFileNode[]): ChangelogEntry[] =>
+ nodes.flatMap((node) => {
+ if (!isChangelogEntryNode(node) || !node.childMdx) {
+ return [];
+ }
+ const { frontmatter } = node.childMdx;
+ return [
+ {
+ link: `/${node.relativeDirectory}/${node.name}`,
+ title: frontmatter.title as string,
+ description: frontmatter.meta_description ?? '',
+ date: frontmatter.date ?? '',
+ products: frontmatter.products ?? [],
+ },
+ ];
+ });
diff --git a/src/components/Changelog/filter-changelog.test.ts b/src/components/Changelog/filter-changelog.test.ts
new file mode 100644
index 0000000000..9c6cf7720f
--- /dev/null
+++ b/src/components/Changelog/filter-changelog.test.ts
@@ -0,0 +1,55 @@
+import { filterChangelog, sortByDateDesc } from './filter-changelog';
+import { ChangelogEntry } from './types';
+
+const entries: ChangelogEntry[] = [
+ {
+ link: '/docs/changelog/a',
+ title: 'AI Transport release v0.3.0',
+ description: 'Declarative codec authoring and LiveObjects pass-through.',
+ date: '2026-06-19',
+ products: ['ai-transport'],
+ },
+ {
+ link: '/docs/changelog/b',
+ title: 'JS Client Library release v2.23.0',
+ description: 'React channel hooks can infer the channel from ChannelProvider.',
+ date: '2026-06-18',
+ products: ['pub-sub'],
+ },
+ {
+ link: '/docs/changelog/c',
+ title: 'Improved visibility into LiveObjects',
+ description: 'Realtime streaming of object operations in the dashboard.',
+ date: '2026-06-09',
+ products: ['liveobjects'],
+ },
+];
+
+describe('filterChangelog', () => {
+ it('returns all entries when no product is selected', () => {
+ expect(filterChangelog(entries, [])).toHaveLength(3);
+ });
+
+ it('filters by a single product', () => {
+ const result = filterChangelog(entries, ['ai-transport']);
+ expect(result.map((e) => e.link)).toEqual(['/docs/changelog/a']);
+ });
+
+ it('filters by multiple products (OR semantics)', () => {
+ const result = filterChangelog(entries, ['ai-transport', 'liveobjects']);
+ expect(result.map((e) => e.link)).toEqual(['/docs/changelog/a', '/docs/changelog/c']);
+ });
+
+ it('returns no entries when the selected product matches nothing', () => {
+ expect(filterChangelog(entries, ['spaces'])).toHaveLength(0);
+ });
+});
+
+describe('sortByDateDesc', () => {
+ it('orders entries newest first without mutating the input', () => {
+ const input = [entries[2], entries[0], entries[1]];
+ const sorted = sortByDateDesc(input);
+ expect(sorted.map((e) => e.date)).toEqual(['2026-06-19', '2026-06-18', '2026-06-09']);
+ expect(input.map((e) => e.date)).toEqual(['2026-06-09', '2026-06-19', '2026-06-18']);
+ });
+});
diff --git a/src/components/Changelog/filter-changelog.ts b/src/components/Changelog/filter-changelog.ts
new file mode 100644
index 0000000000..17d8577d45
--- /dev/null
+++ b/src/components/Changelog/filter-changelog.ts
@@ -0,0 +1,25 @@
+import { ChangelogEntry } from './types';
+
+// Pure filter used by the changelog index. An entry is kept when it matches one of
+// the selected products, or when no product filter is active. Free-text search is
+// intentionally not handled here — the changelog is covered by the site-wide
+// (Inkeep) search, so the index only offers product tag filtering.
+export const filterChangelog = (entries: ChangelogEntry[], selectedProducts: string[]): ChangelogEntry[] =>
+ entries.filter(
+ (entry) => selectedProducts.length === 0 || entry.products.some((product) => selectedProducts.includes(product)),
+ );
+
+// Newest first. Entries with an unparseable date sort last.
+export const sortByDateDesc = (entries: ChangelogEntry[]): ChangelogEntry[] =>
+ [...entries].sort((a, b) => {
+ const aTime = new Date(a.date).getTime();
+ const bTime = new Date(b.date).getTime();
+ const aInvalid = Number.isNaN(aTime);
+ const bInvalid = Number.isNaN(bTime);
+ if (aInvalid || bInvalid) {
+ // Both invalid: treat as equal so the comparator stays antisymmetric.
+ // Otherwise the invalid date sorts last.
+ return Number(aInvalid) - Number(bInvalid);
+ }
+ return bTime - aTime;
+ });
diff --git a/src/components/Changelog/format-date.ts b/src/components/Changelog/format-date.ts
new file mode 100644
index 0000000000..e233aae448
--- /dev/null
+++ b/src/components/Changelog/format-date.ts
@@ -0,0 +1,51 @@
+// Date helpers for the changelog. Entries store an ISO date string in frontmatter.
+//
+// All helpers format in UTC. A date-only ISO string ("2026-06-08") parses as UTC
+// midnight, so display formatting must also use `timeZone: 'UTC'` — otherwise the
+// human-readable date, the month grouping key, and the