|
| 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 | +})(); |
0 commit comments