Skip to content

Commit 79ca048

Browse files
authored
add 'copy for LLM' button (#1034)
* add 'copy for llm' button * improve button ux * add rstrip to page url
1 parent f81a6e9 commit 79ca048

File tree

4 files changed

+360
-0
lines changed

4 files changed

+360
-0
lines changed

docs/assets/css/copy-to-llm.css

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
/**
2+
* Styles for Copy to LLM button and toast notifications
3+
*/
4+
5+
/* Copy to LLM Button - positioned in right sidebar */
6+
.copy-to-llm-button {
7+
display: flex;
8+
align-items: center;
9+
justify-content: center;
10+
gap: 8px;
11+
width: calc(100% - 0.8rem);
12+
padding: 0.5rem 0.8rem;
13+
margin: 0 0.4rem 1rem 0.4rem;
14+
background-color: var(--color-primary-main, #7C47FC);
15+
color: var(--color-default-white, #fff);
16+
border: none;
17+
border-radius: 0.3rem;
18+
font-size: 0.7rem;
19+
font-weight: 500;
20+
cursor: pointer;
21+
transition: all 0.2s ease;
22+
white-space: nowrap;
23+
box-sizing: border-box;
24+
}
25+
26+
.copy-to-llm-button:hover {
27+
background-color: var(--color-primary-light, #9A70FF);
28+
transform: scale(1.02);
29+
}
30+
31+
.copy-to-llm-button:active {
32+
transform: scale(0.98);
33+
}
34+
35+
.copy-to-llm-button svg {
36+
width: 14px;
37+
height: 14px;
38+
flex-shrink: 0;
39+
}
40+
41+
/* Copied state */
42+
.copy-to-llm-button.copied {
43+
background-color: #22c55e;
44+
}
45+
46+
.copy-to-llm-button.copied::after {
47+
content: '✓';
48+
margin-left: 4px;
49+
}
50+
51+
/* Toast notification */
52+
.copy-to-llm-toast {
53+
position: fixed;
54+
bottom: 24px;
55+
right: 24px;
56+
padding: 12px 20px;
57+
background-color: var(--color-default-primary, #131417);
58+
color: var(--color-default-white, #fff);
59+
border-radius: 6px;
60+
font-size: 14px;
61+
font-weight: 500;
62+
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
63+
opacity: 0;
64+
transform: translateY(20px);
65+
transition: all 0.3s ease;
66+
z-index: 10000;
67+
max-width: 320px;
68+
}
69+
70+
.copy-to-llm-toast.show {
71+
opacity: 1;
72+
transform: translateY(0);
73+
}
74+
75+
.copy-to-llm-toast--success {
76+
background-color: #22c55e;
77+
}
78+
79+
.copy-to-llm-toast--error {
80+
background-color: #ef4444;
81+
}
82+
83+
/* Responsive adjustments */
84+
@media (max-width: 768px) {
85+
.copy-to-llm-button {
86+
position: static;
87+
transform: none;
88+
margin: 1rem 0.4rem;
89+
width: calc(100% - 0.8rem);
90+
justify-content: center;
91+
}
92+
93+
.copy-to-llm-button:hover,
94+
.copy-to-llm-button:active {
95+
transform: none;
96+
}
97+
98+
.copy-to-llm-toast {
99+
bottom: 16px;
100+
right: 16px;
101+
left: 16px;
102+
max-width: none;
103+
}
104+
}
105+
106+
/* Dark mode adjustments */
107+
[data-md-color-scheme="slate"] .copy-to-llm-button {
108+
background-color: var(--color-primary-main, #7C47FC);
109+
}
110+
111+
[data-md-color-scheme="slate"] .copy-to-llm-button:hover {
112+
background-color: var(--color-primary-light, #9A70FF);
113+
}
114+
115+
[data-md-color-scheme="slate"] .copy-to-llm-toast {
116+
background-color: #1f2937;
117+
}

docs/assets/js/copy-to-llm.js

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
/**
2+
* Copy to LLM functionality for Spacelift documentation.
3+
* Binds click handler to pre-rendered button in HTML.
4+
*/
5+
6+
(function() {
7+
'use strict';
8+
9+
function attachHandlers() {
10+
const button = document.querySelector('.copy-to-llm-button');
11+
if (!button) {
12+
return;
13+
}
14+
15+
// Remove any existing handlers to avoid duplicates
16+
const newButton = button.cloneNode(true);
17+
button.parentNode.replaceChild(newButton, button);
18+
19+
// Add click handler
20+
newButton.addEventListener('click', handleCopyClick);
21+
}
22+
23+
/**
24+
* Handle button click - copy markdown to clipboard
25+
*/
26+
async function handleCopyClick(event) {
27+
const button = event.currentTarget;
28+
29+
// Get the embedded markdown content from the hidden div
30+
const contentDiv = document.getElementById('llm-markdown-content');
31+
if (!contentDiv) {
32+
showToast('Error: Markdown content not found', 'error');
33+
return;
34+
}
35+
36+
// Get content from data attribute (it's HTML-escaped, browser unescapes automatically)
37+
const markdownContent = contentDiv.dataset.content;
38+
if (!markdownContent) {
39+
showToast('Error: No markdown content available', 'error');
40+
return;
41+
}
42+
43+
// Copy to clipboard
44+
try {
45+
await copyToClipboard(markdownContent);
46+
47+
// Show success feedback
48+
button.classList.add('copied');
49+
showToast('Copied to clipboard!', 'success');
50+
51+
// Reset button state after 2 seconds
52+
setTimeout(() => {
53+
button.classList.remove('copied');
54+
}, 2000);
55+
} catch (err) {
56+
showToast('Failed to copy to clipboard', 'error');
57+
console.error('Copy failed:', err);
58+
}
59+
}
60+
61+
/**
62+
* Copy text to clipboard using modern API with fallback
63+
*/
64+
async function copyToClipboard(text) {
65+
if (navigator.clipboard && navigator.clipboard.writeText) {
66+
return navigator.clipboard.writeText(text);
67+
}
68+
return fallbackCopyToClipboard(text);
69+
}
70+
71+
/**
72+
* Fallback clipboard copy for older browsers
73+
*/
74+
function fallbackCopyToClipboard(text) {
75+
const textArea = document.createElement('textarea');
76+
textArea.value = text;
77+
textArea.style.position = 'fixed';
78+
textArea.style.left = '-999999px';
79+
textArea.style.top = '-999999px';
80+
document.body.appendChild(textArea);
81+
textArea.focus();
82+
textArea.select();
83+
84+
return new Promise((resolve, reject) => {
85+
try {
86+
const successful = document.execCommand('copy');
87+
document.body.removeChild(textArea);
88+
if (successful) {
89+
resolve();
90+
} else {
91+
reject(new Error('execCommand failed'));
92+
}
93+
} catch (err) {
94+
document.body.removeChild(textArea);
95+
reject(err);
96+
}
97+
});
98+
}
99+
100+
/**
101+
* Show toast notification
102+
*/
103+
function showToast(message, type = 'info') {
104+
const existingToast = document.querySelector('.copy-to-llm-toast');
105+
if (existingToast) {
106+
existingToast.remove();
107+
}
108+
109+
const toast = document.createElement('div');
110+
toast.className = `copy-to-llm-toast copy-to-llm-toast--${type}`;
111+
toast.textContent = message;
112+
113+
document.body.appendChild(toast);
114+
115+
setTimeout(() => {
116+
toast.classList.add('show');
117+
}, 10);
118+
119+
setTimeout(() => {
120+
toast.classList.remove('show');
121+
setTimeout(() => {
122+
toast.remove();
123+
}, 300);
124+
}, 3000);
125+
}
126+
127+
// Attach handlers on initial load and instant navigation
128+
if (typeof document$ !== 'undefined') {
129+
document$.subscribe(attachHandlers);
130+
} else {
131+
if (document.readyState === 'loading') {
132+
document.addEventListener('DOMContentLoaded', attachHandlers);
133+
} else {
134+
attachHandlers();
135+
}
136+
}
137+
})();

hooks/copy_to_llm.py

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
"""
2+
Hook to embed markdown content into pages for LLM copy functionality.
3+
Captures the processed markdown (after Jinja rendering) and embeds it with
4+
frontmatter, title, and URL for easy copying to clipboard.
5+
"""
6+
7+
import json
8+
import re
9+
10+
11+
def on_page_content(html, page, config, files):
12+
"""
13+
Called after markdown is converted to HTML but before template is applied.
14+
Capture the processed markdown and store it in page metadata.
15+
"""
16+
# Get the rendered markdown (post-Jinja processing)
17+
markdown_content = page.markdown
18+
19+
# Build the formatted content for LLM
20+
llm_content_parts = []
21+
22+
# Add page title
23+
if page.title:
24+
llm_content_parts.append(f"# {page.title}\n")
25+
26+
# Add page URL
27+
site_url = config.get('site_url', '').rstrip('/')
28+
page_url = f"{site_url}/{page.file.dest_uri}"
29+
llm_content_parts.append(f"**Source:** {page_url}\n")
30+
31+
# Add frontmatter if it exists
32+
if page.meta:
33+
# Only include common frontmatter fields
34+
frontmatter_fields = {}
35+
common_fields = ['title', 'description', 'tags', 'date', 'author']
36+
for field in common_fields:
37+
if field in page.meta:
38+
frontmatter_fields[field] = page.meta[field]
39+
40+
if frontmatter_fields:
41+
llm_content_parts.append("\n---")
42+
for key, value in frontmatter_fields.items():
43+
if isinstance(value, list):
44+
llm_content_parts.append(f"{key}: {', '.join(str(v) for v in value)}")
45+
else:
46+
llm_content_parts.append(f"{key}: {value}")
47+
llm_content_parts.append("---\n")
48+
49+
# Add the actual markdown content
50+
llm_content_parts.append("\n" + markdown_content)
51+
52+
# Combine all parts
53+
llm_content = "\n".join(llm_content_parts)
54+
55+
# Store in page meta for later injection
56+
page.llm_content = llm_content
57+
58+
return html
59+
60+
61+
def on_post_page(output, page, config):
62+
"""
63+
Called after the template is rendered. Inject the LLM content and button.
64+
"""
65+
if not hasattr(page, 'llm_content'):
66+
return output
67+
68+
# Escape the content for HTML embedding
69+
# Use a hidden div instead of script tag because script tags don't work with innerHTML
70+
import html
71+
safe_content = html.escape(page.llm_content)
72+
73+
# Create hidden div with the data
74+
content_div = f"""<div id="llm-markdown-content" style="display: none;" data-content="{safe_content}"></div>"""
75+
76+
# Create the button HTML
77+
button_html = """<button type="button" class="copy-to-llm-button" title="Copy documentation for LLM">
78+
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
79+
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
80+
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
81+
</svg>
82+
<span>Copy for LLM</span>
83+
</button>"""
84+
85+
# Inject button at the top of the right sidebar (Table of Contents)
86+
# Look for the secondary sidebar and inject at the beginning of its content
87+
sidebar_pattern = r'(<div[^>]*class="[^"]*md-sidebar--secondary[^"]*"[^>]*>.*?<div[^>]*class="[^"]*md-sidebar__scrollwrap[^"]*"[^>]*>)'
88+
if re.search(sidebar_pattern, output, re.DOTALL):
89+
output = re.sub(sidebar_pattern, r'\1\n' + button_html + '\n', output, count=1, flags=re.DOTALL)
90+
else:
91+
# Fallback: inject after the first h1 tag if sidebar not found
92+
h1_pattern = r'(<h1[^>]*>.*?</h1>)'
93+
if re.search(h1_pattern, output, re.DOTALL):
94+
output = re.sub(h1_pattern, r'\1\n' + button_html, output, count=1, flags=re.DOTALL)
95+
96+
# Inject div BEFORE closing </article> so it's part of the swapped content
97+
if '</article>' in output:
98+
output = output.replace('</article>', f'{content_div}\n</article>', 1)
99+
elif '</body>' in output:
100+
output = output.replace('</body>', f'{content_div}\n</body>', 1)
101+
102+
return output

mkdocs.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,9 @@ markdown_extensions:
6060
permalink_title: Anchor link to this section
6161
extra_css:
6262
- assets/css/custom.css
63+
- assets/css/copy-to-llm.css
64+
extra_javascript:
65+
- assets/js/copy-to-llm.js
6366
extra:
6467
generator: false
6568
environment: !ENV [DOC_ENV, "dev"]
@@ -86,6 +89,7 @@ extra:
8689
hooks:
8790
- hooks/fetch_banner.py
8891
- hooks/move_index.py
92+
- hooks/copy_to_llm.py
8993
plugins:
9094
- search
9195
- macros

0 commit comments

Comments
 (0)