docusaurus-plugin-copy-page-button
Version:
A Docusaurus plugin that provides a copy page button for extracting content as markdown for LLMs
568 lines (516 loc) • 21.5 kB
JavaScript
import React, { useState, useEffect, useRef } from "react";
import { createPortal } from "react-dom";
import useIsBrowser from '@docusaurus/useIsBrowser';
import styles from "./styles.module.css";
// Static selectors for content cleanup
const SELECTORS_TO_REMOVE = [
".theme-edit-this-page",
".theme-last-updated",
".pagination-nav",
".theme-doc-breadcrumbs",
".theme-doc-footer",
"button",
".copy-code-button",
".buttonGroup",
".clean-btn",
".theme-code-block-title",
".line-number",
];
// Utility function to merge custom styles with default classes
const mergeStyles = (defaultClassName, customStyleConfig = {}) => {
const { className: customClassName, style: customStyle } = customStyleConfig;
const finalClassName = customClassName
? `${defaultClassName} ${customClassName}`
: defaultClassName;
return {
className: finalClassName,
style: customStyle || {}
};
};
// Utility function to separate positioning styles from other styles
const separatePositioningStyles = (styleObject = {}) => {
const positioningProps = ['position', 'top', 'right', 'bottom', 'left', 'zIndex', 'transform'];
const positioning = {};
const nonPositioning = {};
Object.entries(styleObject).forEach(([key, value]) => {
if (positioningProps.includes(key)) {
positioning[key] = value;
} else {
nonPositioning[key] = value;
}
});
return { positioning, nonPositioning };
};
export default function CopyPageButton({ customStyles = {} }) {
const [isOpen, setIsOpen] = useState(false);
const [pageContent, setPageContent] = useState("");
const [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0 });
const dropdownRef = useRef(null);
const buttonRef = useRef(null);
const isBrowser = useIsBrowser();
// Extract custom style configurations
const containerStyleConfig = customStyles.container || {};
const buttonStyleConfig = customStyles.button || {};
const dropdownStyleConfig = customStyles.dropdown || {};
const dropdownItemStyleConfig = customStyles.dropdownItem || {};
useEffect(() => {
const handleClickOutside = (event) => {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target) &&
buttonRef.current &&
!buttonRef.current.contains(event.target)
) {
setIsOpen(false);
}
};
if (isOpen) {
document.addEventListener("mousedown", handleClickOutside);
}
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, [isOpen]);
useEffect(() => {
if (isOpen && buttonRef.current) {
const rect = buttonRef.current.getBoundingClientRect();
setDropdownPosition({
top: rect.bottom + 8,
left: rect.right - 300, // Align dropdown right edge with button
});
}
}, [isOpen]);
useEffect(() => {
if (!isBrowser) return;
const extractPageContent = () => {
const mainContent =
document.querySelector("main article") ||
document.querySelector("main .markdown");
if (!mainContent) return;
const clone = mainContent.cloneNode(true);
// Remove unwanted elements
SELECTORS_TO_REMOVE.forEach((selector) => {
clone.querySelectorAll(selector).forEach((el) => el.remove());
});
// Extract title from first H1 and remove it from content
const firstH1 = clone.querySelector("h1");
const title = firstH1?.textContent.trim() || "Documentation Page";
if (firstH1) {
firstH1.remove();
}
const content = convertToMarkdown(clone);
const currentUrl = window.location.href;
setPageContent(`# ${title}\n\nURL: ${currentUrl}\n\n${content}`);
};
extractPageContent();
}, [isBrowser]);
const convertToMarkdown = (element) => {
const cleanText = (text) => {
return text
.replace(/[\u200B-\u200D\uFEFF]/g, "") // Remove zero-width spaces
.replace(/\u00A0/g, " ") // Replace non-breaking spaces
.replace(/[\u2018\u2019]/g, "'") // Smart quotes
.replace(/[\u201C\u201D]/g, '"')
.replace(/​/g, "") // Clean encoding issues
.replace(/\s+/g, " ") // Normalize whitespace
.trim();
};
const processNode = (node) => {
if (node.nodeType === Node.TEXT_NODE) {
return cleanText(node.textContent);
}
if (node.nodeType === Node.ELEMENT_NODE) {
const tag = node.tagName.toLowerCase();
const childResults = Array.from(node.childNodes).map((child) =>
processNode(child)
);
// Join child results with intelligent spacing
let children = "";
for (let i = 0; i < childResults.length; i++) {
const current = childResults[i];
const previous = i > 0 ? childResults[i - 1] : "";
if (current) {
if (
previous &&
!previous.match(/[\s\n]$/) &&
!current.match(/^[\s\n]/) &&
previous.trim() &&
current.trim()
) {
children += " ";
}
children += current;
}
}
switch (tag) {
case "h1":
return `\n# ${children.trim()}\n\n`;
case "h2":
return `\n## ${children.trim()}\n\n`;
case "h3":
return `\n### ${children.trim()}\n\n`;
case "h4":
return `\n#### ${children.trim()}\n\n`;
case "h5":
return `\n##### ${children.trim()}\n\n`;
case "h6":
return `\n###### ${children.trim()}\n\n`;
case "p":
return children.trim() ? `${children.trim()}\n\n` : "\n";
case "strong":
case "b":
return `**${children}**`;
case "em":
case "i":
return `*${children}*`;
case "code":
if (node.parentElement?.tagName.toLowerCase() === "pre") {
return children;
}
const cleanInlineCode = children
.replace(/[\u200B-\u200D\uFEFF]/g, "") // Remove zero-width spaces
.replace(/\u00A0/g, " ") // Replace non-breaking spaces
.trim();
return `\`${cleanInlineCode}\``;
case "pre":
const codeElement = node.querySelector("code");
if (codeElement) {
const language =
(codeElement.className?.match(/language-(\w+)/) ||
node.className?.match(/language-(\w+)/) ||
codeElement.className?.match(/hljs-(\w+)/) ||
codeElement.className?.match(/prism-(\w+)/) ||
[])[1] || "";
let codeContent = "";
try {
// Method 1: Try to get content from data attributes (some themes store original content)
const originalContent =
codeElement.getAttribute("data-code") ||
node.getAttribute("data-code") ||
codeElement.getAttribute("data-raw");
if (originalContent) {
codeContent = originalContent;
} else {
// Method 2: Look for individual code lines in specific containers
const codeLines = codeElement.querySelectorAll(
"span[data-line], .token-line, .code-line, .highlight-line"
);
if (codeLines.length > 0) {
codeContent = Array.from(codeLines)
.map((lineElement) => {
return lineElement?.textContent || "";
})
.join("\n");
} else {
// Method 3: Look for div-based line structure
const codeLineDivs = codeElement.querySelectorAll("div");
if (codeLineDivs.length > 0) {
codeContent = Array.from(codeLineDivs)
.map((lineDiv) => {
// Skip if this looks like a line number container
if (
lineDiv.className?.includes("codeLineNumber") ||
lineDiv.className?.includes("LineNumber") ||
lineDiv.className?.includes("line-number") ||
lineDiv.style?.userSelect === "none"
) {
return null;
}
return lineDiv?.textContent || "";
})
.filter((line) => line !== null)
.join("\n");
} else {
// Method 4: Direct text extraction with cleanup
let rawText = codeElement.textContent || "";
// Remove line numbers at the start of lines (common pattern: "1 ", "12 ", etc.)
rawText = rawText.replace(/^\d+\s+/gm, "");
// Remove copy button text and other UI elements
rawText = rawText.replace(/^Copy$/gm, "");
rawText = rawText.replace(/^Copied!$/gm, "");
rawText = rawText.replace(
/^\s*Copy to clipboard\s*$/gm,
""
);
codeContent = rawText;
}
}
}
// Final cleanup
codeContent = codeContent
.replace(/[\u200B-\u200D\uFEFF]/g, "") // Remove zero-width spaces
.replace(/\u00A0/g, " ") // Replace non-breaking spaces
.trim();
// Remove empty lines at start and end
codeContent = codeContent.replace(/^\n+|\n+$/g, "");
} catch (error) {
// Fallback to simple text extraction if anything fails
codeContent = codeElement.textContent || "";
}
return `\n\`\`\`${language}\n${codeContent}\n\`\`\`\n\n`;
}
return `\n\`\`\`\n${children}\n\`\`\`\n\n`;
case "ul":
return `\n${children}`;
case "ol":
const items = Array.from(node.querySelectorAll("li"));
return (
"\n" +
items
.map(
(item, index) =>
`${index + 1}. ${processNode(item)
.replace(/^- /, "")
.trim()}\n`
)
.join("")
);
case "li":
return `- ${children.trim()}\n`;
case "a":
const href = node.getAttribute("href");
if (href && !href.startsWith("#") && children.trim()) {
return `[${children.trim()}](${href})`;
}
return children;
case "br":
return "\n";
case "blockquote":
return `\n> ${children.trim()}\n\n`;
case "table":
return `\n${children}\n`;
case "tr":
return `${children}\n`;
case "th":
case "td":
return `| ${children.trim()} `;
case "img":
const src = node.getAttribute("src");
const alt = node.getAttribute("alt") || "";
return src ? `` : "";
case "div":
case "section":
case "article":
// Handle admonitions
if (node.classList?.contains("admonition")) {
const type =
Array.from(node.classList)
.find((cls) => cls.startsWith("alert--"))
?.replace("alert--", "") || "note";
return `\n> **${type.toUpperCase()}**: ${children.trim()}\n\n`;
}
return children + "\n";
default:
return children;
}
}
return "";
};
return processNode(element)
.replace(/\n{3,}/g, "\n\n") // Limit multiple newlines
.replace(/^\n+|\n+$/g, "") // Trim newlines
.trim();
};
const copyToClipboard = async (text) => {
try {
await navigator.clipboard.writeText(text);
} catch (err) {
// Silently fail - clipboard API may not be available
}
};
const openInAI = (baseUrl) => {
if (!isBrowser) return;
const currentUrl = window.location.href;
const prompt = encodeURIComponent(
`Please read and explain this documentation page: ${currentUrl}
Please provide a clear summary and help me understand the key concepts covered in this documentation.`
);
window.open(`${baseUrl}?q=${prompt}`, "_blank");
};
const viewAsMarkdown = () => {
if (!isBrowser) return;
const blob = new Blob([pageContent], { type: "text/plain" });
const url = URL.createObjectURL(blob);
window.open(url, "_blank");
};
const dropdownItems = [
{
id: "copy",
title: "Copy page",
description: "Copy the page as MarkDown for LLMs",
icon: (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
</svg>
),
action: () => copyToClipboard(pageContent),
},
{
id: "view",
title: "View as MarkDown",
description: "View this page as plain text",
icon: (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
<polyline points="14,2 14,8 20,8"></polyline>
<line x1="16" y1="13" x2="8" y2="13"></line>
<line x1="16" y1="17" x2="8" y2="17"></line>
</svg>
),
action: viewAsMarkdown,
},
{
id: "chatgpt",
title: "Open in ChatGPT",
description: "Ask questions about this page",
icon: (
<svg
width="16"
height="16"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
strokeWidth="1.5"
viewBox="-0.17090198558635983 0.482230148717937 41.14235318283891 40.0339509076386"
>
<path
d="M37.532 16.87a9.963 9.963 0 0 0-.856-8.184 10.078 10.078 0 0 0-10.855-4.835A9.964 9.964 0 0 0 18.306.5a10.079 10.079 0 0 0-9.614 6.977 9.967 9.967 0 0 0-6.664 4.834 10.08 10.08 0 0 0 1.24 11.817 9.965 9.965 0 0 0 .856 8.185 10.079 10.079 0 0 0 10.855 4.835 9.965 9.965 0 0 0 7.516 3.35 10.078 10.078 0 0 0 9.617-6.981 9.967 9.967 0 0 0 6.663-4.834 10.079 10.079 0 0 0-1.243-11.813zM22.498 37.886a7.474 7.474 0 0 1-4.799-1.735c.061-.033.168-.091.237-.134l7.964-4.6a1.294 1.294 0 0 0 .655-1.134V19.054l3.366 1.944a.12.12 0 0 1 .066.092v9.299a7.505 7.505 0 0 1-7.49 7.496zM6.392 31.006a7.471 7.471 0 0 1-.894-5.023c.06.036.162.099.237.141l7.964 4.6a1.297 1.297 0 0 0 1.308 0l9.724-5.614v3.888a.12.12 0 0 1-.048.103l-8.051 4.649a7.504 7.504 0 0 1-10.24-2.744zM4.297 13.62A7.469 7.469 0 0 1 8.2 10.333c0 .068-.004.19-.004.274v9.201a1.294 1.294 0 0 0 .654 1.132l9.723 5.614-3.366 1.944a.12.12 0 0 1-.114.01L7.04 23.856a7.504 7.504 0 0 1-2.743-10.237zm27.658 6.437l-9.724-5.615 3.367-1.943a.121.121 0 0 1 .113-.01l8.052 4.648a7.498 7.498 0 0 1-1.158 13.528v-9.476a1.293 1.293 0 0 0-.65-1.132zm3.35-5.043c-.059-.037-.162-.099-.236-.141l-7.965-4.6a1.298 1.298 0 0 0-1.308 0l-9.723 5.614v-3.888a.12.12 0 0 1 .048-.103l8.05-4.645a7.497 7.497 0 0 1 11.135 7.763zm-21.063 6.929l-3.367-1.944a.12.12 0 0 1-.065-.092v-9.299a7.497 7.497 0 0 1 12.293-5.756 6.94 6.94 0 0 0-.236.134l-7.965 4.6a1.294 1.294 0 0 0-.654 1.132l-.006 11.225zm1.829-3.943l4.33-2.501 4.332 2.5v5l-4.331 2.5-4.331-2.5V18z"
fill="currentColor"
/>
</svg>
),
action: () => openInAI("https://chat.openai.com/"),
},
{
id: "claude",
title: "Open in Claude",
description: "Ask questions about this page",
icon: (
<svg
width="16"
height="16"
fill="currentColor"
fillRule="evenodd"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
>
<path d="M4.709 15.955l4.72-2.647.08-.23-.08-.128H9.2l-.79-.048-2.698-.073-2.339-.097-2.266-.122-.571-.121L0 11.784l.055-.352.48-.321.686.06 1.52.103 2.278.158 1.652.097 2.449.255h.389l.055-.157-.134-.098-.103-.097-2.358-1.596-2.552-1.688-1.336-.972-.724-.491-.364-.462-.158-1.008.656-.722.881.06.225.061.893.686 1.908 1.476 2.491 1.833.365.304.145-.103.019-.073-.164-.274-1.355-2.446-1.446-2.49-.644-1.032-.17-.619a2.97 2.97 0 01-.104-.729L6.283.134 6.696 0l.996.134.42.364.62 1.414 1.002 2.229 1.555 3.03.456.898.243.832.091.255h.158V9.01l.128-1.706.237-2.095.23-2.695.08-.76.376-.91.747-.492.584.28.48.685-.067.444-.286 1.851-.559 2.903-.364 1.942h.212l.243-.242.985-1.306 1.652-2.064.73-.82.85-.904.547-.431h1.033l.76 1.129-.34 1.166-1.064 1.347-.881 1.142-1.264 1.7-.79 1.36.073.11.188-.02 2.856-.606 1.543-.28 1.841-.315.833.388.091.395-.328.807-1.969.486-2.309.462-3.439.813-.042.03.049.061 1.549.146.662.036h1.622l3.02.225.79.522.474.638-.079.485-1.215.62-1.64-.389-3.829-.91-1.312-.329h-.182v.11l1.093 1.068 2.006 1.81 2.509 2.33.127.578-.322.455-.34-.049-2.205-1.657-.851-.747-1.926-1.62h-.128v.17l.444.649 2.345 3.521.122 1.08-.17.353-.608.213-.668-.122-1.374-1.925-1.415-2.167-1.143-1.943-.14.08-.674 7.254-.316.37-.729.28-.607-.461-.322-.747.322-1.476.389-1.924.315-1.53.286-1.9.17-.632-.012-.042-.14.018-1.434 1.967-2.18 2.945-1.726 1.845-.414.164-.717-.37.067-.662.401-.589 2.388-3.036 1.44-1.882.93-1.086-.006-.158h-.055L4.132 18.56l-1.13.146-.487-.456.061-.746.231-.243 1.908-1.312-.006.006z" />
</svg>
),
action: () => openInAI("https://claude.ai/new"),
},
];
// Handle positioning styles - if button config has positioning, move it to container
const { positioning: buttonPositioning, nonPositioning: buttonNonPositioning } =
separatePositioningStyles(buttonStyleConfig.style);
// Create final style configs
const finalContainerConfig = {
...containerStyleConfig,
style: {
...containerStyleConfig.style,
...buttonPositioning, // Apply button positioning to container
}
};
const finalButtonConfig = {
...buttonStyleConfig,
style: buttonNonPositioning, // Apply only non-positioning styles to button
};
// Merge custom styles with default styles
const containerProps = mergeStyles(styles.copyPageContainer, finalContainerConfig);
const buttonProps = mergeStyles(styles.copyPageButton, finalButtonConfig);
const dropdownProps = mergeStyles(styles.copyPageDropdown, dropdownStyleConfig);
const dropdownItemProps = mergeStyles(styles.dropdownItem, dropdownItemStyleConfig);
return (
<>
<div
className={containerProps.className}
style={containerProps.style}
>
<button
className={buttonProps.className}
style={buttonProps.style}
onClick={() => setIsOpen(!isOpen)}
aria-expanded={isOpen}
aria-haspopup="true"
ref={buttonRef}
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
</svg>
<span className={styles.copyPageText}>Copy page</span>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
className={
isOpen ? `${styles.chevron} ${styles.open}` : styles.chevron
}
>
<polyline points="6,9 12,15 18,9"></polyline>
</svg>
</button>
</div>
{isOpen && isBrowser && (
createPortal(
<div
className={dropdownProps.className}
style={{
position: "fixed",
top: `${dropdownPosition.top}px`,
left: `${dropdownPosition.left}px`,
zIndex: 10000,
...dropdownProps.style
}}
ref={dropdownRef}
>
{dropdownItems.map((item) => (
<button
key={item.id}
className={dropdownItemProps.className}
style={dropdownItemProps.style}
onClick={() => {
item.action();
setIsOpen(false);
}}
>
{item.icon}
<div>
<div className={styles.itemTitle}>{item.title}</div>
<div className={styles.itemDescription}>
{item.description}
</div>
</div>
</button>
))}
</div>,
document.body
)
)}
</>
);
}