Skip to content

Commit 1263449

Browse files
[docs] Add copy page action (#1351)
Co-authored-by: SolitaryThinker <wlsaidhi@gmail.com>
1 parent b4de5a9 commit 1263449

3 files changed

Lines changed: 226 additions & 0 deletions

File tree

docs/assets/copy-page.js

Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
// Add a one-click copy button for the rendered MkDocs article.
2+
(function () {
3+
const BUTTON_ID = "copy-page-button";
4+
5+
function text(value) {
6+
return (value || "").replace(/\s+/g, " ").trim();
7+
}
8+
9+
function codeLanguage(code) {
10+
const classes = Array.from(code.classList || []);
11+
const language = classes.find((name) => name.startsWith("language-"));
12+
return language ? language.replace("language-", "") : "";
13+
}
14+
15+
function serializeInline(node) {
16+
if (node.nodeType === Node.TEXT_NODE) {
17+
return node.textContent || "";
18+
}
19+
20+
if (node.nodeType !== Node.ELEMENT_NODE) {
21+
return "";
22+
}
23+
24+
const tagName = node.tagName.toLowerCase();
25+
26+
if (tagName === "code" && node.parentElement && node.parentElement.tagName.toLowerCase() !== "pre") {
27+
return "`" + (node.textContent || "").trim() + "`";
28+
}
29+
30+
if (tagName === "a") {
31+
if (node.classList.contains("headerlink")) {
32+
return "";
33+
}
34+
35+
const label = text(Array.from(node.childNodes).map(serializeInline).join(""));
36+
const href = node.href;
37+
return href && label ? `${label} (${href})` : label;
38+
}
39+
40+
if (tagName === "img") {
41+
const alt = node.getAttribute("alt") || "image";
42+
const src = node.src || "";
43+
return src ? `[${alt}](${src})` : `[${alt}]`;
44+
}
45+
46+
if (tagName === "br") {
47+
return "\n";
48+
}
49+
50+
return Array.from(node.childNodes).map(serializeInline).join("");
51+
}
52+
53+
function serializeTable(table) {
54+
const rows = Array.from(table.rows);
55+
if (rows.length === 0) return "";
56+
57+
const mdRows = rows.map(
58+
(row) => "| " + Array.from(row.children).map((cell) => text(serializeInline(cell))).join(" | ") + " |"
59+
);
60+
const separator = "| " + Array.from(rows[0].children)
61+
.map(() => "---")
62+
.join(" | ") + " |";
63+
mdRows.splice(1, 0, separator);
64+
return mdRows.join("\n");
65+
}
66+
67+
function serializeBlock(node, listDepth = 0) {
68+
if (node.nodeType === Node.TEXT_NODE) {
69+
return text(node.textContent);
70+
}
71+
72+
if (node.nodeType !== Node.ELEMENT_NODE) {
73+
return "";
74+
}
75+
76+
const tagName = node.tagName.toLowerCase();
77+
78+
if (["script", "style", "nav", "button"].includes(tagName) || node.id === BUTTON_ID) {
79+
return "";
80+
}
81+
82+
if (/^h[1-6]$/.test(tagName)) {
83+
const level = Number(tagName.slice(1));
84+
return `${"#".repeat(level)} ${text(serializeInline(node))}`;
85+
}
86+
87+
if (tagName === "pre") {
88+
const code = node.querySelector("code");
89+
const content = code ? code.textContent || "" : node.textContent || "";
90+
return `\`\`\`${code ? codeLanguage(code) : ""}\n${content.replace(/\n$/, "")}\n\`\`\``;
91+
}
92+
93+
if (["p", "figcaption"].includes(tagName)) {
94+
return text(serializeInline(node));
95+
}
96+
97+
if (tagName === "blockquote") {
98+
return serializeChildren(node, listDepth)
99+
.split("\n")
100+
.map((line) => (line ? `> ${line}` : ">"))
101+
.join("\n");
102+
}
103+
104+
if (tagName === "ul" || tagName === "ol") {
105+
return Array.from(node.children)
106+
.filter((child) => child.tagName && child.tagName.toLowerCase() === "li")
107+
.map((item, index) => serializeListItem(item, tagName === "ol", index, listDepth))
108+
.join("\n");
109+
}
110+
111+
if (tagName === "table") {
112+
return serializeTable(node);
113+
}
114+
115+
if (["hr"].includes(tagName)) {
116+
return "---";
117+
}
118+
119+
return serializeChildren(node, listDepth);
120+
}
121+
122+
function serializeListItem(item, ordered, index, listDepth) {
123+
const marker = ordered ? `${index + 1}. ` : "- ";
124+
const indent = " ".repeat(listDepth);
125+
const childBlocks = [];
126+
const inlineParts = [];
127+
128+
Array.from(item.childNodes).forEach((child) => {
129+
if (child.nodeType === Node.ELEMENT_NODE && ["ul", "ol"].includes(child.tagName.toLowerCase())) {
130+
childBlocks.push(serializeBlock(child, listDepth + 1));
131+
} else {
132+
const content = serializeInline(child);
133+
if (content) inlineParts.push(content);
134+
}
135+
});
136+
137+
const firstLine = `${indent}${marker}${text(inlineParts.join(" "))}`.trimEnd();
138+
return [firstLine, ...childBlocks.filter(Boolean)].join("\n");
139+
}
140+
141+
function serializeChildren(node, listDepth = 0) {
142+
return Array.from(node.childNodes)
143+
.map((child) => serializeBlock(child, listDepth))
144+
.map((value) => value.trim())
145+
.filter(Boolean)
146+
.join("\n\n");
147+
}
148+
149+
function articleText(article) {
150+
const clone = article.cloneNode(true);
151+
clone.querySelectorAll("script, style, .headerlink, .md-clipboard, #copy-page-button").forEach((node) => node.remove());
152+
153+
const content = serializeChildren(clone).trim();
154+
const title = document.querySelector("h1") || document.querySelector("title");
155+
const pageTitle = title ? text(title.textContent) : "";
156+
157+
if (pageTitle && !content.startsWith("# ")) {
158+
return `# ${pageTitle}\n\n${content}`.trim();
159+
}
160+
161+
return content;
162+
}
163+
164+
async function copyArticle(button, article) {
165+
const originalLabel = button.textContent;
166+
try {
167+
await navigator.clipboard.writeText(articleText(article));
168+
button.textContent = "Copied!";
169+
button.classList.add("copy-page-button--copied");
170+
} catch (error) {
171+
button.textContent = "Copy failed";
172+
button.classList.add("copy-page-button--error");
173+
console.error("Failed to copy page", error);
174+
}
175+
176+
window.setTimeout(() => {
177+
button.textContent = originalLabel;
178+
button.classList.remove("copy-page-button--copied", "copy-page-button--error");
179+
}, 2000);
180+
}
181+
182+
function addCopyButton() {
183+
const article = document.querySelector("article.md-content__inner");
184+
if (!article || document.getElementById(BUTTON_ID)) {
185+
return;
186+
}
187+
188+
const button = document.createElement("button");
189+
button.id = BUTTON_ID;
190+
button.type = "button";
191+
button.className = "copy-page-button md-button md-button--primary";
192+
button.textContent = "Copy page";
193+
button.setAttribute("aria-label", "Copy this page as plain text");
194+
button.addEventListener("click", () => copyArticle(button, article));
195+
196+
article.insertBefore(button, article.firstChild);
197+
}
198+
199+
if (typeof document$ !== "undefined") {
200+
document$.subscribe(addCopyButton);
201+
}
202+
203+
document.addEventListener("DOMContentLoaded", addCopyButton);
204+
window.addEventListener("load", addCopyButton);
205+
})();

docs/assets/custom.css

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,3 +41,20 @@ img {
4141
display: block;
4242
margin: 0 auto;
4343
}
44+
45+
.md-typeset .copy-page-button.md-button {
46+
float: right;
47+
margin: 0 0 1rem 1rem;
48+
padding: 0.2em 0.6em;
49+
font-size: 1em;
50+
font-weight: 400;
51+
line-height: 1.2;
52+
}
53+
54+
.md-typeset .copy-page-button.md-button.copy-page-button--copied {
55+
background-color: var(--md-accent-fg-color);
56+
}
57+
58+
.md-typeset .copy-page-button.md-button.copy-page-button--error {
59+
background-color: var(--md-code-hl-number-color);
60+
}

mkdocs.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,3 +189,7 @@ extra:
189189
# Custom CSS
190190
extra_css:
191191
- assets/custom.css
192+
193+
# Custom JavaScript
194+
extra_javascript:
195+
- assets/copy-page.js

0 commit comments

Comments
 (0)