diff --git a/data/onPostBuild/generateMarkdownFooter.test.ts b/data/onPostBuild/generateMarkdownFooter.test.ts new file mode 100644 index 0000000000..3f2dfb6954 --- /dev/null +++ b/data/onPostBuild/generateMarkdownFooter.test.ts @@ -0,0 +1,509 @@ +import { + flattenNavPages, + mdxNodeToNavLink, + buildMdxPageSet, + buildMetaDescriptionMap, + buildNavigationLookup, + generateNavigationFooter, + buildNavLookup, +} from './generateMarkdownFooter'; +import type { FlatNavPage, NavContext, NavLink, NavMdxNode } from './generateMarkdownFooter'; + +describe('generateMarkdownFooter', () => { + describe('flattenNavPages', () => { + it('should return a non-empty array of pages from all products', () => { + const pages = flattenNavPages(); + expect(pages.length).toBeGreaterThan(0); + }); + + it('should include pages from all 7 products', () => { + const pages = flattenNavPages(); + const products = new Set(pages.map((p) => p.product)); + expect(products.size).toBe(7); + expect(products).toContain('Platform'); + expect(products).toContain('Ably Pub/Sub'); + expect(products).toContain('Ably Chat'); + expect(products).toContain('Ably AI Transport'); + expect(products).toContain('Ably Spaces'); + expect(products).toContain('Ably LiveObjects'); + expect(products).toContain('Ably LiveSync'); + }); + + it('should skip external links', () => { + const pages = flattenNavPages(); + const externalPages = pages.filter((p) => p.link.startsWith('http')); + expect(externalPages).toHaveLength(0); + }); + + it('should preserve depth-first order within a product', () => { + const pages = flattenNavPages(); + const chatPages = pages.filter((p) => p.product === 'Ably Chat'); + const chatLinks = chatPages.map((p) => p.link); + + // About Chat should come first + expect(chatLinks[0]).toBe('/docs/chat'); + + // Getting started overview should come before individual getting started pages + const gsOverviewIdx = chatLinks.indexOf('/docs/chat/getting-started'); + const gsJsIdx = chatLinks.indexOf('/docs/chat/getting-started/javascript'); + expect(gsOverviewIdx).toBeLessThan(gsJsIdx); + + // Messages should come before History (both in Chat features) + const messagesIdx = chatLinks.indexOf('/docs/chat/rooms/messages'); + const historyIdx = chatLinks.indexOf('/docs/chat/rooms/history'); + expect(messagesIdx).toBeLessThan(historyIdx); + }); + + it('should include both content and API pages', () => { + const pages = flattenNavPages(); + const chatPages = pages.filter((p) => p.product === 'Ably Chat'); + const chatLinks = chatPages.map((p) => p.link); + + // Content page + expect(chatLinks).toContain('/docs/chat/rooms/messages'); + // API page (non-external) + expect(chatLinks).toContain('/docs/chat/api'); + }); + + it('should track section keys correctly for sibling grouping', () => { + const pages = flattenNavPages(); + const messagesPage = pages.find((p) => p.link === '/docs/chat/rooms/messages'); + const historyPage = pages.find((p) => p.link === '/docs/chat/rooms/history'); + const setupPage = pages.find((p) => p.link === '/docs/chat/setup'); + + expect(messagesPage?.sectionKey).toBe('Ably Chat::Chat features'); + expect(historyPage?.sectionKey).toBe('Ably Chat::Chat features'); + // Setup is in Concepts section, different from Chat features + expect(setupPage?.sectionKey).toBe('Ably Chat::Concepts'); + }); + + it('should use __root__ section key for top-level pages', () => { + const pages = flattenNavPages(); + const aboutChat = pages.find((p) => p.link === '/docs/chat'); + expect(aboutChat?.sectionKey).toBe('Ably Chat::__root__'); + }); + + it('should handle nested sections correctly', () => { + const pages = flattenNavPages(); + // Chat > Moderation > Direct integrations > Hive (Model Only) + const hivePage = pages.find((p) => p.link === '/docs/chat/moderation/direct/hive-model-only'); + expect(hivePage).toBeDefined(); + expect(hivePage?.sectionKey).toBe('Ably Chat::Direct integrations'); + }); + + it('should handle mixed sections with both direct pages and nested subsections', () => { + const pages = flattenNavPages(); + // Chat > Moderation has Introduction (direct page) + Direct integrations (subsection) + const introPage = pages.find((p) => p.link === '/docs/chat/moderation'); + const hivePage = pages.find((p) => p.link === '/docs/chat/moderation/direct/hive-model-only'); + + expect(introPage?.sectionKey).toBe('Ably Chat::Moderation'); + expect(hivePage?.sectionKey).toBe('Ably Chat::Direct integrations'); + }); + }); + + describe('mdxNodeToNavLink', () => { + it('should handle regular files', () => { + expect(mdxNodeToNavLink('docs/chat/rooms', 'messages')).toBe('/docs/chat/rooms/messages'); + }); + + it('should handle index files', () => { + expect(mdxNodeToNavLink('docs/chat/rooms', 'index')).toBe('/docs/chat/rooms'); + }); + + it('should handle top-level index', () => { + expect(mdxNodeToNavLink('docs', 'index')).toBe('/docs'); + }); + + it('should handle deeply nested files', () => { + expect(mdxNodeToNavLink('docs/chat/moderation/direct', 'bodyguard')).toBe( + '/docs/chat/moderation/direct/bodyguard', + ); + }); + }); + + describe('buildMdxPageSet', () => { + const makeNavMdxNode = (relDir: string, name: string): NavMdxNode => ({ + parent: { relativeDirectory: relDir, name }, + }); + + it('should return correct set of nav links', () => { + const nodes: NavMdxNode[] = [ + makeNavMdxNode('docs/chat/rooms', 'messages'), + makeNavMdxNode('docs/chat/rooms', 'history'), + makeNavMdxNode('docs/chat', 'setup'), + ]; + const pageSet = buildMdxPageSet(nodes); + expect(pageSet.has('/docs/chat/rooms/messages')).toBe(true); + expect(pageSet.has('/docs/chat/rooms/history')).toBe(true); + expect(pageSet.has('/docs/chat/setup')).toBe(true); + expect(pageSet.size).toBe(3); + }); + + it('should handle index files correctly', () => { + const nodes: NavMdxNode[] = [makeNavMdxNode('docs/chat/rooms', 'index')]; + const pageSet = buildMdxPageSet(nodes); + expect(pageSet.has('/docs/chat/rooms')).toBe(true); + }); + }); + + describe('buildMetaDescriptionMap', () => { + const makeNavMdxNode = (relDir: string, name: string, metaDesc?: string): NavMdxNode => ({ + parent: { relativeDirectory: relDir, name }, + frontmatter: metaDesc ? { meta_description: metaDesc } : undefined, + }); + + it('should map nav links to meta descriptions', () => { + const nodes: NavMdxNode[] = [ + makeNavMdxNode('docs/chat/rooms', 'messages', 'Send and receive messages.'), + makeNavMdxNode('docs/chat/rooms', 'history', 'Retrieve message history.'), + ]; + const descMap = buildMetaDescriptionMap(nodes); + expect(descMap.get('/docs/chat/rooms/messages')).toBe('Send and receive messages.'); + expect(descMap.get('/docs/chat/rooms/history')).toBe('Retrieve message history.'); + }); + + it('should handle index files', () => { + const nodes: NavMdxNode[] = [makeNavMdxNode('docs/chat/rooms', 'index', 'Room overview.')]; + const descMap = buildMetaDescriptionMap(nodes); + expect(descMap.get('/docs/chat/rooms')).toBe('Room overview.'); + }); + + it('should skip nodes without meta_description', () => { + const nodes: NavMdxNode[] = [ + makeNavMdxNode('docs/chat/rooms', 'messages', 'Has description.'), + makeNavMdxNode('docs/chat/rooms', 'history'), + ]; + const descMap = buildMetaDescriptionMap(nodes); + expect(descMap.has('/docs/chat/rooms/messages')).toBe(true); + expect(descMap.has('/docs/chat/rooms/history')).toBe(false); + expect(descMap.size).toBe(1); + }); + }); + + describe('buildNavigationLookup', () => { + const siteUrl = 'https://ably.com'; + + it('should compute prev/next correctly for middle page', () => { + const mdxPageSet = new Set(['/docs/chat', '/docs/chat/setup', '/docs/chat/rooms/messages']); + const metaDescriptions = new Map(); + const lookup = buildNavigationLookup(siteUrl, metaDescriptions, mdxPageSet); + + const setupCtx = lookup.get('/docs/chat/setup'); + expect(setupCtx?.prev?.url).toBe('https://ably.com/docs/chat.md'); + expect(setupCtx?.next?.url).toBe('https://ably.com/docs/chat/rooms/messages.md'); + }); + + it('should have no previous for first page in product', () => { + const mdxPageSet = new Set(['/docs/chat', '/docs/chat/getting-started']); + const metaDescriptions = new Map(); + const lookup = buildNavigationLookup(siteUrl, metaDescriptions, mdxPageSet); + + const firstPage = lookup.get('/docs/chat'); + expect(firstPage?.prev).toBeUndefined(); + expect(firstPage?.next).toBeDefined(); + }); + + it('should have no next for last page in product', () => { + const pages = flattenNavPages(); + const chatPages = pages.filter((p) => p.product === 'Ably Chat'); + const lastChatLink = chatPages[chatPages.length - 1].link; + + const mdxPageSet = new Set(chatPages.map((p) => p.link)); + const metaDescriptions = new Map(); + const lookup = buildNavigationLookup(siteUrl, metaDescriptions, mdxPageSet); + + const lastPage = lookup.get(lastChatLink); + expect(lastPage?.next).toBeUndefined(); + expect(lastPage?.prev).toBeDefined(); + }); + + it('should use absolute .md URLs', () => { + const mdxPageSet = new Set(['/docs/chat', '/docs/chat/getting-started']); + const metaDescriptions = new Map(); + const lookup = buildNavigationLookup(siteUrl, metaDescriptions, mdxPageSet); + + const ctx = lookup.get('/docs/chat'); + expect(ctx?.next?.url).toMatch(/^https:\/\/ably\.com\/docs\/.*\.md$/); + }); + + it('should not cross product boundaries', () => { + const pages = flattenNavPages(); + const platformPages = pages.filter((p) => p.product === 'Platform'); + const chatPages = pages.filter((p) => p.product === 'Ably Chat'); + + const lastPlatformLink = platformPages[platformPages.length - 1].link; + const firstChatLink = chatPages[0].link; + + const mdxPageSet = new Set([...platformPages.map((p) => p.link), ...chatPages.map((p) => p.link)]); + const metaDescriptions = new Map(); + const lookup = buildNavigationLookup(siteUrl, metaDescriptions, mdxPageSet); + + const lastPlatform = lookup.get(lastPlatformLink); + const firstChat = lookup.get(firstChatLink); + + expect(lastPlatform?.next).toBeUndefined(); + expect(firstChat?.prev).toBeUndefined(); + }); + + it('should compute siblings correctly', () => { + const mdxPageSet = new Set([ + '/docs/chat/rooms/messages', + '/docs/chat/rooms/history', + '/docs/chat/rooms/presence', + ]); + const metaDescriptions = new Map([ + ['/docs/chat/rooms/history', 'Retrieve message history.'], + ['/docs/chat/rooms/presence', 'See who is online.'], + ]); + const lookup = buildNavigationLookup(siteUrl, metaDescriptions, mdxPageSet); + + const messagesCtx = lookup.get('/docs/chat/rooms/messages'); + expect(messagesCtx?.siblings).toHaveLength(2); + expect(messagesCtx?.siblings[0].name).toBe('Message storage and history'); + expect(messagesCtx?.siblings[0].url).toBe('https://ably.com/docs/chat/rooms/history.md'); + expect(messagesCtx?.siblings[0].description).toBe('Retrieve message history.'); + }); + + it('should use page name as fallback description when meta_description is missing', () => { + const mdxPageSet = new Set(['/docs/chat/rooms/messages', '/docs/chat/rooms/history']); + const metaDescriptions = new Map(); // No descriptions + const lookup = buildNavigationLookup(siteUrl, metaDescriptions, mdxPageSet); + + const messagesCtx = lookup.get('/docs/chat/rooms/messages'); + expect(messagesCtx?.siblings[0].description).toBe('Message storage and history'); + }); + + it('should filter out non-MDX pages', () => { + // /docs/api/control-api is a .tsx page in the Platform nav + const mdxPageSet = new Set(['/docs/platform']); // Only MDX page, not /docs/api/control-api + const metaDescriptions = new Map(); + const lookup = buildNavigationLookup(siteUrl, metaDescriptions, mdxPageSet); + + // The platform page should not have /docs/api/control-api as next + const platformCtx = lookup.get('/docs/platform'); + expect(platformCtx).toBeDefined(); + expect(platformCtx?.next).toBeUndefined(); + }); + }); + + describe('generateNavigationFooter', () => { + const siteUrl = 'https://ably.com'; + + it('should generate full footer with all three sections', () => { + const navContext: NavContext = { + prev: { name: 'Previous Page', url: 'https://ably.com/docs/prev.md', description: 'Previous page desc.' }, + next: { name: 'Next Page', url: 'https://ably.com/docs/next.md', description: 'Next page desc.' }, + siblings: [ + { name: 'Sibling 1', url: 'https://ably.com/docs/sibling1.md', description: 'First sibling.' }, + { name: 'Sibling 2', url: 'https://ably.com/docs/sibling2.md', description: 'Second sibling.' }, + ], + }; + + const footer = generateNavigationFooter(navContext, siteUrl); + + expect(footer).toContain('## Page Navigation'); + expect(footer).toContain('- Previous: [Previous Page](https://ably.com/docs/prev.md): Previous page desc.'); + expect(footer).toContain('- Next: [Next Page](https://ably.com/docs/next.md): Next page desc.'); + expect(footer).toContain('## Related Topics'); + expect(footer).toContain('- [Sibling 1](https://ably.com/docs/sibling1.md): First sibling.'); + expect(footer).toContain('- [Sibling 2](https://ably.com/docs/sibling2.md): Second sibling.'); + expect(footer).toContain('## Documentation Index'); + expect(footer).toContain('Fetch [llms.txt](https://ably.com/llms.txt)'); + }); + + it('should include descriptions in Page Navigation prev/next links', () => { + const navContext: NavContext = { + prev: { name: 'Prev', url: 'https://ably.com/docs/prev.md', description: 'Go back here.' }, + next: { name: 'Next', url: 'https://ably.com/docs/next.md', description: 'Continue here.' }, + siblings: [], + }; + + const footer = generateNavigationFooter(navContext, siteUrl); + + expect(footer).toContain('- Previous: [Prev](https://ably.com/docs/prev.md): Go back here.'); + expect(footer).toContain('- Next: [Next](https://ably.com/docs/next.md): Continue here.'); + }); + + it('should exclude prev/next pages from Related Topics to avoid duplicates', () => { + const navContext: NavContext = { + prev: { name: 'Sibling 1', url: 'https://ably.com/docs/sibling1.md', description: 'First sibling.' }, + next: { name: 'Sibling 2', url: 'https://ably.com/docs/sibling2.md', description: 'Second sibling.' }, + siblings: [ + { name: 'Sibling 1', url: 'https://ably.com/docs/sibling1.md', description: 'First sibling.' }, + { name: 'Sibling 2', url: 'https://ably.com/docs/sibling2.md', description: 'Second sibling.' }, + { name: 'Sibling 3', url: 'https://ably.com/docs/sibling3.md', description: 'Third sibling.' }, + ], + }; + + const footer = generateNavigationFooter(navContext, siteUrl); + + // Sibling 1 and 2 are in Page Navigation, so only Sibling 3 should be in Related Topics + expect(footer).toContain('## Related Topics'); + expect(footer).toContain('- [Sibling 3](https://ably.com/docs/sibling3.md): Third sibling.'); + // Sibling 1 and 2 should NOT appear in Related Topics + const additionalSection = footer.split('## Related Topics')[1].split('## Documentation Index')[0]; + expect(additionalSection).not.toContain('Sibling 1'); + expect(additionalSection).not.toContain('Sibling 2'); + }); + + it('should omit Related Topics when all siblings are already in Page Navigation', () => { + const navContext: NavContext = { + prev: { name: 'Sibling 1', url: 'https://ably.com/docs/sibling1.md', description: 'First.' }, + next: { name: 'Sibling 2', url: 'https://ably.com/docs/sibling2.md', description: 'Second.' }, + siblings: [ + { name: 'Sibling 1', url: 'https://ably.com/docs/sibling1.md', description: 'First.' }, + { name: 'Sibling 2', url: 'https://ably.com/docs/sibling2.md', description: 'Second.' }, + ], + }; + + const footer = generateNavigationFooter(navContext, siteUrl); + + expect(footer).toContain('## Page Navigation'); + expect(footer).not.toContain('## Related Topics'); + expect(footer).toContain('## Documentation Index'); + }); + + it('should omit Previous bullet when no previous page', () => { + const navContext: NavContext = { + next: { name: 'Next Page', url: 'https://ably.com/docs/next.md', description: 'Next desc.' }, + siblings: [], + }; + + const footer = generateNavigationFooter(navContext, siteUrl); + + expect(footer).toContain('## Page Navigation'); + expect(footer).not.toContain('Previous'); + expect(footer).toContain('- Next: [Next Page](https://ably.com/docs/next.md): Next desc.'); + }); + + it('should omit Next bullet when no next page', () => { + const navContext: NavContext = { + prev: { name: 'Previous Page', url: 'https://ably.com/docs/prev.md', description: 'Prev desc.' }, + siblings: [], + }; + + const footer = generateNavigationFooter(navContext, siteUrl); + + expect(footer).toContain('## Page Navigation'); + expect(footer).toContain('- Previous: [Previous Page](https://ably.com/docs/prev.md): Prev desc.'); + expect(footer).not.toContain('Next'); + }); + + it('should omit Page Navigation section when both prev and next are absent', () => { + const navContext: NavContext = { + siblings: [{ name: 'Sibling', url: 'https://ably.com/docs/sibling.md', description: 'A sibling.' }], + }; + + const footer = generateNavigationFooter(navContext, siteUrl); + + expect(footer).not.toContain('## Page Navigation'); + expect(footer).toContain('## Related Topics'); + expect(footer).toContain('## Documentation Index'); + }); + + it('should omit Related Topics section when no siblings', () => { + const navContext: NavContext = { + prev: { name: 'Prev', url: 'https://ably.com/docs/prev.md', description: 'Prev desc.' }, + next: { name: 'Next', url: 'https://ably.com/docs/next.md', description: 'Next desc.' }, + siblings: [], + }; + + const footer = generateNavigationFooter(navContext, siteUrl); + + expect(footer).toContain('## Page Navigation'); + expect(footer).not.toContain('## Related Topics'); + expect(footer).toContain('## Documentation Index'); + }); + + it('should always include Documentation Index section', () => { + const navContext: NavContext = { siblings: [] }; + const footer = generateNavigationFooter(navContext, siteUrl); + + expect(footer).toContain('## Documentation Index'); + expect(footer).toContain('To discover additional Ably documentation:'); + expect(footer).toContain( + '1. Fetch [llms.txt](https://ably.com/llms.txt) for the canonical list of available pages.', + ); + expect(footer).toContain('2. Identify relevant URLs from that index.'); + expect(footer).toContain('3. Fetch target pages as needed.'); + expect(footer).toContain('Avoid using assumed or outdated documentation paths.'); + }); + + it('should use correct siteUrl for llms.txt link', () => { + const navContext: NavContext = { siblings: [] }; + const footer = generateNavigationFooter(navContext, 'https://custom-site.com'); + + expect(footer).toContain( + 'Fetch [llms.txt](https://custom-site.com/llms.txt) for the canonical list of available pages.', + ); + }); + }); + + describe('buildNavLookup', () => { + it('should integrate buildMdxPageSet, buildMetaDescriptionMap, and buildNavigationLookup correctly', () => { + const siteUrl = 'https://ably.com'; + const mdxNodes: NavMdxNode[] = [ + { + parent: { relativeDirectory: 'docs/chat/rooms', name: 'messages' }, + frontmatter: { meta_description: 'Send and receive messages.' }, + }, + { + parent: { relativeDirectory: 'docs/chat/rooms', name: 'history' }, + frontmatter: { meta_description: 'Retrieve message history.' }, + }, + { + parent: { relativeDirectory: 'docs/chat', name: 'setup' }, + frontmatter: { meta_description: 'Set up the Chat SDK.' }, + }, + ]; + + const lookup = buildNavLookup(siteUrl, mdxNodes); + + // Should return a Map + expect(lookup).toBeInstanceOf(Map); + + // Should have entries for pages that exist in both nav data and mdxNodes + // The lookup filters to only pages that have MDX source files + expect(lookup.size).toBeGreaterThan(0); + + // Check that a known page has correct navigation context structure + const messagesCtx = lookup.get('/docs/chat/rooms/messages'); + if (messagesCtx) { + expect(messagesCtx).toHaveProperty('siblings'); + expect(Array.isArray(messagesCtx.siblings)).toBe(true); + // Siblings should have the correct structure + if (messagesCtx.siblings.length > 0) { + expect(messagesCtx.siblings[0]).toHaveProperty('name'); + expect(messagesCtx.siblings[0]).toHaveProperty('url'); + expect(messagesCtx.siblings[0]).toHaveProperty('description'); + } + } + }); + + it('should use meta_description from frontmatter when available', () => { + const siteUrl = 'https://ably.com'; + const mdxNodes: NavMdxNode[] = [ + { + parent: { relativeDirectory: 'docs/chat/rooms', name: 'messages' }, + frontmatter: { meta_description: 'Custom messages description.' }, + }, + { + parent: { relativeDirectory: 'docs/chat/rooms', name: 'history' }, + frontmatter: { meta_description: 'Custom history description.' }, + }, + ]; + + const lookup = buildNavLookup(siteUrl, mdxNodes); + const messagesCtx = lookup.get('/docs/chat/rooms/messages'); + + // If history is a sibling, it should use the custom description + if (messagesCtx) { + const historySibling = messagesCtx.siblings.find((s) => s.url.includes('history')); + if (historySibling) { + expect(historySibling.description).toBe('Custom history description.'); + } + } + }); + }); +}); diff --git a/data/onPostBuild/generateMarkdownFooter.ts b/data/onPostBuild/generateMarkdownFooter.ts new file mode 100644 index 0000000000..0e13042609 --- /dev/null +++ b/data/onPostBuild/generateMarkdownFooter.ts @@ -0,0 +1,284 @@ +import platformNavData from '../../src/data/nav/platform'; +import pubsubNavData from '../../src/data/nav/pubsub'; +import chatNavData from '../../src/data/nav/chat'; +import aiTransportNavData from '../../src/data/nav/aitransport'; +import spacesNavData from '../../src/data/nav/spaces'; +import liveObjectsNavData from '../../src/data/nav/liveobjects'; +import liveSyncNavData from '../../src/data/nav/livesync'; +import { NavProduct, NavProductPages } from '../../src/data/nav/types'; + +/** + * Minimal MDX node shape needed by the navigation footer module. + * The full MdxNode interface (with absolutePath, contentFilePath, etc.) lives in transpileMdxToMarkdown.ts. + */ +export interface NavMdxNode { + parent: { + relativeDirectory: string; + name: string; + }; + frontmatter?: { + meta_description?: string; + }; +} + +export interface FlatNavPage { + name: string; + link: string; + product: string; + sectionKey: string; +} + +export interface NavLink { + name: string; + url: string; + description: string; +} + +export interface NavContext { + prev?: NavLink; + next?: NavLink; + siblings: NavLink[]; +} + +/** + * Flatten all navigation pages from all products into a depth-first ordered list. + * Each entry includes the page name, link, product name, and a section key for sibling grouping. + */ +function flattenNavPages(): FlatNavPage[] { + const allProducts: NavProduct[] = [ + platformNavData, + pubsubNavData, + chatNavData, + aiTransportNavData, + spacesNavData, + liveObjectsNavData, + liveSyncNavData, + ]; + + const result: FlatNavPage[] = []; + + const walkPages = (pages: NavProductPages[], sectionName: string, productName: string) => { + for (const item of pages) { + if ('pages' in item) { + // Section with nested pages + walkPages(item.pages, item.name, productName); + } else if ('link' in item) { + // Leaf page + if (item.external) { + continue; + } + result.push({ + name: item.name, + link: item.link, + product: productName, + sectionKey: `${productName}::${sectionName}`, + }); + } + } + }; + + for (const product of allProducts) { + // Walk content pages + walkPages(product.content, '__root__', product.name); + // Walk API pages + walkPages(product.api, '__root__', product.name); + } + + return result; +} + +/** + * Convert an MDX node's file info to the corresponding nav link. + * - relativeDirectory: 'docs/chat/rooms', fileName: 'messages' → '/docs/chat/rooms/messages' + * - relativeDirectory: 'docs/chat/rooms', fileName: 'index' → '/docs/chat/rooms' + * - relativeDirectory: 'docs', fileName: 'index' → '/docs' + */ +function mdxNodeToNavLink(relativeDirectory: string, fileName: string): string { + // Remove 'docs' or 'docs/' prefix + const pathWithoutDocs = relativeDirectory.replace(/^docs\/?/, ''); + const pathParts = pathWithoutDocs.split('/').filter((p) => p); + + if (fileName !== 'index') { + pathParts.push(fileName); + } + + return pathParts.length > 0 ? `/docs/${pathParts.join('/')}` : '/docs'; +} + +/** + * Build a set of nav links that have corresponding MDX source files. + * Used to filter out .tsx pages from navigation. + */ +function buildMdxPageSet(mdxNodes: NavMdxNode[]): Set { + const pageSet = new Set(); + for (const node of mdxNodes) { + const navLink = mdxNodeToNavLink(node.parent.relativeDirectory, node.parent.name); + pageSet.add(navLink); + } + return pageSet; +} + +/** + * Build a map from nav links to meta_description values from frontmatter. + */ +function buildMetaDescriptionMap(mdxNodes: NavMdxNode[]): Map { + const descMap = new Map(); + for (const node of mdxNodes) { + const metaDesc = node.frontmatter?.meta_description; + if (metaDesc) { + const navLink = mdxNodeToNavLink(node.parent.relativeDirectory, node.parent.name); + descMap.set(navLink, metaDesc); + } + } + return descMap; +} + +/** + * Build a lookup map for navigation context (prev/next/siblings) for each page. + * Filters out pages not in mdxPageSet to avoid broken links to .tsx pages. + */ +function buildNavigationLookup( + siteUrl: string, + metaDescriptions: Map, + mdxPageSet: Set, +): Map { + const baseUrl = siteUrl.replace(/\/$/, ''); + const allPages = flattenNavPages(); + + // Filter to only pages that have MDX source files + const filteredPages = allPages.filter((page) => mdxPageSet.has(page.link)); + + // Group by product for prev/next + const productPages = new Map(); + for (const page of filteredPages) { + const existing = productPages.get(page.product) || []; + existing.push(page); + productPages.set(page.product, existing); + } + + // Group by sectionKey for siblings + const sectionPages = new Map(); + for (const page of filteredPages) { + const existing = sectionPages.get(page.sectionKey) || []; + existing.push(page); + sectionPages.set(page.sectionKey, existing); + } + + const linkToMdUrl = (link: string): string => `${baseUrl}${link}.md`; + + const lookup = new Map(); + + for (const [, pages] of productPages) { + for (let i = 0; i < pages.length; i++) { + const page = pages[i]; + const prev = i > 0 ? pages[i - 1] : undefined; + const next = i < pages.length - 1 ? pages[i + 1] : undefined; + + // Get siblings (other pages in same section, excluding current page) + const siblings = (sectionPages.get(page.sectionKey) || []) + .filter((s) => s.link !== page.link) + .map( + (s): NavLink => ({ + name: s.name, + url: linkToMdUrl(s.link), + description: metaDescriptions.get(s.link) || s.name, + }), + ); + + const navContext: NavContext = { + prev: prev + ? { name: prev.name, url: linkToMdUrl(prev.link), description: metaDescriptions.get(prev.link) || prev.name } + : undefined, + next: next + ? { name: next.name, url: linkToMdUrl(next.link), description: metaDescriptions.get(next.link) || next.name } + : undefined, + siblings, + }; + + lookup.set(page.link, navContext); + } + } + + return lookup; +} + +/** + * Generate a navigation footer with three sections: Page Navigation, Related Topics, Documentation Index. + * The Documentation Index section provides instructions for discovering additional documentation via llms.txt. + * Always returns a footer that includes at least the Documentation Index section, even if there is no + * page navigation or related topics content. + */ +function generateNavigationFooter(navContext: NavContext, siteUrl: string): string { + const baseUrl = siteUrl.replace(/\/$/, ''); + const sections: string[] = []; + + // Page Navigation section (prev/next with descriptions) + if (navContext.prev || navContext.next) { + const navLines: string[] = []; + navLines.push('## Page Navigation'); + navLines.push(''); + if (navContext.prev) { + navLines.push(`- Previous: [${navContext.prev.name}](${navContext.prev.url}): ${navContext.prev.description}`); + } + if (navContext.next) { + navLines.push(`- Next: [${navContext.next.name}](${navContext.next.url}): ${navContext.next.description}`); + } + sections.push(navLines.join('\n')); + } + + // Related Topics section (siblings, excluding pages already in Page Navigation) + const navUrls = new Set(); + if (navContext.prev) { + navUrls.add(navContext.prev.url); + } + if (navContext.next) { + navUrls.add(navContext.next.url); + } + + const filteredSiblings = navContext.siblings.filter((s) => !navUrls.has(s.url)); + if (filteredSiblings.length > 0) { + const siblingLines: string[] = []; + siblingLines.push('## Related Topics'); + siblingLines.push(''); + for (const sibling of filteredSiblings) { + siblingLines.push(`- [${sibling.name}](${sibling.url}): ${sibling.description}`); + } + sections.push(siblingLines.join('\n')); + } + + // Documentation Index section (always present) + const llmLines: string[] = []; + llmLines.push('## Documentation Index'); + llmLines.push(''); + llmLines.push('To discover additional Ably documentation:'); + llmLines.push(''); + llmLines.push(`1. Fetch [llms.txt](${baseUrl}/llms.txt) for the canonical list of available pages.`); + llmLines.push('2. Identify relevant URLs from that index.'); + llmLines.push('3. Fetch target pages as needed.'); + llmLines.push(''); + llmLines.push('Avoid using assumed or outdated documentation paths.'); + sections.push(llmLines.join('\n')); + + return '\n' + sections.join('\n\n') + '\n'; +} + +/** + * High-level function that builds the complete navigation lookup from MDX nodes. + * Encapsulates the three-step process: buildMdxPageSet → buildMetaDescriptionMap → buildNavigationLookup. + * This is the main entry point for transpileMdxToMarkdown to build navigation context. + */ +function buildNavLookup(siteUrl: string, mdxNodes: NavMdxNode[]): Map { + const mdxPageSet = buildMdxPageSet(mdxNodes); + const metaDescriptions = buildMetaDescriptionMap(mdxNodes); + return buildNavigationLookup(siteUrl, metaDescriptions, mdxPageSet); +} + +export { + flattenNavPages, + mdxNodeToNavLink, + buildMdxPageSet, + buildMetaDescriptionMap, + buildNavigationLookup, + generateNavigationFooter, + buildNavLookup, +}; diff --git a/data/onPostBuild/transpileMdxToMarkdown.test.ts b/data/onPostBuild/transpileMdxToMarkdown.test.ts index 64fa3ac467..aef316105b 100644 --- a/data/onPostBuild/transpileMdxToMarkdown.test.ts +++ b/data/onPostBuild/transpileMdxToMarkdown.test.ts @@ -17,6 +17,7 @@ import { transformCodeBlocksWithSubheadings, addLanguageSubheadingsToCodeBlocks, } from './transpileMdxToMarkdown'; +import type { NavContext } from './generateMarkdownFooter'; import * as fs from 'fs'; import * as path from 'path'; @@ -58,6 +59,64 @@ Content without intro`; }); }); + describe('transformMdxToMarkdown with navContext', () => { + it('should append navigation footer when navContext is provided', () => { + const input = `--- +title: Test Page +--- + +Some content here.`; + + const navContext: NavContext = { + prev: { name: 'Previous', url: 'https://ably.com/docs/prev.md', description: 'Previous page.' }, + next: { name: 'Next', url: 'https://ably.com/docs/next.md', description: 'Next page.' }, + siblings: [{ name: 'Sibling', url: 'https://ably.com/docs/sibling.md', description: 'A sibling page.' }], + }; + + const { content } = transformMdxToMarkdown(input, 'https://ably.com', navContext); + + expect(content).toContain('## Page Navigation'); + expect(content).toContain('- Previous: [Previous](https://ably.com/docs/prev.md): Previous page.'); + expect(content).toContain('- Next: [Next](https://ably.com/docs/next.md): Next page.'); + expect(content).toContain('## Related Topics'); + expect(content).toContain('- [Sibling](https://ably.com/docs/sibling.md): A sibling page.'); + expect(content).toContain('## Documentation Index'); + expect(content).toContain('Fetch [llms.txt](https://ably.com/llms.txt)'); + }); + + it('should not append footer when navContext is undefined', () => { + const input = `--- +title: Test Page +--- + +Some content here.`; + + const { content } = transformMdxToMarkdown(input, 'https://ably.com'); + + expect(content).not.toContain('## Page Navigation'); + expect(content).not.toContain('## Documentation Index'); + }); + + it('should not double-process footer URLs through transformation stages', () => { + const input = `--- +title: Test Page +--- + +Some content here.`; + + const navContext: NavContext = { + next: { name: 'Next', url: 'https://ably.com/docs/next.md', description: 'Next page.' }, + siblings: [], + }; + + const { content } = transformMdxToMarkdown(input, 'https://ably.com', navContext); + + // The footer URL should remain exactly as-is (not get .md.md or other double-processing) + expect(content).toContain('https://ably.com/docs/next.md'); + expect(content).not.toContain('https://ably.com/docs/next.md.md'); + }); + }); + describe('removeImportExportStatements', () => { it('should remove single-line imports', () => { const input = `import Foo from 'bar'\n\nContent here`; @@ -444,7 +503,9 @@ import Baz from 'qux'; it('should not add .md to URLs that already have a file extension (.png)', () => { const input = '[Image](https://raw.githubusercontent.com/ably/docs/main/src/images/content/diagrams/test.png)'; const output = convertDocsLinksToMarkdown(input); - expect(output).toBe('[Image](https://raw.githubusercontent.com/ably/docs/main/src/images/content/diagrams/test.png)'); + expect(output).toBe( + '[Image](https://raw.githubusercontent.com/ably/docs/main/src/images/content/diagrams/test.png)', + ); }); // Tests for trailing slash normalization @@ -519,8 +580,7 @@ import Baz from 'qux'; const input = `"/docs/chat/getting-started/react"`; const output = convertJsxLinkProps(input, siteUrl); const expected = - `"[ably docs chat getting-started react]` + - `(http://localhost:3000/docs/chat/getting-started/react)"`; + `"[ably docs chat getting-started react]` + `(http://localhost:3000/docs/chat/getting-started/react)"`; expect(output).toBe(expected); }); @@ -558,8 +618,7 @@ import Baz from 'qux'; `link: '[ably docs chat getting-started javascript]` + `(http://localhost:3000/docs/chat/getting-started/javascript)'`; const expectedReactLink = - `link: '[ably docs chat getting-started react]` + - `(http://localhost:3000/docs/chat/getting-started/react)'`; + `link: '[ably docs chat getting-started react]` + `(http://localhost:3000/docs/chat/getting-started/react)'`; expect(output).toContain(expectedJsLink); expect(output).toContain(expectedReactLink); // Ensure other content is preserved @@ -586,8 +645,7 @@ Real prop: link: '/docs/presence'`; expect(output).toContain("fetch('/docs/api/posts/123')"); expect(output).toContain('fetch("/docs/api/posts/456")'); // Non-code-block content should be converted - const expectedPresenceLink = - `link: '[ably docs presence](http://localhost:3000/docs/presence)'`; + const expectedPresenceLink = `link: '[ably docs presence](http://localhost:3000/docs/presence)'`; expect(output).toContain(expectedPresenceLink); }); }); diff --git a/data/onPostBuild/transpileMdxToMarkdown.ts b/data/onPostBuild/transpileMdxToMarkdown.ts index 84d607267c..96c3aad8ea 100644 --- a/data/onPostBuild/transpileMdxToMarkdown.ts +++ b/data/onPostBuild/transpileMdxToMarkdown.ts @@ -2,6 +2,8 @@ import { GatsbyNode } from 'gatsby'; import * as path from 'path'; import * as fs from 'fs-extra'; import frontMatter from 'front-matter'; +import { generateNavigationFooter, mdxNodeToNavLink, buildNavLookup } from './generateMarkdownFooter'; +import type { NavContext } from './generateMarkdownFooter'; const REPORTER_PREFIX = 'onPostBuild:transpileMdxToMarkdown'; @@ -95,7 +97,7 @@ function addLanguageSubheadingsToCodeBlocks(content: string): string { }); } -interface MdxNode { +export interface MdxNode { parent: { relativeDirectory: string; name: string; @@ -104,6 +106,9 @@ interface MdxNode { internal: { contentFilePath: string; }; + frontmatter?: { + meta_description?: string; + }; } interface MdxQueryResult { @@ -528,11 +533,16 @@ function calculateOutputPath(relativeDirectory: string, fileName: string): strin } /** - * Transform MDX content to clean Markdown + * Transform MDX content to clean Markdown. + * + * When navContext is provided, a navigation footer is appended as the final step + * (after all 14 transformation stages). This ensures the footer's absolute .md URLs + * are not double-processed by stages like convertDocsLinksToMarkdown() or convertRelativeUrls(). */ function transformMdxToMarkdown( sourceContent: string, siteUrl: string, + navContext?: NavContext, ): { content: string; title: string; intro?: string } { // Stage 1: Parse frontmatter const parsed = frontMatter(sourceContent); @@ -582,7 +592,12 @@ function transformMdxToMarkdown( content = addLanguageSubheadingsToCodeBlocks(content); // Stage 14: Prepend title as markdown heading - const finalContent = `# ${title}\n\n${intro ? `${intro}\n\n` : ''}${content}`; + let finalContent = `# ${title}\n\n${intro ? `${intro}\n\n` : ''}${content}`; + + // Stage 15: Append navigation footer (after all transformations to avoid double-processing) + if (navContext) { + finalContent += generateNavigationFooter(navContext, siteUrl); + } return { content: finalContent, title, intro }; } @@ -590,7 +605,12 @@ function transformMdxToMarkdown( /** * Process a single MDX file */ -async function processFile(node: MdxNode, siteUrl: string, reporter: any): Promise { +async function processFile( + node: MdxNode, + siteUrl: string, + reporter: any, + navLookup: Map, +): Promise { const sourcePath = node.internal.contentFilePath; const relativeDirectory = node.parent.relativeDirectory; const fileName = node.parent.name; @@ -598,8 +618,12 @@ async function processFile(node: MdxNode, siteUrl: string, reporter: any): Promi // Read source MDX file const sourceContent = await fs.readFile(sourcePath, 'utf-8'); - // Transform MDX to Markdown - const { content } = transformMdxToMarkdown(sourceContent, siteUrl); + // Derive nav link from MDX node info and look up navigation context + const navLink = mdxNodeToNavLink(relativeDirectory, fileName); + const navContext = navLookup.get(navLink); + + // Transform MDX to Markdown (includes navigation footer when navContext is available) + const { content } = transformMdxToMarkdown(sourceContent, siteUrl, navContext); // Calculate output path const outputPath = calculateOutputPath(relativeDirectory, fileName); @@ -636,6 +660,9 @@ export const onPostBuild: GatsbyNode['onPostBuild'] = async ({ graphql, reporter internal { contentFilePath } + frontmatter { + meta_description + } } } } @@ -670,13 +697,16 @@ export const onPostBuild: GatsbyNode['onPostBuild'] = async ({ graphql, reporter reporter.info(`${REPORTER_PREFIX} Found ${mdxNodes.length} MDX files to transpile`); + // Build navigation lookup (encapsulates MDX page set, meta descriptions, and nav context computation) + const navLookup = buildNavLookup(siteUrl, mdxNodes); + let successCount = 0; let failureCount = 0; // Process each file for (const node of mdxNodes) { try { - await processFile(node, siteUrl, reporter); + await processFile(node, siteUrl, reporter, navLookup); successCount++; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error);