diff --git a/data/onPostBuild/transpileMdxToMarkdown.test.ts b/data/onPostBuild/transpileMdxToMarkdown.test.ts index b92dcf3928..86fcdd759b 100644 --- a/data/onPostBuild/transpileMdxToMarkdown.test.ts +++ b/data/onPostBuild/transpileMdxToMarkdown.test.ts @@ -6,6 +6,8 @@ import { removeJsxComments, convertImagePathsToGitHub, convertDocsLinksToMarkdown, + convertJsxLinkProps, + generateLinkTextFromPath, convertRelativeUrls, replaceTemplateVariables, calculateOutputPath, @@ -437,6 +439,110 @@ import Baz from 'qux'; }); }); + describe('generateLinkTextFromPath', () => { + it('should handle /docs root path without extra "docs"', () => { + const output = generateLinkTextFromPath('/docs'); + expect(output).toBe('ably docs'); + }); + + it('should convert /docs/ path to readable text', () => { + const output = generateLinkTextFromPath('/docs/chat/getting-started/javascript'); + expect(output).toBe('ably docs chat getting-started javascript'); + }); + + it('should handle trailing slashes without producing trailing spaces', () => { + const output = generateLinkTextFromPath('/docs/channels/'); + expect(output).toBe('ably docs channels'); + }); + }); + + describe('convertJsxLinkProps', () => { + it('should convert quoted /docs/ path with single quotes', () => { + const input = `'/docs/chat/getting-started/javascript'`; + const output = convertJsxLinkProps(input, siteUrl); + const expected = + `'[ably docs chat getting-started javascript]` + + `(http://localhost:3000/docs/chat/getting-started/javascript)'`; + expect(output).toBe(expected); + }); + + it('should convert quoted /docs/ path with double quotes', () => { + 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)"`; + expect(output).toBe(expected); + }); + + it('should not convert non-docs paths', () => { + const input = `'/blog/article'`; + const output = convertJsxLinkProps(input, siteUrl); + expect(output).toBe(`'/blog/article'`); + }); + + it('should not convert external URLs', () => { + const input = `'https://example.com/docs/page'`; + const output = convertJsxLinkProps(input, siteUrl); + expect(output).toBe(`'https://example.com/docs/page'`); + }); + + it('should handle Tiles component content like the real example', () => { + const input = ` +{[ + { + title: 'JavaScript', + description: 'Start building with Chat using Ably\\'s JavaScript SDK', + image: 'icon-tech-javascript', + link: '/docs/chat/getting-started/javascript', + }, + { + title: 'React', + description: 'Start building Chat applications using Ably\\'s React SDK.', + image: 'icon-tech-react', + link: '/docs/chat/getting-started/react', + }, +]} +`; + const output = convertJsxLinkProps(input, siteUrl); + const expectedJsLink = + `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)'`; + expect(output).toContain(expectedJsLink); + expect(output).toContain(expectedReactLink); + // Ensure other content is preserved + expect(output).toContain(`title: 'JavaScript'`); + expect(output).toContain(`image: 'icon-tech-javascript'`); + }); + + it('should not convert /docs/ paths inside code blocks', () => { + const input = `Here's an example: + +\`\`\`ruby +link '/docs/channels' +\`\`\` + +\`\`\`javascript +fetch('/docs/api/posts/123') +await fetch("/docs/api/posts/456") +\`\`\` + +Real prop: link: '/docs/presence'`; + const output = convertJsxLinkProps(input, siteUrl); + // Code block content should be preserved + expect(output).toContain("link '/docs/channels'"); + 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)'`; + expect(output).toContain(expectedPresenceLink); + }); + }); + describe('convertRelativeUrls', () => { it('should convert relative URLs to absolute', () => { const input = '[Link text](/docs/channels)'; diff --git a/data/onPostBuild/transpileMdxToMarkdown.ts b/data/onPostBuild/transpileMdxToMarkdown.ts index 2ad308ef9e..ffcacdc569 100644 --- a/data/onPostBuild/transpileMdxToMarkdown.ts +++ b/data/onPostBuild/transpileMdxToMarkdown.ts @@ -32,6 +32,51 @@ interface FrontMatterAttributes { [key: string]: any; } +/** + * Split content into code block and non-code-block sections + * Used to preserve code blocks during content transformations + */ +function splitByCodeBlocks(content: string): Array<{ content: string; isCodeBlock: boolean }> { + const parts: Array<{ content: string; isCodeBlock: boolean }> = []; + const fenceRegex = /```[\s\S]*?```/g; + + let lastIndex = 0; + const matches = Array.from(content.matchAll(fenceRegex)); + + for (const match of matches) { + if (match.index !== undefined && match.index > lastIndex) { + parts.push({ + content: content.slice(lastIndex, match.index), + isCodeBlock: false, + }); + } + parts.push({ + content: match[0], + isCodeBlock: true, + }); + lastIndex = (match.index || 0) + match[0].length; + } + + if (lastIndex < content.length) { + parts.push({ + content: content.slice(lastIndex), + isCodeBlock: false, + }); + } + + return parts; +} + +/** + * Apply a transformation function only to non-code-block content + * Preserves fenced code blocks (``` ... ```) exactly as-is + */ +function transformNonCodeBlocks(content: string, transform: (text: string) => string): string { + return splitByCodeBlocks(content) + .map((part) => (part.isCodeBlock ? part.content : transform(part.content))) + .join(''); +} + /** * Remove import and export statements from content * Uses a line-by-line parser that only removes import/export from the top of the file, @@ -148,47 +193,9 @@ function removeImportExportStatements(content: string): string { * Remove script tags that are not inside code blocks */ function removeScriptTags(content: string): string { - // Split content into code block and non-code-block sections - const parts: Array<{ content: string; isCodeBlock: boolean }> = []; - const fenceRegex = /```[\s\S]*?```/g; - - let lastIndex = 0; - const matches = Array.from(content.matchAll(fenceRegex)); - - for (const match of matches) { - // Add content before code block - if (match.index !== undefined && match.index > lastIndex) { - parts.push({ - content: content.slice(lastIndex, match.index), - isCodeBlock: false, - }); - } - // Add code block itself - parts.push({ - content: match[0], - isCodeBlock: true, - }); - lastIndex = (match.index || 0) + match[0].length; - } - - // Add remaining content after last code block - if (lastIndex < content.length) { - parts.push({ - content: content.slice(lastIndex), - isCodeBlock: false, - }); - } - - // Remove script tags only from non-code-block parts - return parts - .map((part) => { - if (part.isCodeBlock) { - return part.content; // Preserve code blocks exactly - } - // Remove script tags with any attributes and their content - return part.content.replace(/]*>[\s\S]*?<\/script>/gi, ''); - }) - .join(''); + return transformNonCodeBlocks(content, (text) => + text.replace(/]*>[\s\S]*?<\/script>/gi, ''), + ); } /** @@ -197,49 +204,13 @@ function removeScriptTags(content: string): string { * Preserves actual links with href attributes */ function removeAnchorTags(content: string): string { - // Split content into code block and non-code-block sections - const parts: Array<{ content: string; isCodeBlock: boolean }> = []; - const fenceRegex = /```[\s\S]*?```/g; - - let lastIndex = 0; - const matches = Array.from(content.matchAll(fenceRegex)); - - for (const match of matches) { - if (match.index !== undefined && match.index > lastIndex) { - parts.push({ - content: content.slice(lastIndex, match.index), - isCodeBlock: false, - }); - } - parts.push({ - content: match[0], - isCodeBlock: true, - }); - lastIndex = (match.index || 0) + match[0].length; - } - - if (lastIndex < content.length) { - parts.push({ - content: content.slice(lastIndex), - isCodeBlock: false, - }); - } - - // Remove anchor tags only from non-code-block parts - return parts - .map((part) => { - if (part.isCodeBlock) { - return part.content; // Preserve code blocks exactly - } - - // Remove anchor tags from regular content - return part.content - .replace(//gi, '') - .replace(//gi, '') - .replace(/<\/a>/gi, '') - .replace(/<\/a>/gi, ''); - }) - .join(''); + return transformNonCodeBlocks(content, (text) => + text + .replace(//gi, '') + .replace(//gi, '') + .replace(/<\/a>/gi, '') + .replace(/<\/a>/gi, ''), + ); } /** @@ -248,45 +219,9 @@ function removeAnchorTags(content: string): string { * This makes hidden type definition tables visible in markdown output */ function stripHiddenFromTables(content: string): string { - // Split content into code block and non-code-block sections - const parts: Array<{ content: string; isCodeBlock: boolean }> = []; - const fenceRegex = /```[\s\S]*?```/g; - - let lastIndex = 0; - const matches = Array.from(content.matchAll(fenceRegex)); - - for (const match of matches) { - if (match.index !== undefined && match.index > lastIndex) { - parts.push({ - content: content.slice(lastIndex, match.index), - isCodeBlock: false, - }); - } - parts.push({ - content: match[0], - isCodeBlock: true, - }); - lastIndex = (match.index || 0) + match[0].length; - } - - if (lastIndex < content.length) { - parts.push({ - content: content.slice(lastIndex), - isCodeBlock: false, - }); - } - - // Strip hidden attribute from Table tags only in non-code-block parts - return parts - .map((part) => { - if (part.isCodeBlock) { - return part.content; // Preserve code blocks exactly - } - // Remove the hidden attribute from tags - // Handles:
, , - return part.content.replace(/(]*)\bhidden\b\s*/gi, '$1'); - }) - .join(''); + return transformNonCodeBlocks(content, (text) => + text.replace(/(]*)\bhidden\b\s*/gi, '$1'), + ); } /** @@ -295,44 +230,7 @@ function stripHiddenFromTables(content: string): string { * Preserves JSX comments in code blocks */ function removeJsxComments(content: string): string { - // Split content into code block and non-code-block sections - const parts: Array<{ content: string; isCodeBlock: boolean }> = []; - const fenceRegex = /```[\s\S]*?```/g; - - let lastIndex = 0; - const matches = Array.from(content.matchAll(fenceRegex)); - - for (const match of matches) { - if (match.index !== undefined && match.index > lastIndex) { - parts.push({ - content: content.slice(lastIndex, match.index), - isCodeBlock: false, - }); - } - parts.push({ - content: match[0], - isCodeBlock: true, - }); - lastIndex = (match.index || 0) + match[0].length; - } - - if (lastIndex < content.length) { - parts.push({ - content: content.slice(lastIndex), - isCodeBlock: false, - }); - } - - // Remove JSX comments only from non-code-block parts - return parts - .map((part) => { - if (part.isCodeBlock) { - return part.content; // Preserve code blocks exactly - } - // Remove JSX comments from regular content - return part.content.replace(/\{\/\*[\s\S]*?\*\/\}/g, ''); - }) - .join(''); + return transformNonCodeBlocks(content, (text) => text.replace(/\{\/\*[\s\S]*?\*\/\}/g, '')); } /** @@ -432,6 +330,39 @@ function convertDocsLinksToMarkdown(content: string): string { }); } +/** + * Generate a readable link text from a /docs/ URL path + * Converts: /docs/chat/getting-started/javascript → ably docs chat getting-started javascript + * Note: This function expects paths starting with /docs/ + */ +function generateLinkTextFromPath(urlPath: string): string { + // Remove leading /docs/ (or /docs) and split by / + const pathWithoutDocs = urlPath.replace(/^\/docs(?:\/|$)/, ''); + // Filter out empty parts to handle trailing slashes + const parts = pathWithoutDocs.split('/').filter(Boolean); + return parts.length ? `ably docs ${parts.join(' ')}` : 'ably docs'; +} + +/** + * Convert quoted strings containing relative /docs/ URLs to markdown links with absolute URLs + * Converts: '/docs/chat/getting-started/javascript' → '[ably docs chat getting-started javascript](https://ably.com/docs/chat/getting-started/javascript)' + * Matches any quoted string starting with /docs/ (single or double quotes) + * This handles JSX props like link: '/docs/...' as well as other contexts + * Preserves code blocks - does not transform /docs/ paths inside fenced code blocks + */ +function convertJsxLinkProps(content: string, siteUrl: string): string { + const baseUrl = siteUrl.replace(/\/$/, ''); // Remove trailing slash + + return transformNonCodeBlocks(content, (text) => + // Matches any quoted string starting with /docs/: '/docs/...' or "/docs/..." + text.replace(/(['"])(\/docs\/[^'"]+)\1/g, (match, quote, url) => { + const absoluteUrl = `${baseUrl}${url}`; + const linkText = generateLinkTextFromPath(url); + return `${quote}[${linkText}](${absoluteUrl})${quote}`; + }), + ); +} + /** * Convert relative URLs to absolute URLs using the main website domain * Converts: [text](/docs/channels) → [text](https://ably.com/docs/channels) @@ -534,13 +465,16 @@ function transformMdxToMarkdown( // Stage 8: Convert relative URLs to absolute URLs content = convertRelativeUrls(content, siteUrl); - // Stage 9: Convert /docs/ links to .md extension and remove ?lang= params + // Stage 9: Convert quoted /docs/ URLs to markdown links (for JSX props like link: '/docs/...') + content = convertJsxLinkProps(content, siteUrl); + + // Stage 10: Convert /docs/ links to .md extension and remove ?lang= params content = convertDocsLinksToMarkdown(content); - // Stage 10: Replace template variables + // Stage 11: Replace template variables content = replaceTemplateVariables(content); - // Stage 11: Prepend title as markdown heading + // Stage 12: Prepend title as markdown heading const finalContent = `# ${title}\n\n${intro ? `${intro}\n\n` : ''}${content}`; return { content: finalContent, title, intro }; @@ -661,6 +595,8 @@ export { stripHiddenFromTables, convertImagePathsToGitHub, convertDocsLinksToMarkdown, + convertJsxLinkProps, + generateLinkTextFromPath, convertRelativeUrls, replaceTemplateVariables, calculateOutputPath,