diff --git a/.gitignore b/.gitignore index bc614fc0ec5..b00f6dd876a 100644 --- a/.gitignore +++ b/.gitignore @@ -71,6 +71,7 @@ public/**/**/nextImageExportOptimizer/ public/next-image-export-optimizer-hashes.json src/directory/directory.json src/directory/flatDirectory.json +public/ai/ src/references/raw-references.json .yarn/* diff --git a/next-env.d.ts b/next-env.d.ts index a4a7b3f5cfa..ddb76465e52 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,5 +1,6 @@ /// /// +import "./client/www/next-build/dev/types/routes.d.ts"; // NOTE: This file should not be edited -// see https://nextjs.org/docs/pages/building-your-application/configuring/typescript for more information. +// see https://nextjs.org/docs/pages/api-reference/config/typescript for more information. diff --git a/package.json b/package.json index 5fbdba2acd3..92325bced47 100644 --- a/package.json +++ b/package.json @@ -122,7 +122,7 @@ "next-start": "next start --webpack", "prepare": "husky install", "analyze": "ANALYZE=true yarn next-build", - "prebuild": "node src/directory/generateDirectory.mjs && node src/directory/generateFlatDirectory.mjs", + "prebuild": "node src/directory/generateDirectory.mjs && node src/directory/generateFlatDirectory.mjs && node tasks/generate-llms-txt.mjs", "lint": "next lint --webpack", "clean-references": "node tasks/clean-references.mjs" }, diff --git a/src/components/Layout/Layout.tsx b/src/components/Layout/Layout.tsx index 3ff54bd60f6..c6a06f63240 100644 --- a/src/components/Layout/Layout.tsx +++ b/src/components/Layout/Layout.tsx @@ -40,6 +40,7 @@ import { Gen1Banner } from '@/components/Gen1Banner'; import { PinpointEOLBanner } from '@/components/PinpointEOLBanner'; import { LexV1EOLBanner } from '../LexV1EOLBanner'; import { ApiModalProvider } from '../ApiDocs/ApiModalProvider'; +import { MarkdownMenu } from '@/components/MarkdownMenu'; export const Layout = ({ children, @@ -283,7 +284,19 @@ export const Layout = ({ ) : null} {shouldShowAIBanner ? : null} {useCustomTitle ? null : ( - {pageTitle} + + {pageTitle} + + )} {(isGen1GettingStarted || isGen1HowAmplifyWorks) && ( diff --git a/src/components/MarkdownMenu/MarkdownMenu.tsx b/src/components/MarkdownMenu/MarkdownMenu.tsx new file mode 100644 index 00000000000..91325be9b09 --- /dev/null +++ b/src/components/MarkdownMenu/MarkdownMenu.tsx @@ -0,0 +1,194 @@ +import { useState, useRef, useEffect, useCallback } from 'react'; +import { Button, Flex, Text } from '@aws-amplify/ui-react'; + +interface MarkdownMenuProps { + route: string; + isGen1?: boolean; + isHome?: boolean; + isOverview?: boolean; +} + +function getMarkdownUrl(route: string): string { + // Strip platform prefix and trailing slash + // e.g. /react/build-a-backend/auth/set-up-auth/ → build-a-backend/auth/set-up-auth + const parts = route.replace(/^\//, '').replace(/\/$/, '').split('/'); + const withoutPlatform = parts.slice(1).join('/'); + return `/ai/pages/${withoutPlatform}.md`; +} + +export function MarkdownMenu({ route, isGen1, isHome, isOverview }: MarkdownMenuProps) { + const [isOpen, setIsOpen] = useState(false); + const [copied, setCopied] = useState(false); + const menuRef = useRef(null); + const copiedTimerRef = useRef | null>(null); + + const shouldRender = !isGen1 && !isHome && !isOverview; + const mdUrl = getMarkdownUrl(route); + + const handleCopy = useCallback(async () => { + try { + const response = await fetch(mdUrl); + if (!response.ok) return; + const text = await response.text(); + // Guard against accidentally copying HTML (e.g. 404 page) + if (/^\s* setCopied(false), 2000); + } catch { + // Silently fail if clipboard not available + } + }, [mdUrl]); + + const handleOpenMd = useCallback(() => { + window.open(mdUrl, '_blank'); + setIsOpen(false); + }, [mdUrl]); + + useEffect(() => { + return () => { + if (copiedTimerRef.current) clearTimeout(copiedTimerRef.current); + }; + }, []); + + useEffect(() => { + function handleClickOutside(event: MouseEvent) { + if (menuRef.current && !menuRef.current.contains(event.target as Node)) { + setIsOpen(false); + } + } + if (isOpen) { + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + } + }, [isOpen]); + + const handleKeyDown = useCallback( + (event: React.KeyboardEvent) => { + if (event.key === 'Escape') { + setIsOpen(false); + } + if (!isOpen) return; + const items = menuRef.current?.querySelectorAll( + '[role="menuitem"]' + ); + if (!items?.length) return; + const focused = document.activeElement; + const index = Array.from(items).indexOf(focused as HTMLButtonElement); + if (event.key === 'ArrowDown') { + event.preventDefault(); + items[index < items.length - 1 ? index + 1 : 0].focus(); + } else if (event.key === 'ArrowUp') { + event.preventDefault(); + items[index > 0 ? index - 1 : items.length - 1].focus(); + } + }, + [isOpen] + ); + + if (!shouldRender) return null; + + return ( +
+ + {isOpen && ( +
+ + +
+ )} +
+ ); +} diff --git a/src/components/MarkdownMenu/__tests__/MarkdownMenu.test.tsx b/src/components/MarkdownMenu/__tests__/MarkdownMenu.test.tsx new file mode 100644 index 00000000000..6b4e314aaaa --- /dev/null +++ b/src/components/MarkdownMenu/__tests__/MarkdownMenu.test.tsx @@ -0,0 +1,149 @@ +import * as React from 'react'; +import { render, screen, waitFor, act } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MarkdownMenu } from '../MarkdownMenu'; + +// Mock fetch and clipboard +const mockFetch = jest.fn(); +const mockWriteText = jest.fn(); +const mockOpen = jest.fn(); + +beforeEach(() => { + global.fetch = mockFetch; + Object.assign(navigator, { + clipboard: { writeText: mockWriteText } + }); + window.open = mockOpen; + mockFetch.mockReset(); + mockWriteText.mockReset(); + mockOpen.mockReset(); +}); + +describe('MarkdownMenu', () => { + it('should render button with "Use with AI" label', () => { + render( + + ); + expect(screen.getByText('Use with AI')).toBeInTheDocument(); + }); + + it('should not render on Gen1 pages', () => { + const { container } = render( + + ); + expect(container.innerHTML).toBe(''); + }); + + it('should not render on home page', () => { + const { container } = render( + + ); + expect(container.innerHTML).toBe(''); + }); + + it('should show dropdown menu items on click', async () => { + render( + + ); + + await act(async () => { + userEvent.click(screen.getByText('Use with AI')); + }); + + expect(screen.getByText('Copy page MD')).toBeVisible(); + expect(screen.getByText('Open this page as MD')).toBeVisible(); + }); + + it('should copy markdown to clipboard when "Copy page MD" is clicked', async () => { + const mdContent = '# Test Page\n\nSome content.'; + mockFetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(mdContent) + }); + mockWriteText.mockResolvedValueOnce(undefined); + + render( + + ); + + await act(async () => { + userEvent.click(screen.getByText('Use with AI')); + }); + await act(async () => { + userEvent.click(screen.getByText('Copy page MD')); + }); + + await waitFor(() => { + expect(mockFetch).toHaveBeenCalledWith( + '/ai/pages/build-a-backend/auth/set-up-auth.md' + ); + }); + expect(mockWriteText).toHaveBeenCalledWith(mdContent); + }); + + it('should open markdown file in new tab when "Open this page as MD" is clicked', async () => { + render( + + ); + + await act(async () => { + userEvent.click(screen.getByText('Use with AI')); + }); + await act(async () => { + userEvent.click(screen.getByText('Open this page as MD')); + }); + + expect(mockOpen).toHaveBeenCalledWith( + '/ai/pages/build-a-backend/auth/set-up-auth.md', + '_blank' + ); + }); + + it('should construct correct markdown URL from route', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve('content') + }); + mockWriteText.mockResolvedValueOnce(undefined); + + render( + + ); + + await act(async () => { + userEvent.click(screen.getByText('Use with AI')); + }); + await act(async () => { + userEvent.click(screen.getByText('Copy page MD')); + }); + + await waitFor(() => { + expect(mockFetch).toHaveBeenCalledWith( + '/ai/pages/build-a-backend/auth/set-up-auth.md' + ); + }); + }); + + it('should show "Copied!" feedback after copy', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve('content') + }); + mockWriteText.mockResolvedValueOnce(undefined); + + render( + + ); + + await act(async () => { + userEvent.click(screen.getByText('Use with AI')); + }); + await act(async () => { + userEvent.click(screen.getByText('Copy page MD')); + }); + + await waitFor(() => { + expect(screen.getByText('Copied!')).toBeInTheDocument(); + }); + }); +}); diff --git a/src/components/MarkdownMenu/index.ts b/src/components/MarkdownMenu/index.ts new file mode 100644 index 00000000000..daceaa3ef68 --- /dev/null +++ b/src/components/MarkdownMenu/index.ts @@ -0,0 +1 @@ +export { MarkdownMenu } from './MarkdownMenu'; diff --git a/src/styles/markdown-menu.scss b/src/styles/markdown-menu.scss new file mode 100644 index 00000000000..d14e70212d2 --- /dev/null +++ b/src/styles/markdown-menu.scss @@ -0,0 +1,55 @@ +.markdown-menu { + position: relative; + flex-shrink: 0; + + &__button { + padding: var(--amplify-space-xxs) var(--amplify-space-small); + border: 1px solid var(--amplify-colors-border-primary); + border-radius: var(--amplify-radii-small); + background: var(--amplify-colors-background-primary); + color: var(--amplify-colors-font-secondary); + font-size: var(--amplify-font-sizes-small); + cursor: pointer; + + &:hover { + background: var(--amplify-colors-overlay-5); + color: var(--amplify-colors-font-primary); + } + } + + &__chevron { + transition: transform 0.15s ease; + } + + &__dropdown { + position: absolute; + right: 0; + top: calc(100% + var(--amplify-space-xxs)); + background: var(--amplify-colors-background-primary); + border: 1px solid var(--amplify-colors-border-primary); + border-radius: var(--amplify-radii-small); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); + z-index: 10; + min-width: 200px; + padding: var(--amplify-space-xxs) 0; + } + + &__item { + display: flex; + align-items: center; + gap: var(--amplify-space-small); + width: 100%; + padding: var(--amplify-space-xs) var(--amplify-space-medium); + border: none; + background: none; + color: var(--amplify-colors-font-primary); + font-size: var(--amplify-font-sizes-small); + cursor: pointer; + text-align: left; + white-space: nowrap; + + &:hover { + background: var(--amplify-colors-overlay-5); + } + } +} diff --git a/src/styles/styles.scss b/src/styles/styles.scss index c6958471e7b..4e85db13d8b 100644 --- a/src/styles/styles.scss +++ b/src/styles/styles.scss @@ -25,6 +25,7 @@ @import './icons.scss'; @import './layout.scss'; @import './link-card.scss'; +@import './markdown-menu.scss'; @import './menu.scss'; @import './modal.scss'; @import './next-previous.scss'; diff --git a/tasks/__tests__/generate-llms-txt.test.ts b/tasks/__tests__/generate-llms-txt.test.ts new file mode 100644 index 00000000000..c09bcdee4e6 --- /dev/null +++ b/tasks/__tests__/generate-llms-txt.test.ts @@ -0,0 +1,273 @@ +import { + collectGen2Pages, + transformMdxToMarkdown, + generateFrontmatter, + generateLlmsIndex +} from '../generate-llms-txt.mjs'; + +describe('generate-llms-txt', () => { + describe('collectGen2Pages', () => { + it('should return only Gen2 pages', () => { + const mockDirectory = { + path: 'src/pages/[platform]/index.tsx', + children: [ + { + path: 'src/pages/[platform]/build-a-backend/auth/set-up-auth/index.mdx', + title: 'Set up Amplify Auth', + description: 'Learn how to set up auth.', + platforms: ['react', 'nextjs'], + lastUpdated: '2025-01-15T00:00:00.000Z', + route: '/[platform]/build-a-backend/auth/set-up-auth' + }, + { + path: 'src/pages/gen1/[platform]/start/index.mdx', + title: 'Gen1 Start', + description: 'Gen1 getting started.', + platforms: ['react'], + route: '/gen1/[platform]/start' + } + ] + }; + + const pages = collectGen2Pages(mockDirectory); + expect(pages.length).toBe(1); + expect(pages[0].title).toBe('Set up Amplify Auth'); + }); + + it('should skip external links', () => { + const mockDirectory = { + path: 'src/pages/[platform]/index.tsx', + children: [ + { + path: 'src/pages/[platform]/build-a-backend/auth/index.mdx', + title: 'Auth', + route: '/[platform]/build-a-backend/auth', + platforms: ['react'], + isExternal: true + } + ] + }; + + const pages = collectGen2Pages(mockDirectory); + expect(pages.length).toBe(0); + }); + + it('should extract correct section from route', () => { + const mockDirectory = { + path: 'src/pages/[platform]/index.tsx', + children: [ + { + path: 'src/pages/[platform]/build-a-backend/auth/set-up-auth/index.mdx', + title: 'Set up Auth', + description: '', + platforms: ['react'], + route: '/[platform]/build-a-backend/auth/set-up-auth', + lastUpdated: '' + } + ] + }; + + const pages = collectGen2Pages(mockDirectory); + expect(pages[0].section).toBe('build-a-backend/auth'); + }); + }); + + describe('transformMdxToMarkdown', () => { + it('should strip exports and imports', () => { + const input = `import { getCustomStaticPath } from '@/utils/getCustomStaticPath'; + +export const meta = { + title: 'Test', + description: 'test page', + platforms: ['react'] +}; + +export function getStaticPaths() { + return getCustomStaticPath(meta.platforms); +}; + +export function getStaticProps() { + return { + props: { + meta + } + }; +}; + +# Hello World + +Some content here.`; + + const result = transformMdxToMarkdown(input); + expect(result).not.toContain('export const meta'); + expect(result).not.toContain('import'); + expect(result).not.toContain('getStaticPaths'); + expect(result).not.toContain('getStaticProps'); + expect(result).toContain('# Hello World'); + expect(result).toContain('Some content here.'); + }); + + it('should convert InlineFilter to HTML comments', () => { + const input = `React only content`; + const result = transformMdxToMarkdown(input); + expect(result).toContain(''); + expect(result).toContain('React only content'); + expect(result).toContain(''); + }); + + it('should convert Callout info', () => { + const input = `This is important info.`; + const result = transformMdxToMarkdown(input); + expect(result).toContain('> **Info:** This is important info.'); + }); + + it('should convert Callout warning', () => { + const input = `Be careful!`; + const result = transformMdxToMarkdown(input); + expect(result).toContain('> **Warning:** Be careful!'); + }); + + it('should convert BlockSwitcher/Block to headings', () => { + const input = ` + +TypeScript content here. + + +JavaScript content here. + +`; + + const result = transformMdxToMarkdown(input); + expect(result).toContain('#### [TypeScript]'); + expect(result).toContain('TypeScript content here.'); + expect(result).toContain('#### [JavaScript]'); + expect(result).toContain('JavaScript content here.'); + expect(result).not.toContain(''); + expect(result).not.toContain(''); + }); + + it('should convert Accordion to details/summary', () => { + const input = `Some hidden content.`; + const result = transformMdxToMarkdown(input); + expect(result).toContain('
Click to expand'); + expect(result).toContain('Some hidden content.'); + expect(result).toContain('
'); + }); + + it('should strip Overview component', () => { + const input = ` + +## Next section`; + const result = transformMdxToMarkdown(input); + expect(result).not.toContain('Overview'); + expect(result).toContain('## Next section'); + }); + + it('should preserve code blocks', () => { + const input = `Some text. + +\`\`\`tsx + + + This should not be transformed + + +\`\`\` + +More text.`; + + const result = transformMdxToMarkdown(input); + expect(result).toContain(''); + expect(result).toContain(''); + expect(result).toContain('This should not be transformed'); + }); + + it('should strip JSX comments', () => { + const input = `Some text. +{/* This is a comment */} +More text.`; + + const result = transformMdxToMarkdown(input); + expect(result).not.toContain('This is a comment'); + expect(result).toContain('Some text.'); + expect(result).toContain('More text.'); + }); + + it('should handle nested InlineFilters', () => { + const input = ` +Outer content. +Inner react only. +`; + + const result = transformMdxToMarkdown(input); + expect(result).toContain(''); + expect(result).toContain(''); + expect(result).toContain('Outer content.'); + expect(result).toContain('Inner react only.'); + }); + }); + + describe('generateFrontmatter', () => { + it('should produce valid YAML frontmatter', () => { + const mockNode = { + title: 'Set up Amplify Auth', + description: 'Learn how to set up auth.', + platforms: ['react', 'nextjs'], + route: '/[platform]/build-a-backend/auth/set-up-auth', + path: 'src/pages/[platform]/build-a-backend/auth/set-up-auth/index.mdx', + lastUpdated: '2025-01-15T00:00:00.000Z', + section: 'build-a-backend/auth' + }; + + const result = generateFrontmatter(mockNode); + expect(result).toMatch(/^---/); + expect(result).toMatch(/---$/); + expect(result).toContain('title: "Set up Amplify Auth"'); + expect(result).toContain('section: "build-a-backend/auth"'); + expect(result).toContain('"react"'); + expect(result).toContain('"nextjs"'); + expect(result).toContain('gen: 2'); + expect(result).toContain('last-updated: "2025-01-15T00:00:00.000Z"'); + expect(result).toContain( + 'url: "https://docs.amplify.aws/react/build-a-backend/auth/set-up-auth/"' + ); + }); + }); + + describe('generateLlmsIndex', () => { + it('should group pages by section', () => { + const mockNodes = [ + { + title: 'Set up Auth', + description: 'Auth setup guide.', + platforms: ['react'], + route: '/[platform]/build-a-backend/auth/set-up-auth', + section: 'build-a-backend' + }, + { + title: 'Quickstart', + description: 'Get started quickly.', + platforms: ['react'], + route: '/[platform]/start/quickstart', + section: 'start' + }, + { + title: 'Data Modeling', + description: 'Model your data.', + platforms: ['react'], + route: '/[platform]/build-a-backend/data/data-modeling', + section: 'build-a-backend' + } + ]; + + const result = generateLlmsIndex(mockNodes); + expect(result).toContain('## build-a-backend'); + expect(result).toContain('## start'); + expect(result).toContain('- [Set up Auth]'); + expect(result).toContain('- [Quickstart]'); + expect(result).toContain('- [Data Modeling]'); + expect(result).toContain(': Auth setup guide.'); + expect(result).toContain('Markdown: /ai/pages/'); + }); + }); +}); diff --git a/tasks/generate-llms-txt.mjs b/tasks/generate-llms-txt.mjs new file mode 100644 index 00000000000..bb21a399f68 --- /dev/null +++ b/tasks/generate-llms-txt.mjs @@ -0,0 +1,406 @@ +import { promises as fs, readFileSync } from 'fs'; +import path from 'path'; + +const AI_PATH = './public/ai'; +const PAGES_PATH = `${AI_PATH}/pages`; +const DOMAIN = 'https://docs.amplify.aws'; +const PROJECT_ROOT = path.resolve('.'); + +/** + * Recursively walk directory.json and collect Gen2 page nodes + * (routes starting with /[platform]/). + */ +export function collectGen2Pages(node, section = '') { + const pages = []; + + if (!node) return pages; + + const route = node.route || ''; + const isGen2 = route.startsWith('/[platform]/'); + const isExternal = node.isExternal === true; + + if (isGen2 && !isExternal && node.title) { + // Extract section: everything between /[platform]/ and the last segment + const routeWithoutPlatform = route.replace('/[platform]/', ''); + const parts = routeWithoutPlatform.split('/'); + const pageSection = parts.length > 1 ? parts.slice(0, -1).join('/') : parts[0]; + + pages.push({ + title: node.title, + description: node.description || '', + platforms: node.platforms || [], + route: node.route, + path: node.path, + lastUpdated: node.lastUpdated || '', + section: section || pageSection + }); + } + + if (node.children) { + // Determine section from this node if it's a Gen2 container + const currentSection = isGen2 && route + ? route.replace('/[platform]/', '').split('/')[0] + : section; + + for (const child of node.children) { + const childSection = isGen2 + ? route.replace('/[platform]/', '') + : currentSection; + pages.push(...collectGen2Pages(child, childSection)); + } + } + + return pages; +} + +/** + * Strip an export block that starts with the given prefix, handling nested braces. + */ +function stripExportBlock(content, prefix) { + const idx = content.indexOf(prefix); + if (idx === -1) return content; + + // Find the first opening brace after the prefix + const braceStart = content.indexOf('{', idx); + if (braceStart === -1) return content; + + // Count braces to find the matching closing brace + let depth = 0; + let i = braceStart; + for (; i < content.length; i++) { + if (content[i] === '{') depth++; + else if (content[i] === '}') { + depth--; + if (depth === 0) break; + } + } + + // Remove from prefix start to after the closing brace (and optional semicolons/whitespace) + let end = i + 1; + while (end < content.length && (content[end] === ';' || content[end] === '\n' || content[end] === '\r' || content[end] === ' ')) { + end++; + } + + return content.slice(0, idx) + content.slice(end); +} + +/** + * Transform raw MDX content into clean markdown. + */ +export function transformMdxToMarkdown(rawMdx, srcDir = './src') { + let content = rawMdx; + + // Step 1: Extract fenced code blocks as placeholders + const codeBlocks = []; + content = content.replace(/```[\s\S]*?```/g, (match) => { + const index = codeBlocks.length; + codeBlocks.push(match); + return `__CODE_BLOCK_${index}__`; + }); + + // Step 2: Strip export blocks (meta, getStaticPaths, getStaticProps) + // Use brace-counting to handle nested braces + content = stripExportBlock(content, 'export const meta'); + content = stripExportBlock(content, 'export function getStaticPaths'); + content = stripExportBlock(content, 'export const getStaticPaths'); + content = stripExportBlock(content, 'export function getStaticProps'); + content = stripExportBlock(content, 'export const getStaticProps'); + // Catch any remaining single-line export statements + content = content.replace(/^export\s+(const|function|default)\s+.*$/gm, ''); + + // Step 3: Resolve Fragments - capture imports and inline content + const fragmentImports = {}; + const importRegex = /import\s+(\w+)\s+from\s+['"]([^'"]+\.mdx)['"]\s*;?/g; + let importMatch; + while ((importMatch = importRegex.exec(content)) !== null) { + fragmentImports[importMatch[1]] = importMatch[2]; + } + + // Replace with inlined content + content = content.replace( + //g, + (match) => { + // Extract the variable names from the fragments prop + const varNames = new Set(); + const varRegex = /:\s*(\w+)/g; + let varMatch; + while ((varMatch = varRegex.exec(match)) !== null) { + varNames.add(varMatch[1]); + } + + // Inline the first unique fragment (they're typically the same content for all platforms) + for (const varName of varNames) { + const fragmentPath = fragmentImports[varName]; + if (fragmentPath) { + try { + const resolvedPath = path.resolve(srcDir, '..', fragmentPath); + // Ensure the resolved path stays within the project root + if (!resolvedPath.startsWith(PROJECT_ROOT)) { + continue; + } + const fragmentContent = readFileSync(resolvedPath, 'utf-8'); + return transformMdxToMarkdown(fragmentContent, srcDir); + } catch { + // If we can't read the fragment, just remove the tag + } + } + } + return ''; + } + ); + + // Step 4: Strip import statements + content = content.replace(/^import\s+.*?;\s*$/gm, ''); + content = content.replace(/^import\s+.*?from\s+['"].*?['"]\s*$/gm, ''); + + // Step 5: Convert ... + // The regex matches innermost InlineFilter pairs first. Multiple passes handle + // nesting — each pass peels off one layer. We loop until no more replacements + // occur rather than using a fixed count. + let prevContent; + do { + prevContent = content; + content = content.replace( + /([\s\S]*?)<\/InlineFilter>/g, + (match, filters, inner) => { + // Clean up the filter list + const filterList = filters.replace(/[[\]'"]/g, '').trim(); + return `\n${inner.trim()}\n`; + } + ); + } while (content !== prevContent); + + // Step 6: Convert / → #### [X] + content = content.replace(//g, ''); + content = content.replace(/<\/BlockSwitcher>/g, ''); + content = content.replace(//g, (match, name) => { + return `#### [${name}]`; + }); + content = content.replace(/<\/Block>/g, ''); + + // Step 7: Convert → blockquote + content = content.replace( + /([\s\S]*?)<\/Callout>/g, + (match, type, inner) => { + const label = type === 'warning' ? 'Warning' : 'Info'; + const lines = inner.trim().split('\n'); + return lines.map((line, i) => { + if (i === 0) return `> **${label}:** ${line}`; + return `> ${line}`; + }).join('\n'); + } + ); + + // Step 8: Convert
+ content = content.replace( + /]*)?>/g, + '
$1' + ); + content = content.replace(/<\/Accordion>/g, '
'); + + // Step 9: Strip ,