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/changelog-content.test.ts b/data/onPostBuild/changelog-content.test.ts new file mode 100644 index 0000000000..b7fbec7a30 --- /dev/null +++ b/data/onPostBuild/changelog-content.test.ts @@ -0,0 +1,82 @@ +import { extractEntryBodyHtml, wrapCdata } from './changelog-content'; + +const SITE = 'https://ably.com'; + +// Minimal stand-in for a built entry page: the ChangelogHeader chrome (which must +// NOT appear in the feed) followed by the wrapped body region. +const page = (bodyInner: string): string => ` + +
+
+
+ All updates +

JS Client Library release v2.23.0

+ +
+
${bodyInner}
+
+
+ +`; + +describe('extractEntryBodyHtml', () => { + it('extracts the body region and excludes the header chrome', () => { + const html = extractEntryBodyHtml(page('

Version 2.23.0 has been released.

'), SITE); + expect(html).toBe('

Version 2.23.0 has been released.

'); + }); + + it('does not leak the back-link or "All updates" header text', () => { + const html = extractEntryBodyHtml(page('

Body

'), SITE) ?? ''; + expect(html).not.toContain('All updates'); + expect(html).not.toContain('href="/docs/changelog"'); + expect(html).not.toContain(' { + const html = '

No marker here

'; + expect(extractEntryBodyHtml(html, SITE)).toBeNull(); + }); + + it('returns null when the body region is empty', () => { + expect(extractEntryBodyHtml(page(' '), SITE)).toBeNull(); + }); + + it('absolutizes relative href and src, leaving absolute ones untouched', () => { + const html = + extractEntryBodyHtml( + page('chatgh'), + SITE, + ) ?? ''; + expect(html).toContain('href="https://ably.com/docs/chat"'); + expect(html).toContain('src="https://ably.com/static/x.png"'); + expect(html).toContain('href="https://github.com/ably"'); + }); + + it('strips script and style tags', () => { + const html = extractEntryBodyHtml(page('

ok

'), SITE) ?? ''; + expect(html).toContain('

ok

'); + expect(html).not.toContain('evil()'); + expect(html).not.toContain('.x{}'); + }); + + it('absolutizes every URL in a srcset', () => { + const html = extractEntryBodyHtml(page(''), SITE) ?? ''; + expect(html).toContain('https://ably.com/a.png 1x'); + expect(html).toContain('https://ably.com/b.png 2x'); + }); +}); + +describe('wrapCdata', () => { + it('wraps content in a CDATA section', () => { + expect(wrapCdata('

hi

')).toBe('hi

]]>'); + }); + + it('splits an embedded ]]> using the standard CDATA-escape so it round-trips', () => { + // `]]>` becomes `]]` + (close)`]]>` + (reopen)``, which a parser + // reassembles back into the original `]]>` rather than terminating early. + const wrapped = wrapCdata('a]]>b'); + expect(wrapped).toBe('b]]>'); + expect(wrapped.startsWith('')).toBe(true); + }); +}); diff --git a/data/onPostBuild/changelog-content.ts b/data/onPostBuild/changelog-content.ts new file mode 100644 index 0000000000..2ef07357de --- /dev/null +++ b/data/onPostBuild/changelog-content.ts @@ -0,0 +1,68 @@ +// Use the slim build: it provides `load` without cheerio's `fromURL` helper, which +// pulls in `undici` (unnecessary here, and it fails to load under the Jest runtime). +import { load } from 'cheerio/slim'; +import { absolutizeUrl } from '../../src/components/Changelog/absolutize-url'; + +// Pure helpers for building the RSS feed's bodies from the +// already-built changelog entry pages. Kept free of `fs`/Gatsby so they can be +// unit-tested with HTML fixtures; the file reading lives in changelogFeed.ts. + +// Selector for the changelog entry body region. This is a deliberate contract +// with src/components/Layout/MDXWrapper.tsx, which wraps each entry's MDX body in +// `
`. Keep the two in sync — if the marker is removed the +// feed build fails its minimum-coverage assertion rather than silently regressing. +export const CHANGELOG_BODY_SELECTOR = '[data-changelog-body]'; + +// Absolutize every URL in a srcset attribute ("url1 1x, url2 2x"). +const absolutizeSrcset = (srcset: string, siteUrl: string): string => + srcset + .split(',') + .map((candidate) => { + const [url, ...descriptors] = candidate.trim().split(/\s+/); + if (!url) { + return candidate.trim(); + } + return [absolutizeUrl(url, siteUrl), ...descriptors].join(' '); + }) + .filter(Boolean) + .join(', '); + +// Extract the inner HTML of a built changelog entry's body region, with scripts/ +// styles stripped and all URLs absolutized. Returns null when the region is absent +// or empty so the caller can fall back to a summary-only feed item. +export const extractEntryBodyHtml = (pageHtml: string, siteUrl: string): string | null => { + const $ = load(pageHtml); + const body = $(CHANGELOG_BODY_SELECTOR).first(); + if (body.length === 0) { + return null; + } + + // Defensive: a feed item should never carry executable/style content. + body.find('script, style').remove(); + + body.find('[href]').each((_, el) => { + const value = $(el).attr('href'); + if (value) { + $(el).attr('href', absolutizeUrl(value, siteUrl)); + } + }); + body.find('[src]').each((_, el) => { + const value = $(el).attr('src'); + if (value) { + $(el).attr('src', absolutizeUrl(value, siteUrl)); + } + }); + body.find('[srcset]').each((_, el) => { + const value = $(el).attr('srcset'); + if (value) { + $(el).attr('srcset', absolutizeSrcset(value, siteUrl)); + } + }); + + const html = body.html(); + return html && html.trim() ? html.trim() : null; +}; + +// Wrap HTML for inclusion in a CDATA section, splitting any `]]>` sequence so the +// content can't terminate the section early and break the XML. +export const wrapCdata = (content: string): string => `/g, ']]]]>')}]]>`; diff --git a/data/onPostBuild/changelogFeed.ts b/data/onPostBuild/changelogFeed.ts new file mode 100644 index 0000000000..27c8a23a52 --- /dev/null +++ b/data/onPostBuild/changelogFeed.ts @@ -0,0 +1,221 @@ +import { GatsbyNode } from 'gatsby'; +import * as path from 'path'; +import * as fs from 'fs'; +import { ChangelogFileNode, nodesToEntries } from '../../src/components/Changelog/entries'; +import { sortByDateDesc } from '../../src/components/Changelog/filter-changelog'; +import { CHANGELOG_PATH, CHANGELOG_RSS_PATH } from '../../src/components/Changelog/constants'; +import { isKnownProductSlug, productTags } from '../../src/components/Changelog/tags'; +import { absolutizeUrl } from '../../src/components/Changelog/absolutize-url'; +import { extractEntryBodyHtml, wrapCdata } from './changelog-content'; + +const REPORTER_PREFIX = 'onPostBuild:changelogFeed'; + +const FEED_TITLE = 'Ably Changelog'; +const FEED_DESCRIPTION = 'New features, improvements, and fixes across the Ably platform and SDKs.'; + +// Cap the feed to the most recent entries, matching the previous +// changelog.ably.com feed and standard RSS practice (readers only surface recent +// items, and an unbounded feed grows without limit). The full history remains +// browsable on the changelog index page. +const FEED_MAX_ITEMS = 20; + +interface ChangelogFeedQueryResult { + site: { + siteMetadata: { + siteUrl: string | null; + }; + }; + entries: { + nodes: ChangelogFileNode[]; + }; +} + +// Minimal XML escaping for text placed inside elements/attributes. +const escapeXml = (value: string): string => + value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + +// Generates an RSS 2.0 feed from the changelog MDX entries and writes it to +// public/docs/changelog/rss.xml. Mirrors the query/siteUrl handling used by the +// llms.txt and markdown post-build steps; no extra plugin dependency is needed. +export const onPostBuild: GatsbyNode['onPostBuild'] = async ({ graphql, reporter, basePath }) => { + const query = ` + query { + site { + siteMetadata { + siteUrl + } + } + entries: allFile( + filter: { + sourceInstanceName: { eq: "pages" } + extension: { eq: "mdx" } + relativeDirectory: { regex: "/^docs/changelog(/|$)/" } + } + ) { + nodes { + name + relativeDirectory + childMdx { + frontmatter { + title + meta_description + date + products + } + } + } + } + } + `; + + const { data, errors } = await graphql(query); + + if (errors) { + reporter.panicOnBuild(`${REPORTER_PREFIX} GraphQL query failed: ${JSON.stringify(errors, null, 2)}`); + throw errors; + } + + if (!data) { + reporter.warn(`${REPORTER_PREFIX} No data returned; skipping changelog feed.`); + return; + } + + // All sourced changelog entries (the query is unbounded). Validate product slugs + // here — before the feed is filtered/capped — so a typo'd or unregistered product + // in any entry's frontmatter fails the build loudly rather than silently shipping + // an un-filterable grey badge on the index. + const allEntries = nodesToEntries(data.entries.nodes); + const invalidProducts = allEntries.flatMap((entry) => + entry.products.filter((product) => !isKnownProductSlug(product)).map((product) => `${entry.link} → "${product}"`), + ); + if (invalidProducts.length > 0) { + reporter.panicOnBuild( + `${REPORTER_PREFIX} Unknown product slug(s) in changelog frontmatter (expected one of: ` + + `${Object.keys(productTags).join(', ')}):\n ${invalidProducts.join('\n ')}`, + ); + return; + } + + const siteUrl = data.site.siteMetadata.siteUrl; + if (!siteUrl) { + reporter.warn(`${REPORTER_PREFIX} Site URL not found; skipping changelog feed.`); + return; + } + + const prefix = `${siteUrl}${basePath ?? ''}`; + const feedUrl = absolutizeUrl(CHANGELOG_RSS_PATH, prefix); + const changelogUrl = absolutizeUrl(CHANGELOG_PATH, prefix); + + // Read the body of an entry's already-built page so we can include it as full + // content. Gatsby writes pages to public//index.html (no pathPrefix here); + // the `.html` fallback guards against a future trailingSlash/layout change. + const readEntryHtml = (link: string): string | null => { + const segments = link.split('/').filter(Boolean); + const candidates = [ + path.join(process.cwd(), 'public', ...segments, 'index.html'), + `${path.join(process.cwd(), 'public', ...segments)}.html`, + ]; + const found = candidates.find((candidate) => fs.existsSync(candidate)); + return found ? fs.readFileSync(found, 'utf-8') : null; + }; + + // Shared identification/mapping with the on-page changelog, then feed-specific + // shaping: drop entries without a date (RSS items need a pubDate) and turn each + // entry's site path into an absolute, prefixed URL. + const entries = sortByDateDesc(allEntries) + .filter((entry) => Boolean(entry.date)) + .slice(0, FEED_MAX_ITEMS) + .map((entry) => ({ + title: entry.title, + description: entry.description, + date: entry.date, + products: entry.products, + link: entry.link, + url: absolutizeUrl(entry.link, prefix), + })); + + // Build full-content () for each entry from its built HTML. + // Any failure degrades that single item to summary-only and logs a warning, so + // one bad page never breaks the feed; total failure is caught by the guard below. + let fullContentCount = 0; + const itemsData = entries.map((entry) => { + let contentEncoded: string | null = null; + try { + const pageHtml = readEntryHtml(entry.link); + if (!pageHtml) { + reporter.warn(`${REPORTER_PREFIX} Built HTML not found for ${entry.link}; emitting summary only.`); + } else { + const body = extractEntryBodyHtml(pageHtml, prefix); + if (body) { + contentEncoded = wrapCdata(body); + fullContentCount += 1; + } else { + reporter.warn( + `${REPORTER_PREFIX} No [data-changelog-body] content for ${entry.link}; emitting summary only.`, + ); + } + } + } catch (err) { + reporter.warn(`${REPORTER_PREFIX} Failed to extract content for ${entry.link}: ${(err as Error).message}`); + } + return { ...entry, contentEncoded }; + }); + + // Guard against a silent, site-wide regression: if there are entries but not one + // produced full content, the [data-changelog-body] contract has almost certainly + // been broken upstream. Fail the build loudly rather than ship a degraded feed. + if (entries.length > 0 && fullContentCount === 0) { + reporter.panicOnBuild( + `${REPORTER_PREFIX} Produced full content for 0 of ${entries.length} entries. The ` + + `[data-changelog-body] marker (src/components/Layout/MDXWrapper.tsx) may have been removed or renamed.`, + ); + return; + } + + const items = itemsData + .map((entry) => { + const categories = entry.products.map((product) => ` ${escapeXml(product)}`).join('\n'); + return [ + ' ', + ` ${escapeXml(entry.title)}`, + ` ${escapeXml(entry.url)}`, + ` ${escapeXml(entry.url)}`, + ` ${new Date(entry.date).toUTCString()}`, + ` ${escapeXml(entry.description)}`, + entry.contentEncoded ? ` ${entry.contentEncoded}` : '', + categories, + ' ', + ] + .filter(Boolean) + .join('\n'); + }) + .join('\n'); + + const xml = ` + + + ${escapeXml(FEED_TITLE)} + ${escapeXml(changelogUrl)} + ${escapeXml(FEED_DESCRIPTION)} + +${items} + + +`; + + const outputDir = path.join(process.cwd(), 'public', 'docs', 'changelog'); + try { + fs.mkdirSync(outputDir, { recursive: true }); + fs.writeFileSync(path.join(outputDir, 'rss.xml'), xml); + reporter.info( + `${REPORTER_PREFIX} Wrote changelog feed: ${entries.length} items, ${fullContentCount} with full content`, + ); + } catch (err) { + reporter.panic(`${REPORTER_PREFIX} Error writing changelog feed`, err as Error); + } +}; diff --git a/data/onPostBuild/index.ts b/data/onPostBuild/index.ts index e8bc0eaad1..3bdf9b6b38 100644 --- a/data/onPostBuild/index.ts +++ b/data/onPostBuild/index.ts @@ -1,6 +1,7 @@ import { GatsbyNode, Reporter } from 'gatsby'; import { onPostBuild as llmstxt } from './llmstxt'; import { onPostBuild as transpileMdxToMarkdown } from './transpileMdxToMarkdown'; +import { onPostBuild as changelogFeed } from './changelogFeed'; import { onPostBuild as compressAssets } from './compressAssets'; import { validateRedirectFile, REDIRECT_FILE_PATH } from '../utils/validateRedirectFile'; @@ -35,5 +36,6 @@ export const onPostBuild: GatsbyNode['onPostBuild'] = async (args) => { // Run all onPostBuild functions in sequence await llmstxt(args); await transpileMdxToMarkdown(args); + await changelogFeed(args); await compressAssets(args); }; diff --git a/gatsby-config.ts b/gatsby-config.ts index a6a5597171..e70285bb49 100644 --- a/gatsby-config.ts +++ b/gatsby-config.ts @@ -86,13 +86,6 @@ export const plugins = [ }, }, 'gatsby-plugin-react-helmet', - { - resolve: `gatsby-source-rss-feed`, - options: { - url: `https://changelog.ably.com/rss`, - name: `AblyChangelog`, - }, - }, 'gatsby-plugin-root-import', { resolve: 'gatsby-plugin-mdx', diff --git a/package.json b/package.json index 20287c8f0f..7207893438 100644 --- a/package.json +++ b/package.json @@ -77,7 +77,6 @@ "gatsby-remark-gifs": "^1.2.0", "gatsby-remark-images": "7.16.0", "gatsby-source-filesystem": "5.16.0", - "gatsby-source-rss-feed": "^1.2.2", "gatsby-transformer-remark": "6.16.0", "gatsby-transformer-sharp": "5.16.0", "gatsby-transformer-yaml": "5.16.0", diff --git a/src/components/Changelog/ChangelogContent.test.tsx b/src/components/Changelog/ChangelogContent.test.tsx new file mode 100644 index 0000000000..954e02258d --- /dev/null +++ b/src/components/Changelog/ChangelogContent.test.tsx @@ -0,0 +1,143 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { useLocation } from '@reach/router'; +import ChangelogContent from './ChangelogContent'; +import { ChangelogEntry } from './types'; +import { ImageProps } from '../Image'; + +// ChangelogContent reads the shared `?product=` filter from the router and renders +// a client-paginated timeline; override only useLocation so each test controls the +// URL, keeping the rest of reach-router intact (Gatsby's relies on it). +jest.mock('@reach/router', () => ({ + ...jest.requireActual('@reach/router'), + useLocation: jest.fn(), +})); + +// The decorative background uses gatsby-plugin-image transitively via src/Image. +jest.mock('gatsby-plugin-image', () => ({ + GatsbyImage: jest.fn(() => null), + StaticImage: jest.fn(() => null), + getImage: jest.fn(), +})); + +const mockLocation = (search = '') => + (useLocation as jest.Mock).mockReturnValue({ + pathname: '/docs/changelog', + search, + hash: '', + state: null, + key: 'test', + }); + +// 12 chat entries + 3 spaces entries = 15 total, all in June 2026 (one month group). +const CHAT_COUNT = 12; +const SPACES_COUNT = 3; +const makeEntries = (): ChangelogEntry[] => [ + ...Array.from({ length: CHAT_COUNT }, (_, i) => ({ + link: `/docs/changelog/2026/06/chat-${i + 1}`, + title: `Chat entry ${i + 1}`, + description: `Chat description ${i + 1}`, + // Newest first; dates 2026-06-28 down to 2026-06-17. + date: `2026-06-${String(28 - i).padStart(2, '0')}`, + products: ['chat'], + })), + ...Array.from({ length: SPACES_COUNT }, (_, i) => ({ + link: `/docs/changelog/2026/06/spaces-${i + 1}`, + title: `Spaces entry ${i + 1}`, + description: `Spaces description ${i + 1}`, + date: `2026-06-0${i + 1}`, + products: ['spaces'], + })), +]; + +// Decorative background SVGs the component looks up by name; stubbing them keeps +// getImageFromList from warning about missing images during the test. +const IMAGES = [ + { base: 'mobile-grid.svg', extension: 'svg', publicURL: '/mobile-grid.svg' }, + { base: 'pattern-grid.svg', extension: 'svg', publicURL: '/pattern-grid.svg' }, +] as unknown as ImageProps[]; + +const renderContent = (entries: ChangelogEntry[]) => render(); + +// Each entry card is rendered as a list item; counting them is the cleanest +// proxy for "how many entries are currently visible". +const visibleCount = () => screen.getAllByRole('listitem').length; + +describe('ChangelogContent', () => { + beforeEach(() => { + mockLocation(''); + }); + + describe('pagination', () => { + it('caps the initial render at one page (10) and reports the total', () => { + renderContent(makeEntries()); + + expect(visibleCount()).toBe(10); + expect(screen.getByText('Showing 10 of 15')).toBeInTheDocument(); + expect(screen.getByText('Show more')).toBeInTheDocument(); + }); + + it('reveals another page on "Show more" and hides the button once exhausted', () => { + renderContent(makeEntries()); + + fireEvent.click(screen.getByText('Show more')); + + expect(visibleCount()).toBe(15); + expect(screen.queryByText('Show more')).not.toBeInTheDocument(); + }); + + it('does not paginate when there are fewer entries than a page', () => { + renderContent(makeEntries().slice(0, 5)); + + expect(visibleCount()).toBe(5); + expect(screen.queryByText('Show more')).not.toBeInTheDocument(); + }); + }); + + describe('product filter', () => { + it('narrows the timeline to the selected product', () => { + renderContent(makeEntries()); + + fireEvent.click(screen.getByTestId('product-spaces')); + + expect(visibleCount()).toBe(SPACES_COUNT); + expect(screen.getByText('Spaces entry 1')).toBeInTheDocument(); + expect(screen.queryByText('Chat entry 1')).not.toBeInTheDocument(); + }); + + it('resets pagination to the first page when the filter changes', () => { + renderContent(makeEntries()); + + // Expand to show every entry... + fireEvent.click(screen.getByText('Show more')); + expect(visibleCount()).toBe(15); + + // ...then apply a filter whose result set still exceeds one page. The view + // should collapse back to 10 rather than inherit the expanded count. + fireEvent.click(screen.getByTestId('product-chat')); + + expect(visibleCount()).toBe(10); + expect(screen.getByText('Showing 10 of 12')).toBeInTheDocument(); + }); + + it('hydrates the filter from the ?product= query string on mount', () => { + mockLocation('?product=spaces'); + + renderContent(makeEntries()); + + expect(visibleCount()).toBe(SPACES_COUNT); + expect(screen.getByText('Spaces entry 1')).toBeInTheDocument(); + expect(screen.queryByText('Chat entry 1')).not.toBeInTheDocument(); + }); + + it('ignores unknown product slugs in the query string', () => { + mockLocation('?product=not-a-product'); + + renderContent(makeEntries()); + + // Unknown slug is dropped, so the filter is empty and all entries show. + expect(visibleCount()).toBe(10); + expect(screen.getByText('Showing 10 of 15')).toBeInTheDocument(); + }); + }); +}); diff --git a/src/components/Changelog/ChangelogContent.tsx b/src/components/Changelog/ChangelogContent.tsx new file mode 100644 index 0000000000..5afdf04101 --- /dev/null +++ b/src/components/Changelog/ChangelogContent.tsx @@ -0,0 +1,140 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { useLocation } from '@reach/router'; +import Button from '@ably/ui/core/Button'; +import ChangelogFilter from './ChangelogFilter'; +import ChangelogTimeline from './ChangelogTimeline'; +import { filterChangelog, sortByDateDesc } from './filter-changelog'; +import { productTags } from './tags'; +import { ChangelogEntry } from './types'; +import { CHANGELOG_RSS_PATH } from './constants'; +import { Image, ImageProps, getImageFromList } from 'src/components/Image'; + +// Number of entries shown initially and revealed per "Show more" click. +const PAGE_SIZE = 10; + +// Top-level client component for the changelog index page. Owns the product filter +// state, hydrates it from the URL, and renders the hero, filter rail, and timeline. +// Free-text search is intentionally left to the site-wide (Inkeep) search; the +// index offers product tag filtering plus a paginated "Show more" timeline. +const ChangelogContent = ({ entries, images = [] }: { entries: ChangelogEntry[]; images?: ImageProps[] }) => { + const location = useLocation(); + + // Decorative top-right background pattern (changelog-specific assets). It is + // offset by the fixed site header height (top-[3.9375rem] = 63px) so it sits + // directly below the header rather than underneath it. + const mobileBackground = getImageFromList(images, 'mobile-grid.svg'); + const desktopBackground = getImageFromList(images, 'pattern-grid.svg'); + + const getInitialProducts = (): string[] => { + const params = new URLSearchParams(location.search); + const productParam = params.get('product'); + if (!productParam) { + return []; + } + const validSlugs = Object.keys(productTags); + return productParam + .split(',') + .map((slug) => slug.trim()) + .filter((slug) => validSlugs.includes(slug)); + }; + + const [selected, setSelected] = useState([]); + const [visibleCount, setVisibleCount] = useState(PAGE_SIZE); + + // Hydrate the filter from a shared `?product=` URL after mount rather than in the + // useState initializer. The server renders with no query string, so seeding state + // from the URL up-front would mismatch the first client render; applying it in an + // effect keeps hydration consistent and then reflects the shared filter. + useEffect(() => { + setSelected(getInitialProducts()); + // Mount-only: later URL changes are driven by the filter itself, not read back. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Filtering happens over the full dataset; pagination is only a display cap + // applied afterwards. + const filteredEntries = useMemo(() => sortByDateDesc(filterChangelog(entries, selected)), [entries, selected]); + const visibleEntries = filteredEntries.slice(0, visibleCount); + const hasMore = filteredEntries.length > visibleCount; + + // Reset the page size whenever the filter changes so a new result set starts + // from the top rather than inheriting a previously expanded count. + useEffect(() => { + setVisibleCount(PAGE_SIZE); + }, [selected]); + + const rssUrl = CHANGELOG_RSS_PATH; + + return ( + <> + {mobileBackground && ( + + )} + {desktopBackground && ( + + )} + +
+
+
+

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..4cdbc1836e --- /dev/null +++ b/src/components/Changelog/ChangelogEntryCard.tsx @@ -0,0 +1,34 @@ +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 in the timeline: date, title (links to the entry page), product +// tags, and an excerpt. Styling mirrors the homepage changelog widget's entry rows. +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..f383fbf0bc --- /dev/null +++ b/src/components/Changelog/ChangelogFilter.tsx @@ -0,0 +1,81 @@ +import React, { ChangeEvent, useCallback } from 'react'; +import { navigate } from 'gatsby'; +import { useLocation } from '@reach/router'; +import ExamplesCheckbox from '../Examples/ExamplesCheckbox'; +import { productTags } from './tags'; + +// Product filter for the changelog index. Selected products are mirrored into the +// `?product=` query string so a filtered view is shareable and survives a reload +// (same approach as the Examples filter). On mobile this stacks above the timeline; +// on desktop it is a left rail. Free-text search is handled by the 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 handleSelect = useCallback( + (e: ChangeEvent) => { + const value = e.target.value; + + if (value === 'all') { + setSelected([]); + syncUrl([]); + return; + } + + const next = selected.includes(value) ? selected.filter((item) => item !== value) : [...selected, value]; + + setSelected(next); + syncUrl(next); + }, + [selected, setSelected, syncUrl], + ); + + return ( +
    +
    +

    PRODUCT

    +
    + + {Object.entries(productTags).map(([slug, { label }]) => ( + + ))} +
    +
    +
    + ); +}; + +export default ChangelogFilter; diff --git a/src/components/Changelog/ChangelogHeader.tsx b/src/components/Changelog/ChangelogHeader.tsx new file mode 100644 index 0000000000..d417520e3b --- /dev/null +++ b/src/components/Changelog/ChangelogHeader.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import Icon from '@ably/ui/core/Icon'; +import Link from '../Link'; +import { formatFullDate, toISODate } from './format-date'; + +// Header rendered at the top of an individual changelog entry page, in place of +// the standard doc PageHeader. Shows a back-link, the title, and the publish date. +const ChangelogHeader = ({ title, date }: { title: string; date?: string }) => ( +
    + + + All updates + +

    {title}

    + {date && ( + + )} +
    +); + +export default ChangelogHeader; diff --git a/src/components/Changelog/ChangelogTag.tsx b/src/components/Changelog/ChangelogTag.tsx new file mode 100644 index 0000000000..3990f2aa4b --- /dev/null +++ b/src/components/Changelog/ChangelogTag.tsx @@ -0,0 +1,13 @@ +import React from 'react'; +import Badge from '@ably/ui/core/Badge'; +import cn from '@ably/ui/core/utils/cn'; +import { getProductTag } from './tags'; + +// Renders a single product tag, matching the Examples grid: a default (neutral) +// Badge with an uppercase, product-coloured label. +const ChangelogTag = ({ product }: { product: string }) => { + const { label, colorClass } = getProductTag(product); + return {label}; +}; + +export default ChangelogTag; diff --git a/src/components/Changelog/ChangelogTimeline.tsx b/src/components/Changelog/ChangelogTimeline.tsx new file mode 100644 index 0000000000..43bf8713ba --- /dev/null +++ b/src/components/Changelog/ChangelogTimeline.tsx @@ -0,0 +1,57 @@ +import React, { useMemo } from 'react'; +import ChangelogEntryCard from './ChangelogEntryCard'; +import { ChangelogEntry } from './types'; +import { toMonthKey, formatMonth } from './format-date'; + +type MonthGroup = { + monthKey: string; + // The date of the first (newest) entry in the month, used to format the heading. + date: string; + entries: ChangelogEntry[]; +}; + +// Groups entries (already newest-first) by calendar month, preserving order. +const groupByMonth = (entries: ChangelogEntry[]): MonthGroup[] => { + const groups: MonthGroup[] = []; + entries.forEach((entry) => { + const monthKey = toMonthKey(entry.date); + const lastGroup = groups[groups.length - 1]; + if (lastGroup && lastGroup.monthKey === monthKey) { + lastGroup.entries.push(entry); + } else { + groups.push({ monthKey, date: entry.date, entries: [entry] }); + } + }); + return groups; +}; + +// Reverse-chronological timeline with a left-hand month rail. Each month heading +// groups the entries published that month; each entry card shows its own date. +// Entries are expected newest-first (the index page sorts before slicing); month +// grouping relies on that ordering rather than re-sorting here. +const ChangelogTimeline = ({ entries }: { entries: ChangelogEntry[] }) => { + const groups = useMemo(() => groupByMonth(entries), [entries]); + + if (groups.length === 0) { + return

    No updates match your filters.

    ; + } + + return ( +
    + {groups.map((group) => ( +
    +

    + {formatMonth(group.date)} +

    +
      + {group.entries.map((entry) => ( + + ))} +
    +
    + ))} +
    + ); +}; + +export default ChangelogTimeline; diff --git a/src/components/Changelog/absolutize-url.test.ts b/src/components/Changelog/absolutize-url.test.ts new file mode 100644 index 0000000000..aba6b87ec3 --- /dev/null +++ b/src/components/Changelog/absolutize-url.test.ts @@ -0,0 +1,25 @@ +import { absolutizeUrl } from './absolutize-url'; + +const SITE = 'https://ably.com'; + +describe('absolutizeUrl', () => { + it('prefixes site-relative and bare paths', () => { + expect(absolutizeUrl('/docs/chat', SITE)).toBe('https://ably.com/docs/chat'); + expect(absolutizeUrl('images/x.png', SITE)).toBe('https://ably.com/images/x.png'); + }); + + it('leaves absolute, protocol-relative, anchor, and non-http schemes untouched', () => { + expect(absolutizeUrl('https://x.com/y', SITE)).toBe('https://x.com/y'); + expect(absolutizeUrl('//cdn.com/y', SITE)).toBe('//cdn.com/y'); + expect(absolutizeUrl('#section', SITE)).toBe('#section'); + expect(absolutizeUrl('mailto:a@b.com', SITE)).toBe('mailto:a@b.com'); + }); + + it('tolerates a trailing slash on the base URL', () => { + expect(absolutizeUrl('/docs', 'https://ably.com/')).toBe('https://ably.com/docs'); + }); + + it('returns empty input unchanged', () => { + expect(absolutizeUrl('', SITE)).toBe(''); + }); +}); diff --git a/src/components/Changelog/absolutize-url.ts b/src/components/Changelog/absolutize-url.ts new file mode 100644 index 0000000000..122407863f --- /dev/null +++ b/src/components/Changelog/absolutize-url.ts @@ -0,0 +1,19 @@ +// Single source of truth for turning a possibly-relative URL into an absolute one +// against a site origin. Used by the OG-image meta (MDXWrapper), the RSS feed's +// item/link URLs (changelogFeed), and the feed body's href/src/srcset rewriting +// (changelog-content), so all changelog URL handling shares one set of rules. + +// True for values that already resolve on their own: absolute URLs of any scheme, +// protocol-relative URLs, in-page anchors, and non-navigational schemes (mailto:, …). +export const isSelfResolving = (value: string): boolean => + value.startsWith('#') || value.startsWith('//') || /^[a-z][a-z0-9+.-]*:/i.test(value); + +// Resolve a possibly-relative URL against a base origin. Self-resolving and empty +// values are returned unchanged; a trailing slash on the base is tolerated. +export const absolutizeUrl = (value: string, base: string): string => { + if (!value || isSelfResolving(value)) { + return value; + } + const origin = base.replace(/\/$/, ''); + return `${origin}${value.startsWith('/') ? '' : '/'}${value}`; +}; diff --git a/src/components/Changelog/constants.ts b/src/components/Changelog/constants.ts new file mode 100644 index 0000000000..2dc31b5413 --- /dev/null +++ b/src/components/Changelog/constants.ts @@ -0,0 +1,5 @@ +// Canonical changelog routes, defined once and shared by the index page, the +// on-page RSS link, the page , and the RSS feed builder so the route and +// its feed URL can't drift apart. +export const CHANGELOG_PATH = '/docs/changelog'; +export const CHANGELOG_RSS_PATH = '/docs/changelog/rss.xml'; diff --git a/src/components/Changelog/entries.test.ts b/src/components/Changelog/entries.test.ts new file mode 100644 index 0000000000..4e45dce3aa --- /dev/null +++ b/src/components/Changelog/entries.test.ts @@ -0,0 +1,80 @@ +import { isChangelogEntryNode, nodesToEntries, ChangelogFileNode } from './entries'; + +const node = ( + overrides: Partial & { name: string; relativeDirectory: string }, +): ChangelogFileNode => ({ + childMdx: { + frontmatter: { title: 'Some entry', meta_description: 'A description', date: '2026-06-19', products: ['pub-sub'] }, + }, + ...overrides, +}); + +describe('isChangelogEntryNode', () => { + it('accepts a nested entry with a title', () => { + expect(isChangelogEntryNode(node({ name: 'js-sdk-2-23-0', relativeDirectory: 'docs/changelog/2026/06' }))).toBe( + true, + ); + }); + + it('accepts an entry placed directly under docs/changelog', () => { + expect(isChangelogEntryNode(node({ name: 'an-entry', relativeDirectory: 'docs/changelog' }))).toBe(true); + }); + + it('rejects a directory index', () => { + expect(isChangelogEntryNode(node({ name: 'index', relativeDirectory: 'docs/changelog' }))).toBe(false); + }); + + it('rejects files outside docs/changelog', () => { + expect(isChangelogEntryNode(node({ name: 'messages', relativeDirectory: 'docs/chat/rooms' }))).toBe(false); + expect(isChangelogEntryNode(node({ name: 'x', relativeDirectory: 'docs/changelog-archive' }))).toBe(false); + }); + + it('rejects a node without a childMdx or title', () => { + expect(isChangelogEntryNode({ name: 'x', relativeDirectory: 'docs/changelog', childMdx: null })).toBe(false); + expect( + isChangelogEntryNode( + node({ + name: 'x', + relativeDirectory: 'docs/changelog', + childMdx: { frontmatter: { title: null, meta_description: null, date: null, products: null } }, + }), + ), + ).toBe(false); + }); +}); + +describe('nodesToEntries', () => { + it('filters non-entries and builds the site path from the file location', () => { + const entries = nodesToEntries([ + node({ name: 'js-sdk-2-23-0', relativeDirectory: 'docs/changelog/2026/06' }), + node({ name: 'index', relativeDirectory: 'docs/changelog' }), + node({ name: 'messages', relativeDirectory: 'docs/chat/rooms' }), + ]); + expect(entries).toEqual([ + { + link: '/docs/changelog/2026/06/js-sdk-2-23-0', + title: 'Some entry', + description: 'A description', + date: '2026-06-19', + products: ['pub-sub'], + }, + ]); + }); + + it('defaults missing optional frontmatter', () => { + const [entry] = nodesToEntries([ + node({ + name: 'minimal', + relativeDirectory: 'docs/changelog/2026/06', + childMdx: { frontmatter: { title: 'Minimal', meta_description: null, date: null, products: null } }, + }), + ]); + expect(entry).toEqual({ + link: '/docs/changelog/2026/06/minimal', + title: 'Minimal', + description: '', + date: '', + products: [], + }); + }); +}); diff --git a/src/components/Changelog/entries.ts b/src/components/Changelog/entries.ts new file mode 100644 index 0000000000..a96593d64e --- /dev/null +++ b/src/components/Changelog/entries.ts @@ -0,0 +1,67 @@ +import { ChangelogEntry } from './types'; + +// Shared mapping from sourced MDX file nodes to changelog entries. Used by the +// index page, the homepage widget, and the RSS post-build step so the three stay +// in lock-step (same identification rules, same link construction). + +// Root directory (relative to the `pages` filesystem source) that holds changelog +// entry MDX files. Entries are nested by year/month, e.g. `docs/changelog/2026/06`. +export const CHANGELOG_ROOT = 'docs/changelog'; + +// The `allFile` queries that back the changelog should scope to changelog MDX files +// only — rather than pulling every MDX node site-wide and filtering in JS — using +// this filter (inlined per query, as `graphql` requires a static literal): +// +// filter: { +// sourceInstanceName: { eq: "pages" } +// extension: { eq: "mdx" } +// relativeDirectory: { regex: "/^docs\\/changelog(\\/|$)/" } +// } +// +// `isChangelogEntryNode` still re-checks the directory below so the consumers can't +// drift from the query. + +// Minimal shape of a sourced changelog file node, as returned by the `allFile` → +// `childMdx` queries. Kept deliberately loose so each consumer can request only +// the frontmatter fields it needs on top of this. +export type ChangelogFileNode = { + name: string; + relativeDirectory: string; + childMdx: { + frontmatter: { + title: string | null; + meta_description: string | null; + date: string | null; + products: string[] | null; + }; + } | null; +}; + +// True for files that are changelog entries: under `docs/changelog/` (at any depth), +// not a directory `index`, and carrying a title. The directory check is redundant +// with `CHANGELOG_FILE_FILTER` but guards against a consumer querying a wider set. +export const isChangelogEntryNode = (node: ChangelogFileNode): boolean => + node.childMdx != null && + node.name !== 'index' && + (node.relativeDirectory === CHANGELOG_ROOT || node.relativeDirectory.startsWith(`${CHANGELOG_ROOT}/`)) && + Boolean(node.childMdx.frontmatter.title); + +// Filters a list of sourced file nodes to changelog entries and maps each to a +// `ChangelogEntry`. The site path is derived from the file's location, e.g. +// `docs/changelog/2026/06` + `js-sdk-2-23-0` → `/docs/changelog/2026/06/js-sdk-2-23-0`. +export const nodesToEntries = (nodes: ChangelogFileNode[]): ChangelogEntry[] => + nodes.flatMap((node) => { + if (!isChangelogEntryNode(node) || !node.childMdx) { + return []; + } + const { frontmatter } = node.childMdx; + return [ + { + link: `/${node.relativeDirectory}/${node.name}`, + title: frontmatter.title as string, + description: frontmatter.meta_description ?? '', + date: frontmatter.date ?? '', + products: frontmatter.products ?? [], + }, + ]; + }); diff --git a/src/components/Changelog/filter-changelog.test.ts b/src/components/Changelog/filter-changelog.test.ts new file mode 100644 index 0000000000..9c6cf7720f --- /dev/null +++ b/src/components/Changelog/filter-changelog.test.ts @@ -0,0 +1,55 @@ +import { filterChangelog, sortByDateDesc } from './filter-changelog'; +import { ChangelogEntry } from './types'; + +const entries: ChangelogEntry[] = [ + { + link: '/docs/changelog/a', + title: 'AI Transport release v0.3.0', + description: 'Declarative codec authoring and LiveObjects pass-through.', + date: '2026-06-19', + products: ['ai-transport'], + }, + { + link: '/docs/changelog/b', + title: 'JS Client Library release v2.23.0', + description: 'React channel hooks can infer the channel from ChannelProvider.', + date: '2026-06-18', + products: ['pub-sub'], + }, + { + link: '/docs/changelog/c', + title: 'Improved visibility into LiveObjects', + description: 'Realtime streaming of object operations in the dashboard.', + date: '2026-06-09', + products: ['liveobjects'], + }, +]; + +describe('filterChangelog', () => { + it('returns all entries when no product is selected', () => { + expect(filterChangelog(entries, [])).toHaveLength(3); + }); + + it('filters by a single product', () => { + const result = filterChangelog(entries, ['ai-transport']); + expect(result.map((e) => e.link)).toEqual(['/docs/changelog/a']); + }); + + it('filters by multiple products (OR semantics)', () => { + const result = filterChangelog(entries, ['ai-transport', 'liveobjects']); + expect(result.map((e) => e.link)).toEqual(['/docs/changelog/a', '/docs/changelog/c']); + }); + + it('returns no entries when the selected product matches nothing', () => { + expect(filterChangelog(entries, ['spaces'])).toHaveLength(0); + }); +}); + +describe('sortByDateDesc', () => { + it('orders entries newest first without mutating the input', () => { + const input = [entries[2], entries[0], entries[1]]; + const sorted = sortByDateDesc(input); + expect(sorted.map((e) => e.date)).toEqual(['2026-06-19', '2026-06-18', '2026-06-09']); + expect(input.map((e) => e.date)).toEqual(['2026-06-09', '2026-06-19', '2026-06-18']); + }); +}); diff --git a/src/components/Changelog/filter-changelog.ts b/src/components/Changelog/filter-changelog.ts new file mode 100644 index 0000000000..17d8577d45 --- /dev/null +++ b/src/components/Changelog/filter-changelog.ts @@ -0,0 +1,25 @@ +import { ChangelogEntry } from './types'; + +// Pure filter used by the changelog index. An entry is kept when it matches one of +// the selected products, or when no product filter is active. Free-text search is +// intentionally not handled here — the changelog is covered by the site-wide +// (Inkeep) search, so the index only offers product tag filtering. +export const filterChangelog = (entries: ChangelogEntry[], selectedProducts: string[]): ChangelogEntry[] => + entries.filter( + (entry) => selectedProducts.length === 0 || entry.products.some((product) => selectedProducts.includes(product)), + ); + +// Newest first. Entries with an unparseable date sort last. +export const sortByDateDesc = (entries: ChangelogEntry[]): ChangelogEntry[] => + [...entries].sort((a, b) => { + const aTime = new Date(a.date).getTime(); + const bTime = new Date(b.date).getTime(); + const aInvalid = Number.isNaN(aTime); + const bInvalid = Number.isNaN(bTime); + if (aInvalid || bInvalid) { + // Both invalid: treat as equal so the comparator stays antisymmetric. + // Otherwise the invalid date sorts last. + return Number(aInvalid) - Number(bInvalid); + } + return bTime - aTime; + }); diff --git a/src/components/Changelog/format-date.ts b/src/components/Changelog/format-date.ts new file mode 100644 index 0000000000..e233aae448 --- /dev/null +++ b/src/components/Changelog/format-date.ts @@ -0,0 +1,51 @@ +// Date helpers for the changelog. Entries store an ISO date string in frontmatter. +// +// All helpers format in UTC. A date-only ISO string ("2026-06-08") parses as UTC +// midnight, so display formatting must also use `timeZone: 'UTC'` — otherwise the +// human-readable date, the month grouping key, and the
    + + +); export const ChangelogSection = () => { - const data = useStaticQuery(graphql` - query ChangelogFeedQuery { - allFeedAblyChangelog(sort: { isoDate: DESC }, limit: 3) { - edges { - node { - title - link - content - isoDate + const data = useStaticQuery(graphql` + query HomepageChangelogQuery { + entries: allFile( + filter: { + sourceInstanceName: { eq: "pages" } + extension: { eq: "mdx" } + relativeDirectory: { regex: "/^docs/changelog(/|$)/" } + } + ) { + nodes { + name + relativeDirectory + childMdx { + frontmatter { + title + meta_description + date + products + } } } } - feedAblyChangelogMeta { - link - } } `); + // Pull the three most recent changelog entries straight from the MDX collection + // (no external RSS feed), ordered by the `date` frontmatter. + const entries: ChangelogEntry[] = sortByDateDesc(nodesToEntries(data.entries.nodes)).slice(0, 3); + return (
    @@ -190,31 +75,15 @@ export const ChangelogSection = () => { View all
      - {data.allFeedAblyChangelog.edges.map(({ node }: ChangelogFeedEdge, index: Key) => { - try { - const { tags, description } = parseChangelogContent(node.content); - - return ( - - ); - } catch (error) { - console.error('Error parsing changelog content:', error); - return null; - } - })} + {entries.map((entry) => ( + + ))}
    ); diff --git a/src/components/Layout/Footer.tsx b/src/components/Layout/Footer.tsx index 79c8761358..3e8484fc8f 100644 --- a/src/components/Layout/Footer.tsx +++ b/src/components/Layout/Footer.tsx @@ -21,7 +21,7 @@ type FeedbackButton = { }; const leftFooterLinks = [ - { label: 'Changelog', link: 'https://changelog.ably.com/' }, + { label: 'Changelog', link: '/docs/changelog' }, { label: 'About Ably', link: '/about' }, { label: 'Blog', link: '/blog' }, ]; diff --git a/src/components/Layout/Header.tsx b/src/components/Layout/Header.tsx index 6d8aa70d4d..46cde16a3f 100644 --- a/src/components/Layout/Header.tsx +++ b/src/components/Layout/Header.tsx @@ -202,6 +202,15 @@ const Header: React.FC = () => { > Examples + + Changelog + {isMobileMenuOpen && (
    = ({ children, pageContext }) => { const { activePage } = useLayoutContext(); const { leftSidebar, rightSidebar, template } = pageContext.layout ?? {}; const showProductBar = activePage.hasProductBar; + // Changelog entries have no product left-nav, but are prose like docs articles, + // so they use the same constrained reading width rather than the wide landing width. + const isChangelogEntry = template === 'changelog-entry'; const isRedocPage = location.pathname === '/docs/api/control-api' || location.pathname === '/docs/api/chat-rest' || @@ -55,8 +62,8 @@ const Layout: React.FC = ({ children, pageContext }) => { as="main" className={cn( 'flex-1 min-w-0 px-6 sm:px-8 md:px-10 lg:pl-12 lg:pr-12', - { 'max-w-[800px] box-content mx-auto lg:mr-0': !isRedocPage && leftSidebar }, - { 'max-w-screen-lg mx-auto': !isRedocPage && !leftSidebar }, + { 'max-w-[800px] box-content mx-auto lg:mr-0': !isRedocPage && (leftSidebar || isChangelogEntry) }, + { 'max-w-screen-lg mx-auto': !isRedocPage && !leftSidebar && !isChangelogEntry }, { 'overflow-x-clip': !isRedocPage }, )} > diff --git a/src/components/Layout/MDXWrapper.tsx b/src/components/Layout/MDXWrapper.tsx index 177e989ca4..c11cf0e19a 100644 --- a/src/components/Layout/MDXWrapper.tsx +++ b/src/components/Layout/MDXWrapper.tsx @@ -20,6 +20,9 @@ import { useCopyableHeaders } from './mdx/headers'; import { Table, NestedTableProvider } from './mdx/NestedTable'; import { Tiles } from './mdx/tiles'; import { PageHeader } from './mdx/PageHeader'; +import ChangelogHeader from '../Changelog/ChangelogHeader'; +import { CHANGELOG_DEFAULT_OG_IMAGE, CHANGELOG_DEFAULT_OG_IMAGE_ALT } from '../Changelog/og-image'; +import { absolutizeUrl } from '../Changelog/absolutize-url'; import Admonition from './mdx/Admonition'; import { MethodSignature } from './mdx/MethodSignature'; import { Tabs, Tab } from './mdx/Tabs'; @@ -236,9 +239,27 @@ const MDXWrapper: React.FC = ({ children, pageContext, location const keywords = getFrontmatter(frontmatter, 'meta_keywords') as string; const metaTitle = getMetaTitle(title, (activePage.product as ProductName) || META_PRODUCT_FALLBACK) as string; - const { canonicalUrl } = useSiteMetadata(); + // Changelog entries use a dedicated header (date + product tags) in place of the + // standard PageHeader. The template is assigned in data/onCreatePage.ts. + const isChangelogEntry = pageContext.layout?.template === 'changelog-entry'; + const changelogDate = getFrontmatter(frontmatter, 'date') as string; + + const { canonicalUrl, siteUrl } = useSiteMetadata(); const canonical = canonicalUrl(location.pathname); + // Social/OG image. Any frontmatter `meta_image` is resolved to an absolute URL. + // Changelog entries fall back to a shared default image; other pages set an + // image only when they explicitly provide one. + const rawMetaImage = getFrontmatter(frontmatter, 'meta_image') as string; + const ogImage = rawMetaImage + ? absolutizeUrl(rawMetaImage, siteUrl) + : isChangelogEntry + ? CHANGELOG_DEFAULT_OG_IMAGE + : undefined; + const ogImageAlt = + (getFrontmatter(frontmatter, 'meta_image_alt') as string) || + (isChangelogEntry && ogImage ? CHANGELOG_DEFAULT_OG_IMAGE_ALT : ''); + // Generate markdown URL for noscript fallback (uses shared utility for consistent URL handling) const markdownUrl = getMarkdownUrl(canonical); @@ -294,6 +315,8 @@ const MDXWrapper: React.FC = ({ children, pageContext, location description={description} keywords={keywords} structuredData={structuredData} + ogImage={ogImage} + ogImageAlt={ogImageAlt || undefined} /> {/* Fallback for non-JS clients (LLMs, bots, screen readers with JS disabled) */}