Skip to content

Commit 6033790

Browse files
committed
feat: add copy page as Markdown button
Added a 'Copy page' button next to the page title that converts the current page content to Markdown format and copies it to the clipboard. This helps developers easily copy documentation content for use in LLMs, notes, or other tools. Implementation details: - New CopyPageButton.astro component using Turndown for HTML-to-Markdown - Custom rules for code blocks, tables, and cleanup of UI elements - Integrated into PageTitle.astro override - Added i18n translations for en, es, zh, and ja locales - Responsive: hides label text on small screens, shows icon only
1 parent 4eb85aa commit 6033790

File tree

9 files changed

+284
-3
lines changed

9 files changed

+284
-3
lines changed

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,8 @@
6969
"react-syntax-highlighter": "^16.1.0",
7070
"starlight-image-zoom": "^0.13.2",
7171
"starlight-links-validator": "^0.19.2",
72-
"tailwindcss": "^4.2.1"
72+
"tailwindcss": "^4.2.1",
73+
"turndown": "^7.2.2"
7374
},
7475
"devDependencies": {
7576
"@astrojs/check": "^0.9.6",
@@ -89,6 +90,7 @@
8990
"@types/react": "^19.2.14",
9091
"@types/react-dom": "^19.2.3",
9192
"@types/react-syntax-highlighter": "^15.5.13",
93+
"@types/turndown": "^5.0.6",
9294
"@vercel/node": "^5.6.7",
9395
"astro-embed": "^0.12.0",
9496
"astro-favicons": "^3.1.5",

pnpm-lock.yaml

Lines changed: 23 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
---
2+
export interface Props {
3+
copyLabel?: string;
4+
copiedLabel?: string;
5+
}
6+
7+
const { copyLabel = "Copy page", copiedLabel = "Copied!" } = Astro.props;
8+
---
9+
10+
<copy-page-button>
11+
<button class="copy-page-btn" type="button" aria-label={copyLabel}>
12+
<svg
13+
class="copy-icon"
14+
xmlns="http://www.w3.org/2000/svg"
15+
width="16"
16+
height="16"
17+
viewBox="0 0 24 24"
18+
fill="none"
19+
stroke="currentColor"
20+
stroke-width="2"
21+
stroke-linecap="round"
22+
stroke-linejoin="round"
23+
>
24+
<rect width="14" height="14" x="8" y="8" rx="2" ry="2"></rect>
25+
<path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"></path>
26+
</svg>
27+
<svg
28+
class="check-icon"
29+
xmlns="http://www.w3.org/2000/svg"
30+
width="16"
31+
height="16"
32+
viewBox="0 0 24 24"
33+
fill="none"
34+
stroke="currentColor"
35+
stroke-width="2"
36+
stroke-linecap="round"
37+
stroke-linejoin="round"
38+
>
39+
<path d="M20 6 9 17l-5-5"></path>
40+
</svg>
41+
<span class="copy-label">{copyLabel}</span>
42+
<span class="copied-label">{copiedLabel}</span>
43+
</button>
44+
</copy-page-button>
45+
46+
<script>
47+
import TurndownService from "turndown";
48+
49+
class CopyPageButton extends HTMLElement {
50+
private button: HTMLButtonElement | null;
51+
private turndownService: TurndownService | null = null;
52+
53+
constructor() {
54+
super();
55+
this.button = this.querySelector("button");
56+
this.button?.addEventListener("click", () => this.handleCopy());
57+
}
58+
59+
private getTurndown(): TurndownService {
60+
if (!this.turndownService) {
61+
this.turndownService = new TurndownService({
62+
headingStyle: "atx",
63+
codeBlockStyle: "fenced",
64+
bulletListMarker: "-",
65+
});
66+
67+
this.turndownService.addRule("codeBlock", {
68+
filter: (node) => {
69+
return node.nodeName === "PRE" && node.querySelector("code") !== null;
70+
},
71+
replacement: (_content, node) => {
72+
const codeEl = (node as HTMLElement).querySelector("code");
73+
if (!codeEl) return _content;
74+
const lang =
75+
codeEl.className
76+
.split(" ")
77+
.find((c) => c.startsWith("language-"))
78+
?.replace("language-", "") || "";
79+
const code = codeEl.textContent || "";
80+
return `\n\`\`\`${lang}\n${code.replace(/\n$/, "")}\n\`\`\`\n`;
81+
},
82+
});
83+
84+
this.turndownService.addRule("skipCopyButtons", {
85+
filter: (node) => {
86+
const el = node as HTMLElement;
87+
return (
88+
el.classList?.contains("copy") ||
89+
(el.classList?.contains("expressive-code") === false &&
90+
el.querySelector?.(".copy") !== null)
91+
);
92+
},
93+
replacement: () => "",
94+
});
95+
96+
this.turndownService.addRule("tables", {
97+
filter: "table",
98+
replacement: (_content, node) => {
99+
const table = node as HTMLTableElement;
100+
const rows = Array.from(table.querySelectorAll("tr"));
101+
if (rows.length === 0) return _content;
102+
103+
const result: string[] = [];
104+
const headerRow = rows[0];
105+
const headerCells = Array.from(headerRow?.querySelectorAll("th, td") ?? []);
106+
if (headerCells.length === 0) return _content;
107+
108+
result.push(`| ${headerCells.map((c) => (c.textContent || "").trim()).join(" | ")} |`);
109+
result.push(`| ${headerCells.map(() => "---").join(" | ")} |`);
110+
111+
for (let i = 1; i < rows.length; i++) {
112+
const cells = Array.from(rows[i]?.querySelectorAll("td, th") ?? []);
113+
result.push(`| ${cells.map((c) => (c.textContent || "").trim()).join(" | ")} |`);
114+
}
115+
116+
return `\n${result.join("\n")}\n`;
117+
},
118+
});
119+
120+
this.turndownService.remove(["script", "style", "nav", "button"]);
121+
}
122+
return this.turndownService;
123+
}
124+
125+
private async handleCopy() {
126+
const content = document.querySelector(".sl-markdown-content");
127+
if (!content || !this.button) return;
128+
129+
try {
130+
const title = document.querySelector("#_top")?.textContent?.trim() || "";
131+
const clone = content.cloneNode(true) as HTMLElement;
132+
133+
clone
134+
.querySelectorAll("button, .copy, .header-link, script, style, .not-content")
135+
.forEach((el) => el.remove());
136+
137+
const turndown = this.getTurndown();
138+
let markdown = turndown.turndown(clone.innerHTML);
139+
140+
if (title) {
141+
markdown = `# ${title}\n\n${markdown}`;
142+
}
143+
144+
markdown = markdown.replace(/\n{3,}/g, "\n\n").trim();
145+
146+
await navigator.clipboard.writeText(markdown);
147+
this.showCopied();
148+
} catch (err) {
149+
console.error("Failed to copy page as markdown:", err);
150+
}
151+
}
152+
153+
private showCopied() {
154+
this.button?.classList.add("copied");
155+
setTimeout(() => {
156+
this.button?.classList.remove("copied");
157+
}, 2000);
158+
}
159+
}
160+
161+
customElements.define("copy-page-button", CopyPageButton);
162+
</script>
163+
164+
<style>
165+
copy-page-button {
166+
display: inline-flex;
167+
}
168+
169+
.copy-page-btn {
170+
display: inline-flex;
171+
align-items: center;
172+
gap: 0.375rem;
173+
padding: 0.25rem 0.625rem;
174+
font-size: var(--sl-text-xs);
175+
font-weight: 500;
176+
color: var(--sl-color-gray-3);
177+
background: transparent;
178+
border: 1px solid var(--sl-color-gray-5);
179+
border-radius: 1rem;
180+
cursor: pointer;
181+
transition: all 0.2s ease;
182+
white-space: nowrap;
183+
line-height: 1.5;
184+
}
185+
186+
.copy-page-btn:hover {
187+
color: var(--sl-color-white);
188+
border-color: var(--sl-color-gray-3);
189+
background: var(--sl-color-gray-6);
190+
}
191+
192+
.copy-page-btn .check-icon,
193+
.copy-page-btn .copied-label {
194+
display: none;
195+
}
196+
197+
.copy-page-btn.copied .copy-icon,
198+
.copy-page-btn.copied .copy-label {
199+
display: none;
200+
}
201+
202+
.copy-page-btn.copied .check-icon,
203+
.copy-page-btn.copied .copied-label {
204+
display: inline;
205+
}
206+
207+
.copy-page-btn.copied {
208+
color: var(--sl-color-green-high);
209+
border-color: var(--sl-color-green-high);
210+
}
211+
</style>

src/content/i18n-schema.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,9 @@ export const AstroDocsI18nSchema = z
7272
// Upgrade guide
7373
"upgrade.implementationPR": z.string(),
7474

75+
// Copy page button
76+
"copyPage.copy": z.string(),
77+
"copyPage.copied": z.string(),
7578
// DocSearch component strings
7679
// These two keys are Astro Docs-specific and apply to the search box in the header.
7780
"docsearch.button": z.string(),

src/content/i18n/en.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@
66
"pageSidebar.addToChatGPT": "Add to ChatGPT",
77
"pageSidebar.aiHelp": "Get AI help with this page",
88

9+
"copyPage.copy": "Copy page",
10+
"copyPage.copied": "Copied!",
11+
912
"docsearch.searchBox.resetButtonTitle": "Clear the query",
1013
"docsearch.searchBox.resetButtonAriaLabel": "Clear the query",
1114
"docsearch.searchBox.cancelButtonText": "Cancel",

src/content/i18n/es.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@
66
"pageSidebar.addToChatGPT": "Agregar a ChatGPT",
77
"pageSidebar.aiHelp": "Obtener ayuda de IA con esta página",
88

9+
"copyPage.copy": "Copiar página",
10+
"copyPage.copied": "¡Copiado!",
11+
912
"docsearch.searchBox.resetButtonTitle": "Limpiar la consulta",
1013
"docsearch.searchBox.resetButtonAriaLabel": "Limpiar la consulta",
1114
"docsearch.searchBox.cancelButtonText": "Cancelar",

src/content/i18n/ja.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22
"pageSidebar.feedback": "フィードバック",
33
"pageSidebar.feedbackLink": "問題を報告",
44

5+
"copyPage.copy": "ページをコピー",
6+
"copyPage.copied": "コピーしました!",
7+
58
"docsearch.searchBox.resetButtonTitle": "検索をクリア",
69
"docsearch.searchBox.resetButtonAriaLabel": "検索をクリア",
710
"docsearch.searchBox.cancelButtonText": "キャンセル",

src/content/i18n/zh.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@
66
"pageSidebar.addToChatGPT": "添加到 ChatGPT",
77
"pageSidebar.aiHelp": "获取此页面的 AI 帮助",
88

9+
"copyPage.copy": "复制页面",
10+
"copyPage.copied": "已复制!",
11+
912
"docsearch.searchBox.resetButtonTitle": "清除查询",
1013
"docsearch.searchBox.resetButtonAriaLabel": "清除查询",
1114
"docsearch.searchBox.cancelButtonText": "取消",

0 commit comments

Comments
 (0)