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.
|
projectName: "goose", // Usually your repo name.
|
||||||
|
|
||||||
onBrokenLinks: "throw",
|
onBrokenLinks: "throw",
|
||||||
onBrokenMarkdownLinks: "warn",
|
|
||||||
|
markdown: {
|
||||||
|
hooks: {
|
||||||
|
onBrokenMarkdownLinks: "warn",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
// Even if you don't use internationalization, you can use this field to set
|
// 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
|
// useful metadata like html lang. For example, if your site is Chinese, you
|
||||||
@@ -315,6 +320,12 @@ const config: Config = {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
tailwindPlugin,
|
tailwindPlugin,
|
||||||
|
[
|
||||||
|
require.resolve("./plugins/markdown-export.cjs"),
|
||||||
|
{
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
],
|
],
|
||||||
themes: ["@inkeep/docusaurus/chatButton", "@inkeep/docusaurus/searchBar"],
|
themes: ["@inkeep/docusaurus/chatButton", "@inkeep/docusaurus/searchBar"],
|
||||||
themeConfig: {
|
themeConfig: {
|
||||||
|
|||||||
@@ -13,7 +13,8 @@
|
|||||||
"write-translations": "docusaurus write-translations",
|
"write-translations": "docusaurus write-translations",
|
||||||
"write-heading-ids": "docusaurus write-heading-ids",
|
"write-heading-ids": "docusaurus write-heading-ids",
|
||||||
"typecheck": "tsc",
|
"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": {
|
"dependencies": {
|
||||||
"@docusaurus/core": "^3.9.2",
|
"@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 LayoutType from '@theme/DocItem/Layout';
|
||||||
import type {WrapperProps} from '@docusaurus/types';
|
import type {WrapperProps} from '@docusaurus/types';
|
||||||
import {useDoc} from '@docusaurus/plugin-content-docs/client';
|
import {useDoc} from '@docusaurus/plugin-content-docs/client';
|
||||||
@@ -14,7 +14,7 @@ import DocBreadcrumbs from '@theme/DocBreadcrumbs';
|
|||||||
import ContentVisibility from '@theme/ContentVisibility';
|
import ContentVisibility from '@theme/ContentVisibility';
|
||||||
import Heading from '@theme/Heading';
|
import Heading from '@theme/Heading';
|
||||||
import MDXContent from '@theme/MDXContent';
|
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 layoutStyles from './styles.module.css';
|
||||||
import TurndownService from 'turndown';
|
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
|
// Hook to determine if we should show the copy button
|
||||||
function useShouldShowCopyButton(): boolean {
|
function useShouldShowCopyButton(): boolean {
|
||||||
const {metadata} = useDoc();
|
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 {
|
function CustomDocItemContent({children}: {children: ReactNode}): ReactNode {
|
||||||
const shouldShowCopyButton = useShouldShowCopyButton();
|
const shouldShowCopyButton = useShouldShowCopyButton();
|
||||||
const {metadata, frontMatter, contentTitle} = useDoc();
|
const {metadata, frontMatter, contentTitle} = useDoc();
|
||||||
@@ -409,12 +495,12 @@ function CustomDocItemContent({children}: {children: ReactNode}): ReactNode {
|
|||||||
{syntheticTitle && (
|
{syntheticTitle && (
|
||||||
<header className="flex justify-between items-start mb-4 flex-col md:flex-row gap-2 md:gap-0">
|
<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>
|
<Heading as="h1" className="m-0 flex-1">{syntheticTitle}</Heading>
|
||||||
{shouldShowCopyButton && <CopyPageButton />}
|
{shouldShowCopyButton && <PageActionsMenu />}
|
||||||
</header>
|
</header>
|
||||||
)}
|
)}
|
||||||
{!syntheticTitle && shouldShowCopyButton && (
|
{!syntheticTitle && shouldShowCopyButton && (
|
||||||
<div className="flex justify-end mb-4">
|
<div className="flex justify-end mb-4">
|
||||||
<CopyPageButton />
|
<PageActionsMenu />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<MDXContent>{children}</MDXContent>
|
<MDXContent>{children}</MDXContent>
|
||||||
|
|||||||
Reference in New Issue
Block a user