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