From d59c95b0931559ddd961668b21d94d804a975849 Mon Sep 17 00:00:00 2001 From: Satyajit Sahoo Date: Wed, 4 Mar 2026 13:34:45 +0100 Subject: [PATCH 1/2] Add support for .md URLs --- docusaurus.config.ts | 7 +- src/__tests__/process-markdown.test.ts | 296 +++++++++++++++ src/components/CopyMarkdownButton/index.tsx | 59 +-- src/plugins/llms-txt.ts | 231 ++++++++---- src/plugins/process-markdown.ts | 391 ++++++++++++++++++++ src/plugins/rehype-static-to-dynamic.ts | 2 +- src/plugins/remark-raw-markdown.ts | 13 - 7 files changed, 884 insertions(+), 115 deletions(-) create mode 100644 src/__tests__/process-markdown.test.ts create mode 100644 src/plugins/process-markdown.ts delete mode 100644 src/plugins/remark-raw-markdown.ts diff --git a/docusaurus.config.ts b/docusaurus.config.ts index fa7af6a8ef..adc2c88869 100644 --- a/docusaurus.config.ts +++ b/docusaurus.config.ts @@ -9,7 +9,6 @@ import rehypeCodeblockMeta from './src/plugins/rehype-codeblock-meta.ts'; import rehypeStaticToDynamic from './src/plugins/rehype-static-to-dynamic.ts'; import rehypeVideoAspectRatio from './src/plugins/rehype-video-aspect-ratio.ts'; import remarkNpm2Yarn from './src/plugins/remark-npm2yarn.ts'; -import remarkRawMarkdown from './src/plugins/remark-raw-markdown.ts'; import darkTheme from './src/themes/react-navigation-dark'; import lightTheme from './src/themes/react-navigation-light'; @@ -175,7 +174,7 @@ const config: Config = { }, breadcrumbs: false, sidebarCollapsed: false, - remarkPlugins: [remarkRawMarkdown, [remarkNpm2Yarn, { sync: true }]], + remarkPlugins: [[remarkNpm2Yarn, { sync: true }]], rehypePlugins: [ [ rehypeCodeblockMeta, @@ -186,10 +185,10 @@ const config: Config = { ], }, blog: { - remarkPlugins: [remarkRawMarkdown, [remarkNpm2Yarn, { sync: true }]], + remarkPlugins: [[remarkNpm2Yarn, { sync: true }]], }, pages: { - remarkPlugins: [remarkRawMarkdown, [remarkNpm2Yarn, { sync: true }]], + remarkPlugins: [[remarkNpm2Yarn, { sync: true }]], }, theme: { customCss: './src/css/custom.css', diff --git a/src/__tests__/process-markdown.test.ts b/src/__tests__/process-markdown.test.ts new file mode 100644 index 0000000000..e737645990 --- /dev/null +++ b/src/__tests__/process-markdown.test.ts @@ -0,0 +1,296 @@ +import dedent from 'dedent'; +import assert from 'node:assert'; +import { describe, test } from 'node:test'; +import { processMarkdown } from '../plugins/process-markdown.ts'; + +describe('processMarkdown', () => { + test('strips MDX import statements', async () => { + const input = dedent` + import Tabs from '@theme/Tabs'; + import TabItem from '@theme/TabItem'; + + Some content here. + `; + + const { content: result } = await processMarkdown(input); + assert.ok(!result.includes('import')); + assert.ok(result.includes('Some content here.')); + }); + + test('transforms Tabs with two TabItems into labeled sections', async () => { + const input = dedent` + Before tabs. + + + + + Static content here. + + + + + Dynamic content here. + + + + + After tabs. + `; + + const { content: result } = await processMarkdown(input); + + assert.ok(!result.includes('')); + assert.ok(!result.includes('')); + assert.ok(result.includes('**Static:**')); + assert.ok(result.includes('Static content here.')); + assert.ok(result.includes('**Dynamic:**')); + assert.ok(result.includes('Dynamic content here.')); + assert.ok(result.includes('Before tabs.')); + assert.ok(result.includes('After tabs.')); + }); + + test('transforms nested Tabs', async () => { + const input = dedent` + + + + Expo instructions. + + + + + CLI instructions. + + + + + Kotlin code. + + + + + Java code. + + + + + More CLI content. + + + + `; + + const { content: result } = await processMarkdown(input); + + assert.ok(!result.includes(' { + const input = dedent` + + + + Only content. + + + + `; + + const { content: result } = await processMarkdown(input); + + assert.ok(!result.includes(' { + const input = dedent` + Before code. + + \`\`\`js name="Example" snack static2dynamic + import * as React from 'react'; + import { createStaticNavigation } from '@react-navigation/native'; + import { createNativeStackNavigator } from '@react-navigation/native-stack'; + + function HomeScreen() { + return null; + } + + const MyStack = createNativeStackNavigator({ + screens: { + Home: HomeScreen, + }, + }); + + const Navigation = createStaticNavigation(MyStack); + + export default function App() { + return ; + } + \`\`\` + + After code. + `; + + const { content: result } = await processMarkdown(input); + + assert.ok(result.includes('**Static:**')); + assert.ok(result.includes('**Dynamic:**')); + // The dynamic version should have NavigationContainer + assert.ok(result.includes('NavigationContainer')); + // Meta attributes should be stripped from fence lines + assert.ok(!result.includes('static2dynamic')); + // Check that 'snack' doesn't appear in fence meta (it may appear in code content) + assert.ok(!result.match(/^```\w*.*snack/m)); + assert.ok(!result.match(/^```\w*.*name=/m)); + assert.ok(result.includes('Before code.')); + assert.ok(result.includes('After code.')); + }); + + test('strips code fence meta attributes', async () => { + const input = dedent` + \`\`\`js name="Test" snack + const x = 1; + \`\`\` + + \`\`\`bash npm2yarn + npm install something + \`\`\` + `; + + const { content: result } = await processMarkdown(input); + + assert.ok(!result.includes('name=')); + assert.ok(!result.includes('snack')); + assert.ok(!result.includes('npm2yarn')); + assert.ok(result.includes('```js')); + assert.ok(result.includes('```bash')); + assert.ok(result.includes('const x = 1;')); + assert.ok(result.includes('npm install something')); + }); + + test('converts admonitions to blockquotes', async () => { + const input = dedent` + Some text. + + :::warning + + This is a warning. + + :::: + + More text. + `; + + const { content: result } = await processMarkdown(input); + + assert.ok(!result.includes(':::')); + assert.ok(result.includes('> **Warning:**')); + assert.ok(result.includes('> This is a warning.')); + assert.ok(result.includes('Some text.')); + assert.ok(result.includes('More text.')); + }); + + test('converts info admonitions', async () => { + const input = dedent` + :::info + + Some info here. + + :::: + `; + + const { content: result } = await processMarkdown(input); + + assert.ok(result.includes('> **Info:**')); + assert.ok(result.includes('> Some info here.')); + }); + + test('cleans up extra blank lines', async () => { + const input = 'Line 1.\n\n\n\n\nLine 2.'; + const { content: result } = await processMarkdown(input); + + assert.ok(!result.includes('\n\n\n')); + assert.ok(result.includes('Line 1.\n\nLine 2.')); + }); + + test('handles Tabs with code blocks inside', async () => { + const input = dedent` + + + + \`\`\`js + const x = createStackNavigator({}); + \`\`\` + + + + + \`\`\`js + function MyStack() { + return ; + } + \`\`\` + + + + `; + + const { content: result } = await processMarkdown(input); + + assert.ok(result.includes('**Static:**')); + assert.ok(result.includes('createStackNavigator')); + assert.ok(result.includes('**Dynamic:**')); + assert.ok(result.includes('Stack.Navigator')); + assert.ok(!result.includes(' { + const input = dedent` + # Title + + Regular paragraph with **bold** and [links](https://example.com). + + - List item 1 + - List item 2 + + > A blockquote. + `; + + const { content: result } = await processMarkdown(input); + + assert.strictEqual(result, input); + }); + + test('strips frontmatter and returns parsed data', async () => { + const input = dedent` + --- + id: getting-started + title: Getting started + description: Learn how to get started + --- + + Some content here. + `; + + const { frontmatter, content } = await processMarkdown(input); + + assert.strictEqual(frontmatter.title, 'Getting started'); + assert.strictEqual(frontmatter.description, 'Learn how to get started'); + assert.strictEqual(frontmatter.id, 'getting-started'); + assert.ok(!content.includes('---')); + assert.ok(content.includes('Some content here.')); + }); +}); diff --git a/src/components/CopyMarkdownButton/index.tsx b/src/components/CopyMarkdownButton/index.tsx index 6475d9a76a..a38662d3a4 100644 --- a/src/components/CopyMarkdownButton/index.tsx +++ b/src/components/CopyMarkdownButton/index.tsx @@ -1,4 +1,3 @@ -import { useDoc } from '@docusaurus/plugin-content-docs/client'; import { useEffect, useRef, useState } from 'react'; import styles from './styles.module.css'; @@ -10,22 +9,36 @@ const ACTIONS = [ type ActionType = (typeof ACTIONS)[number]; -export function CopyButton() { - const { frontMatter } = useDoc(); - - const markdown = - 'rawMarkdown' in frontMatter && typeof frontMatter.rawMarkdown === 'string' - ? frontMatter.rawMarkdown - : null; +function getMdPath() { + return `${window.location.pathname.replace(/\/$/, '')}.md`; +} +export function CopyButton() { const [isOpen, setIsOpen] = useState(false); const [isVisible, setIsVisible] = useState(false); const [copied, setCopied] = useState(false); + const markdownRef = useRef | undefined>(undefined); + const containerRef = useRef(null); const dropdownRef = useRef(null); const buttonRef = useRef(null); + const loadMarkdown = () => { + const promise = fetch(getMdPath()) + .then((res) => (res.ok ? res.text() : null)) + .catch(() => null); + + markdownRef.current = promise; + + return promise; + }; + + // Preload the .md file on mount so it's ready when the user clicks copy + useEffect(() => { + loadMarkdown(); + }, []); + useEffect(() => { if (!isOpen) { return; @@ -58,27 +71,29 @@ export function CopyButton() { }; const onAction = (action: ActionType) => { - const prompt = `Read from ${window.location.href} so I can ask questions about it.`; - switch (action.value) { case 'copy': - if (markdown) { - navigator.clipboard.writeText(markdown).then(() => { - setCopied(true); - setTimeout(() => setCopied(false), 2000); + (markdownRef.current ?? loadMarkdown()) + .then((markdown) => markdown ?? loadMarkdown()) + .then((markdown) => { + if (markdown) { + navigator.clipboard.writeText(markdown).then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }); + } }); - } break; case 'chatgpt': - case 'claude': - window.open( - `${action.href}?q=${encodeURIComponent(prompt)}`, + case 'claude': { + const mdUrl = `${window.location.origin}${getMdPath()}`; + const prompt = `Read from ${mdUrl} so I can ask questions about it.`; - '_blank' - ); + window.open(`${action.href}?q=${encodeURIComponent(prompt)}`, '_blank'); break; + } } onClose(); @@ -127,10 +142,6 @@ export function CopyButton() { } }; - if (!markdown) { - return null; - } - return (