Skip to content

Commit 082b8e3

Browse files
authored
docs: add a copy to markdown button to docusaurus theme (#12189)
* copy-page-component * copy-theme-and-add-button
1 parent 2d32e52 commit 082b8e3

3 files changed

Lines changed: 261 additions & 0 deletions

File tree

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
import React, { useCallback, useState } from "react";
2+
import { Copy as CopyIcon, Check as CheckIcon } from "lucide-react";
3+
4+
function nodeToInlineMarkdown(node: Node): string {
5+
if (node.nodeType === Node.TEXT_NODE) {
6+
return node.textContent ?? "";
7+
}
8+
9+
if (node.nodeType !== Node.ELEMENT_NODE) return "";
10+
const el = node as HTMLElement;
11+
const tag = el.tagName.toLowerCase();
12+
13+
const childText = Array.from(el.childNodes)
14+
.map(nodeToInlineMarkdown)
15+
.join("");
16+
17+
switch (tag) {
18+
case "strong":
19+
case "b":
20+
return `**${childText}**`;
21+
case "em":
22+
case "i":
23+
return `*${childText}*`;
24+
case "code":
25+
return `\`${(el.textContent ?? "").replace(/`/g, "\\`")}\``;
26+
case "a": {
27+
const href = el.getAttribute("href") ?? "";
28+
const text = childText || href;
29+
return href ? `[${text}](${href})` : text;
30+
}
31+
default:
32+
return childText;
33+
}
34+
}
35+
36+
function nodeToBlockMarkdown(node: Node): string {
37+
if (node.nodeType === Node.TEXT_NODE) {
38+
return node.textContent ?? "";
39+
}
40+
41+
if (node.nodeType !== Node.ELEMENT_NODE) return "";
42+
const el = node as HTMLElement;
43+
const tag = el.tagName.toLowerCase();
44+
45+
// Skip non-content elements
46+
if (tag === "style" || tag === "script") {
47+
return "";
48+
}
49+
50+
const inlineChildren = (): string =>
51+
Array.from(el.childNodes)
52+
.map(nodeToInlineMarkdown)
53+
.join("");
54+
55+
const blockChildren = (): string =>
56+
Array.from(el.childNodes)
57+
.map(nodeToBlockMarkdown)
58+
.join("");
59+
60+
switch (tag) {
61+
case "h1":
62+
return `# ${inlineChildren()}\n\n`;
63+
case "h2":
64+
return `## ${inlineChildren()}\n\n`;
65+
case "h3":
66+
return `### ${inlineChildren()}\n\n`;
67+
case "h4":
68+
return `#### ${inlineChildren()}\n\n`;
69+
case "h5":
70+
return `##### ${inlineChildren()}\n\n`;
71+
case "h6":
72+
return `###### ${inlineChildren()}\n\n`;
73+
case "p":
74+
return `${inlineChildren()}\n\n`;
75+
case "pre": {
76+
const codeEl = el.querySelector("code");
77+
const codeText = codeEl?.textContent ?? el.textContent ?? "";
78+
const classAttr = codeEl?.getAttribute("class") ?? "";
79+
const langMatch = classAttr.match(/language-([a-z0-9]+)/i);
80+
const lang = langMatch?.[1] ?? "";
81+
const trimmed = codeText.replace(/\s+$/g, "");
82+
return `\`\`\`${lang}\n${trimmed}\n\`\`\`\n\n`;
83+
}
84+
case "ul": {
85+
const items = Array.from(el.children)
86+
.filter((child) => child.tagName.toLowerCase() === "li")
87+
.map((li) => `- ${nodeToInlineMarkdown(li)}`);
88+
return items.join("\n") + (items.length ? "\n\n" : "");
89+
}
90+
case "ol": {
91+
const items = Array.from(el.children)
92+
.filter((child) => child.tagName.toLowerCase() === "li")
93+
.map((li, idx) => `${idx + 1}. ${nodeToInlineMarkdown(li)}`);
94+
return items.join("\n") + (items.length ? "\n\n" : "");
95+
}
96+
case "blockquote": {
97+
const text = blockChildren()
98+
.split("\n")
99+
.map((line) => (line ? `> ${line}` : ">"))
100+
.join("\n");
101+
return `${text}\n\n`;
102+
}
103+
case "table": {
104+
// Minimal table handling: fall back to plain text
105+
return `${el.innerText}\n\n`;
106+
}
107+
case "button":
108+
// Skip interactive UI buttons like the copy control
109+
return "";
110+
case "hr":
111+
return `---\n\n`;
112+
default:
113+
return blockChildren();
114+
}
115+
}
116+
117+
async function copyCurrentPageAsMarkdown(): Promise<boolean> {
118+
if (typeof document === "undefined" || typeof window === "undefined") return false;
119+
120+
const container =
121+
(document.querySelector(".theme-doc-markdown") as HTMLElement | null) ??
122+
(document.querySelector("article") as HTMLElement | null) ??
123+
(document.querySelector("main") as HTMLElement | null);
124+
125+
if (!container) return false;
126+
127+
const parts = Array.from(container.childNodes).map(nodeToBlockMarkdown);
128+
const markdown = parts.join("").replace(/\n{3,}/g, "\n\n").trim() + "\n";
129+
130+
if (!navigator.clipboard?.writeText) {
131+
return false;
132+
}
133+
134+
await navigator.clipboard.writeText(markdown);
135+
return true;
136+
}
137+
138+
export function CopyPageButton(): JSX.Element | null {
139+
const [copied, setCopied] = useState(false);
140+
141+
const handleClick = useCallback(async () => {
142+
const ok = await copyCurrentPageAsMarkdown();
143+
if (ok) {
144+
setCopied(true);
145+
window.setTimeout(() => setCopied(false), 1500);
146+
}
147+
}, []);
148+
149+
return (
150+
<div
151+
style={{
152+
display: "flex",
153+
justifyContent: "flex-start",
154+
margin: "0.5rem 0 1.25rem 0",
155+
}}
156+
>
157+
<button
158+
type="button"
159+
onClick={handleClick}
160+
style={{
161+
display: "inline-flex",
162+
alignItems: "center",
163+
gap: "0.3rem",
164+
cursor: "pointer",
165+
borderRadius: "999px",
166+
padding: "0.22rem 0.7rem",
167+
border: "1px solid var(--ifm-color-secondary-dark)",
168+
backgroundColor: "var(--ifm-background-surface-color)",
169+
color: "var(--ifm-font-color-base)",
170+
fontSize: "0.7rem",
171+
}}
172+
>
173+
<span aria-hidden="true" style={{ display: "inline-flex", alignItems: "center" }}>
174+
{copied ? (
175+
<CheckIcon size={11} strokeWidth={2} style={{ marginRight: "0.22rem" }} />
176+
) : (
177+
<CopyIcon size={11} strokeWidth={2} style={{ marginRight: "0.22rem" }} />
178+
)}
179+
</span>
180+
<span style={{ fontSize: "0.75rem", fontWeight: 500 }}>
181+
{copied ? "Copied!" : "Copy page"}
182+
</span>
183+
</button>
184+
</div>
185+
);
186+
}
187+
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import React from "react";
2+
import clsx from "clsx";
3+
import { useWindowSize } from "@docusaurus/theme-common";
4+
import { useDoc } from "@docusaurus/plugin-content-docs/client";
5+
import DocItemPaginator from "@theme/DocItem/Paginator";
6+
import DocVersionBanner from "@theme/DocVersionBanner";
7+
import DocVersionBadge from "@theme/DocVersionBadge";
8+
import DocItemFooter from "@theme/DocItem/Footer";
9+
import DocItemTOCMobile from "@theme/DocItem/TOC/Mobile";
10+
import DocItemTOCDesktop from "@theme/DocItem/TOC/Desktop";
11+
import DocItemContent from "@theme/DocItem/Content";
12+
import DocBreadcrumbs from "@theme/DocBreadcrumbs";
13+
import ContentVisibility from "@theme/ContentVisibility";
14+
import { CopyPageButton } from "@site/src/components/CopyPageButton";
15+
import styles from "./styles.module.css";
16+
17+
// Copied from default theme and extended to insert a "Copy page" button
18+
// between the breadcrumbs and the rest of the doc content.
19+
function useDocTOC() {
20+
const { frontMatter, toc } = useDoc();
21+
const windowSize = useWindowSize();
22+
const hidden = frontMatter.hide_table_of_contents;
23+
const canRender = !hidden && toc.length > 0;
24+
const mobile = canRender ? <DocItemTOCMobile /> : undefined;
25+
const desktop =
26+
canRender && (windowSize === "desktop" || windowSize === "ssr") ? (
27+
<DocItemTOCDesktop />
28+
) : undefined;
29+
return {
30+
hidden,
31+
mobile,
32+
desktop,
33+
};
34+
}
35+
36+
export default function DocItemLayout({ children }) {
37+
const docTOC = useDocTOC();
38+
const { metadata } = useDoc();
39+
return (
40+
<div className="row">
41+
<div className={clsx("col", !docTOC.hidden && styles.docItemCol)}>
42+
<ContentVisibility metadata={metadata} />
43+
<DocVersionBanner />
44+
<div className={styles.docItemContainer}>
45+
<article>
46+
<DocBreadcrumbs />
47+
<CopyPageButton />
48+
<DocVersionBadge />
49+
{docTOC.mobile}
50+
<DocItemContent>{children}</DocItemContent>
51+
<DocItemFooter />
52+
</article>
53+
<DocItemPaginator />
54+
</div>
55+
</div>
56+
{docTOC.desktop && <div className="col col--3">{docTOC.desktop}</div>}
57+
</div>
58+
);
59+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
/**
2+
* Local override of the default DocItem layout styles.
3+
* Copied from @docusaurus/theme-classic with minimal adjustments.
4+
*/
5+
6+
.docItemContainer header + *,
7+
.docItemContainer article > *:first-child {
8+
margin-top: 0;
9+
}
10+
11+
@media (min-width: 997px) {
12+
.docItemCol {
13+
max-width: 75% !important;
14+
}
15+
}

0 commit comments

Comments
 (0)