mirror of
https://github.com/open-policy-agent/opa.git
synced 2026-08-12 19:32:48 -06:00
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:
Generated
+732
-722
File diff suppressed because it is too large
Load Diff
+9
-7
@@ -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;
|
||||
}
|
||||
@@ -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,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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user