Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,15 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [2.3.0] - Unreleased

### Changed
- Exposed `MarkdownActionsDropdown` as a first-class Docusaurus theme component at `theme/MarkdownActionsDropdown`, so consumers can swizzle it directly with `npm run swizzle docusaurus-markdown-source-plugin MarkdownActionsDropdown -- --eject`.
- Updated `Root` to render `@theme/MarkdownActionsDropdown`, allowing swizzled dropdown overrides to be picked up automatically while keeping the existing injection behavior internal.

### Fixed
- Made `theme/MarkdownActionsDropdown` self-contained so swizzled copies no longer fail to build by trying to resolve plugin-local imports like `../../lib/markdown-path` inside the consumer site's `src/theme/` directory.

## [2.2.5] - 2026-05-03

### Fixed
Expand Down
18 changes: 16 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ article .markdown header h1 {

/* Add hover effect for dropdown items */
.dropdown__link:hover {
background-color: var(--ifm-hover-overlay);
background-color: rgba(0, 0, 0, 0.05);
}

/* Responsive adjustments for mobile */
Expand Down Expand Up @@ -296,7 +296,9 @@ location ~* ^/docs/.*/img/.* {
| `X-Robots-Tag: noindex, nofollow` | Prevents search engines from indexing (avoids duplicate content SEO issues) while allowing AI assistants to access |
| `Cache-Control` | Balances performance (caching) with content freshness |

## CSS Customization
## Customization

### CSS Customization

You can customize the dropdown appearance by overriding these CSS classes in your `custom.css`:

Expand All @@ -317,6 +319,18 @@ You can customize the dropdown appearance by overriding these CSS classes in you
}
```

### Component Customization with Swizzling

For deeper changes such as button labels, icons, menu items, analytics, or custom copy/open behavior, swizzle the dropdown component itself:

```bash
npm run swizzle docusaurus-markdown-source-plugin MarkdownActionsDropdown -- --eject
```

This creates a local theme override at `src/theme/MarkdownActionsDropdown/index.js` in your Docusaurus site. The plugin's internal `Root` component will keep handling page injection, but it renders `@theme/MarkdownActionsDropdown`, so your swizzled component is used automatically.

If you only need to wrap the default dropdown, import the original component from `@theme-original/MarkdownActionsDropdown` inside the swizzled file and compose around it.

## Troubleshooting

### Dropdown Not Appearing
Expand Down
142 changes: 1 addition & 141 deletions components/MarkdownActionsDropdown/index.js
Original file line number Diff line number Diff line change
@@ -1,141 +1 @@
import React, { useState, useRef, useEffect } from 'react';
import { getMarkdownUrl } from '../../lib/markdown-path';

export default function MarkdownActionsDropdown() {
const [copied, setCopied] = useState(false);
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef(null);
const copyResetTimerRef = useRef(null);

// Clear any pending copy-reset timer on unmount so it cannot fire setState
// on a torn-down component or flip UI state after the user has navigated.
useEffect(() => () => {
if (copyResetTimerRef.current) {
clearTimeout(copyResetTimerRef.current);
copyResetTimerRef.current = null;
}
}, []);

// Get pathname from window.location for URL construction
const currentPath = typeof window !== 'undefined' ? window.location.pathname : '';

// Handle click outside to close dropdown
useEffect(() => {
// Only add listener if dropdown is open
if (!isOpen) return;

const handleClickOutside = (event) => {
// If the click is outside the dropdown, close it
if (dropdownRef.current && !dropdownRef.current.contains(event.target)) {
setIsOpen(false);
}
};

// Add event listener to document
// Use mousedown instead of click for better UX (fires before click)
document.addEventListener('mousedown', handleClickOutside);

// Cleanup function to remove event listener
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isOpen]);

const markdownUrl = getMarkdownUrl(currentPath);

// Handle opening markdown in new tab
const handleOpenMarkdown = () => {
window.open(markdownUrl, '_blank');
setIsOpen(false);
};

// Handle copying markdown to clipboard
const handleCopyMarkdown = async () => {
// Cancel any in-flight reset timer up front so a stale timer can't flip
// state during a slow fetch or after a rapid second click.
if (copyResetTimerRef.current) {
clearTimeout(copyResetTimerRef.current);
copyResetTimerRef.current = null;
}

try {
const response = await fetch(markdownUrl);
if (!response.ok) {
throw new Error('Failed to fetch markdown');
}
const markdown = await response.text();
await navigator.clipboard.writeText(markdown);

setCopied(true);
copyResetTimerRef.current = setTimeout(() => {
setCopied(false);
copyResetTimerRef.current = null;
}, 2000);
} catch (error) {
console.error('Failed to copy markdown:', error);
alert('Failed to copy markdown. Please try again.');
}
};

return (
<div
ref={dropdownRef}
className={`dropdown ${isOpen ? 'dropdown--show' : ''}`}
>
<button
className="button button--outline button--secondary button--sm"
onClick={() => setIsOpen(!isOpen)}
aria-haspopup="true"
aria-expanded={isOpen}
style={{display: 'inline-flex', alignItems: 'center'}}
>
Open Markdown
<svg width="14" height="14" viewBox="0 0 16 16" style={{marginLeft: '6px'}}>
<path fill="currentColor" d="M4.427 6.427l3.396 3.396a.25.25 0 00.354 0l3.396-3.396a.25.25 0 00-.177-.427H4.604a.25.25 0 00-.177.427z"/>
</svg>
</button>

<ul className="dropdown__menu">
<li>
<button
className="dropdown__link"
onClick={handleOpenMarkdown}
style={{cursor: 'pointer', border: 'none', background: 'none', width: '100%', textAlign: 'left'}}
>
<svg width="16" height="16" viewBox="0 0 16 16" style={{marginRight: '8px', verticalAlign: 'middle'}}>
<path fill="currentColor" d="M8 0a8 8 0 110 16A8 8 0 018 0zM1.5 8a6.5 6.5 0 1013 0 6.5 6.5 0 00-13 0z"/>
<path fill="currentColor" d="M8 3.5a.5.5 0 01.5.5v4a.5.5 0 01-1 0V4a.5.5 0 01.5-.5z"/>
<path fill="currentColor" d="M7.5 11.5a.5.5 0 111 0 .5.5 0 01-1 0z"/>
</svg>
View as Markdown
</button>
</li>
<li>
<button
className="dropdown__link"
onClick={handleCopyMarkdown}
disabled={copied}
style={{cursor: 'pointer', border: 'none', background: 'none', width: '100%', textAlign: 'left'}}
>
{copied ? (
<>
<svg width="16" height="16" viewBox="0 0 16 16" style={{marginRight: '8px', verticalAlign: 'middle'}}>
<path fill="currentColor" d="M13.78 4.22a.75.75 0 010 1.06l-7.25 7.25a.75.75 0 01-1.06 0L2.22 9.28a.75.75 0 011.06-1.06L6 10.94l6.72-6.72a.75.75 0 011.06 0z"/>
</svg>
Copied!
</>
) : (
<>
<svg width="16" height="16" viewBox="0 0 16 16" style={{marginRight: '8px', verticalAlign: 'middle'}}>
<path fill="currentColor" d="M0 6.75C0 5.784.784 5 1.75 5h1.5a.75.75 0 010 1.5h-1.5a.25.25 0 00-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 00.25-.25v-1.5a.75.75 0 011.5 0v1.5A1.75 1.75 0 019.25 16h-7.5A1.75 1.75 0 010 14.25v-7.5z"/>
<path fill="currentColor" d="M5 1.75C5 .784 5.784 0 6.75 0h7.5C15.216 0 16 .784 16 1.75v7.5A1.75 1.75 0 0114.25 11h-7.5A1.75 1.75 0 015 9.25v-7.5zm1.75-.25a.25.25 0 00-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 00.25-.25v-7.5a.25.25 0 00-.25-.25h-7.5z"/>
</svg>
Copy Page as Markdown
</>
)}
</button>
</li>
</ul>
</div>
);
}
export { default } from '../../theme/MarkdownActionsDropdown';
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "docusaurus-markdown-source-plugin",
"version": "2.2.5",
"version": "2.3.0",
"description": "A lightweight Docusaurus plugin that exposes your markdown files as raw .md URLs, perfect for LLMs and documentation tools",
"main": "index.js",
"scripts": {
Expand Down
147 changes: 147 additions & 0 deletions theme/MarkdownActionsDropdown/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import React, { useState, useRef, useEffect } from 'react';

function getMarkdownUrl(routePath) {
return routePath.endsWith('/')
? routePath + 'index.md'
: routePath + '.md';
}

export default function MarkdownActionsDropdown() {
const [copied, setCopied] = useState(false);
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef(null);
const copyResetTimerRef = useRef(null);

// Clear any pending copy-reset timer on unmount so it cannot fire setState
// on a torn-down component or flip UI state after the user has navigated.
useEffect(() => () => {
if (copyResetTimerRef.current) {
clearTimeout(copyResetTimerRef.current);
copyResetTimerRef.current = null;
}
}, []);

// Get pathname from window.location for URL construction
const currentPath = typeof window !== 'undefined' ? window.location.pathname : '';

// Handle click outside to close dropdown
useEffect(() => {
// Only add listener if dropdown is open
if (!isOpen) return;

const handleClickOutside = (event) => {
// If the click is outside the dropdown, close it
if (dropdownRef.current && !dropdownRef.current.contains(event.target)) {
setIsOpen(false);
}
};

// Add event listener to document
// Use mousedown instead of click for better UX (fires before click)
document.addEventListener('mousedown', handleClickOutside);

// Cleanup function to remove event listener
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isOpen]);

const markdownUrl = getMarkdownUrl(currentPath);

// Handle opening markdown in new tab
const handleOpenMarkdown = () => {
window.open(markdownUrl, '_blank');
setIsOpen(false);
};

// Handle copying markdown to clipboard
const handleCopyMarkdown = async () => {
// Cancel any in-flight reset timer up front so a stale timer can't flip
// state during a slow fetch or after a rapid second click.
if (copyResetTimerRef.current) {
clearTimeout(copyResetTimerRef.current);
copyResetTimerRef.current = null;
}

try {
const response = await fetch(markdownUrl);
if (!response.ok) {
throw new Error('Failed to fetch markdown');
}
const markdown = await response.text();
await navigator.clipboard.writeText(markdown);

setCopied(true);
copyResetTimerRef.current = setTimeout(() => {
setCopied(false);
copyResetTimerRef.current = null;
}, 2000);
} catch (error) {
console.error('Failed to copy markdown:', error);
alert('Failed to copy markdown. Please try again.');
}
};

return (
<div
ref={dropdownRef}
className={`dropdown ${isOpen ? 'dropdown--show' : ''}`}
>
<button
className="button button--outline button--secondary button--sm"
onClick={() => setIsOpen(!isOpen)}
aria-haspopup="true"
aria-expanded={isOpen}
style={{display: 'inline-flex', alignItems: 'center'}}
>
Open Markdown
<svg width="14" height="14" viewBox="0 0 16 16" style={{marginLeft: '6px'}}>
<path fill="currentColor" d="M4.427 6.427l3.396 3.396a.25.25 0 00.354 0l3.396-3.396a.25.25 0 00-.177-.427H4.604a.25.25 0 00-.177.427z"/>
</svg>
</button>

<ul className="dropdown__menu">
<li>
<button
className="dropdown__link"
onClick={handleOpenMarkdown}
style={{cursor: 'pointer', border: 'none', background: 'none', width: '100%', textAlign: 'left'}}
>
<svg width="16" height="16" viewBox="0 0 16 16" style={{marginRight: '8px', verticalAlign: 'middle'}}>
<path fill="currentColor" d="M8 0a8 8 0 110 16A8 8 0 018 0zM1.5 8a6.5 6.5 0 1013 0 6.5 6.5 0 00-13 0z"/>
<path fill="currentColor" d="M8 3.5a.5.5 0 01.5.5v4a.5.5 0 01-1 0V4a.5.5 0 01.5-.5z"/>
<path fill="currentColor" d="M7.5 11.5a.5.5 0 111 0 .5.5 0 01-1 0z"/>
</svg>
View as Markdown
</button>
</li>
<li>
<button
className="dropdown__link"
onClick={handleCopyMarkdown}
disabled={copied}
style={{cursor: 'pointer', border: 'none', background: 'none', width: '100%', textAlign: 'left'}}
>
{copied ? (
<>
<svg width="16" height="16" viewBox="0 0 16 16" style={{marginRight: '8px', verticalAlign: 'middle'}}>
<path fill="currentColor" d="M13.78 4.22a.75.75 0 010 1.06l-7.25 7.25a.75.75 0 01-1.06 0L2.22 9.28a.75.75 0 011.06-1.06L6 10.94l6.72-6.72a.75.75 0 011.06 0z"/>
</svg>
Copied!
</>
) : (
<>
<svg width="16" height="16" viewBox="0 0 16 16" style={{marginRight: '8px', verticalAlign: 'middle'}}>
<path fill="currentColor" d="M0 6.75C0 5.784.784 5 1.75 5h1.5a.75.75 0 010 1.5h-1.5a.25.25 0 00-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 00.25-.25v-1.5a.75.75 0 011.5 0v1.5A1.75 1.75 0 019.25 16h-7.5A1.75 1.75 0 010 14.25v-7.5z"/>
<path fill="currentColor" d="M5 1.75C5 .784 5.784 0 6.75 0h7.5C15.216 0 16 .784 16 1.75v7.5A1.75 1.75 0 0114.25 11h-7.5A1.75 1.75 0 015 9.25v-7.5zm1.75-.25a.25.25 0 00-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 00.25-.25v-7.5a.25.25 0 00-.25-.25h-7.5z"/>
</svg>
Copy Page as Markdown
</>
)}
</button>
</li>
</ul>
</div>
);
}

2 changes: 1 addition & 1 deletion theme/Root.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import React, { useEffect, useRef } from 'react';
import { useLocation } from '@docusaurus/router';
import { createRoot } from 'react-dom/client';
import { usePluginData } from '@docusaurus/useGlobalData';
import MarkdownActionsDropdown from '../components/MarkdownActionsDropdown';
import MarkdownActionsDropdown from '@theme/MarkdownActionsDropdown';
import { decodeHashSafely } from '../lib/decode-hash';

export default function Root({ children }) {
Expand Down