From 703502d352368e6b870701a687f690c02a9d3f02 Mon Sep 17 00:00:00 2001 From: Chavez Harris Date: Sat, 10 Jan 2026 16:30:13 -0400 Subject: [PATCH 01/26] moved copy-page-button folder --- website/docusaurus.config.ts | 2 +- website/{ => src}/plugins/copy-page-button/CopyPageButton.js | 0 website/{ => src}/plugins/copy-page-button/client.js | 0 website/{ => src}/plugins/copy-page-button/index.js | 0 website/{ => src}/plugins/copy-page-button/styles.module.css | 0 5 files changed, 1 insertion(+), 1 deletion(-) rename website/{ => src}/plugins/copy-page-button/CopyPageButton.js (100%) rename website/{ => src}/plugins/copy-page-button/client.js (100%) rename website/{ => src}/plugins/copy-page-button/index.js (100%) rename website/{ => src}/plugins/copy-page-button/styles.module.css (100%) diff --git a/website/docusaurus.config.ts b/website/docusaurus.config.ts index 7c236577e..e91961693 100644 --- a/website/docusaurus.config.ts +++ b/website/docusaurus.config.ts @@ -217,7 +217,7 @@ const config: Config = { // smartlookKey: '05d0e4ca90c61150955104a9d4b76ab16a0b2380', // } // ], - require.resolve('./plugins/copy-page-button'), + require.resolve('./src/plugins/copy-page-button'), ], themeConfig: { image: '/img/docs-cover.png', diff --git a/website/plugins/copy-page-button/CopyPageButton.js b/website/src/plugins/copy-page-button/CopyPageButton.js similarity index 100% rename from website/plugins/copy-page-button/CopyPageButton.js rename to website/src/plugins/copy-page-button/CopyPageButton.js diff --git a/website/plugins/copy-page-button/client.js b/website/src/plugins/copy-page-button/client.js similarity index 100% rename from website/plugins/copy-page-button/client.js rename to website/src/plugins/copy-page-button/client.js diff --git a/website/plugins/copy-page-button/index.js b/website/src/plugins/copy-page-button/index.js similarity index 100% rename from website/plugins/copy-page-button/index.js rename to website/src/plugins/copy-page-button/index.js diff --git a/website/plugins/copy-page-button/styles.module.css b/website/src/plugins/copy-page-button/styles.module.css similarity index 100% rename from website/plugins/copy-page-button/styles.module.css rename to website/src/plugins/copy-page-button/styles.module.css From 70f37cb82d02508407c56b4e73e11cd0e88f209c Mon Sep 17 00:00:00 2001 From: Chavez Harris Date: Sun, 11 Jan 2026 14:57:07 -0400 Subject: [PATCH 02/26] First draft fix --- website/docusaurus.config.ts | 2 +- .../copy-page-button/CopyPageButton.js | 600 ------------------ .../src/plugins/copy-page-button/client.js | 138 ---- website/src/plugins/copy-page-button/index.js | 34 - .../copy-page-button/styles.module.css | 177 ------ 5 files changed, 1 insertion(+), 950 deletions(-) delete mode 100644 website/src/plugins/copy-page-button/CopyPageButton.js delete mode 100644 website/src/plugins/copy-page-button/client.js delete mode 100644 website/src/plugins/copy-page-button/index.js delete mode 100644 website/src/plugins/copy-page-button/styles.module.css diff --git a/website/docusaurus.config.ts b/website/docusaurus.config.ts index e91961693..a889af696 100644 --- a/website/docusaurus.config.ts +++ b/website/docusaurus.config.ts @@ -217,7 +217,7 @@ const config: Config = { // smartlookKey: '05d0e4ca90c61150955104a9d4b76ab16a0b2380', // } // ], - require.resolve('./src/plugins/copy-page-button'), + // require.resolve('./src/plugins/copy-page-button'), ], themeConfig: { image: '/img/docs-cover.png', diff --git a/website/src/plugins/copy-page-button/CopyPageButton.js b/website/src/plugins/copy-page-button/CopyPageButton.js deleted file mode 100644 index 8cd292c36..000000000 --- a/website/src/plugins/copy-page-button/CopyPageButton.js +++ /dev/null @@ -1,600 +0,0 @@ -import React, { useState, useEffect, useRef } from 'react'; -import { createPortal } from 'react-dom'; -import styles from './styles.module.css'; - -// Static selectors for content cleanup -const SELECTORS_TO_REMOVE = [ - '.theme-edit-this-page', - '.theme-last-updated', - '.pagination-nav', - '.theme-doc-breadcrumbs', - '.theme-doc-footer', - 'button', - '.copy-code-button', - '.buttonGroup', - '.clean-btn', - '.theme-code-block-title', - '.line-number', -]; - -export default function CopyPageButton({ - enabledActions = ['copy', 'view', 'chatgpt', 'claude'], -}) { - const [isOpen, setIsOpen] = useState(false); - const [pageContent, setPageContent] = useState(''); - const [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0 }); - const dropdownRef = useRef(null); - const buttonRef = useRef(null); - const [mounted, setMounted] = useState(false); - - useEffect(() => { - const handleClickOutside = (event) => { - if ( - dropdownRef.current && - !dropdownRef.current.contains(event.target) && - buttonRef.current && - !buttonRef.current.contains(event.target) - ) { - setIsOpen(false); - } - }; - - if (isOpen) { - document.addEventListener('mousedown', handleClickOutside); - } - - return () => { - document.removeEventListener('mousedown', handleClickOutside); - }; - }, [isOpen]); - - useEffect(() => { - if (isOpen && buttonRef.current) { - const rect = buttonRef.current.getBoundingClientRect(); - const isMobile = window.innerWidth <= 767; // Mobile break-point - - setDropdownPosition({ - top: rect.bottom + 8, // dropdown below the button - left: isMobile - ? rect.left // mobile: left-aligned with button - : rect.right - 300, // desktop: right-aligned with button - }); - } - }, [isOpen]); - - useEffect(() => { - if (typeof window === 'undefined') return; - - const content = extractPageContent(); - if (content) { - setPageContent(content); - } - }, []); - - useEffect(() => { - // mark mounted to avoid createPortal on the server (document may be undefined) - setMounted(true); - }, []); - - const convertToMarkdown = (element) => { - const cleanText = (text) => { - return text - .replace(/[\u200B-\u200D\uFEFF]/g, '') // Remove zero-width spaces - .replace(/\u00A0/g, ' ') // Replace non-breaking spaces - .replace(/[\u2018\u2019]/g, "'") // Smart quotes - .replace(/[\u201C\u201D]/g, '"') - .replace(/​/g, '') // Clean encoding issues - .replace(/\s+/g, ' ') // Normalize whitespace - .trim(); - }; - - const processNode = (node) => { - if (node.nodeType === Node.TEXT_NODE) { - return cleanText(node.textContent); - } - - if (node.nodeType === Node.ELEMENT_NODE) { - const tag = node.tagName.toLowerCase(); - const childResults = Array.from(node.childNodes).map((child) => - processNode(child), - ); - - // Join child results with intelligent spacing - let children = ''; - for (let i = 0; i < childResults.length; i++) { - const current = childResults[i]; - const previous = i > 0 ? childResults[i - 1] : ''; - - if (current) { - if ( - previous && - !previous.match(/[\s\n]$/) && - !current.match(/^[\s\n]/) && - previous.trim() && - current.trim() - ) { - children += ' '; - } - children += current; - } - } - - switch (tag) { - case 'h1': - return `\n# ${children.trim()}\n\n`; - case 'h2': - return `\n## ${children.trim()}\n\n`; - case 'h3': - return `\n### ${children.trim()}\n\n`; - case 'h4': - return `\n#### ${children.trim()}\n\n`; - case 'h5': - return `\n##### ${children.trim()}\n\n`; - case 'h6': - return `\n###### ${children.trim()}\n\n`; - case 'p': - return children.trim() ? `${children.trim()}\n\n` : '\n'; - case 'strong': - case 'b': - return `**${children}**`; - case 'em': - case 'i': - return `*${children}*`; - case 'code': - if (node.parentElement?.tagName.toLowerCase() === 'pre') { - return children; - } - const cleanInlineCode = children - .replace(/[\u200B-\u200D\uFEFF]/g, '') // Remove zero-width spaces - .replace(/\u00A0/g, ' ') // Replace non-breaking spaces - .trim(); - return `\`${cleanInlineCode}\``; - case 'pre': - const codeElement = node.querySelector('code'); - if (codeElement) { - const language = - (codeElement.className?.match(/language-(\w+)/) || - node.className?.match(/language-(\w+)/) || - codeElement.className?.match(/hljs-(\w+)/) || - codeElement.className?.match(/prism-(\w+)/) || - [])[1] || ''; - - let codeContent = ''; - - try { - // Method 1: Try to get content from data attributes (some themes store original content) - const originalContent = - codeElement.getAttribute('data-code') || - node.getAttribute('data-code') || - codeElement.getAttribute('data-raw'); - - if (originalContent) { - codeContent = originalContent; - } else { - // Method 2: Look for individual code lines in specific containers - const codeLines = codeElement.querySelectorAll( - 'span[data-line], .token-line, .code-line, .highlight-line', - ); - if (codeLines.length > 0) { - codeContent = Array.from(codeLines) - .map((lineElement) => { - return lineElement?.textContent || ''; - }) - .join('\n'); - } else { - // Method 3: Look for div-based line structure - const codeLineDivs = codeElement.querySelectorAll('div'); - if (codeLineDivs.length > 0) { - codeContent = Array.from(codeLineDivs) - .map((lineDiv) => { - // Skip if this looks like a line number container - if ( - lineDiv.className?.includes('codeLineNumber') || - lineDiv.className?.includes('LineNumber') || - lineDiv.className?.includes('line-number') || - lineDiv.style?.userSelect === 'none' - ) { - return null; - } - return lineDiv?.textContent || ''; - }) - .filter((line) => line !== null) - .join('\n'); - } else { - // Method 4: Direct text extraction with cleanup - let rawText = codeElement.textContent || ''; - - // Remove line numbers at the start of lines (common pattern: "1 ", "12 ", etc.) - rawText = rawText.replace(/^\d+\s+/gm, ''); - - // Remove copy button text and other UI elements - rawText = rawText.replace(/^Copy$/gm, ''); - rawText = rawText.replace(/^Copied!$/gm, ''); - rawText = rawText.replace( - /^\s*Copy to clipboard\s*$/gm, - '', - ); - - codeContent = rawText; - } - } - } - - // Final cleanup - codeContent = codeContent - .replace(/[\u200B-\u200D\uFEFF]/g, '') // Remove zero-width spaces - .replace(/\u00A0/g, ' ') // Replace non-breaking spaces - .trim(); - - // Remove empty lines at start and end - codeContent = codeContent.replace(/^\n+|\n+$/g, ''); - } catch (error) { - // Fallback to simple text extraction if anything fails - codeContent = codeElement.textContent || ''; - } - - return `\n\`\`\`${language}\n${codeContent}\n\`\`\`\n\n`; - } - return `\n\`\`\`\n${children}\n\`\`\`\n\n`; - case 'ul': - return `\n${children}`; - case 'ol': - const items = Array.from(node.querySelectorAll('li')); - return ( - '\n' + - items - .map( - (item, index) => - `${index + 1}. ${processNode(item) - .replace(/^- /, '') - .trim()}\n`, - ) - .join('') - ); - case 'li': - return `- ${children.trim()}\n`; - case 'a': - const href = node.getAttribute('href'); - if (href && !href.startsWith('#') && children.trim()) { - return `[${children.trim()}](${href})`; - } - return children; - case 'br': - return '\n'; - case 'blockquote': - return `\n> ${children.trim()}\n\n`; - case 'table': - return `\n${children}\n`; - case 'tr': - return `${children}\n`; - case 'th': - case 'td': - return `| ${children.trim()} `; - case 'img': - const src = node.getAttribute('src'); - const alt = node.getAttribute('alt') || ''; - return src ? `![${alt}](${src})` : ''; - case 'div': - case 'section': - case 'article': - // Handle admonitions - if (node.classList?.contains('admonition')) { - const type = - Array.from(node.classList) - .find((cls) => cls.startsWith('alert--')) - ?.replace('alert--', '') || 'note'; - return `\n> **${type.toUpperCase()}**: ${children.trim()}\n\n`; - } - return children + '\n'; - default: - return children; - } - } - - return ''; - }; - - return processNode(element) - .replace(/\n{3,}/g, '\n\n') // Limit multiple newlines - .replace(/^\n+|\n+$/g, '') // Trim newlines - .trim(); - }; - - const extractPageContent = () => { - console.log('Extracting page content...'); - - const mainContent = - document.querySelector('main article') || - document.querySelector('main .markdown'); - - console.log('Found main content element:', !!mainContent); - if (!mainContent) { - console.error( - 'No main content found - looking for alternative selectors', - ); - // Try alternative selectors - const alternatives = - document.querySelector('main') || - document.querySelector('article') || - document.querySelector('.main-wrapper'); - console.log('Alternative content element found:', !!alternatives); - if (!alternatives) return ''; - } - - const targetElement = - mainContent || - document.querySelector('main') || - document.querySelector('article'); - const clone = targetElement.cloneNode(true); - - // Remove unwanted elements - SELECTORS_TO_REMOVE.forEach((selector) => { - clone.querySelectorAll(selector).forEach((el) => el.remove()); - }); - - // Extract title from first H1 and remove it from content - const firstH1 = clone.querySelector('h1'); - const title = firstH1?.textContent.trim() || 'Documentation Page'; - console.log('Extracted title:', title); - if (firstH1) { - firstH1.remove(); - } - - const content = convertToMarkdown(clone); - console.log('Converted content length:', content.length); - console.log('Content preview:', content.substring(0, 200)); - - const currentUrl = window.location.href; - const finalContent = `# ${title}\n\nURL: ${currentUrl}\n\n${content}`; - console.log('Final page content set with length:', finalContent.length); - return finalContent; - }; - - const copyToClipboard = async (text) => { - console.log('copyToClipboard called with text length:', text?.length); - console.log('Text content preview:', text?.substring(0, 100)); - - // If no content, try to extract it now - if (!text || text.trim() === '') { - console.log('No pageContent available, extracting now...'); - const extractedContent = extractPageContent(); - if (extractedContent) { - setPageContent(extractedContent); - text = extractedContent; - } else { - console.error('Failed to extract content'); - return; - } - } - - try { - if (navigator.clipboard && navigator.clipboard.writeText) { - await navigator.clipboard.writeText(text); - console.log('Content copied to clipboard successfully'); - } else { - // Fallback for older browsers - const textArea = document.createElement('textarea'); - textArea.value = text; - document.body.appendChild(textArea); - textArea.select(); - document.execCommand('copy'); - document.body.removeChild(textArea); - console.log('Content copied to clipboard using fallback method'); - } - } catch (err) { - console.error('Failed to copy to clipboard:', err); - } - }; - - const openInAI = (baseUrl) => { - try { - const currentUrl = window.location.href; - const prompt = encodeURIComponent( - `Please read and explain this documentation page: ${currentUrl} - -Please provide a clear summary and help me understand the key concepts covered in this documentation.`, - ); - window.open(`${baseUrl}?q=${prompt}`, '_blank'); - console.log('Opened AI tool with prompt'); - } catch (err) { - console.error('Failed to open AI tool:', err); - } - }; - - const viewAsMarkdown = () => { - console.log( - 'viewAsMarkdown called with pageContent length:', - pageContent?.length, - ); - console.log('PageContent preview:', pageContent?.substring(0, 100)); - - let contentToView = pageContent; - - // If no content, try to extract it now - if (!contentToView || contentToView.trim() === '') { - console.log('No pageContent available, extracting now...'); - const extractedContent = extractPageContent(); - if (extractedContent) { - setPageContent(extractedContent); - contentToView = extractedContent; - } else { - console.error('Failed to extract content'); - return; - } - } - - try { - const blob = new Blob([contentToView], { type: 'text/plain' }); - const url = URL.createObjectURL(blob); - window.open(url, '_blank'); - console.log('Opened markdown view'); - } catch (err) { - console.error('Failed to open markdown view:', err); - } - }; - - const allDropdownItems = [ - { - id: 'copy', - title: 'Copy page', - description: 'Copy the page as Markdown for LLMs', - icon: ( - - - - - ), - action: () => copyToClipboard(pageContent), - }, - { - id: 'view', - title: 'View as Markdown', - description: 'View this page as plain text', - icon: ( - - - - - - - ), - action: viewAsMarkdown, - }, - { - id: 'chatgpt', - title: 'Open in ChatGPT', - description: 'Ask questions about this page', - icon: ( - - - - ), - action: () => openInAI('https://chat.openai.com/'), - }, - { - id: 'claude', - title: 'Open in Claude', - description: 'Ask questions about this page', - icon: ( - - - - ), - action: () => openInAI('https://claude.ai/new'), - }, - ]; - - // Filter dropdown items based on enabled actions - const dropdownItems = allDropdownItems.filter((item) => - enabledActions.includes(item.id), - ); - - return ( - <> -
- -
- - {mounted && isOpen && - createPortal( -
- {dropdownItems.map((item) => ( - - ))} -
, - // Ensure body exists - (typeof document !== 'undefined' && document.body) ? document.body : null - )} - - - ); -} diff --git a/website/src/plugins/copy-page-button/client.js b/website/src/plugins/copy-page-button/client.js deleted file mode 100644 index 6dca7b15c..000000000 --- a/website/src/plugins/copy-page-button/client.js +++ /dev/null @@ -1,138 +0,0 @@ -import React from 'react'; -import { createRoot } from 'react-dom/client'; -import CopyPageButton from './CopyPageButton'; -import ExecutionEnvironment from '@docusaurus/ExecutionEnvironment'; - -// Only run in browser -if (ExecutionEnvironment.canUseDOM) { - let root = null; - let lastUrl = location.href; - let recheckInterval = null; - - const getPluginOptions = () => - (typeof window !== 'undefined' && window.__COPY_PAGE_BUTTON_OPTIONS__) || - {}; - - const cleanup = () => { - const container = document.getElementById('copy-page-button-container'); - if (container) { - if (root) { - try { - root.unmount(); - } catch (e) {} - root = null; - } - container.remove(); - } - if (recheckInterval) { - clearInterval(recheckInterval); - recheckInterval = null; - } - }; - - // Inject button next to main

in docs header (preserve scroll to prevent mobile jump) - const injectNextToHeading = () => { - const header = document.querySelector('.theme-doc-markdown header'); - if (!header) return; - - const h1 = header.querySelector('h1'); - if (!h1) return; - - // Avoid duplicates - if (header.querySelector('#copy-page-button-container')) return; - - // Save current scroll position (works for mobile and desktop) - const scrollX = window.scrollX || window.pageXOffset || 0; - const scrollY = window.scrollY || window.pageYOffset || 0; - - // Remove old container (if present) to avoid duplicates - cleanup(); - - const container = document.createElement('div'); - container.id = 'copy-page-button-container'; - - const pluginOptions = getPluginOptions(); - const customStyles = pluginOptions.customStyles || {}; - const containerStyles = customStyles.container?.style || {}; - Object.assign(container.style, containerStyles); - - // Insert after the

using insertAdjacentElement to avoid affecting focus - h1.insertAdjacentElement('afterend', container); - - // Render React root into container - if (root) { - try { - root.unmount(); - } catch (e) {} - } - root = createRoot(container); - - root.render( - React.createElement(CopyPageButton, { - customStyles: pluginOptions.customStyles, - enabledActions: pluginOptions.enabledActions, - }), - ); - }; - - const initializeButton = () => { - setTimeout(() => { - injectNextToHeading(); - - // Re-check in case of hydration delays - let attempts = 0; - const maxAttempts = 30; - recheckInterval = setInterval(() => { - attempts++; - const hasButton = document.getElementById('copy-page-button-container'); - const h1 = document.querySelector('.theme-doc-markdown header h1'); - if (h1 && !hasButton) injectNextToHeading(); - if (attempts > maxAttempts || hasButton) { - clearInterval(recheckInterval); - recheckInterval = null; - } - }, 300); - }, 150); - }; - - const handleRouteChange = () => { - cleanup(); - // Delay slightly to let Docusaurus render the new heading, then inject - setTimeout(() => { - injectNextToHeading(); - }, 250); - }; - - // --- Bootstrapping --- - if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', initializeButton); - } else { - initializeButton(); - } - - // Handle SPA navigation - window.addEventListener('popstate', handleRouteChange); - if (typeof document !== 'undefined') { - document.addEventListener('docusaurus-route-update', handleRouteChange); - } - - // Detect pushState/replaceState - const originalPushState = history.pushState; - const originalReplaceState = history.replaceState; - const checkUrlChange = () => { - if (location.href !== lastUrl) { - lastUrl = location.href; - handleRouteChange(); - } - }; - - history.pushState = function (...args) { - originalPushState.apply(this, args); - setTimeout(checkUrlChange, 0); - }; - - history.replaceState = function (...args) { - originalReplaceState.apply(this, args); - setTimeout(checkUrlChange, 0); - }; -} diff --git a/website/src/plugins/copy-page-button/index.js b/website/src/plugins/copy-page-button/index.js deleted file mode 100644 index a54c30727..000000000 --- a/website/src/plugins/copy-page-button/index.js +++ /dev/null @@ -1,34 +0,0 @@ -const path = require("path"); - -module.exports = function copyPageButtonPlugin(context, options = {}) { - const { - customStyles = {}, - enabledActions = ['copy', 'view', 'chatgpt', 'claude'], - ...otherOptions - } = options; - - return { - name: "copy-page-button-plugin", - - getClientModules() { - return [path.resolve(__dirname, "./client.js")]; - }, - - injectHtmlTags() { - return { - headTags: [ - { - tagName: 'script', - innerHTML: ` - window.__COPY_PAGE_BUTTON_OPTIONS__ = ${JSON.stringify({ - customStyles, - enabledActions, - ...otherOptions - })}; - ` - } - ] - }; - }, - }; -}; diff --git a/website/src/plugins/copy-page-button/styles.module.css b/website/src/plugins/copy-page-button/styles.module.css deleted file mode 100644 index c6d6c6ece..000000000 --- a/website/src/plugins/copy-page-button/styles.module.css +++ /dev/null @@ -1,177 +0,0 @@ -#copy-page-button-container > * { - pointer-events: auto; -} - -.copyPageContainer { - position: relative; - display: inline-block; -} - -.copyPageButton { - display: flex; - align-items: center; - gap: 8px; - padding: 8px 12px; - margin-bottom: 16px; - background: var(--ifm-navbar-background-color, #1c1e21); - border: 1px solid var(--ifm-color-emphasis-300); - border-radius: 6px; - color: var(--ifm-navbar-link-color); - cursor: pointer; - font-size: 14px; - font-weight: 500; - transition: all 0.2s ease; - white-space: nowrap; -} - -.copyPageButton:hover { - background: var(--ifm-color-emphasis-100); - border-color: var(--ifm-color-emphasis-400); -} - -.copyPageButton:focus { - background: var(--ifm-color-emphasis-200, #eee); - border-color: var(--ifm-color-primary, #007acc); - outline: none; -} - -.chevron { - transition: transform 0.2s ease; -} - -.chevron.open { - transform: rotate(180deg); -} - -.copyPageDropdown { - min-width: 300px; - background: var(--ifm-dropdown-background-color, #1c1e21); - border: 1px solid var(--ifm-color-emphasis-300); - border-radius: 8px; - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); - overflow: hidden; - background-color: pink; -} - -.dropdownItem { - display: flex; - align-items: center; - gap: 12px; - width: 100%; - padding: 12px 16px; - background: transparent; - border: none; - color: var(--ifm-font-color-base); - cursor: pointer; - text-align: left; - transition: background-color 0.2s ease; - border-bottom: 1px solid var(--ifm-color-emphasis-200); -} - -.dropdownItem:last-child { - border-bottom: none; -} - -.dropdownItem:hover { - background: var(--ifm-color-emphasis-100); -} - -.dropdownItem svg { - flex-shrink: 0; - opacity: 0.7; -} - -.itemTitle { - font-size: 14px; - font-weight: 500; - margin-bottom: 2px; - color: var(--ifm-font-color-base); -} - -.itemDescription { - font-size: 13px; - color: var(--ifm-color-emphasis-700); - line-height: 1.3; -} - -[data-theme='dark'] .copyPageButton { - background: var(--ifm-navbar-background-color); - border-color: var(--ifm-color-emphasis-300); -} - -[data-theme='dark'] .copyPageButton:hover { - background: var(--ifm-color-emphasis-200); -} - -[data-theme='dark'] .copyPageDropdown { - background: var(--ifm-dropdown-background-color); - border-color: var(--ifm-color-emphasis-300); - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25); -} - -[data-theme='dark'] .dropdownItem:hover { - background: var(--ifm-color-emphasis-200); -} - -[data-theme='light'] .copyPageButton { - background: #ffffff; - border-color: #d0d7de; - color: #24292f; -} - -[data-theme='light'] .copyPageButton:hover { - background: #f6f8fa; - border-color: #8c959f; -} - -[data-theme='light'] .copyPageDropdown { - background: #ffffff; - border-color: #d0d7de; - box-shadow: 0 8px 24px rgba(140, 149, 159, 0.2); -} - -[data-theme='light'] .dropdownItem { - color: #24292f; - border-color: #d0d7de; -} - -[data-theme='light'] .dropdownItem:hover { - background: #f6f8fa; -} - -[data-theme='light'] .itemDescription { - color: #656d76; -} - -/* Keep the copy button aligned beside the main heading on all screen sizes */ -/* Header and button container */ -:global(.theme-doc-markdown header) { - display: flex; - align-items: center; - justify-content: space-between; /* space between title and copy button */ - flex-wrap: wrap; /* allow wrapping on small screens */ - width: 100%; - gap: 1rem; -} - -:global(.theme-doc-markdown header h1) { - width: 75%; -} - -/* Copy button container stays inline on desktop, wraps on small screens */ -:global(#copy-page-button-container) { - flex-shrink: 0; /* prevent shrinking */ -} - -/* On mobile: wrap button below header */ -@media (max-width: 767px) { - :global(.theme-doc-markdown header h1) { - width: 100%; - } - :global(#copy-page-button-container) { - margin-left: 0; - width: 100%; /* optional: take full width if you want */ - display: flex; - justify-content: flex-start; /* button aligns left under title */ - } -} From f1b2cb6ecea0045b3dc7d70171efe3af99217cce Mon Sep 17 00:00:00 2001 From: Chavez Harris Date: Sun, 11 Jan 2026 20:46:32 -0400 Subject: [PATCH 03/26] Fix copy page button loading delay --- website/src/components/CopyPageButton.tsx | 592 +++++++++++++++++++ website/src/css/copy-page-button.module.scss | 140 +++++ website/src/css/doc-item-content.module.scss | 10 + website/src/theme/DocItem/Content/index.tsx | 44 ++ 4 files changed, 786 insertions(+) create mode 100644 website/src/components/CopyPageButton.tsx create mode 100644 website/src/css/copy-page-button.module.scss create mode 100644 website/src/css/doc-item-content.module.scss create mode 100644 website/src/theme/DocItem/Content/index.tsx diff --git a/website/src/components/CopyPageButton.tsx b/website/src/components/CopyPageButton.tsx new file mode 100644 index 000000000..ee4da970c --- /dev/null +++ b/website/src/components/CopyPageButton.tsx @@ -0,0 +1,592 @@ +import { useEffect, useRef, useState, useCallback, useMemo } from 'react'; +import styles from '../css/copy-page-button.module.scss'; + +// --- TYPES --- +type ActionId = 'copy' | 'view' | 'chatgpt' | 'claude'; + +interface DropdownItem { + id: ActionId; + title: string; + description: string; + icon: JSX.Element; + action: () => void; +} + +interface DropdownPosition { + top: number; + left: number; +} + +// --- CONFIGS --- + +const CONFIG = { + MOBILE_BREAKPOINT: 767, + DROPDOWN_OFFSET: 8, + DROPDOWN_WIDTH: 300, + DEBUG: process.env.NODE_ENV === 'development', + COPY_SUCCESS_DURATION: 2000, + MIN_CONTENT_LENGTH: 100, // Named constant for magic number +} as const; + +// static selectors for content cleanup +const DEFAULT_SELECTORS_TO_REMOVE = [ + '.theme-edit-this-page', + '.theme-last-updated', + '.pagination-nav', + '.theme-doc-breadcrumbs', + '.theme-doc-footer', + 'button', + '.copy-code-button', + '.buttonGroup', + '.clean-btn', + '.theme-code-block-title', + '.line-number', +] as const; + +const DEFAULT_CONTENT_SELECTORS = [ + 'main article', + 'main .markdown', + 'main', + 'article', + '.main-wrapper', + '[role="main"]', +] as const; + +const ENABLED_ACTIONS = ['copy', 'view', 'chatgpt', 'claude'] as const; + +// --- UTILS --- + +const log = (...args: any[]) => { + if (CONFIG.DEBUG) { + console.log('[CopyPageButton]', ...args); + } +}; + +// Extracted text cleaning utility +const cleanSpecialChars = (text: string): string => { + return text + .replace(/[\u200B-\u200D\uFEFF]/g, '') // Remove zero-width spaces + .replace(/\u00A0/g, ' ') // Replace non-breaking spaces + .replace(/[\u2018\u2019]/g, "'") // Smart quotes + .replace(/[\u201C\u201D]/g, '"') + .replace(/​/g, '') // Clean encoding issues + .replace(/\s+/g, ' ') // Normalize whitespace + .trim(); +}; + +const sanitizeContent = (content: string): string => { + return content + .replace(/)<[^<]*)*<\/script>/gi, '') + .replace(/javascript:/gi, '') + .replace(/on\w+\s*=/gi, ''); +}; + +// --- CONTENT EXTRACTION --- + +const findContentElement = (): HTMLElement | null => { + for (const selector of DEFAULT_CONTENT_SELECTORS) { + const element = document.querySelector(selector); + if ( + element?.textContent && + element.textContent.trim().length > CONFIG.MIN_CONTENT_LENGTH + ) { + log('Found content with selector:', selector); + return element as HTMLElement; + } + } + return null; +}; + +const extractCodeContent = (codeElement: HTMLElement): string => { + // Strategy 1: Data attributes + const dataContent = + codeElement.getAttribute('data-code') || + codeElement.getAttribute('data-raw'); + if (dataContent) return dataContent; + + // Strategy 2: Line-based elements + const lineSelectors = + 'span[data-line], .token-line, .code-line, .highlight-line'; + const codeLines = codeElement.querySelectorAll(lineSelectors); + if (codeLines.length > 0) { + return Array.from(codeLines) + .map((line) => line.textContent || '') + .join('\n'); + } + + // Strategy 3: Div-based structure + const codeLineDivs = codeElement.querySelectorAll('div'); + if (codeLineDivs.length > 0) { + return Array.from(codeLineDivs) + .filter((div) => { + const className = div.className || ''; + return ( + !className.includes('codeLineNumber') && + !className.includes('LineNumber') && + !className.includes('line-number') && + div.style?.userSelect !== 'none' + ); + }) + .map((div) => div.textContent || '') + .join('\n'); + } + + // Strategy 4: Direct text with cleanup + return (codeElement.textContent || '') + .replace(/^\d+\s+/gm, '') // Remove line numbers + .replace(/^Copy$/gm, '') + .replace(/^Copied!$/gm, '') + .replace(/^\s*Copy to clipboard\s*$/gm, ''); +}; + +// -- MAIN COMPONENT -- + +export default function CopyPageButton() { + const enabledActions = ENABLED_ACTIONS; + const [isOpen, setIsOpen] = useState(false); + const [pageContent, setPageContent] = useState(''); + const [dropdownPosition, setDropdownPosition] = useState({ + top: 0, + left: 0, + }); + const dropdownRef = useRef(null); + const buttonRef = useRef(null); + + useEffect(() => { + const handleClickOutside = (event: Event) => { + if ( + dropdownRef.current && + !dropdownRef.current.contains(event.target as Node) && + buttonRef.current && + !buttonRef.current.contains(event.target as Node) + ) { + setIsOpen(false); + } + }; + + if (isOpen) { + document.addEventListener('mousedown', handleClickOutside); + } + + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [isOpen]); + + // Only recalculate position when isOpen changes to true + useEffect(() => { + if (isOpen && buttonRef.current) { + const rect = buttonRef.current.getBoundingClientRect(); + const isMobile = window.innerWidth <= CONFIG.MOBILE_BREAKPOINT; + + setDropdownPosition({ + top: rect.bottom + CONFIG.DROPDOWN_OFFSET, + left: isMobile ? rect.left : rect.right - CONFIG.DROPDOWN_WIDTH, + }); + } + }, [isOpen]); + + const convertToMarkdown = useCallback((element: HTMLElement): string => { + const cleanText = (text: string) => cleanSpecialChars(text); + + const processNode = (node: Node): string => { + if (node.nodeType === Node.TEXT_NODE) { + return cleanText(node.textContent || ''); + } + + if (node.nodeType === Node.ELEMENT_NODE) { + const el = node as HTMLElement; + const tag = el.tagName.toLowerCase(); + const childResults = Array.from(node.childNodes).map((child) => + processNode(child), + ); + + const children = childResults + .reduce((acc: string[], current: string, i: number) => { + if (!current) return acc; + + const previous = i > 0 ? childResults[i - 1] : ''; + const needsSpace = + previous && + !previous.match(/[\s\n]$/) && + !current.match(/^[\s\n]/) && + previous.trim() && + current.trim(); + + if (needsSpace && acc.length > 0) { + acc.push(' '); + } + acc.push(current); + return acc; + }, []) + .join(''); + + switch (tag) { + case 'h1': + return `\n# ${children.trim()}\n\n`; + case 'h2': + return `\n## ${children.trim()}\n\n`; + case 'h3': + return `\n### ${children.trim()}\n\n`; + case 'h4': + return `\n#### ${children.trim()}\n\n`; + case 'h5': + return `\n##### ${children.trim()}\n\n`; + case 'h6': + return `\n###### ${children.trim()}\n\n`; + case 'p': + return children.trim() ? `${children.trim()}\n\n` : '\n'; + case 'strong': + case 'b': + return `**${children}**`; + case 'em': + case 'i': + return `*${children}*`; + case 'code': + if (el.parentElement?.tagName.toLowerCase() === 'pre') { + return children; + } + return `\`${cleanSpecialChars(children)}\``; + case 'pre': + const codeElement = el.querySelector('code'); + if (codeElement) { + const language = + (codeElement.className?.match(/language-(\w+)/) || + el.className?.match(/language-(\w+)/) || + codeElement.className?.match(/hljs-(\w+)/) || + codeElement.className?.match(/prism-(\w+)/) || + [])[1] || ''; + + const codeContent = cleanSpecialChars( + extractCodeContent(codeElement), + ).replace(/^\n+|\n+$/g, ''); + + return `\n\`\`\`${language}\n${codeContent}\n\`\`\`\n\n`; + } + return `\n\`\`\`\n${children}\n\`\`\`\n\n`; + case 'ul': + return `\n${children}`; + case 'ol': + const items = Array.from(el.querySelectorAll('li')); + return ( + '\n' + + items + .map( + (item, index) => + `${index + 1}. ${processNode(item) + .replace(/^- /, '') + .trim()}\n`, + ) + .join('') + ); + case 'li': + return `- ${children.trim()}\n`; + case 'a': + const href = el.getAttribute('href'); + if (href && !href.startsWith('#') && children.trim()) { + return `[${children.trim()}](${href})`; + } + return children; + case 'br': + return '\n'; + case 'blockquote': + return `\n> ${children.trim()}\n\n`; + case 'table': + return `\n${children}\n`; + case 'tr': + return `${children}\n`; + case 'th': + case 'td': + return `| ${children.trim()} `; + case 'img': + const src = el.getAttribute('src'); + const alt = el.getAttribute('alt') || ''; + return src ? `![${alt}](${src})` : ''; + case 'div': + case 'section': + case 'article': + if (el.classList?.contains('admonition')) { + const type = + Array.from(el.classList) + .find((cls) => cls.startsWith('alert--')) + ?.replace('alert--', '') || 'note'; + return `\n> **${type.toUpperCase()}**: ${children.trim()}\n\n`; + } + return children + '\n'; + default: + return children; + } + } + + return ''; + }; + + return processNode(element) + .replace(/\n{3,}/g, '\n\n') + .replace(/^\n+|\n+$/g, '') + .trim(); + }, []); + + const extractPageContent = useCallback(() => { + log('Extracting page content...'); + const mainContent = findContentElement(); + if (!mainContent) { + log('No main content found on page'); + return ''; + } + + const clone = mainContent.cloneNode(true) as HTMLElement; + + DEFAULT_SELECTORS_TO_REMOVE.forEach((selector) => { + try { + clone.querySelectorAll(selector).forEach((el) => el.remove()); + } catch (err) { + log('Error removing selector:', selector, err); + } + }); + + const firstH1 = clone.querySelector('h1'); + const title = firstH1?.textContent?.trim() || 'Documentation Page'; + log('Extracted title:', title); + if (firstH1) { + firstH1.remove(); + } + + const content = convertToMarkdown(clone); + const currentUrl = window.location.href; + const finalContent = `# ${title}\n\nURL: ${currentUrl}\n\n${content}`; + + return sanitizeContent(finalContent); + }, [convertToMarkdown]); + + const getPageContent = useCallback(() => { + if (pageContent) return pageContent; + + const extracted = extractPageContent(); + setPageContent(extracted); + return extracted; + }, [pageContent, extractPageContent]); + + const copyToClipboard = useCallback(async () => { + const content = getPageContent(); + + if (!content || content.trim() === '') { + log('Failed to extract content'); + return; + } + + try { + if (navigator.clipboard && navigator.clipboard.writeText) { + await navigator.clipboard.writeText(content); + log('Content copied to clipboard successfully'); + } else { + const textArea = document.createElement('textarea'); + textArea.value = content; + document.body.appendChild(textArea); + textArea.select(); + document.execCommand('copy'); + document.body.removeChild(textArea); + log('Content copied to clipboard using fallback method'); + } + } catch (err) { + log('Failed to copy to clipboard:', err); + } + }, [getPageContent]); + + const openInAI = useCallback((baseUrl: string) => { + try { + const currentUrl = window.location.href; + const prompt = encodeURIComponent( + `Please read and explain this documentation page: ${currentUrl} + +Please provide a clear summary and help me understand the key concepts covered in this documentation.`, + ); + window.open(`${baseUrl}?q=${prompt}`, '_blank'); + log('Opened AI tool with prompt'); + } catch (err) { + log('Failed to open AI tool:', err); + } + }, []); + + const viewAsMarkdown = useCallback(() => { + const content = getPageContent(); + + if (!content || content.trim() === '') { + log('Failed to extract content'); + return; + } + + try { + const blob = new Blob([content], { type: 'text/plain' }); + const url = URL.createObjectURL(blob); + window.open(url, '_blank'); + log('Opened markdown view'); + } catch (err) { + log('Failed to open markdown view:', err); + } + }, [getPageContent]); + + const allDropdownItems: DropdownItem[] = useMemo( + () => [ + { + id: 'copy', + title: 'Copy page', + description: 'Copy the page as Markdown for LLMs', + icon: ( + + + + + ), + action: copyToClipboard, + }, + { + id: 'view', + title: 'View as Markdown', + description: 'View this page as plain text', + icon: ( + + + + + + + ), + action: viewAsMarkdown, + }, + { + id: 'chatgpt', + title: 'Open in ChatGPT', + description: 'Ask questions about this page', + icon: ( + + + + ), + action: () => openInAI('https://chat.openai.com/'), + }, + { + id: 'claude', + title: 'Open in Claude', + description: 'Ask questions about this page', + icon: ( + + + + ), + action: () => openInAI('https://claude.ai/new'), + }, + ], + [copyToClipboard, viewAsMarkdown, openInAI], + ); + + const dropdownItems = useMemo( + () => allDropdownItems.filter((item) => enabledActions.includes(item.id)), + [allDropdownItems, enabledActions], + ); + + return ( + <> +
+ +
+ + {isOpen && ( +
+ {dropdownItems.map((item) => ( + + ))} +
+ )} + + ); +} diff --git a/website/src/css/copy-page-button.module.scss b/website/src/css/copy-page-button.module.scss new file mode 100644 index 000000000..f4f1127f2 --- /dev/null +++ b/website/src/css/copy-page-button.module.scss @@ -0,0 +1,140 @@ +.copyPageButtonContainer { + position: relative; + display: inline-block; +} + +.copyPageButton { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + margin-bottom: 16px; + background: var(--ifm-navbar-background-color, #1c1e21); + border: 1px solid var(--ifm-color-emphasis-300); + border-radius: 6px; + color: var(--ifm-navbar-link-color); + cursor: pointer; + font-size: 14px; + font-weight: 500; + transition: all 0.2s ease; + white-space: nowrap; +} + +.copyPageButton:hover { + background: var(--ifm-color-emphasis-100); + border-color: var(--ifm-color-emphasis-400); +} + +.copyPageButton:focus { + background: var(--ifm-color-emphasis-200, #eee); + border-color: var(--ifm-color-primary, #007acc); + outline: none; +} + +.chevron { + transition: transform 0.2s ease; +} + +.chevron.open { + transform: rotate(180deg); +} + +.copyPageDropdown { + min-width: 300px; + background: var(--ifm-dropdown-background-color, #1c1e21); + border: 1px solid var(--ifm-color-emphasis-300); + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + overflow: hidden; + background-color: pink; +} + +.dropdownItem { + display: flex; + align-items: center; + gap: 12px; + width: 100%; + padding: 12px 16px; + background: transparent; + border: none; + color: var(--ifm-font-color-base); + cursor: pointer; + text-align: left; + transition: background-color 0.2s ease; + border-bottom: 1px solid var(--ifm-color-emphasis-200); +} + +.dropdownItem:last-child { + border-bottom: none; +} + +.dropdownItem:hover { + background: var(--ifm-color-emphasis-100); +} + +.dropdownItem svg { + flex-shrink: 0; + opacity: 0.7; +} + +.itemTitle { + font-size: 14px; + font-weight: 500; + margin-bottom: 2px; + color: var(--ifm-font-color-base); +} + +.itemDescription { + font-size: 13px; + color: var(--ifm-color-emphasis-700); + line-height: 1.3; +} + +[data-theme='dark'] .copyPageButton { + background: var(--ifm-navbar-background-color); + border-color: var(--ifm-color-emphasis-300); +} + +[data-theme='dark'] .copyPageButton:hover { + background: var(--ifm-color-emphasis-200); +} + +[data-theme='dark'] .copyPageDropdown { + background: var(--ifm-dropdown-background-color); + border-color: var(--ifm-color-emphasis-300); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25); +} + +[data-theme='dark'] .dropdownItem:hover { + background: var(--ifm-color-emphasis-200); +} + +[data-theme='light'] .copyPageButton { + background: #ffffff; + border-color: #d0d7de; + color: #24292f; +} + +[data-theme='light'] .copyPageButton:hover { + background: #f6f8fa; + border-color: #8c959f; +} + +[data-theme='light'] .copyPageDropdown { + background: #ffffff; + border-color: #d0d7de; + box-shadow: 0 8px 24px rgba(140, 149, 159, 0.2); +} + +[data-theme='light'] .dropdownItem { + color: #24292f; + border-color: #d0d7de; +} + +[data-theme='light'] .dropdownItem:hover { + background: #f6f8fa; +} + +[data-theme='light'] .itemDescription { + color: #656d76; +} diff --git a/website/src/css/doc-item-content.module.scss b/website/src/css/doc-item-content.module.scss new file mode 100644 index 000000000..a544a95f6 --- /dev/null +++ b/website/src/css/doc-item-content.module.scss @@ -0,0 +1,10 @@ +header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; + + @media (max-width: 767px) { + display: block; + } +} diff --git a/website/src/theme/DocItem/Content/index.tsx b/website/src/theme/DocItem/Content/index.tsx new file mode 100644 index 000000000..dd0207bb1 --- /dev/null +++ b/website/src/theme/DocItem/Content/index.tsx @@ -0,0 +1,44 @@ +import React, { type ReactNode } from 'react'; +import clsx from 'clsx'; +import { ThemeClassNames } from '@docusaurus/theme-common'; +import { useDoc } from '@docusaurus/plugin-content-docs/client'; +import Heading from '@theme/Heading'; +import MDXContent from '@theme/MDXContent'; +import type { Props } from '@theme/DocItem/Content'; +import CopyPageButton from '@site/src/components/CopyPageButton'; +import styles from '@site/src/css/doc-item-content.module.scss'; + +/** + Title can be declared inside md content or declared through + front matter and added manually. To make both cases consistent, + the added title is added under the same div.markdown block + See https://github.com/facebook/docusaurus/pull/4882#issuecomment-853021120 + + We render a "synthetic title" if: + - user doesn't ask to hide it with front matter + - the markdown content does not already contain a top-level h1 heading +*/ +function useSyntheticTitle(): string | null { + const { metadata, frontMatter, contentTitle } = useDoc(); + const shouldRender = + !frontMatter.hide_title && typeof contentTitle === 'undefined'; + if (!shouldRender) { + return null; + } + return metadata.title; +} + +export default function DocItemContent({ children }: Props): ReactNode { + const syntheticTitle = useSyntheticTitle(); + return ( +
+ {syntheticTitle && ( +
+ {syntheticTitle} + +
+ )} + {children} +
+ ); +} From 43c4227a4b7b91feb81346303decd1a85fcf958f Mon Sep 17 00:00:00 2001 From: Chavez Harris Date: Mon, 12 Jan 2026 14:24:01 -0400 Subject: [PATCH 04/26] Remove `copy-page-button` plugin. --- website/docusaurus.config.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/website/docusaurus.config.ts b/website/docusaurus.config.ts index a889af696..3e0cfc7af 100644 --- a/website/docusaurus.config.ts +++ b/website/docusaurus.config.ts @@ -217,7 +217,6 @@ const config: Config = { // smartlookKey: '05d0e4ca90c61150955104a9d4b76ab16a0b2380', // } // ], - // require.resolve('./src/plugins/copy-page-button'), ], themeConfig: { image: '/img/docs-cover.png', From e469c2e6ae544e5ffc7f45caf45f698d099c4f56 Mon Sep 17 00:00:00 2001 From: Chavez Harris Date: Mon, 12 Jan 2026 14:50:05 -0400 Subject: [PATCH 05/26] Removed unused config --- website/src/components/CopyPageButton.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/website/src/components/CopyPageButton.tsx b/website/src/components/CopyPageButton.tsx index ee4da970c..667e12cd7 100644 --- a/website/src/components/CopyPageButton.tsx +++ b/website/src/components/CopyPageButton.tsx @@ -24,7 +24,6 @@ const CONFIG = { DROPDOWN_OFFSET: 8, DROPDOWN_WIDTH: 300, DEBUG: process.env.NODE_ENV === 'development', - COPY_SUCCESS_DURATION: 2000, MIN_CONTENT_LENGTH: 100, // Named constant for magic number } as const; From e793c5baefa7242781c786b6ba0e36df2498ba3b Mon Sep 17 00:00:00 2001 From: Chavez Harris Date: Mon, 12 Jan 2026 14:53:56 -0400 Subject: [PATCH 06/26] Updates --- website/src/components/CopyPageButton.tsx | 24 +++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/website/src/components/CopyPageButton.tsx b/website/src/components/CopyPageButton.tsx index 667e12cd7..29714ad43 100644 --- a/website/src/components/CopyPageButton.tsx +++ b/website/src/components/CopyPageButton.tsx @@ -24,7 +24,7 @@ const CONFIG = { DROPDOWN_OFFSET: 8, DROPDOWN_WIDTH: 300, DEBUG: process.env.NODE_ENV === 'development', - MIN_CONTENT_LENGTH: 100, // Named constant for magic number + MIN_CONTENT_LENGTH: 100, } as const; // static selectors for content cleanup @@ -61,15 +61,15 @@ const log = (...args: any[]) => { } }; -// Extracted text cleaning utility +// Text cleaning const cleanSpecialChars = (text: string): string => { return text - .replace(/[\u200B-\u200D\uFEFF]/g, '') // Remove zero-width spaces - .replace(/\u00A0/g, ' ') // Replace non-breaking spaces - .replace(/[\u2018\u2019]/g, "'") // Smart quotes + .replace(/[\u200B-\u200D\uFEFF]/g, '') + .replace(/\u00A0/g, ' ') + .replace(/[\u2018\u2019]/g, "'") .replace(/[\u201C\u201D]/g, '"') - .replace(/​/g, '') // Clean encoding issues - .replace(/\s+/g, ' ') // Normalize whitespace + .replace(/​/g, '') + .replace(/\s+/g, ' ') .trim(); }; @@ -97,13 +97,13 @@ const findContentElement = (): HTMLElement | null => { }; const extractCodeContent = (codeElement: HTMLElement): string => { - // Strategy 1: Data attributes + // 1: Data attributes const dataContent = codeElement.getAttribute('data-code') || codeElement.getAttribute('data-raw'); if (dataContent) return dataContent; - // Strategy 2: Line-based elements + // 2: Line-based elements const lineSelectors = 'span[data-line], .token-line, .code-line, .highlight-line'; const codeLines = codeElement.querySelectorAll(lineSelectors); @@ -113,7 +113,7 @@ const extractCodeContent = (codeElement: HTMLElement): string => { .join('\n'); } - // Strategy 3: Div-based structure + // 3: Div-based structure const codeLineDivs = codeElement.querySelectorAll('div'); if (codeLineDivs.length > 0) { return Array.from(codeLineDivs) @@ -130,7 +130,7 @@ const extractCodeContent = (codeElement: HTMLElement): string => { .join('\n'); } - // Strategy 4: Direct text with cleanup + // 4: Direct text return (codeElement.textContent || '') .replace(/^\d+\s+/gm, '') // Remove line numbers .replace(/^Copy$/gm, '') @@ -172,7 +172,7 @@ export default function CopyPageButton() { }; }, [isOpen]); - // Only recalculate position when isOpen changes to true + // Recalculate position when isOpen changes to true useEffect(() => { if (isOpen && buttonRef.current) { const rect = buttonRef.current.getBoundingClientRect(); From 946fb6155747f702e5a738d5b2b8cb7fdb4c9b97 Mon Sep 17 00:00:00 2001 From: Chavez Harris Date: Mon, 12 Jan 2026 14:59:20 -0400 Subject: [PATCH 07/26] Updates --- website/src/components/CopyPageButton.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/website/src/components/CopyPageButton.tsx b/website/src/components/CopyPageButton.tsx index 29714ad43..c9bc417f5 100644 --- a/website/src/components/CopyPageButton.tsx +++ b/website/src/components/CopyPageButton.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef, useState, useCallback, useMemo } from 'react'; import styles from '../css/copy-page-button.module.scss'; // --- TYPES --- + type ActionId = 'copy' | 'view' | 'chatgpt' | 'claude'; interface DropdownItem { @@ -73,6 +74,7 @@ const cleanSpecialChars = (text: string): string => { .trim(); }; +// Sanitize content const sanitizeContent = (content: string): string => { return content .replace(/)<[^<]*)*<\/script>/gi, '') @@ -138,7 +140,7 @@ const extractCodeContent = (codeElement: HTMLElement): string => { .replace(/^\s*Copy to clipboard\s*$/gm, ''); }; -// -- MAIN COMPONENT -- + export default function CopyPageButton() { const enabledActions = ENABLED_ACTIONS; From cf273701cb15263c7d493cc9a4d52f16538c1b7c Mon Sep 17 00:00:00 2001 From: Chavez Harris Date: Mon, 12 Jan 2026 15:02:15 -0400 Subject: [PATCH 08/26] Update CopyPageButton.tsx --- website/src/components/CopyPageButton.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/src/components/CopyPageButton.tsx b/website/src/components/CopyPageButton.tsx index c9bc417f5..c0d71fc95 100644 --- a/website/src/components/CopyPageButton.tsx +++ b/website/src/components/CopyPageButton.tsx @@ -28,7 +28,7 @@ const CONFIG = { MIN_CONTENT_LENGTH: 100, } as const; -// static selectors for content cleanup +// Static selectors for content cleanup const DEFAULT_SELECTORS_TO_REMOVE = [ '.theme-edit-this-page', '.theme-last-updated', From 3d6c956edb1e99f09ef9a14731d9de086b6ac44f Mon Sep 17 00:00:00 2001 From: Chavez Harris Date: Mon, 12 Jan 2026 15:03:04 -0400 Subject: [PATCH 09/26] Update CopyPageButton.tsx --- website/src/components/CopyPageButton.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/website/src/components/CopyPageButton.tsx b/website/src/components/CopyPageButton.tsx index c0d71fc95..86664580c 100644 --- a/website/src/components/CopyPageButton.tsx +++ b/website/src/components/CopyPageButton.tsx @@ -140,8 +140,6 @@ const extractCodeContent = (codeElement: HTMLElement): string => { .replace(/^\s*Copy to clipboard\s*$/gm, ''); }; - - export default function CopyPageButton() { const enabledActions = ENABLED_ACTIONS; const [isOpen, setIsOpen] = useState(false); From 8247cc01286a725e58f9cbe78cdb30fca936e06d Mon Sep 17 00:00:00 2001 From: Chavez Harris Date: Tue, 13 Jan 2026 13:25:27 -0400 Subject: [PATCH 10/26] Moved the copy page button above the header so it shows on all page regardless of the synthetic header. --- website/src/css/copy-page-button.module.scss | 8 +++++++- website/src/css/doc-item-content.module.scss | 10 ---------- website/src/theme/DocItem/Content/index.tsx | 5 ++--- 3 files changed, 9 insertions(+), 14 deletions(-) delete mode 100644 website/src/css/doc-item-content.module.scss diff --git a/website/src/css/copy-page-button.module.scss b/website/src/css/copy-page-button.module.scss index f4f1127f2..e757866b1 100644 --- a/website/src/css/copy-page-button.module.scss +++ b/website/src/css/copy-page-button.module.scss @@ -1,6 +1,12 @@ .copyPageButtonContainer { position: relative; - display: inline-block; + display: flex; + width: 100%; + justify-content: right; + + @media screen and (max-width: 767px) { + justify-content: start; + } } .copyPageButton { diff --git a/website/src/css/doc-item-content.module.scss b/website/src/css/doc-item-content.module.scss deleted file mode 100644 index a544a95f6..000000000 --- a/website/src/css/doc-item-content.module.scss +++ /dev/null @@ -1,10 +0,0 @@ -header { - display: flex; - justify-content: space-between; - align-items: center; - gap: 1rem; - - @media (max-width: 767px) { - display: block; - } -} diff --git a/website/src/theme/DocItem/Content/index.tsx b/website/src/theme/DocItem/Content/index.tsx index dd0207bb1..d0f8db24d 100644 --- a/website/src/theme/DocItem/Content/index.tsx +++ b/website/src/theme/DocItem/Content/index.tsx @@ -6,7 +6,6 @@ import Heading from '@theme/Heading'; import MDXContent from '@theme/MDXContent'; import type { Props } from '@theme/DocItem/Content'; import CopyPageButton from '@site/src/components/CopyPageButton'; -import styles from '@site/src/css/doc-item-content.module.scss'; /** Title can be declared inside md content or declared through @@ -32,10 +31,10 @@ export default function DocItemContent({ children }: Props): ReactNode { const syntheticTitle = useSyntheticTitle(); return (
+ {syntheticTitle && ( -
+
{syntheticTitle} -
)} {children} From ed8185e0fe3c53b2ea4293dff092f4e382d7c53f Mon Sep 17 00:00:00 2001 From: Chavez Harris Date: Tue, 13 Jan 2026 13:36:19 -0400 Subject: [PATCH 11/26] Update CopyPageButton.tsx --- website/src/components/CopyPageButton.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/website/src/components/CopyPageButton.tsx b/website/src/components/CopyPageButton.tsx index 86664580c..113c2acca 100644 --- a/website/src/components/CopyPageButton.tsx +++ b/website/src/components/CopyPageButton.tsx @@ -21,7 +21,6 @@ interface DropdownPosition { // --- CONFIGS --- const CONFIG = { - MOBILE_BREAKPOINT: 767, DROPDOWN_OFFSET: 8, DROPDOWN_WIDTH: 300, DEBUG: process.env.NODE_ENV === 'development', @@ -176,11 +175,10 @@ export default function CopyPageButton() { useEffect(() => { if (isOpen && buttonRef.current) { const rect = buttonRef.current.getBoundingClientRect(); - const isMobile = window.innerWidth <= CONFIG.MOBILE_BREAKPOINT; setDropdownPosition({ top: rect.bottom + CONFIG.DROPDOWN_OFFSET, - left: isMobile ? rect.left : rect.right - CONFIG.DROPDOWN_WIDTH, + left: rect.left }); } }, [isOpen]); From 251a8768683b13e28c7b5f79248f2e87f930249d Mon Sep 17 00:00:00 2001 From: Chavez Harris Date: Tue, 13 Jan 2026 13:36:25 -0400 Subject: [PATCH 12/26] Update copy-page-button.module.scss --- website/src/css/copy-page-button.module.scss | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/website/src/css/copy-page-button.module.scss b/website/src/css/copy-page-button.module.scss index e757866b1..7b19d5f21 100644 --- a/website/src/css/copy-page-button.module.scss +++ b/website/src/css/copy-page-button.module.scss @@ -2,11 +2,7 @@ position: relative; display: flex; width: 100%; - justify-content: right; - - @media screen and (max-width: 767px) { - justify-content: start; - } + margin: 16px 0; } .copyPageButton { @@ -14,7 +10,6 @@ align-items: center; gap: 8px; padding: 8px 12px; - margin-bottom: 16px; background: var(--ifm-navbar-background-color, #1c1e21); border: 1px solid var(--ifm-color-emphasis-300); border-radius: 6px; From 516ea8bc757afc28a5bce027b12174d1eea8591d Mon Sep 17 00:00:00 2001 From: Chavez Harris Date: Wed, 14 Jan 2026 12:24:33 -0400 Subject: [PATCH 13/26] Use synthetic title --- website/docs/targeting/feature-flag-evaluation.mdx | 2 -- .../docs/targeting/targeting-rule/targeting-rule-overview.mdx | 4 +--- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/website/docs/targeting/feature-flag-evaluation.mdx b/website/docs/targeting/feature-flag-evaluation.mdx index e2be3077c..6f6b2d923 100644 --- a/website/docs/targeting/feature-flag-evaluation.mdx +++ b/website/docs/targeting/feature-flag-evaluation.mdx @@ -4,8 +4,6 @@ title: Feature Flag Evaluation description: This document offers an in-depth explanation of how the ConfigCat SDK determines the value of a feature flag. --- -# Feature Flag Evaluation - This document offers an in-depth explanation of how the SDK determines the value of a feature flag when executing the `GetValue` function. Understanding this process requires prior knowledge of [targeting concepts](../targeting-overview). The feature flag's value is determined by: diff --git a/website/docs/targeting/targeting-rule/targeting-rule-overview.mdx b/website/docs/targeting/targeting-rule/targeting-rule-overview.mdx index be5143407..b8c378a30 100644 --- a/website/docs/targeting/targeting-rule/targeting-rule-overview.mdx +++ b/website/docs/targeting/targeting-rule/targeting-rule-overview.mdx @@ -1,11 +1,9 @@ --- id: targeting-rule-overview -title: Targeting Rule Overview +title: Targeting Rule description: Targeting Rules allow you to set different feature flag values for specific users or groups of users in your application. --- -# Targeting Rule - ## What is a Targeting Rule? _Targeting Rules_ allow you to set different feature flag values for specific users or groups of users in your application. From 9339290b8b3be4cf4c17cb2dd1c91680cc06d12d Mon Sep 17 00:00:00 2001 From: Chavez Harris Date: Wed, 14 Jan 2026 14:42:37 -0400 Subject: [PATCH 14/26] Update button to sit next to the header. --- website/src/components/CopyPageButton.tsx | 4 +++- website/src/css/copy-page-button.module.scss | 5 ++--- website/src/css/doc-item-content.module.scss | 11 +++++++++++ website/src/theme/DocItem/Content/index.tsx | 5 +++-- 4 files changed, 19 insertions(+), 6 deletions(-) create mode 100644 website/src/css/doc-item-content.module.scss diff --git a/website/src/components/CopyPageButton.tsx b/website/src/components/CopyPageButton.tsx index 113c2acca..86664580c 100644 --- a/website/src/components/CopyPageButton.tsx +++ b/website/src/components/CopyPageButton.tsx @@ -21,6 +21,7 @@ interface DropdownPosition { // --- CONFIGS --- const CONFIG = { + MOBILE_BREAKPOINT: 767, DROPDOWN_OFFSET: 8, DROPDOWN_WIDTH: 300, DEBUG: process.env.NODE_ENV === 'development', @@ -175,10 +176,11 @@ export default function CopyPageButton() { useEffect(() => { if (isOpen && buttonRef.current) { const rect = buttonRef.current.getBoundingClientRect(); + const isMobile = window.innerWidth <= CONFIG.MOBILE_BREAKPOINT; setDropdownPosition({ top: rect.bottom + CONFIG.DROPDOWN_OFFSET, - left: rect.left + left: isMobile ? rect.left : rect.right - CONFIG.DROPDOWN_WIDTH, }); } }, [isOpen]); diff --git a/website/src/css/copy-page-button.module.scss b/website/src/css/copy-page-button.module.scss index 7b19d5f21..f4f1127f2 100644 --- a/website/src/css/copy-page-button.module.scss +++ b/website/src/css/copy-page-button.module.scss @@ -1,8 +1,6 @@ .copyPageButtonContainer { position: relative; - display: flex; - width: 100%; - margin: 16px 0; + display: inline-block; } .copyPageButton { @@ -10,6 +8,7 @@ align-items: center; gap: 8px; padding: 8px 12px; + margin-bottom: 16px; background: var(--ifm-navbar-background-color, #1c1e21); border: 1px solid var(--ifm-color-emphasis-300); border-radius: 6px; diff --git a/website/src/css/doc-item-content.module.scss b/website/src/css/doc-item-content.module.scss new file mode 100644 index 000000000..d89e04b25 --- /dev/null +++ b/website/src/css/doc-item-content.module.scss @@ -0,0 +1,11 @@ +header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; + + @media (max-width: 767px) { + display: block; + } + } + \ No newline at end of file diff --git a/website/src/theme/DocItem/Content/index.tsx b/website/src/theme/DocItem/Content/index.tsx index d0f8db24d..dd0207bb1 100644 --- a/website/src/theme/DocItem/Content/index.tsx +++ b/website/src/theme/DocItem/Content/index.tsx @@ -6,6 +6,7 @@ import Heading from '@theme/Heading'; import MDXContent from '@theme/MDXContent'; import type { Props } from '@theme/DocItem/Content'; import CopyPageButton from '@site/src/components/CopyPageButton'; +import styles from '@site/src/css/doc-item-content.module.scss'; /** Title can be declared inside md content or declared through @@ -31,10 +32,10 @@ export default function DocItemContent({ children }: Props): ReactNode { const syntheticTitle = useSyntheticTitle(); return (
- {syntheticTitle && ( -
+
{syntheticTitle} +
)} {children} From 542f8474350813a0a79f30d6f0e695adcf770c33 Mon Sep 17 00:00:00 2001 From: Chavez Harris Date: Thu, 15 Jan 2026 19:44:08 -0400 Subject: [PATCH 15/26] Add copy page button to the API Documentation and across all pages with a single solution. --- website/src/css/doc-item-content.module.scss | 11 ---- website/src/theme/DocItem/Content/index.tsx | 44 --------------- website/src/theme/Heading/index.tsx | 57 ++++++++++++++++++++ website/src/theme/Heading/styles.module.css | 27 ++++++++++ 4 files changed, 84 insertions(+), 55 deletions(-) delete mode 100644 website/src/css/doc-item-content.module.scss delete mode 100644 website/src/theme/DocItem/Content/index.tsx create mode 100644 website/src/theme/Heading/index.tsx create mode 100644 website/src/theme/Heading/styles.module.css diff --git a/website/src/css/doc-item-content.module.scss b/website/src/css/doc-item-content.module.scss deleted file mode 100644 index d89e04b25..000000000 --- a/website/src/css/doc-item-content.module.scss +++ /dev/null @@ -1,11 +0,0 @@ -header { - display: flex; - justify-content: space-between; - align-items: center; - gap: 1rem; - - @media (max-width: 767px) { - display: block; - } - } - \ No newline at end of file diff --git a/website/src/theme/DocItem/Content/index.tsx b/website/src/theme/DocItem/Content/index.tsx deleted file mode 100644 index dd0207bb1..000000000 --- a/website/src/theme/DocItem/Content/index.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import React, { type ReactNode } from 'react'; -import clsx from 'clsx'; -import { ThemeClassNames } from '@docusaurus/theme-common'; -import { useDoc } from '@docusaurus/plugin-content-docs/client'; -import Heading from '@theme/Heading'; -import MDXContent from '@theme/MDXContent'; -import type { Props } from '@theme/DocItem/Content'; -import CopyPageButton from '@site/src/components/CopyPageButton'; -import styles from '@site/src/css/doc-item-content.module.scss'; - -/** - Title can be declared inside md content or declared through - front matter and added manually. To make both cases consistent, - the added title is added under the same div.markdown block - See https://github.com/facebook/docusaurus/pull/4882#issuecomment-853021120 - - We render a "synthetic title" if: - - user doesn't ask to hide it with front matter - - the markdown content does not already contain a top-level h1 heading -*/ -function useSyntheticTitle(): string | null { - const { metadata, frontMatter, contentTitle } = useDoc(); - const shouldRender = - !frontMatter.hide_title && typeof contentTitle === 'undefined'; - if (!shouldRender) { - return null; - } - return metadata.title; -} - -export default function DocItemContent({ children }: Props): ReactNode { - const syntheticTitle = useSyntheticTitle(); - return ( -
- {syntheticTitle && ( -
- {syntheticTitle} - -
- )} - {children} -
- ); -} diff --git a/website/src/theme/Heading/index.tsx b/website/src/theme/Heading/index.tsx new file mode 100644 index 000000000..32b3950b6 --- /dev/null +++ b/website/src/theme/Heading/index.tsx @@ -0,0 +1,57 @@ +import React, {type ReactNode} from 'react'; +import clsx from 'clsx'; +import {translate} from '@docusaurus/Translate'; +import {useAnchorTargetClassName} from '@docusaurus/theme-common'; +import Link from '@docusaurus/Link'; +import useBrokenLinks from '@docusaurus/useBrokenLinks'; +import type {Props} from '@theme/Heading'; +import './styles.module.css'; +import CopyPageButton from '@site/src/components/CopyPageButton'; + +export default function Heading({as: As, id, ...props}: Props): ReactNode { + const brokenLinks = useBrokenLinks(); + const anchorTargetClassName = useAnchorTargetClassName(id); + + // H1 headings do not need an id because they don't appear in the TOC. + if (As === 'h1' || !id) { + return ( +
+ + {As === 'h1' && ( + + ) } +
+ + ) + } + + brokenLinks.collectAnchor(id); + + const anchorTitle = translate( + { + id: 'theme.common.headingLinkTitle', + message: 'Direct link to {heading}', + description: 'Title for link to heading', + }, + { + heading: typeof props.children === 'string' ? props.children : id, + }, + ); + + return ( + + {props.children} + + ​ + + + ); +} diff --git a/website/src/theme/Heading/styles.module.css b/website/src/theme/Heading/styles.module.css new file mode 100644 index 000000000..205d9250a --- /dev/null +++ b/website/src/theme/Heading/styles.module.css @@ -0,0 +1,27 @@ +:global(.hash-link) { + opacity: 0; + padding-left: 0.5rem; + transition: opacity var(--ifm-transition-fast); + user-select: none; +} + +:global(.hash-link::before) { + content: '#'; +} + +:global(.hash-link:focus), +:global(*:hover > .hash-link) { + opacity: 1; +} + +/* Custom Styles */ +:global(.custom-header) { + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; + + @media (max-width: 767px) { + display: block; + } +} From fd64c10a32fde0827875fbdedf2d2618690e8b1e Mon Sep 17 00:00:00 2001 From: Chavez Harris <74829200+codedbychavez@users.noreply.github.com> Date: Thu, 15 Jan 2026 19:46:59 -0400 Subject: [PATCH 16/26] Update website/src/theme/Heading/index.tsx Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- website/src/theme/Heading/index.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/website/src/theme/Heading/index.tsx b/website/src/theme/Heading/index.tsx index 32b3950b6..7b56dee13 100644 --- a/website/src/theme/Heading/index.tsx +++ b/website/src/theme/Heading/index.tsx @@ -15,10 +15,10 @@ export default function Heading({as: As, id, ...props}: Props): ReactNode { // H1 headings do not need an id because they don't appear in the TOC. if (As === 'h1' || !id) { return ( -
- {As === 'h1' && ( + )} + ) }
From bf17fb2a4ab81ed8e9ad913726e6db6562f2538a Mon Sep 17 00:00:00 2001 From: Chavez Harris Date: Thu, 15 Jan 2026 20:26:25 -0400 Subject: [PATCH 17/26] Updates --- website/src/theme/Heading/index.tsx | 7 ++----- website/src/theme/Heading/styles.module.css | 2 +- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/website/src/theme/Heading/index.tsx b/website/src/theme/Heading/index.tsx index 32b3950b6..646938226 100644 --- a/website/src/theme/Heading/index.tsx +++ b/website/src/theme/Heading/index.tsx @@ -15,13 +15,10 @@ export default function Heading({as: As, id, ...props}: Props): ReactNode { // H1 headings do not need an id because they don't appear in the TOC. if (As === 'h1' || !id) { return ( -
+
- {As === 'h1' && ( - - ) } + {As === 'h1' && }
- ) } diff --git a/website/src/theme/Heading/styles.module.css b/website/src/theme/Heading/styles.module.css index 205d9250a..988da3744 100644 --- a/website/src/theme/Heading/styles.module.css +++ b/website/src/theme/Heading/styles.module.css @@ -15,7 +15,7 @@ } /* Custom Styles */ -:global(.custom-header) { +:global(.custom-h1-wrapper) { display: flex; justify-content: space-between; align-items: center; From 2902ede6b983d3b155e106e12ff1485d80ac4373 Mon Sep 17 00:00:00 2001 From: Chavez Harris <74829200+codedbychavez@users.noreply.github.com> Date: Thu, 15 Jan 2026 20:32:41 -0400 Subject: [PATCH 18/26] Update website/src/theme/Heading/index.tsx Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- website/src/theme/Heading/index.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/website/src/theme/Heading/index.tsx b/website/src/theme/Heading/index.tsx index 646938226..3741dd277 100644 --- a/website/src/theme/Heading/index.tsx +++ b/website/src/theme/Heading/index.tsx @@ -14,11 +14,12 @@ export default function Heading({as: As, id, ...props}: Props): ReactNode { // H1 headings do not need an id because they don't appear in the TOC. if (As === 'h1' || !id) { - return (
{As === 'h1' && }
+ {As === 'h1' && } +
) } From f3646cfc25ccd67d840b77167498662b0ad3bf32 Mon Sep 17 00:00:00 2001 From: Chavez Harris Date: Fri, 16 Jan 2026 07:17:43 -0400 Subject: [PATCH 19/26] Updates --- website/src/components/CopyPageButton.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/website/src/components/CopyPageButton.tsx b/website/src/components/CopyPageButton.tsx index 86664580c..ad2734213 100644 --- a/website/src/components/CopyPageButton.tsx +++ b/website/src/components/CopyPageButton.tsx @@ -26,7 +26,7 @@ const CONFIG = { DROPDOWN_WIDTH: 300, DEBUG: process.env.NODE_ENV === 'development', MIN_CONTENT_LENGTH: 100, -} as const; +}; // Static selectors for content cleanup const DEFAULT_SELECTORS_TO_REMOVE = [ @@ -41,7 +41,7 @@ const DEFAULT_SELECTORS_TO_REMOVE = [ '.clean-btn', '.theme-code-block-title', '.line-number', -] as const; +]; const DEFAULT_CONTENT_SELECTORS = [ 'main article', From 52d44c7a1e8ba8fe7f5292192539670e51898889 Mon Sep 17 00:00:00 2001 From: Chavez Harris <74829200+codedbychavez@users.noreply.github.com> Date: Fri, 16 Jan 2026 07:21:25 -0400 Subject: [PATCH 20/26] Update website/src/theme/Heading/index.tsx Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- website/src/theme/Heading/index.tsx | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/website/src/theme/Heading/index.tsx b/website/src/theme/Heading/index.tsx index 3741dd277..85281f687 100644 --- a/website/src/theme/Heading/index.tsx +++ b/website/src/theme/Heading/index.tsx @@ -13,13 +13,18 @@ export default function Heading({as: As, id, ...props}: Props): ReactNode { const anchorTargetClassName = useAnchorTargetClassName(id); // H1 headings do not need an id because they don't appear in the TOC. - if (As === 'h1' || !id) { + // H1 headings do not need an id because they don't appear in the TOC. + if (As === 'h1') { + return (
- {As === 'h1' && } -
- {As === 'h1' && } +
+ ); + } + if (!id) { + return ; + } ) } From 547e220a2c71632b6ff4165cafe9983dafcc79d6 Mon Sep 17 00:00:00 2001 From: Chavez Harris Date: Fri, 16 Jan 2026 07:29:35 -0400 Subject: [PATCH 21/26] Improvements --- website/src/theme/Heading/index.tsx | 32 ++++++++++++++--------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/website/src/theme/Heading/index.tsx b/website/src/theme/Heading/index.tsx index 85281f687..65d883abf 100644 --- a/website/src/theme/Heading/index.tsx +++ b/website/src/theme/Heading/index.tsx @@ -1,32 +1,32 @@ -import React, {type ReactNode} from 'react'; +import React, { type ReactNode } from 'react'; import clsx from 'clsx'; -import {translate} from '@docusaurus/Translate'; -import {useAnchorTargetClassName} from '@docusaurus/theme-common'; +import { translate } from '@docusaurus/Translate'; +import { useAnchorTargetClassName } from '@docusaurus/theme-common'; import Link from '@docusaurus/Link'; import useBrokenLinks from '@docusaurus/useBrokenLinks'; -import type {Props} from '@theme/Heading'; +import type { Props } from '@theme/Heading'; import './styles.module.css'; import CopyPageButton from '@site/src/components/CopyPageButton'; -export default function Heading({as: As, id, ...props}: Props): ReactNode { +export default function Heading({ as: As, id, ...props }: Props): ReactNode { const brokenLinks = useBrokenLinks(); const anchorTargetClassName = useAnchorTargetClassName(id); // H1 headings do not need an id because they don't appear in the TOC. - // H1 headings do not need an id because they don't appear in the TOC. - if (As === 'h1') { + if (As === 'h1' || !id) { return ( -
- - -
+ <> + {As === 'h1' ? ( +
+ + +
+ ) : ( + + )} + ); } - if (!id) { - return ; - } - ) - } brokenLinks.collectAnchor(id); From 1875296fe0c3b359eea2a0ee10e72a37ac798291 Mon Sep 17 00:00:00 2001 From: Chavez Harris Date: Fri, 16 Jan 2026 07:38:51 -0400 Subject: [PATCH 22/26] Update `styles.module.scss` --- website/src/theme/Heading/styles.module.css | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/website/src/theme/Heading/styles.module.css b/website/src/theme/Heading/styles.module.css index 988da3744..06f550db5 100644 --- a/website/src/theme/Heading/styles.module.css +++ b/website/src/theme/Heading/styles.module.css @@ -20,8 +20,10 @@ justify-content: space-between; align-items: center; gap: 1rem; +} - @media (max-width: 767px) { +@media (max-width: 767px) { + :global(.custom-h1-wrapper) { display: block; } } From 453e40b70d21fe2e68661e038714ff3f811001bc Mon Sep 17 00:00:00 2001 From: Chavez Harris Date: Fri, 16 Jan 2026 08:06:23 -0400 Subject: [PATCH 23/26] refactor code --- website/src/components/CopyPageButton.tsx | 17 +++-------------- 1 file changed, 3 insertions(+), 14 deletions(-) diff --git a/website/src/components/CopyPageButton.tsx b/website/src/components/CopyPageButton.tsx index ad2734213..58dac92ad 100644 --- a/website/src/components/CopyPageButton.tsx +++ b/website/src/components/CopyPageButton.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState, useCallback, useMemo } from 'react'; +import { useEffect, useRef, useState, useCallback } from 'react'; import styles from '../css/copy-page-button.module.scss'; // --- TYPES --- @@ -52,8 +52,6 @@ const DEFAULT_CONTENT_SELECTORS = [ '[role="main"]', ] as const; -const ENABLED_ACTIONS = ['copy', 'view', 'chatgpt', 'claude'] as const; - // --- UTILS --- const log = (...args: any[]) => { @@ -141,7 +139,6 @@ const extractCodeContent = (codeElement: HTMLElement): string => { }; export default function CopyPageButton() { - const enabledActions = ENABLED_ACTIONS; const [isOpen, setIsOpen] = useState(false); const [pageContent, setPageContent] = useState(''); const [dropdownPosition, setDropdownPosition] = useState({ @@ -425,8 +422,7 @@ Please provide a clear summary and help me understand the key concepts covered i } }, [getPageContent]); - const allDropdownItems: DropdownItem[] = useMemo( - () => [ + const dropdownItems: DropdownItem[] = [ { id: 'copy', title: 'Copy page', @@ -506,14 +502,7 @@ Please provide a clear summary and help me understand the key concepts covered i ), action: () => openInAI('https://claude.ai/new'), }, - ], - [copyToClipboard, viewAsMarkdown, openInAI], - ); - - const dropdownItems = useMemo( - () => allDropdownItems.filter((item) => enabledActions.includes(item.id)), - [allDropdownItems, enabledActions], - ); + ]; return ( <> From f269fe8208dbd378ac3f688cd21d8bd52c5a90c3 Mon Sep 17 00:00:00 2001 From: Chavez Harris Date: Fri, 16 Jan 2026 09:18:23 -0400 Subject: [PATCH 24/26] Improvements 2 --- website/src/components/CopyPageButton.tsx | 171 +++++++++++----------- 1 file changed, 83 insertions(+), 88 deletions(-) diff --git a/website/src/components/CopyPageButton.tsx b/website/src/components/CopyPageButton.tsx index 58dac92ad..f8873bf04 100644 --- a/website/src/components/CopyPageButton.tsx +++ b/website/src/components/CopyPageButton.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState, useCallback } from 'react'; +import { useEffect, useRef, useState, useCallback, useMemo } from 'react'; import styles from '../css/copy-page-button.module.scss'; // --- TYPES --- @@ -140,7 +140,6 @@ const extractCodeContent = (codeElement: HTMLElement): string => { export default function CopyPageButton() { const [isOpen, setIsOpen] = useState(false); - const [pageContent, setPageContent] = useState(''); const [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0, @@ -356,12 +355,8 @@ export default function CopyPageButton() { }, [convertToMarkdown]); const getPageContent = useCallback(() => { - if (pageContent) return pageContent; - - const extracted = extractPageContent(); - setPageContent(extracted); - return extracted; - }, [pageContent, extractPageContent]); + return extractPageContent(); + }, [extractPageContent]); const copyToClipboard = useCallback(async () => { const content = getPageContent(); @@ -422,87 +417,87 @@ Please provide a clear summary and help me understand the key concepts covered i } }, [getPageContent]); - const dropdownItems: DropdownItem[] = [ - { - id: 'copy', - title: 'Copy page', - description: 'Copy the page as Markdown for LLMs', - icon: ( - - - - - ), - action: copyToClipboard, - }, - { - id: 'view', - title: 'View as Markdown', - description: 'View this page as plain text', - icon: ( - - - - - - - ), - action: viewAsMarkdown, - }, - { - id: 'chatgpt', - title: 'Open in ChatGPT', - description: 'Ask questions about this page', - icon: ( - - - - ), - action: () => openInAI('https://chat.openai.com/'), - }, - { - id: 'claude', - title: 'Open in Claude', - description: 'Ask questions about this page', - icon: ( - [ + { + id: 'copy', + title: 'Copy page', + description: 'Copy the page as Markdown for LLMs', + icon: ( + + + + + ), + action: copyToClipboard, + }, + { + id: 'view', + title: 'View as Markdown', + description: 'View this page as plain text', + icon: ( + + + + + + + ), + action: viewAsMarkdown, + }, + { + id: 'chatgpt', + title: 'Open in ChatGPT', + description: 'Ask questions about this page', + icon: ( + + - - - ), - action: () => openInAI('https://claude.ai/new'), - }, - ]; + /> + + ), + action: () => openInAI('https://chat.openai.com/'), + }, + { + id: 'claude', + title: 'Open in Claude', + description: 'Ask questions about this page', + icon: ( + + + + ), + action: () => openInAI('https://claude.ai/new'), + }, + ], [copyToClipboard, viewAsMarkdown, openInAI]); return ( <> From 5047d22f6a98603eade076ff05d3af2d15f2ca1d Mon Sep 17 00:00:00 2001 From: Chavez Harris Date: Fri, 16 Jan 2026 14:54:23 -0400 Subject: [PATCH 25/26] Add first subheading check --- website/validate-document.js | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/website/validate-document.js b/website/validate-document.js index 930f0ba3b..1091bfc35 100644 --- a/website/validate-document.js +++ b/website/validate-document.js @@ -14,6 +14,20 @@ const heightAttributeRegex = attributeRegex('height'); const decodingAttributeRegex = attributeRegex('decoding'); const loadingAttributeRegex = attributeRegex('loading'); +const checkFirstSubheading = (content) => { + const lines = content.split('\n'); + for (const line of lines) { + if (line.startsWith('#')) { + if (line.startsWith('## ')) { + return null; + } else { + return 'The first subheading must be a H2 (##).'; + } + } + } + return 'No subheading found.'; +}; + const checkImageNameConvention = (imagePath, errors) => { const imageName = path.basename(imagePath); const imageNameMatch = imageName.match(imageNameRegex); @@ -144,6 +158,15 @@ const checkDocumentFile = async (fileFullPath, ignore) => { try { const content = fs.readFileSync(fileFullPath, 'utf-8'); + + console.log('Running first subheading check...'); + const subheadingError = checkFirstSubheading(content); + if (subheadingError) { + errors.push(`First subheading error: ${subheadingError}`); + } else { + console.log('First subheading check passed.'); + } + console.log('Running image checks...'); const imageCheckErrors = await checkImages(content); if (imageCheckErrors.length) { From f6dbcb5659cc853d9d655727e28133e3b1eea9ad Mon Sep 17 00:00:00 2001 From: Chavez Harris Date: Wed, 28 Jan 2026 20:32:37 -0400 Subject: [PATCH 26/26] Fix button alignment with heading --- website/src/css/copy-page-button.module.scss | 1 - website/src/theme/Heading/styles.module.css | 8 ++++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/website/src/css/copy-page-button.module.scss b/website/src/css/copy-page-button.module.scss index f4f1127f2..5fe899321 100644 --- a/website/src/css/copy-page-button.module.scss +++ b/website/src/css/copy-page-button.module.scss @@ -8,7 +8,6 @@ align-items: center; gap: 8px; padding: 8px 12px; - margin-bottom: 16px; background: var(--ifm-navbar-background-color, #1c1e21); border: 1px solid var(--ifm-color-emphasis-300); border-radius: 6px; diff --git a/website/src/theme/Heading/styles.module.css b/website/src/theme/Heading/styles.module.css index 06f550db5..2e915c85c 100644 --- a/website/src/theme/Heading/styles.module.css +++ b/website/src/theme/Heading/styles.module.css @@ -20,6 +20,14 @@ justify-content: space-between; align-items: center; gap: 1rem; + + /* Remove the margin from the h1 element... */ + h1 { + margin: 0 !important; + } + + /* Apply it to the wrapper instead */ + margin-bottom: var(--ifm-heading-margin-bottom); } @media (max-width: 767px) {