From fb8081cefc159f99df03a7fd40b1cabce59407ca Mon Sep 17 00:00:00 2001
From: Mark Hulbert <39801222+m-hulbert@users.noreply.github.com>
Date: Fri, 26 Jun 2026 11:26:02 +0100
Subject: [PATCH 1/7] feat(changelog): add shared entry data model and helpers
Pure data layer for the in-repo changelog: entry types, file-node mapping,
product tags, date formatting (UTC-stable), product filtering/sorting, and URL
absolutization, with unit tests.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.../Changelog/absolutize-url.test.ts | 25 ++++++
src/components/Changelog/absolutize-url.ts | 19 +++++
src/components/Changelog/constants.ts | 5 ++
src/components/Changelog/entries.test.ts | 80 +++++++++++++++++++
src/components/Changelog/entries.ts | 67 ++++++++++++++++
.../Changelog/filter-changelog.test.ts | 55 +++++++++++++
src/components/Changelog/filter-changelog.ts | 25 ++++++
src/components/Changelog/format-date.ts | 51 ++++++++++++
src/components/Changelog/og-image.ts | 10 +++
src/components/Changelog/tags.test.ts | 25 ++++++
src/components/Changelog/tags.ts | 37 +++++++++
src/components/Changelog/types.ts | 11 +++
12 files changed, 410 insertions(+)
create mode 100644 src/components/Changelog/absolutize-url.test.ts
create mode 100644 src/components/Changelog/absolutize-url.ts
create mode 100644 src/components/Changelog/constants.ts
create mode 100644 src/components/Changelog/entries.test.ts
create mode 100644 src/components/Changelog/entries.ts
create mode 100644 src/components/Changelog/filter-changelog.test.ts
create mode 100644 src/components/Changelog/filter-changelog.ts
create mode 100644 src/components/Changelog/format-date.ts
create mode 100644 src/components/Changelog/og-image.ts
create mode 100644 src/components/Changelog/tags.test.ts
create mode 100644 src/components/Changelog/tags.ts
create mode 100644 src/components/Changelog/types.ts
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
]]>');
+ });
+
+ 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/src/components/Changelog/query-scope.test.ts b/src/components/Changelog/query-scope.test.ts
new file mode 100644
index 0000000000..458531743a
--- /dev/null
+++ b/src/components/Changelog/query-scope.test.ts
@@ -0,0 +1,36 @@
+import * as fs from 'fs';
+import * as path from 'path';
+import { CHANGELOG_ROOT } from './entries';
+
+// The changelog `allFile` queries must scope to changelog MDX only. `graphql`
+// requires a static string literal, so the `relativeDirectory` regex can't be
+// shared as a constant and is hand-copied into each query. These guards fail the
+// build if the two copies drift from each other or from CHANGELOG_ROOT — the
+// failure mode the entries.ts comment warns about (index and feed sourcing
+// different entry sets).
+const QUERY_FILES = ['src/pages/docs/changelog/index.tsx', 'data/onPostBuild/changelogFeed.ts'];
+
+const extractScopeRegex = (source: string): string | null => {
+ const match = source.match(/relativeDirectory:\s*\{\s*regex:\s*"([^"]+)"\s*\}/);
+ return match ? match[1] : null;
+};
+
+describe('changelog query scope', () => {
+ const scopes = QUERY_FILES.map((file) => ({
+ file,
+ regex: extractScopeRegex(fs.readFileSync(path.join(process.cwd(), file), 'utf-8')),
+ }));
+
+ it.each(scopes)('$file has a relativeDirectory scope regex', ({ regex }) => {
+ expect(regex).not.toBeNull();
+ });
+
+ it('uses an identical scope regex across every query', () => {
+ const unique = new Set(scopes.map((s) => s.regex));
+ expect(unique.size).toBe(1);
+ });
+
+ it('scopes to CHANGELOG_ROOT', () => {
+ expect(scopes[0].regex).toContain(CHANGELOG_ROOT);
+ });
+});
From 5133f0d844aadd2cd8cda082e3446f1a0096e612 Mon Sep 17 00:00:00 2001
From: Mark Hulbert <39801222+m-hulbert@users.noreply.github.com>
Date: Fri, 26 Jun 2026 11:26:24 +0100
Subject: [PATCH 5/7] feat(changelog): link the changelog into site navigation
Add a Changelog header nav link, point the footer link at the in-repo
/docs/changelog, and rewrite the homepage changelog widget to source entries
from the in-repo MDX instead of the external feed.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
src/components/Homepage/Changelog.tsx | 251 ++++++--------------------
src/components/Layout/Footer.tsx | 2 +-
src/components/Layout/Header.tsx | 9 +
3 files changed, 70 insertions(+), 192 deletions(-)
diff --git a/src/components/Homepage/Changelog.tsx b/src/components/Homepage/Changelog.tsx
index b98f44282b..e871022751 100644
--- a/src/components/Homepage/Changelog.tsx
+++ b/src/components/Homepage/Changelog.tsx
@@ -1,188 +1,73 @@
-import { useStaticQuery } from 'gatsby';
-import { graphql } from 'gatsby';
-import { Key } from 'react';
-import Badge from '@ably/ui/core/Badge';
+import { useStaticQuery, graphql } from 'gatsby';
import FeaturedLink from '@ably/ui/core/FeaturedLink';
import Link from '../Link';
-
-interface ChangelogFeedItemNode {
- title: string;
- link: string;
- content: string;
- isoDate: string;
-}
-
-interface ChangelogFeedEdge {
- node: ChangelogFeedItemNode;
-}
-
-interface ChangelogQueryData {
- allFeedAblyChangelog: {
- edges: ChangelogFeedEdge[];
- };
- feedAblyChangelogMeta: {
- link: string;
+import ChangelogTag from '../Changelog/ChangelogTag';
+import { sortByDateDesc } from '../Changelog/filter-changelog';
+import { formatShortDate } from '../Changelog/format-date';
+import { ChangelogEntry } from '../Changelog/types';
+import { ChangelogFileNode, nodesToEntries } from '../Changelog/entries';
+
+interface HomepageChangelogData {
+ entries: {
+ nodes: ChangelogFileNode[];
};
}
-const ChangelogEntry = ({
- date,
- href,
- title,
- description,
- tags,
-}: {
- date: string;
- href: string;
- title: string;
- description: string;
- tags?: string[];
-}) => {
- const getTagStyle = (tag: string) => {
- const tagLower = tag.toLowerCase();
-
- if (tagLower.includes('fix')) {
- return 'blue';
- }
- if (tagLower.includes('improvement')) {
- return 'green';
- }
- if (tagLower.includes('new')) {
- return 'red';
- }
- return 'neutral';
- };
-
- return (
-
Date: Fri, 26 Jun 2026 11:26:29 +0100
Subject: [PATCH 6/7] chore(changelog): drop the external Headway RSS source
Remove the gatsby-source-rss-feed plugin and dependency now that the homepage
and changelog source entries from the in-repo MDX.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
gatsby-config.ts | 7 -------
package.json | 1 -
yarn.lock | 33 ++-------------------------------
3 files changed, 2 insertions(+), 39 deletions(-)
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/yarn.lock b/yarn.lock
index acf0862fb2..06088554e3 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -7040,7 +7040,7 @@ enquirer@^2.3.5:
ansi-colors "^4.1.1"
strip-ansi "^6.0.1"
-entities@^2.0.0, entities@^2.0.3:
+entities@^2.0.0:
version "2.2.0"
resolved "https://registry.yarnpkg.com/entities/-/entities-2.2.0.tgz#098dc90ebb83d8dffa089d55256b351d34c4da55"
integrity sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==
@@ -8618,14 +8618,6 @@ gatsby-source-filesystem@5.16.0:
valid-url "^1.0.9"
xstate "^4.38.0"
-gatsby-source-rss-feed@^1.2.2:
- version "1.2.2"
- resolved "https://registry.yarnpkg.com/gatsby-source-rss-feed/-/gatsby-source-rss-feed-1.2.2.tgz#f557fd9018a4410e738efb8d345857090642fccf"
- integrity sha512-Ris5Dtdj856aups8xzPtNOG6prKjaGzcXxRdFrgExQBVv2o1fUPHwj7OGrdV32tNUg2mHOZurr5qFWAdaRIMUg==
- dependencies:
- lodash "^4.17.15"
- rss-parser "^3.7.6"
-
gatsby-transformer-remark@6.16.0:
version "6.16.0"
resolved "https://registry.yarnpkg.com/gatsby-transformer-remark/-/gatsby-transformer-remark-6.16.0.tgz#040af325902674cd79d5712fcf8c4f51dfe50267"
@@ -14223,14 +14215,6 @@ rrweb-cssom@^0.8.0:
resolved "https://registry.yarnpkg.com/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz#3021d1b4352fbf3b614aaeed0bc0d5739abe0bc2"
integrity sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==
-rss-parser@^3.7.6:
- version "3.13.0"
- resolved "https://registry.yarnpkg.com/rss-parser/-/rss-parser-3.13.0.tgz#f1f83b0a85166b8310ec531da6fbaa53ff0f50f0"
- integrity sha512-7jWUBV5yGN3rqMMj7CZufl/291QAhvrrGpDNE4k/02ZchL0npisiYYqULF71jCEKoIiHvK/Q2e6IkDwPziT7+w==
- dependencies:
- entities "^2.0.3"
- xml2js "^0.5.0"
-
run-async@^2.4.0:
version "2.4.1"
resolved "https://registry.yarnpkg.com/run-async/-/run-async-2.4.1.tgz#8440eccf99ea3e70bd409d49aab88e10c189a455"
@@ -14307,7 +14291,7 @@ sanitize-html@^2.11.0:
parse-srcset "^1.0.2"
postcss "^8.3.11"
-sax@>=0.6.0, sax@^1.2.4:
+sax@^1.2.4:
version "1.4.4"
resolved "https://registry.yarnpkg.com/sax/-/sax-1.4.4.tgz#f29c2bba80ce5b86f4343b4c2be9f2b96627cf8b"
integrity sha512-1n3r/tGXO6b6VXMdFT54SHzT9ytu9yr7TaELowdYpMqY/Ao7EnlQGmAQ1+RatX7Tkkdm6hONI2owqNx2aZj5Sw==
@@ -16535,19 +16519,6 @@ xml-name-validator@^5.0.0:
resolved "https://registry.yarnpkg.com/xml-name-validator/-/xml-name-validator-5.0.0.tgz#82be9b957f7afdacf961e5980f1bf227c0bf7673"
integrity sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==
-xml2js@^0.5.0:
- version "0.5.0"
- resolved "https://registry.yarnpkg.com/xml2js/-/xml2js-0.5.0.tgz#d9440631fbb2ed800203fad106f2724f62c493b7"
- integrity sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA==
- dependencies:
- sax ">=0.6.0"
- xmlbuilder "~11.0.0"
-
-xmlbuilder@~11.0.0:
- version "11.0.1"
- resolved "https://registry.yarnpkg.com/xmlbuilder/-/xmlbuilder-11.0.1.tgz#be9bae1c8a046e76b31127726347d0ad7002beb3"
- integrity sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==
-
xmlchars@^2.2.0:
version "2.2.0"
resolved "https://registry.yarnpkg.com/xmlchars/-/xmlchars-2.2.0.tgz#060fe1bcb7f9c76fe2a17db86a9bc3ab894210cb"
From 952b51a69547db4b27e93f28a9c3c5e8a1bbd273 Mon Sep 17 00:00:00 2001
From: Mark Hulbert <39801222+m-hulbert@users.noreply.github.com>
Date: Fri, 26 Jun 2026 11:26:34 +0100
Subject: [PATCH 7/7] docs(changelog): add the initial changelog entries
Seed the in-repo changelog with the first five entries (June 2026): JS SDK
2.22.1 and 2.23.0, AI Transport JS SDK 0.2.0 and 0.3.0, and improved LiveObjects
visibility.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.../2026/06/ai-transport-js-sdk-0-2-0.mdx | 26 +++++++++++++++++++
.../2026/06/ai-transport-js-sdk-0-3-0.mdx | 23 ++++++++++++++++
.../improved-visibility-into-liveobjects.mdx | 14 ++++++++++
.../docs/changelog/2026/06/js-sdk-2-22-1.mdx | 13 ++++++++++
.../docs/changelog/2026/06/js-sdk-2-23-0.mdx | 15 +++++++++++
5 files changed, 91 insertions(+)
create mode 100644 src/pages/docs/changelog/2026/06/ai-transport-js-sdk-0-2-0.mdx
create mode 100644 src/pages/docs/changelog/2026/06/ai-transport-js-sdk-0-3-0.mdx
create mode 100644 src/pages/docs/changelog/2026/06/improved-visibility-into-liveobjects.mdx
create mode 100644 src/pages/docs/changelog/2026/06/js-sdk-2-22-1.mdx
create mode 100644 src/pages/docs/changelog/2026/06/js-sdk-2-23-0.mdx
diff --git a/src/pages/docs/changelog/2026/06/ai-transport-js-sdk-0-2-0.mdx b/src/pages/docs/changelog/2026/06/ai-transport-js-sdk-0-2-0.mdx
new file mode 100644
index 0000000000..42efdfddf6
--- /dev/null
+++ b/src/pages/docs/changelog/2026/06/ai-transport-js-sdk-0-2-0.mdx
@@ -0,0 +1,26 @@
+---
+title: "Ably AI Transport JS SDK release v0.2.0"
+meta_description: "Version 0.2.0 of the Ably AI Transport JS SDK introduces a clearer session/run API, an event-sourced codec, and suspend/resume for human-in-the-loop handoff."
+date: "2026-06-08"
+products:
+ - ai-transport
+---
+
+Version 0.2.0 of the Ably AI Transport JS SDK has been released.
+
+This release gives a clearer session/run API and more robust support for branching conversations and human-in-the-loop handoff.
+
+The main changes are:
+
+- **Session/run model**: the transport/turn API is renamed to session/run (`createClientSession` / `createAgentSession`, `ClientSession` / `AgentSession`, `ActiveRun`, `Run.pipe`).
+- **Event-sourced codec**: the codec is now a reducer over a run-keyed conversation tree, where editing a prompt forks a branch and regenerating continues a run.
+- **Suspend and resume**: runs awaiting participant input can suspend and later resume, via new run lifecycle events.
+- **Wire protocol**: realigned on-the-wire message names and headers.
+- **Session construction**: sessions now take an Ably client and channel name.
+- **Runtime**: Node 20 is dropped (Node 22+ is now required).
+
+
+
+For the full changelog, see the [GitHub release page](https://github.com/ably/ably-ai-transport-js/releases/tag/0.2.0).
diff --git a/src/pages/docs/changelog/2026/06/ai-transport-js-sdk-0-3-0.mdx b/src/pages/docs/changelog/2026/06/ai-transport-js-sdk-0-3-0.mdx
new file mode 100644
index 0000000000..6c3f16b567
--- /dev/null
+++ b/src/pages/docs/changelog/2026/06/ai-transport-js-sdk-0-3-0.mdx
@@ -0,0 +1,23 @@
+---
+title: "Ably AI Transport JS SDK release v0.3.0"
+meta_description: "Version 0.3.0 of the Ably AI Transport JS SDK makes codecs simpler to author and adds Pub/Sub Presence and LiveObjects pass-through on the same session."
+date: "2026-06-19"
+products:
+ - ai-transport
+---
+
+Version 0.3.0 of the Ably AI Transport JS SDK has been released.
+
+This release makes codecs much simpler to author and lets you layer Ably Pub/Sub Presence and LiveObjects onto the same session as your AI stream.
+
+The main changes are:
+
+- **Declarative codec authoring**: defining or customising a codec is now declarative. You describe each wire event once and `defineCodec` derives both the encode and decode sides, instead of hand-writing and hand-syncing separate encoder, decoder, and reducer logic. Event ordering and de-duplication now live in the transport, so conversations converge correctly even when events arrive late or out of order.
+- **Presence and LiveObjects pass-through**: sessions now expose the Ably Presence and LiveObjects APIs on their channel, so you can show who is in a conversation or share synced state alongside AI messaging, with no separate channel to manage. You can set explicit channel modes (merged with the session defaults) so LiveObjects gets the object modes it needs, and Presence works directly with `ably-js`'s `usePresence` React hook.
+- **Other fixes and improvements**: history now paginates by message rather than by run for more predictable loading; agent run errors carry their cause to the client instead of a generic message; the client ID is read from your Ably client automatically; and regenerating a reply after a tool call no longer corrupts the conversation.
+
+
+
+For the full changelog, see the [GitHub release page](https://github.com/ably/ably-ai-transport-js/releases/tag/0.3.0).
diff --git a/src/pages/docs/changelog/2026/06/improved-visibility-into-liveobjects.mdx b/src/pages/docs/changelog/2026/06/improved-visibility-into-liveobjects.mdx
new file mode 100644
index 0000000000..d810cb01e8
--- /dev/null
+++ b/src/pages/docs/changelog/2026/06/improved-visibility-into-liveobjects.mdx
@@ -0,0 +1,14 @@
+---
+title: "Improved visibility into LiveObjects"
+meta_description: "The Ably dashboard now streams LiveObjects operations in realtime and visualises every object on a channel alongside its current values."
+date: "2026-06-09"
+products:
+ - liveobjects
+---
+
+The dashboard has been updated to give you more visibility into the LiveObjects you create and maintain on your channels. This includes two main items:
+
+- A realtime stream of each object operation as it occurs.
+- A visualisation of all LiveObjects on the channel that is updated in realtime. This lets you quickly see exactly which objects exist on a channel and their associated values.
+
+This functionality is available in two locations. If you view it under LiveObjects > Objects then you only see LiveObjects data for your channel. If you view it under Pub/Sub > Channels, then you can see all operations happening on the channel, such as messages being published and updated, and users entering and leaving presence.
diff --git a/src/pages/docs/changelog/2026/06/js-sdk-2-22-1.mdx b/src/pages/docs/changelog/2026/06/js-sdk-2-22-1.mdx
new file mode 100644
index 0000000000..8d8d35271a
--- /dev/null
+++ b/src/pages/docs/changelog/2026/06/js-sdk-2-22-1.mdx
@@ -0,0 +1,13 @@
+---
+title: "JS Client Library release v2.22.1"
+meta_description: "Version 2.22.1 of the Ably JavaScript client library restores mockability of the deprecated v1 callback API by changing its return type from never back to void."
+date: "2026-06-08"
+products:
+ - pub-sub
+---
+
+Version 2.22.1 of the JS Client Library has been released.
+
+This patch release fixes the deprecated v1 callback API overloads, which were declared with a `never` return type in 2.22.0. That return type broke mock assignment, making it impossible to stub or mock these methods in tests. The return type is now `void`, restoring compatibility for code that mocks the deprecated v1 callback interface.
+
+For the full changelog, see the [GitHub release page](https://github.com/ably/ably-js/releases/tag/2.22.1).
diff --git a/src/pages/docs/changelog/2026/06/js-sdk-2-23-0.mdx b/src/pages/docs/changelog/2026/06/js-sdk-2-23-0.mdx
new file mode 100644
index 0000000000..2c9d513afe
--- /dev/null
+++ b/src/pages/docs/changelog/2026/06/js-sdk-2-23-0.mdx
@@ -0,0 +1,15 @@
+---
+title: "JS Client Library release v2.23.0"
+meta_description: "Version 2.23.0 of the Ably JavaScript client library lets React channel hooks infer the channel from ChannelProvider, and fixes a presence re-enter bug."
+date: "2026-06-19"
+products:
+ - pub-sub
+---
+
+Version 2.23.0 of the JS Client Library has been released.
+
+The React channel hooks can now infer the channel from the nearest `ChannelProvider`, letting you omit the `channelName` argument and use new listener-only and callback-only overloads of the library's React hooks.
+
+This release also fixes a presence bug where automatic re-enter could fail with "Unable to perform operation on channel" NACKs after reconnecting from a transient disconnect.
+
+For the full changelog, see the [GitHub release page](https://github.com/ably/ably-js/releases/tag/2.23.0).