Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions data/onCreateNode/create-graphql-schema-customization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 9 additions & 1 deletion data/onCreatePage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ const pageLayoutOptions: Record<string, LayoutOptions> = {
},
'/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 },
};

Expand Down Expand Up @@ -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,
Expand Down
82 changes: 82 additions & 0 deletions data/onPostBuild/changelog-content.test.ts
Original file line number Diff line number Diff line change
@@ -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 => `
<html><body>
<main>
<article class="flex-1">
<div class="my-8 border-b pb-8">
<a href="/docs/changelog">All updates</a>
<h1>JS Client Library release v2.23.0</h1>
<time datetime="2026-06-19">19 June 2026</time>
</div>
<div data-changelog-body>${bodyInner}</div>
</article>
</main>
</body></html>
`;

describe('extractEntryBodyHtml', () => {
it('extracts the body region and excludes the header chrome', () => {
const html = extractEntryBodyHtml(page('<p>Version 2.23.0 has been released.</p>'), SITE);
expect(html).toBe('<p>Version 2.23.0 has been released.</p>');
});

it('does not leak the back-link or "All updates" header text', () => {
const html = extractEntryBodyHtml(page('<p>Body</p>'), SITE) ?? '';
expect(html).not.toContain('All updates');
expect(html).not.toContain('href="/docs/changelog"');
expect(html).not.toContain('<h1');
});

it('returns null when the body region is absent (drives the summary fallback)', () => {
const html = '<html><body><article><p>No marker here</p></article></body></html>';
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('<a href="/docs/chat">chat</a><img src="/static/x.png"/><a href="https://github.com/ably">gh</a>'),
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('<p>ok</p><script>evil()</script><style>.x{}</style>'), SITE) ?? '';
expect(html).toContain('<p>ok</p>');
expect(html).not.toContain('evil()');
expect(html).not.toContain('.x{}');
});

it('absolutizes every URL in a srcset', () => {
const html = extractEntryBodyHtml(page('<img srcset="/a.png 1x, /b.png 2x"/>'), 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('<p>hi</p>')).toBe('<![CDATA[<p>hi</p>]]>');
});

it('splits an embedded ]]> using the standard CDATA-escape so it round-trips', () => {
// `]]>` becomes `]]` + (close)`]]>` + (reopen)`<![CDATA[` + `>`, which a parser
// reassembles back into the original `]]>` rather than terminating early.
const wrapped = wrapCdata('a]]>b');
expect(wrapped).toBe('<![CDATA[a]]]]><![CDATA[>b]]>');
expect(wrapped.startsWith('<![CDATA[')).toBe(true);
expect(wrapped.endsWith(']]>')).toBe(true);
});
});
68 changes: 68 additions & 0 deletions data/onPostBuild/changelog-content.ts
Original file line number Diff line number Diff line change
@@ -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 <content:encoded> 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
// `<div data-changelog-body>`. 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 => `<![CDATA[${content.replace(/]]>/g, ']]]]><![CDATA[>')}]]>`;
Loading