diff --git a/data/onCreateNode/create-graphql-schema-customization.ts b/data/onCreateNode/create-graphql-schema-customization.ts index 32f645aea0..1fc115d6ce 100644 --- a/data/onCreateNode/create-graphql-schema-customization.ts +++ b/data/onCreateNode/create-graphql-schema-customization.ts @@ -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 diff --git a/data/onCreatePage.ts b/data/onCreatePage.ts index a8fbdad732..ddfa5d8b8d 100644 --- a/data/onCreatePage.ts +++ b/data/onCreatePage.ts @@ -21,6 +21,7 @@ const pageLayoutOptions: Record = { }, '/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 }, }; @@ -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, diff --git a/data/onPostBuild/changelogFeed.test.ts b/data/onPostBuild/changelogFeed.test.ts new file mode 100644 index 0000000000..68957fc316 --- /dev/null +++ b/data/onPostBuild/changelogFeed.test.ts @@ -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 => ({ + 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('Ably Changelog'); + expect(xml).toContain('https://ably.com/docs/changelog'); + expect(xml).toContain( + '', + ); + }); + + 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('Hello'); + expect(xml).toContain('https://ably.com/docs/changelog/a'); + expect(xml).toContain('https://ably.com/docs/changelog/a'); + expect(xml).toContain(`${new Date('2026-06-19').toUTCString()}`); + expect(xml).toContain('A short blurb.'); + }); + + 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('pub-sub'); + expect(xml).toContain('chat'); + }); + + it('escapes XML metacharacters in text content', () => { + const xml = buildFeedXml([item({ title: `A & B "d" 'e'` })], OPTS); + expect(xml).toContain('A & B <c> "d" 'e''); + expect(xml).not.toContain(''); + // 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(//g) ?? []).toHaveLength(2); + expect(xml.indexOf('First')).toBeLessThan(xml.indexOf('Second')); + }); +}); diff --git a/data/onPostBuild/changelogFeed.ts b/data/onPostBuild/changelogFeed.ts new file mode 100644 index 0000000000..07e1839a43 --- /dev/null +++ b/data/onPostBuild/changelogFeed.ts @@ -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, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + +// 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 ) — 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) => ` ${escapeXml(product)}`).join('\n'); + return [ + ' ', + ` ${escapeXml(item.title)}`, + ` ${escapeXml(item.url)}`, + ` ${escapeXml(item.url)}`, + ` ${new Date(item.date).toUTCString()}`, + ` ${escapeXml(item.description)}`, + categories, + ' ', + ] + .filter(Boolean) + .join('\n'); + }) + .join('\n'); + + return ` + + + ${escapeXml(FEED_TITLE)} + ${escapeXml(opts.changelogUrl)} + ${escapeXml(FEED_DESCRIPTION)} + +${renderedItems} + + +`; +}; + +// 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(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); + } +}; 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..8c0c0eeeba --- /dev/null +++ b/src/components/Changelog/ChangelogContent.tsx @@ -0,0 +1,144 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { useLocation } from '@reach/router'; +import Button from '@ably/ui/core/Button'; +import cn from '@ably/ui/core/utils/cn'; +import { secondaryButtonClassName } from '../Layout/utils/styles'; +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; + +// Icon-only RSS button linking to the generated feed. Built from the shared secondary +// button styling (sm size, squared off) so its chrome, hover, and focus match the site. +const RssButton = () => ( + + + +); + +// Top-level client component for the changelog index page. Owns the product filter +// state, hydrates it from the URL, and renders the hero, product filter lozenges, +// and a single-column, horizontally-ruled timeline. Free-text search is +// intentionally left to the site-wide (Inkeep) search. +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]); + + return ( + <> + {mobileBackground && ( + + )} + {desktopBackground && ( + + )} + +
+
+
+

Changelog

+ +
+

+ New features, improvements, and fixes across the Ably platform and SDKs. +

+
+ +
+
+ +
+ + {hasMore && ( +
+ +

+ Showing {visibleEntries.length} of {filteredEntries.length} +

+
+ )} +
+
+ + ); +}; + +export default ChangelogContent; diff --git a/src/components/Changelog/ChangelogEntryCard.tsx b/src/components/Changelog/ChangelogEntryCard.tsx new file mode 100644 index 0000000000..8391576e6e --- /dev/null +++ b/src/components/Changelog/ChangelogEntryCard.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import Link from '../Link'; +import ChangelogTag from './ChangelogTag'; +import { ChangelogEntry } from './types'; +import { formatFullDate, toISODate } from './format-date'; + +// A single entry row: the full date in a left column, then the title (links to the +// entry page), product tags, and an excerpt on the right. Rows are separated by a +// horizontal rule via the list's `divide-y` in ChangelogTimeline. +const ChangelogEntryCard = ({ entry }: { entry: ChangelogEntry }) => ( +
  • + + {entry.date && ( + + )} +
    +

    {entry.title}

    + {entry.products.length > 0 && ( +
    + {entry.products.map((product) => ( + + ))} +
    + )} + {entry.description && ( +

    {entry.description}

    + )} +
    + +
  • +); + +export default ChangelogEntryCard; diff --git a/src/components/Changelog/ChangelogFilter.tsx b/src/components/Changelog/ChangelogFilter.tsx new file mode 100644 index 0000000000..af28e40cb4 --- /dev/null +++ b/src/components/Changelog/ChangelogFilter.tsx @@ -0,0 +1,82 @@ +import React, { useCallback } from 'react'; +import { navigate } from 'gatsby'; +import { useLocation } from '@reach/router'; +import cn from '@ably/ui/core/utils/cn'; +import { productTags } from './tags'; + +// Product filter for the changelog index, rendered as a row of toggle lozenges +// below the header. Selected products are mirrored into the `?product=` query +// string so a filtered view is shareable and survives a reload. Multi-select with +// OR semantics; "All" clears the filter. Free-text search is left to site-wide search. +const ChangelogFilter = ({ + selected, + setSelected, +}: { + selected: string[]; + setSelected: (products: string[]) => void; +}) => { + const location = useLocation(); + + const syncUrl = useCallback( + (products: string[]) => { + const params = new URLSearchParams(location.search); + if (products.length > 0) { + params.set('product', products.join(',')); + } else { + params.delete('product'); + } + const query = params.toString(); + navigate(`${location.pathname}${query ? `?${query}` : ''}`, { replace: true }); + }, + [location.pathname, location.search], + ); + + const toggle = useCallback( + (slug: string) => { + const next = + slug === 'all' ? [] : selected.includes(slug) ? selected.filter((item) => item !== slug) : [...selected, slug]; + setSelected(next); + syncUrl(next); + }, + [selected, setSelected, syncUrl], + ); + + const lozenge = (active: boolean) => + cn('rounded-full border px-3 py-1 ui-text-p4 transition-colors cursor-pointer', { + 'border-neutral-1300 bg-neutral-1300 text-neutral-000 dark:border-neutral-000 dark:bg-neutral-000 dark:text-neutral-1300': + active, + 'border-neutral-300 text-neutral-1000 hover:border-neutral-500 dark:border-neutral-1000 dark:text-neutral-300 dark:hover:border-neutral-800': + !active, + }); + + return ( +
    + + {Object.entries(productTags).map(([slug, { label }]) => { + const active = selected.includes(slug); + return ( + + ); + })} +
    + ); +}; + +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..f960ed1080 --- /dev/null +++ b/src/components/Changelog/ChangelogTimeline.tsx @@ -0,0 +1,22 @@ +import React from 'react'; +import ChangelogEntryCard from './ChangelogEntryCard'; +import { ChangelogEntry } from './types'; + +// Reverse-chronological list of entries (already sorted newest-first by the index +// page). Each entry is a row showing its own full date; rows are separated by a +// horizontal rule via `divide-y`. +const ChangelogTimeline = ({ entries }: { entries: ChangelogEntry[] }) => { + if (entries.length === 0) { + return

    No updates match your filters.

    ; + } + + return ( +
      + {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..61d0e526c7 --- /dev/null +++ b/src/components/Changelog/absolutize-url.ts @@ -0,0 +1,18 @@ +// 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) and the RSS feed's +// item/link URLs (changelogFeed), so 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..8242d2448e --- /dev/null +++ b/src/components/Changelog/filter-changelog.test.ts @@ -0,0 +1,89 @@ +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']); + }); + + const sameDay: ChangelogEntry[] = [ + { link: '/docs/changelog/2026/06/zephyr', title: 'Z', description: '', date: '2026-06-19', products: [] }, + { link: '/docs/changelog/2026/06/alpha', title: 'A', description: '', date: '2026-06-19', products: [] }, + { link: '/docs/changelog/2026/06/mango', title: 'M', description: '', date: '2026-06-19', products: [] }, + ]; + + it('breaks same-day ties alphabetically by slug', () => { + const sorted = sortByDateDesc(sameDay); + expect(sorted.map((e) => e.link)).toEqual([ + '/docs/changelog/2026/06/alpha', + '/docs/changelog/2026/06/mango', + '/docs/changelog/2026/06/zephyr', + ]); + }); + + it('is deterministic regardless of input order for same-day entries', () => { + const a = sortByDateDesc(sameDay).map((e) => e.link); + const b = sortByDateDesc([...sameDay].reverse()).map((e) => e.link); + expect(a).toEqual(b); + }); + + it('sorts undated entries last (by slug among themselves)', () => { + const mixed: ChangelogEntry[] = [ + { link: '/docs/changelog/x', title: 'X', description: '', date: '', products: [] }, + { link: '/docs/changelog/dated', title: 'D', description: '', date: '2026-06-19', products: [] }, + { link: '/docs/changelog/a', title: 'A', description: '', date: '', products: [] }, + ]; + expect(sortByDateDesc(mixed).map((e) => e.link)).toEqual([ + '/docs/changelog/dated', + '/docs/changelog/a', + '/docs/changelog/x', + ]); + }); +}); diff --git a/src/components/Changelog/filter-changelog.ts b/src/components/Changelog/filter-changelog.ts new file mode 100644 index 0000000000..e8220cebc3 --- /dev/null +++ b/src/components/Changelog/filter-changelog.ts @@ -0,0 +1,32 @@ +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 sharing a date are ordered by slug so the result is stable +// and deterministic across builds (dates are date-only, so same-day entries would +// otherwise inherit the arbitrary GraphQL query order). Unparseable dates 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); + // Valid dates sort before invalid ones. + if (aInvalid !== bInvalid) { + return aInvalid ? 1 : -1; + } + // Among valid dates, newest first. + if (!aInvalid && bTime !== aTime) { + return bTime - aTime; + } + // Tie (same day, or both undated): order by slug. Links are unique, so this + // fully breaks the tie and keeps ordering stable from one build to the next. + return a.link.localeCompare(b.link); + }); diff --git a/src/components/Changelog/format-date.ts b/src/components/Changelog/format-date.ts new file mode 100644 index 0000000000..b74218d1f1 --- /dev/null +++ b/src/components/Changelog/format-date.ts @@ -0,0 +1,33 @@ +// 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