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:
@@ -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: {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user