Skip to content

Commit 4e9ea8a

Browse files
Merge pull request #90 from AlexKlimenkov/copy-btn
[dev] add copy page/view as markdown button to docs pages
2 parents 2a9a183 + 27537a4 commit 4e9ea8a

6 files changed

Lines changed: 426 additions & 0 deletions

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@
44
# Production
55
/build
66

7+
# Generated by dhx-copy-page-plugin
8+
/static/llms-md
9+
710
# Generated files
811
.docusaurus
912
.cache-loader

docusaurus.config.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,7 @@ const config = {
226226
onAfterDataTransformation
227227
}
228228
],
229+
path.resolve(__dirname, './plugins/dhx-copy-page-plugin'),
229230
[
230231
require.resolve('docusaurus-gtm-plugin'),
231232
{
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
const fs = require('fs');
2+
const path = require('path');
3+
4+
const OUT_SUBDIR = path.join('static', 'llms-md');
5+
const FRONTMATTER_RE = /^---\r?\n[\s\S]*?\r?\n---\r?\n+/;
6+
7+
function stripFrontmatter(md) {
8+
return md.replace(FRONTMATTER_RE, '');
9+
}
10+
11+
function walkMarkdown(rootDir, acc = []) {
12+
for (const entry of fs.readdirSync(rootDir, { withFileTypes: true })) {
13+
const full = path.join(rootDir, entry.name);
14+
if (entry.isDirectory()) {
15+
walkMarkdown(full, acc);
16+
} else if (entry.isFile() && /\.mdx?$/.test(entry.name)) {
17+
acc.push(full);
18+
}
19+
}
20+
return acc;
21+
}
22+
23+
function localeSourceDir(siteDir, locale, defaultLocale) {
24+
return locale === defaultLocale
25+
? path.join(siteDir, 'docs')
26+
: path.join(siteDir, 'i18n', locale, 'docusaurus-plugin-content-docs', 'current');
27+
}
28+
29+
module.exports = function dhxCopyPagePlugin(context) {
30+
const { siteDir, siteConfig } = context;
31+
const { locales, defaultLocale } = siteConfig.i18n;
32+
33+
return {
34+
name: 'dhx-copy-page-plugin',
35+
36+
async loadContent() {
37+
const outRoot = path.join(siteDir, OUT_SUBDIR);
38+
if (fs.existsSync(outRoot)) {
39+
try {
40+
fs.rmSync(outRoot, {
41+
recursive: true,
42+
force: true,
43+
maxRetries: 5,
44+
retryDelay: 100,
45+
});
46+
} catch (err) {
47+
// On Windows, rmSync can race with file watchers / AV scanners and
48+
// throw ENOTEMPTY/EPERM. Falling through is fine — we overwrite
49+
// existing files below; only stale files would linger, and full
50+
// builds always start from a clean outDir anyway.
51+
console.warn(`[dhx-copy-page-plugin] could not clear ${outRoot}: ${err.code || err.message}`);
52+
}
53+
}
54+
fs.mkdirSync(outRoot, { recursive: true });
55+
56+
const defaultDir = localeSourceDir(siteDir, defaultLocale, defaultLocale);
57+
const defaultFiles = fs.existsSync(defaultDir) ? walkMarkdown(defaultDir) : [];
58+
59+
for (const locale of locales) {
60+
const localeDir = path.join(outRoot, locale);
61+
const sourceDir = localeSourceDir(siteDir, locale, defaultLocale);
62+
63+
// Seed every locale with the default-locale content so untranslated
64+
// pages still resolve. Docusaurus falls back to the default locale's
65+
// source when an i18n translation is missing — the .md mirror needs
66+
// to mirror that fallback or the button will 404 on those pages.
67+
for (const file of defaultFiles) {
68+
const rel = path.relative(defaultDir, file).replace(/\\/g, '/');
69+
const destPath = path.join(localeDir, rel.replace(/\.mdx?$/, '.md'));
70+
fs.mkdirSync(path.dirname(destPath), { recursive: true });
71+
fs.writeFileSync(destPath, stripFrontmatter(fs.readFileSync(file, 'utf8')));
72+
}
73+
74+
if (locale === defaultLocale || !fs.existsSync(sourceDir)) continue;
75+
76+
// Overlay locale-specific translations on top of the default seed.
77+
for (const file of walkMarkdown(sourceDir)) {
78+
const rel = path.relative(sourceDir, file).replace(/\\/g, '/');
79+
const destPath = path.join(localeDir, rel.replace(/\.mdx?$/, '.md'));
80+
fs.mkdirSync(path.dirname(destPath), { recursive: true });
81+
fs.writeFileSync(destPath, stripFrontmatter(fs.readFileSync(file, 'utf8')));
82+
}
83+
}
84+
},
85+
};
86+
};
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
import React, { useEffect, useRef, useState } from 'react';
2+
3+
import styles from './styles.module.scss';
4+
5+
const CHATGPT_URL = 'https://chatgpt.com/?prompt=';
6+
const CLAUDE_URL = 'https://claude.ai/new?q=';
7+
8+
const buildPrompt = (absoluteMdUrl, pageTitle) =>
9+
`Read ${absoluteMdUrl} and help me with questions about "${pageTitle}".`;
10+
11+
const toAbsolute = (mdUrl) => {
12+
if (typeof window === 'undefined') return mdUrl;
13+
return new URL(mdUrl, window.location.origin).toString();
14+
};
15+
16+
const CopyIcon = () => (
17+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
18+
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
19+
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
20+
</svg>
21+
);
22+
23+
const CheckIcon = () => (
24+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
25+
<polyline points="20 6 9 17 4 12" />
26+
</svg>
27+
);
28+
29+
const ChevronIcon = () => (
30+
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
31+
<polyline points="6 9 12 15 18 9" />
32+
</svg>
33+
);
34+
35+
const ExternalIcon = () => (
36+
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
37+
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
38+
<polyline points="15 3 21 3 21 9" />
39+
<line x1="10" y1="14" x2="21" y2="3" />
40+
</svg>
41+
);
42+
43+
export default function CopyPageButton({ mdUrl, pageTitle }) {
44+
const [copied, setCopied] = useState(false);
45+
const [open, setOpen] = useState(false);
46+
const wrapperRef = useRef(null);
47+
const copiedTimer = useRef(null);
48+
49+
useEffect(() => {
50+
if (!open) return undefined;
51+
const onMouseDown = (e) => {
52+
if (wrapperRef.current && !wrapperRef.current.contains(e.target)) {
53+
setOpen(false);
54+
}
55+
};
56+
const onKey = (e) => {
57+
if (e.key === 'Escape') setOpen(false);
58+
};
59+
document.addEventListener('mousedown', onMouseDown);
60+
document.addEventListener('keydown', onKey);
61+
return () => {
62+
document.removeEventListener('mousedown', onMouseDown);
63+
document.removeEventListener('keydown', onKey);
64+
};
65+
}, [open]);
66+
67+
useEffect(() => () => {
68+
if (copiedTimer.current) clearTimeout(copiedTimer.current);
69+
}, []);
70+
71+
const copyMarkdown = async () => {
72+
try {
73+
const res = await fetch(mdUrl);
74+
if (!res.ok) throw new Error(`Fetch failed: ${res.status}`);
75+
const text = await res.text();
76+
await navigator.clipboard.writeText(text);
77+
setCopied(true);
78+
if (copiedTimer.current) clearTimeout(copiedTimer.current);
79+
copiedTimer.current = setTimeout(() => setCopied(false), 2000);
80+
} catch (err) {
81+
console.error('CopyPageButton: failed to copy markdown', err);
82+
}
83+
};
84+
85+
const viewAsMarkdown = () => {
86+
setOpen(false);
87+
window.open(mdUrl, '_blank', 'noopener,noreferrer');
88+
};
89+
90+
const openInLLM = (baseUrl) => {
91+
setOpen(false);
92+
const prompt = buildPrompt(toAbsolute(mdUrl), pageTitle);
93+
window.open(baseUrl + encodeURIComponent(prompt), '_blank', 'noopener,noreferrer');
94+
};
95+
96+
return (
97+
<div className={styles.wrapper} ref={wrapperRef}>
98+
<button
99+
type="button"
100+
className={styles.mainButton}
101+
onClick={copyMarkdown}
102+
aria-label={copied ? 'Page markdown copied' : 'Copy page as markdown'}
103+
>
104+
<span className={styles.icon}>{copied ? <CheckIcon /> : <CopyIcon />}</span>
105+
<span className={styles.label}>{copied ? 'Copied!' : 'Copy page'}</span>
106+
</button>
107+
<button
108+
type="button"
109+
className={styles.chevronButton}
110+
onClick={() => setOpen((v) => !v)}
111+
aria-haspopup="menu"
112+
aria-expanded={open}
113+
aria-label="Open page actions menu"
114+
>
115+
<ChevronIcon />
116+
</button>
117+
{open && (
118+
<div className={styles.menu} role="menu">
119+
<button type="button" className={styles.menuItem} onClick={viewAsMarkdown} role="menuitem">
120+
<span className={styles.menuIcon}><ExternalIcon /></span>
121+
<span className={styles.menuText}>
122+
<span className={styles.menuTitle}>View as Markdown</span>
123+
<span className={styles.menuDesc}>Open the raw .md in a new tab</span>
124+
</span>
125+
</button>
126+
<button type="button" className={styles.menuItem} onClick={() => openInLLM(CHATGPT_URL)} role="menuitem">
127+
<span className={styles.menuIcon}><ExternalIcon /></span>
128+
<span className={styles.menuText}>
129+
<span className={styles.menuTitle}>Open in ChatGPT</span>
130+
<span className={styles.menuDesc}>Ask ChatGPT about this page</span>
131+
</span>
132+
</button>
133+
<button type="button" className={styles.menuItem} onClick={() => openInLLM(CLAUDE_URL)} role="menuitem">
134+
<span className={styles.menuIcon}><ExternalIcon /></span>
135+
<span className={styles.menuText}>
136+
<span className={styles.menuTitle}>Open in Claude</span>
137+
<span className={styles.menuDesc}>Ask Claude about this page</span>
138+
</span>
139+
</button>
140+
</div>
141+
)}
142+
</div>
143+
);
144+
}
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
.wrapper {
2+
display: inline-flex;
3+
position: relative;
4+
margin: 0 0 1.5rem;
5+
font-size: 0.875rem;
6+
line-height: 1;
7+
vertical-align: middle;
8+
}
9+
10+
.mainButton,
11+
.chevronButton {
12+
display: inline-flex;
13+
align-items: center;
14+
gap: 0.4rem;
15+
height: 2rem;
16+
padding: 0 0.75rem;
17+
background: var(--ifm-background-surface-color, var(--ifm-color-emphasis-100));
18+
color: var(--ifm-color-emphasis-900);
19+
border: 1px solid var(--ifm-color-emphasis-300);
20+
font: inherit;
21+
font-weight: 500;
22+
cursor: pointer;
23+
transition: background-color 0.15s ease, border-color 0.15s ease, color 0.15s ease;
24+
25+
&:hover {
26+
background: var(--ifm-color-emphasis-200);
27+
color: var(--ifm-color-emphasis-1000);
28+
}
29+
30+
&:focus-visible {
31+
outline: 2px solid var(--ifm-color-primary);
32+
outline-offset: 2px;
33+
z-index: 1;
34+
}
35+
}
36+
37+
.mainButton {
38+
border-radius: 6px 0 0 6px;
39+
border-right: none;
40+
}
41+
42+
.chevronButton {
43+
border-radius: 0 6px 6px 0;
44+
padding: 0 0.5rem;
45+
}
46+
47+
.icon {
48+
display: inline-flex;
49+
align-items: center;
50+
color: var(--ifm-color-emphasis-700);
51+
}
52+
53+
.mainButton:hover .icon {
54+
color: var(--ifm-color-emphasis-900);
55+
}
56+
57+
.label {
58+
white-space: nowrap;
59+
}
60+
61+
.menu {
62+
position: absolute;
63+
top: calc(100% + 4px);
64+
right: 0;
65+
min-width: 16rem;
66+
padding: 0.25rem;
67+
background: var(--ifm-background-surface-color, var(--ifm-background-color));
68+
border: 1px solid var(--ifm-color-emphasis-300);
69+
border-radius: 8px;
70+
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08), 0 2px 4px rgba(0, 0, 0, 0.04);
71+
z-index: 20;
72+
73+
[data-theme='dark'] & {
74+
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4), 0 2px 4px rgba(0, 0, 0, 0.2);
75+
}
76+
}
77+
78+
.menuItem {
79+
display: flex;
80+
align-items: flex-start;
81+
gap: 0.625rem;
82+
width: 100%;
83+
padding: 0.5rem 0.625rem;
84+
background: transparent;
85+
color: var(--ifm-color-emphasis-900);
86+
border: none;
87+
border-radius: 5px;
88+
text-align: left;
89+
font: inherit;
90+
cursor: pointer;
91+
transition: background-color 0.12s ease;
92+
93+
&:hover,
94+
&:focus-visible {
95+
background: var(--ifm-color-emphasis-200);
96+
outline: none;
97+
}
98+
}
99+
100+
.menuIcon {
101+
display: inline-flex;
102+
align-items: center;
103+
margin-top: 0.15rem;
104+
color: var(--ifm-color-emphasis-600);
105+
}
106+
107+
.menuText {
108+
display: flex;
109+
flex-direction: column;
110+
gap: 0.15rem;
111+
}
112+
113+
.menuTitle {
114+
font-weight: 500;
115+
color: var(--ifm-color-emphasis-1000);
116+
}
117+
118+
.menuDesc {
119+
font-size: 0.75rem;
120+
color: var(--ifm-color-emphasis-600);
121+
}

0 commit comments

Comments
 (0)