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 (