markdown export from URL (#5830)

* Add Markdown export of any doc page by appending `.md` to the URL
* New "View as Markdown" dropdown menu from the "copy page" button
This commit is contained in:
Emma Youndtsmith
2025-11-21 15:37:41 -06:00
committed by GitHub
parent d54a06afda
commit 043e4a741e
5 changed files with 217 additions and 7 deletions
+12 -1
View File
@@ -29,7 +29,12 @@ const config: Config = {
projectName: "goose", // Usually your repo name.
onBrokenLinks: "throw",
onBrokenMarkdownLinks: "warn",
markdown: {
hooks: {
onBrokenMarkdownLinks: "warn",
},
},
// Even if you don't use internationalization, you can use this field to set
// useful metadata like html lang. For example, if your site is Chinese, you
@@ -315,6 +320,12 @@ const config: Config = {
},
],
tailwindPlugin,
[
require.resolve("./plugins/markdown-export.cjs"),
{
enabled: true,
},
],
],
themes: ["@inkeep/docusaurus/chatButton", "@inkeep/docusaurus/searchBar"],
themeConfig: {
+2 -1
View File
@@ -13,7 +13,8 @@
"write-translations": "docusaurus write-translations",
"write-heading-ids": "docusaurus write-heading-ids",
"typecheck": "tsc",
"generate-detail-pages": "node scripts/generate-detail-pages.js"
"generate-detail-pages": "node scripts/generate-detail-pages.js",
"serve-static": "node scripts/serve-static.js"
},
"dependencies": {
"@docusaurus/core": "^3.9.2",
+61
View File
@@ -0,0 +1,61 @@
const fs = require('fs');
const path = require('path');
const globby = require('globby');
module.exports = function markdownExportPlugin(context, options) {
const pluginOptions = {
enabled: true,
...options,
};
return {
name: 'markdown-export',
async postBuild({ outDir }) {
if (!pluginOptions.enabled) {
return;
}
console.log('[markdown-export] Starting markdown export...');
const docsDir = path.join(context.siteDir, 'docs');
const outputDir = path.join(outDir, 'docs');
// Get all markdown files
const files = await globby('**/*.{md,mdx}', { cwd: docsDir });
for (const file of files) {
const inputPath = path.join(docsDir, file);
const outputPath = path.join(outputDir, file.replace('.mdx', '.md'));
// Ensure output subdirectory exists
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
const content = fs.readFileSync(inputPath, 'utf-8');
// Strip frontmatter and clean up
const cleaned = stripFrontmatter(content);
// Write the cleaned markdown alongside HTML files
fs.writeFileSync(outputPath, cleaned);
}
console.log(`[markdown-export] Successfully exported ${files.length} markdown files to ${outputDir}`);
},
};
};
function stripFrontmatter(content) {
// Remove YAML frontmatter (everything between --- at the start)
const withoutFrontmatter = content.replace(/^---\s*\n[\s\S]*?\n---\s*\n/, '');
// Clean up any remaining import statements (for .mdx files)
const withoutImports = withoutFrontmatter.replace(/^import .+$/gm, '');
// Remove excessive empty lines and trim
return withoutImports
.replace(/\n{3,}/g, '\n\n')
.trim();
}
+51
View File
@@ -0,0 +1,51 @@
#!/usr/bin/env node
/**
* Simple static file server for testing markdown exports locally.
* Unlike `docusaurus serve`, this serves files as-is without routing logic.
*/
const http = require('http');
const serveStatic = require('serve-static');
const path = require('path');
const buildDir = path.join(__dirname, '..', 'build');
const port = process.env.PORT || 3001;
const serve = serveStatic(buildDir, {
index: ['index.html'],
setHeaders: (res, filePath) => {
// Set proper content type for markdown files
if (filePath.endsWith('.md')) {
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
}
}
});
const server = http.createServer((req, res) => {
// Handle requests to /goose/ by serving from the build directory
if (req.url.startsWith('/goose/')) {
// Strip /goose/ prefix and serve the file
req.url = req.url.substring(6); // Remove '/goose'
serve(req, res, () => {
res.statusCode = 404;
res.end('Not found');
});
} else if (req.url === '/') {
// Redirect root to /goose/
res.writeHead(302, { Location: '/goose/' });
res.end();
} else {
// For any other path, return 404
res.statusCode = 404;
res.end('Not found - try /goose/');
}
});
server.listen(port, () => {
console.log(`\n🚀 Static file server running at http://localhost:${port}`);
console.log(`\n🏠 Homepage: http://localhost:${port}/goose/`);
console.log(`\n📝 Test markdown exports:`);
console.log(` http://localhost:${port}/goose/docs/quickstart.md`);
console.log(` http://localhost:${port}/goose/docs/getting-started/installation.md\n`);
});
@@ -1,4 +1,4 @@
import React, {type ReactNode, useState, useEffect} from 'react';
import React, {type ReactNode, useState, useEffect, useRef} from 'react';
import type LayoutType from '@theme/DocItem/Layout';
import type {WrapperProps} from '@docusaurus/types';
import {useDoc} from '@docusaurus/plugin-content-docs/client';
@@ -14,7 +14,7 @@ import DocBreadcrumbs from '@theme/DocBreadcrumbs';
import ContentVisibility from '@theme/ContentVisibility';
import Heading from '@theme/Heading';
import MDXContent from '@theme/MDXContent';
import {Copy, Check} from 'lucide-react';
import {Copy, Check, ChevronDown, FileText, ExternalLink, Eye, Code, FileCode} from 'lucide-react';
import layoutStyles from './styles.module.css';
import TurndownService from 'turndown';
@@ -357,6 +357,92 @@ function CopyPageButton(): ReactNode {
);
}
// New wrapper component that adds dropdown menu to copy button
function PageActionsMenu(): ReactNode {
const [dropdownOpen, setDropdownOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setDropdownOpen(false);
}
};
if (dropdownOpen) {
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}
}, [dropdownOpen]);
// Handle keyboard navigation (Escape to close)
useEffect(() => {
const handleEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
setDropdownOpen(false);
}
};
if (dropdownOpen) {
document.addEventListener('keydown', handleEscape);
return () => document.removeEventListener('keydown', handleEscape);
}
}, [dropdownOpen]);
const handleViewMarkdown = () => {
const currentPath = window.location.pathname;
const mdPath = currentPath.endsWith('/')
? `${currentPath.slice(0, -1)}.md`
: `${currentPath}.md`;
window.open(mdPath, '_blank');
setDropdownOpen(false);
};
return (
<div className="relative inline-flex" ref={dropdownRef}>
{/* Button group container - unified appearance */}
<div className="flex items-center bg-black dark:bg-white rounded-md">
{/* Original Copy Page Button - keep its original styling but remove right border radius */}
<div className="[&>button]:rounded-r-none">
<CopyPageButton />
</div>
{/* Divider */}
<div className="w-px h-4 bg-gray-700 dark:bg-gray-300"></div>
{/* Chevron Dropdown Trigger - attached to copy button */}
<button
onClick={() => setDropdownOpen(!dropdownOpen)}
className="px-2 py-1.5 bg-black dark:bg-white text-white dark:text-black rounded-l-none rounded-r-md text-sm font-medium transition-all duration-200 ease-in-out hover:opacity-90 focus:outline-none focus:ring-2 focus:ring-black dark:focus:ring-white focus:ring-offset-2 flex items-center justify-center"
aria-label="More page actions"
aria-expanded={dropdownOpen}
aria-haspopup="true"
>
<ChevronDown size={16} className={`transition-transform duration-200 ${dropdownOpen ? 'rotate-180' : ''}`} />
</button>
</div>
{/* Dropdown Menu */}
{dropdownOpen && (
<div className="absolute right-0 top-full mt-1 w-56 bg-black dark:bg-white rounded-md shadow-lg border border-gray-700 dark:border-gray-300 z-50">
<button
onClick={handleViewMarkdown}
className="w-full flex items-center justify-between gap-1.5 px-3 py-1.5 text-sm text-white dark:text-black hover:opacity-90 hover:-translate-y-px active:translate-y-px transition-all duration-200 ease-in-out font-medium bg-transparent rounded-md"
>
<div className="flex items-center gap-1.5">
<FileCode size={16} className="flex-shrink-0" />
<span>View as Markdown</span>
</div>
<ExternalLink size={16} className="flex-shrink-0" />
</button>
{/* Future menu items can be added here */}
</div>
)}
</div>
);
}
// Hook to determine if we should show the copy button
function useShouldShowCopyButton(): boolean {
const {metadata} = useDoc();
@@ -395,7 +481,7 @@ function useDocTOC() {
};
}
// Custom Content component that includes the copy button
// Custom Content component that includes the page actions menu
function CustomDocItemContent({children}: {children: ReactNode}): ReactNode {
const shouldShowCopyButton = useShouldShowCopyButton();
const {metadata, frontMatter, contentTitle} = useDoc();
@@ -409,12 +495,12 @@ function CustomDocItemContent({children}: {children: ReactNode}): ReactNode {
{syntheticTitle && (
<header className="flex justify-between items-start mb-4 flex-col md:flex-row gap-2 md:gap-0">
<Heading as="h1" className="m-0 flex-1">{syntheticTitle}</Heading>
{shouldShowCopyButton && <CopyPageButton />}
{shouldShowCopyButton && <PageActionsMenu />}
</header>
)}
{!syntheticTitle && shouldShowCopyButton && (
<div className="flex justify-end mb-4">
<CopyPageButton />
<PageActionsMenu />
</div>
)}
<MDXContent>{children}</MDXContent>