Add copy-as-markdown button to doc pages (#8540)

Allow users to copy entire page content as markdown for pasting into LLMs.

Follows https://github.com/open-policy-agent/opa/pull/8535

Upgrades Docusaurus to 3.10.0 too, adds turndown for HTML-to-markdown
conversion.

Signed-off-by: Charlie Egan <charlie_egan@apple.com>
This commit is contained in:
Charlie Egan
2026-04-22 17:37:07 +01:00
committed by GitHub
parent 8f7a0d8495
commit 5e2142efc0
6 changed files with 907 additions and 732 deletions
+732 -722
View File
File diff suppressed because it is too large Load Diff
+9 -7
View File
@@ -10,23 +10,25 @@
"license": "ISC",
"description": "",
"dependencies": {
"@docusaurus/core": "^3.9.2",
"@docusaurus/plugin-content-docs": "^3.9.2",
"@docusaurus/plugin-google-gtag": "^3.9.2",
"@docusaurus/preset-classic": "^3.9.2",
"@docusaurus/theme-mermaid": "^3.9.2",
"@docusaurus/core": "^3.10.0",
"@docusaurus/plugin-content-docs": "^3.10.0",
"@docusaurus/plugin-google-gtag": "^3.10.0",
"@docusaurus/preset-classic": "^3.10.0",
"@docusaurus/theme-mermaid": "^3.10.0",
"@easyops-cn/docusaurus-search-local": "^0.49.2",
"@floating-ui/react": "^0.27.16",
"@iconify/react": "^6.0.0",
"@mermaid-js/layout-elk": "^0.1.9",
"eslint": "^9.39.2",
"glob": "^11.0.3",
"markdownlint-cli2": "^0.21.0",
"js-yaml": "^4.1.0",
"markdownlint-cli2": "^0.21.0",
"md-front-matter": "^1.0.4",
"raw-loader": "^4.0.2",
"react-markdown": "^10.1.0",
"recharts": "3.7.0"
"recharts": "3.7.0",
"turndown": "^7.2.4",
"turndown-plugin-gfm": "^1.0.2"
},
"engines": {
"node": ">=22.0.0"
@@ -0,0 +1,149 @@
import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import BrowserOnly from "@docusaurus/BrowserOnly";
import IconCopy from "@theme/Icon/Copy";
import IconSuccess from "@theme/Icon/Success";
import styles from "./styles.module.css";
export default function CopyPageMarkdown() {
return (
<BrowserOnly>
{() => <CopyButtonPortal />}
</BrowserOnly>
);
}
function CopyButtonPortal() {
const [container, setContainer] = useState(null);
useLayoutEffect(() => {
// Find the page heading and insert a container after it
const heading = document.querySelector(
"article .markdown > header, article .markdown > h1, article .markdown > h2",
);
if (!heading) return;
const el = document.createElement("div");
heading.insertAdjacentElement("afterend", el);
setContainer(el);
return () => el.remove();
}, []);
if (!container) return null;
return createPortal(<CopyButton />, container);
}
function CopyButton() {
const [copied, setCopied] = useState(false);
const copyTimeout = useRef(undefined);
useEffect(() => () => window.clearTimeout(copyTimeout.current), []);
const handleClick = useCallback(async () => {
const article = document.querySelector("article");
if (!article) return;
const markdownDiv = article.querySelector(".markdown");
if (!markdownDiv) return;
const clone = markdownDiv.cloneNode(true);
// Strip UI-only elements before conversion
const selectorsToRemove = [
"[data-copy-exclude]", // components that opt out of copy
".hash-link", // Docusaurus heading anchor icons
".buttonGroup", // Docusaurus code block copy/wrap buttons
"[hidden]", // hidden elements
];
for (const sel of selectorsToRemove) {
for (const el of clone.querySelectorAll(sel)) {
el.remove();
}
}
const TurndownService = (await import("turndown")).default;
const { gfm } = await import("turndown-plugin-gfm");
const turndown = new TurndownService({
headingStyle: "atx",
codeBlockStyle: "fenced",
});
turndown.use(gfm);
// Always render tables as GFM, compressing multi-line cell content
turndown.addRule("docusaurusTable", {
filter(node) {
return node.nodeName === "TABLE";
},
replacement(_content, node) {
const rows = node.rows;
if (!rows || rows.length === 0) return _content;
const cellText = (cell) =>
turndown.turndown(cell.innerHTML)
.replace(/\n/g, " ")
.replace(/\|/g, "\\|")
.trim();
const headerCells = Array.from(rows[0].cells).map(cellText);
const lines = [];
lines.push("| " + headerCells.join(" | ") + " |");
lines.push("| " + headerCells.map(() => "---").join(" | ") + " |");
for (let i = 1; i < rows.length; i++) {
const cells = Array.from(rows[i].cells).map(cellText);
lines.push("| " + cells.join(" | ") + " |");
}
return "\n\n" + lines.join("\n") + "\n\n";
},
});
let markdown = turndown.turndown(clone.innerHTML);
// Post-process: clean up markdown artifacts
markdown = markdown
// Strip deep heading markers (####+) that leak into table cells;
// also removes h4+ headings elsewhere, which is acceptable
.replace(/#{4,}\s*/g, "")
// Collapse runs of 3+ blank lines to 2
.replace(/\n{3,}/g, "\n\n")
.trim();
try {
await navigator.clipboard.writeText(markdown);
} catch {
// Fallback for 'insecure' contexts (e.g. localhost over HTTP)
const textarea = document.createElement("textarea");
textarea.value = markdown;
textarea.style.position = "fixed";
textarea.style.opacity = "0";
document.body.appendChild(textarea);
textarea.select();
document.execCommand("copy");
document.body.removeChild(textarea);
}
setCopied(true);
window.clearTimeout(copyTimeout.current);
copyTimeout.current = window.setTimeout(() => setCopied(false), 2000);
}, []);
const label = copied ? "Copied!" : "Copy Content for Chatbot or LLM";
return (
<div className={styles.wrapper} data-copy-exclude>
<button
className={`button button--secondary button--sm ${copied ? "button--success" : ""}`}
onClick={handleClick}
title={label}
aria-label={label}
>
<span className={styles.icons} aria-hidden="true">
{copied ? <IconSuccess /> : <IconCopy />}
</span>
{label}
</button>
</div>
);
}
@@ -0,0 +1,7 @@
.wrapper {
margin: 0.75rem 0 1rem;
}
.icons {
display: flex;
}
+7 -2
View File
@@ -56,7 +56,7 @@ export default function RunSnippet({ id, files, depends, command, playgroundLink
// json file
return (
<BrowserOnly>
{() => <codapi-snippet editor="basic" id={id}></codapi-snippet>}
{() => <codapi-snippet editor="basic" id={id} data-copy-exclude></codapi-snippet>}
</BrowserOnly>
);
}
@@ -78,6 +78,7 @@ export default function RunSnippet({ id, files, depends, command, playgroundLink
depends-on={depends}
init-delay={500} // we need this for codapi-toolbar to work
className={isLoading ? styles.dn : styles.codeApiSnippet}
data-copy-exclude
>
<codapi-toolbar>
<button>Evaluate</button>
@@ -91,13 +92,17 @@ export default function RunSnippet({ id, files, depends, command, playgroundLink
)}
</BrowserOnly>
</div>
{/* Output is hidden visually but included in the DOM for copy-as-markdown */}
{showInitialOutput && (
<div>
<div className={styles.dn}>Output</div>
<pre>{output}</pre>
</div>
)}
{/* must be at the end or it'll become the codapi policy */}
<div className={isLoading ? styles.codeApiSnippetLoadingPlaceholder : styles.dn}>Loading...</div>
<div className={isLoading ? styles.codeApiSnippetLoadingPlaceholder : styles.dn} data-copy-exclude>
Loading...
</div>
</>
);
}
+3 -1
View File
@@ -3,6 +3,7 @@ import React from "react";
import { useDoc } from "@docusaurus/plugin-content-docs/client";
import Content from "@theme-original/DocItem/Content";
import CopyPageMarkdown from "@site/src/components/CopyPageMarkdown";
import FeedbackForm from "@site/src/components/FeedbackForm";
export default function ContentWrapper(props) {
@@ -11,8 +12,9 @@ export default function ContentWrapper(props) {
return (
<>
<Content {...props} />
<CopyPageMarkdown />
{showFeedbackForm && (
<div className="feedback-form-wrapper">
<div className="feedback-form-wrapper" data-copy-exclude>
<FeedbackForm enablePopup={true} />
</div>
)}