feat: add copy page button functionality and fix llms-text output (#5419)

* feat: add copy page button functionality and fix llms-text output

- Add copy page button with CSS and JS implementation
- Implement copy page hooks for MkDocs integration
- Fix HTML filtering and DOM text reinterpreted as HTML issues
- Update llms-text target to generate docs/llm.txt instead of docs/llms-full.txt
- Add necessary styling and package.json dependencies

* fix missing button in preview

* remove the over-processing

* disable API reference
This commit is contained in:
Xin Jin
2025-07-11 15:13:55 -04:00
committed by GitHub
parent c3d882e87a
commit 4da35babda
6 changed files with 354 additions and 1 deletions
+162
View File
@@ -0,0 +1,162 @@
"""
Copy page functionality hooks for MkDocs.
This module provides hooks to inject original markdown content into HTML pages
for the copy page functionality, allowing users to copy clean markdown content
optimized for LLMs.
"""
import json
import re
from pathlib import Path
from typing import Optional
from mkdocs.config.defaults import MkDocsConfig
from mkdocs.structure.pages import Page
def _process_includes(content: str, docs_dir: Path) -> str:
"""Process MkDocs includes like {!../README.md!}."""
include_pattern = r'\{!([^!]+)!\}'
def replace_include(match):
include_path = match.group(1)
# Resolve relative path
if include_path.startswith('../'):
# Go up from docs dir
include_file = docs_dir.parent / include_path[3:]
else:
include_file = docs_dir / include_path
try:
with open(include_file, 'r', encoding='utf-8') as f:
included_content = f.read()
# Remove frontmatter from included content to avoid duplication
included_content = re.sub(r'^---\n.*?\n---\n', '', included_content, flags=re.DOTALL)
return included_content
except:
return f"[Content from {include_path}]"
return re.sub(include_pattern, replace_include, content)
def _clean_markdown(content: str) -> str:
"""Minimal cleanup of markdown content - preserve original as much as possible."""
# Remove frontmatter
content = re.sub(r'^---\n.*?\n---\n', '', content, flags=re.DOTALL)
# Remove script tags (security)
content = re.sub(r'<script[^>]*>.*?</script\s*>', '', content, flags=re.DOTALL | re.IGNORECASE)
# Remove style tags (security)
content = re.sub(r'<style[^>]*>.*?</style\s*>', '', content, flags=re.DOTALL | re.IGNORECASE)
# Remove HTML comments
content = re.sub(r'<!--.*?-->', '', content, flags=re.DOTALL)
# Just strip and return - preserve original structure
return content.strip()
def inject_markdown_content(html: str, page: Page, config: MkDocsConfig) -> str:
"""
Inject the original markdown content into the HTML for copy page functionality.
Args:
html: The HTML content to inject into
page: The MkDocs page object
config: The MkDocs configuration
Returns:
Modified HTML with markdown content injected as JSON
"""
if not hasattr(page, 'file') or not page.file:
return html
# Get the original markdown file path
docs_dir = Path(config.get('docs_dir', 'docs'))
src_path = page.file.src_path
# Handle different file types
if src_path.endswith('.ipynb'):
# For notebook files, we might want to use the converted markdown
# For now, just return the HTML as-is
return html
markdown_file = docs_dir / src_path
if not markdown_file.exists():
return html
try:
# Read the original markdown content
with open(markdown_file, 'r', encoding='utf-8') as f:
markdown_content = f.read()
# Special handling for index page - use relative path to the actual README.md
if src_path == 'index.md':
# Relative path to the repository README.md file (go up two levels from docs/docs)
readme_path = docs_dir.parent.parent / 'README.md'
try:
with open(readme_path, 'r', encoding='utf-8') as f:
readme_content = f.read()
# Remove frontmatter if present
processed_markdown = re.sub(r'^---\n.*?\n---\n', '', readme_content, flags=re.DOTALL)
processed_markdown = processed_markdown.strip()
except Exception as e:
# If we can't read the README, fallback to original behavior
processed_markdown = _process_includes(markdown_content, docs_dir)
processed_markdown = re.sub(r'^---\n.*?\n---\n', '', processed_markdown, flags=re.DOTALL)
processed_markdown = processed_markdown.strip()
else:
# Process any includes in the markdown to get the full content
processed_markdown = _process_includes(markdown_content, docs_dir)
# Clean up the processed markdown normally for other pages
processed_markdown = _clean_markdown(processed_markdown)
# Create the JSON data
markdown_data = {
'markdown': processed_markdown,
'title': page.title or 'Page Content',
'url': page.url or ''
}
# Properly escape the JSON for HTML
json_content = json.dumps(markdown_data, ensure_ascii=False)
json_content = json_content.replace('</', '\\u003c/')
json_content = json_content.replace('<script', '\\u003cscript')
json_content = json_content.replace('</script', '\\u003c/script')
script_content = f'<script id="page-markdown-content" type="application/json">{json_content}</script>'
# Insert before </head> if it exists, otherwise before </body>
if '</head>' in html:
html = html.replace('</head>', f'{script_content}</head>')
elif '</body>' in html:
html = html.replace('</body>', f'{script_content}</body>')
except Exception as e:
# If anything goes wrong, just return the original HTML
# Could log the error here if needed
pass
return html
def on_post_page(output: str, page: Page, config: MkDocsConfig) -> str:
"""
MkDocs hook to inject markdown content into HTML pages.
This hook is called after each page is rendered and injects the original
markdown content as JSON for the copy page functionality.
Args:
output: The HTML output of the page
page: The MkDocs page object
config: The MkDocs configuration
Returns:
Modified HTML with markdown content injected
"""
return inject_markdown_content(output, page, config)
+1
View File
@@ -362,6 +362,7 @@ markdown_extensions:
- github-callouts
hooks:
- _scripts/notebook_hooks.py
- _scripts/copy_page_hooks.py
extra:
social:
- icon: fontawesome/brands/js
+16
View File
@@ -0,0 +1,16 @@
/* Minimal CSS for copy page button */
.copy-page-btn {
background: transparent;
border: 1px solid var(--md-default-fg-color--lightest);
padding: 6px 12px;
margin-right: 8px;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
color: var(--md-default-fg-color);
transition: all 0.2s ease;
}
.copy-page-btn:hover {
background: var(--md-default-fg-color--lightest);
}
+38
View File
@@ -0,0 +1,38 @@
// Simple copy page functionality - just copy the markdown content
function copyPageAsMarkdown() {
const markdownScript = document.getElementById('page-markdown-content');
if (!markdownScript) {
alert('Markdown content not available for this page');
return;
}
try {
const data = JSON.parse(markdownScript.textContent);
const content = `# ${data.title}\n\nSource: ${window.location.href}\n\n${data.markdown}`;
navigator.clipboard.writeText(content).then(() => {
// Simple notification
const notification = document.createElement('div');
notification.textContent = 'Page content copied to clipboard';
notification.style.cssText = 'position:fixed;top:20px;right:20px;background:#4CAF50;color:white;padding:10px;border-radius:4px;z-index:9999;';
document.body.appendChild(notification);
setTimeout(() => notification.remove(), 3000);
}).catch(() => {
alert('Failed to copy content');
});
} catch (e) {
alert('Failed to parse page content');
}
}
// Add button to header - simpler approach
document.addEventListener('DOMContentLoaded', function() {
const headerSource = document.querySelector('.md-header__source');
if (headerSource) {
const button = document.createElement('button');
button.textContent = 'Copy page';
button.onclick = copyPageAsMarkdown;
button.style.cssText = 'background:none;border:1px solid #ddd;padding:6px 12px;margin-right:8px;border-radius:4px;cursor:pointer;';
headerSource.parentNode.insertBefore(button, headerSource);
}
});
+135
View File
@@ -13,6 +13,130 @@ j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
{% block extrahead %}
<meta name="algolia-site-verification" content="165B7E7C89E49946" />
<script>
// Simple copy page functionality - uses original markdown source
function copyPageAsMarkdown() {
const markdownScript = document.getElementById('page-markdown-content');
if (!markdownScript) {
alert('Markdown content not available for this page');
return;
}
try {
let rawContent = markdownScript.textContent;
// Safe HTML entity decoding function
function decodeHtmlEntities(text) {
const parser = new DOMParser();
const doc = parser.parseFromString(text, 'text/html');
return doc.documentElement.textContent || '';
}
// Always decode HTML entities since the browser might encode them
rawContent = decodeHtmlEntities(rawContent);
const data = JSON.parse(rawContent);
const content = `# ${data.title}\n\nSource: ${window.location.href}\n\n${data.markdown}`;
navigator.clipboard.writeText(content).then(() => {
// Simple notification
const notification = document.createElement('div');
notification.textContent = 'Page content copied to clipboard';
notification.style.cssText = 'position:fixed;top:20px;right:20px;background:#4CAF50;color:white;padding:10px 16px;border-radius:4px;z-index:9999;box-shadow:0 2px 10px rgba(0,0,0,0.2);';
document.body.appendChild(notification);
setTimeout(() => notification.remove(), 3000);
}).catch(() => {
alert('Failed to copy content');
});
} catch (e) {
console.error('Failed to parse page content:', e);
alert('Failed to parse page content: ' + e.message);
}
}
// Add dropdown button to header when page loads
document.addEventListener('DOMContentLoaded', function() {
const headerSource = document.querySelector('.md-header__source');
if (headerSource) {
// Create dropdown container
const dropdownContainer = document.createElement('div');
dropdownContainer.style.cssText = 'position:relative;display:inline-block;margin-left:8px;';
// Create main button
const button = document.createElement('button');
button.innerHTML = 'Copy page <span style="margin-left:8px;font-size:12px;color:#9ca3af;">▾</span>';
button.style.cssText = 'background:transparent;border:1px solid #d1d5db;padding:6px 12px;border-radius:4px;cursor:pointer;font-size:14px;color:#374151;transition:all 0.2s ease;white-space:nowrap;display:flex;align-items:center;';
// Create dropdown menu
const dropdown = document.createElement('div');
dropdown.className = 'copy-page-dropdown';
dropdown.style.cssText = 'position:absolute;top:100%;left:0;background:white;border:1px solid #e5e7eb;border-radius:6px;box-shadow:0 4px 12px rgba(0,0,0,0.15);z-index:1000;min-width:180px;display:none;padding:4px 0;';
// Create dropdown options
const option1 = document.createElement('div');
option1.textContent = 'Copy as Markdown for LLMs';
option1.className = 'copy-page-option';
option1.style.cssText = 'padding:8px 16px;cursor:pointer;font-size:14px;color:#374151;margin:2px 0;';
option1.onmouseover = function() {
this.style.background = document.documentElement.getAttribute('data-md-color-scheme') === 'slate' ? '#4a5568' : '#f8fafc';
};
option1.onmouseout = function() { this.style.background = 'transparent'; };
option1.onclick = function() {
// Check if we're on a reference page
if (window.location.pathname.includes('/reference/')) {
alert('Copy Page not yet available in API reference pages.');
} else {
copyPageAsMarkdown();
}
dropdown.style.display = 'none';
};
const option2 = document.createElement('div');
option2.textContent = "View LangGraph's llms.txt";
option2.className = 'copy-page-option';
option2.style.cssText = 'padding:8px 16px;cursor:pointer;font-size:14px;color:#374151;margin:2px 0;';
option2.onmouseover = function() {
this.style.background = document.documentElement.getAttribute('data-md-color-scheme') === 'slate' ? '#4a5568' : '#f8fafc';
};
option2.onmouseout = function() { this.style.background = 'transparent'; };
option2.onclick = function() {
window.open('/llms-txt-overview/', '_blank');
dropdown.style.display = 'none';
};
// Add options to dropdown
dropdown.appendChild(option1);
dropdown.appendChild(option2);
// Button hover effects
button.onmouseover = function() {
this.style.background = '#f3f4f6';
this.style.borderColor = '#9ca3af';
};
button.onmouseout = function() {
this.style.background = 'transparent';
this.style.borderColor = '#d1d5db';
};
// Toggle dropdown
button.onclick = function(e) {
e.stopPropagation();
dropdown.style.display = dropdown.style.display === 'none' ? 'block' : 'none';
};
// Close dropdown when clicking outside
document.addEventListener('click', function() {
dropdown.style.display = 'none';
});
// Assemble dropdown
dropdownContainer.appendChild(button);
dropdownContainer.appendChild(dropdown);
headerSource.parentNode.insertBefore(dropdownContainer, headerSource.nextSibling);
}
});
</script>
<style>
@import url("https://fonts.googleapis.com/css2?family=Public+Sans&display=swap");
:root {
@@ -198,6 +322,17 @@ j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
color: #000000;
}
/* Copy page dropdown dark mode support */
[data-md-color-scheme="slate"] .copy-page-dropdown {
background: #1f2937 !important;
border-color: #374151 !important;
box-shadow: 0 4px 12px rgba(0,0,0,0.5) !important;
}
[data-md-color-scheme="slate"] .copy-page-option {
color: #e5e7eb !important;
}
</style>
{% endblock %}
+2 -1
View File
@@ -9,7 +9,8 @@
"@langchain/core": "^0.3.38",
"@langchain/openai": "^0.4.2",
"msgpack-lite": "^0.1.26",
"nock": "^14.0.1"
"nock": "^14.0.1",
"he": "^1.2.0"
},
"devDependencies": {
"@tsconfig/recommended": "^1.0.8",