mirror of
https://github.com/langchain-ai/langgraph.git
synced 2026-08-17 21:25:46 +02:00
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:
@@ -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)
|
||||
@@ -362,6 +362,7 @@ markdown_extensions:
|
||||
- github-callouts
|
||||
hooks:
|
||||
- _scripts/notebook_hooks.py
|
||||
- _scripts/copy_page_hooks.py
|
||||
extra:
|
||||
social:
|
||||
- icon: fontawesome/brands/js
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user