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. diff --git a/website/docusaurus.config.ts b/website/docusaurus.config.ts index 7c236577e..3e0cfc7af 100644 --- a/website/docusaurus.config.ts +++ b/website/docusaurus.config.ts @@ -217,7 +217,6 @@ const config: Config = { // smartlookKey: '05d0e4ca90c61150955104a9d4b76ab16a0b2380', // } // ], - require.resolve('./plugins/copy-page-button'), ], themeConfig: { image: '/img/docs-cover.png', diff --git a/website/plugins/copy-page-button/client.js b/website/plugins/copy-page-button/client.js deleted file mode 100644 index 6dca7b15c..000000000 --- a/website/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/plugins/copy-page-button/index.js b/website/plugins/copy-page-button/index.js deleted file mode 100644 index a54c30727..000000000 --- a/website/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/plugins/copy-page-button/CopyPageButton.js b/website/src/components/CopyPageButton.tsx similarity index 50% rename from website/plugins/copy-page-button/CopyPageButton.js rename to website/src/components/CopyPageButton.tsx index 8cd292c36..f8873bf04 100644 --- a/website/plugins/copy-page-button/CopyPageButton.js +++ b/website/src/components/CopyPageButton.tsx @@ -1,9 +1,35 @@ -import React, { useState, useEffect, useRef } from 'react'; -import { createPortal } from 'react-dom'; -import styles from './styles.module.css'; +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', + MIN_CONTENT_LENGTH: 100, +}; // Static selectors for content cleanup -const SELECTORS_TO_REMOVE = [ +const DEFAULT_SELECTORS_TO_REMOVE = [ '.theme-edit-this-page', '.theme-last-updated', '.pagination-nav', @@ -17,23 +43,117 @@ const SELECTORS_TO_REMOVE = [ '.line-number', ]; -export default function CopyPageButton({ - enabledActions = ['copy', 'view', 'chatgpt', 'claude'], -}) { +const DEFAULT_CONTENT_SELECTORS = [ + 'main article', + 'main .markdown', + 'main', + 'article', + '.main-wrapper', + '[role="main"]', +] as const; + +// --- UTILS --- + +const log = (...args: any[]) => { + if (CONFIG.DEBUG) { + console.log('[CopyPageButton]', ...args); + } +}; + +// Text cleaning +const cleanSpecialChars = (text: string): string => { + return text + .replace(/[\u200B-\u200D\uFEFF]/g, '') + .replace(/\u00A0/g, ' ') + .replace(/[\u2018\u2019]/g, "'") + .replace(/[\u201C\u201D]/g, '"') + .replace(/​/g, '') + .replace(/\s+/g, ' ') + .trim(); +}; + +// Sanitize content +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 => { + // 1: Data attributes + const dataContent = + codeElement.getAttribute('data-code') || + codeElement.getAttribute('data-raw'); + if (dataContent) return dataContent; + + // 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'); + } + + // 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'); + } + + // 4: Direct text + return (codeElement.textContent || '') + .replace(/^\d+\s+/gm, '') // Remove line numbers + .replace(/^Copy$/gm, '') + .replace(/^Copied!$/gm, '') + .replace(/^\s*Copy to clipboard\s*$/gm, ''); +}; + +export default function CopyPageButton() { 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); + const [dropdownPosition, setDropdownPosition] = useState({ + top: 0, + left: 0, + }); + const dropdownRef = useRef(null); + const buttonRef = useRef(null); useEffect(() => { - const handleClickOutside = (event) => { + const handleClickOutside = (event: Event) => { if ( dropdownRef.current && - !dropdownRef.current.contains(event.target) && + !dropdownRef.current.contains(event.target as Node) && buttonRef.current && - !buttonRef.current.contains(event.target) + !buttonRef.current.contains(event.target as Node) ) { setIsOpen(false); } @@ -48,76 +168,53 @@ export default function CopyPageButton({ }; }, [isOpen]); + // Recalculate position when isOpen changes to true useEffect(() => { if (isOpen && buttonRef.current) { const rect = buttonRef.current.getBoundingClientRect(); - const isMobile = window.innerWidth <= 767; // Mobile break-point + const isMobile = window.innerWidth <= CONFIG.MOBILE_BREAKPOINT; 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 + top: rect.bottom + CONFIG.DROPDOWN_OFFSET, + left: isMobile ? rect.left : rect.right - CONFIG.DROPDOWN_WIDTH, }); } }, [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 = useCallback((element: HTMLElement): string => { + const cleanText = (text: string) => cleanSpecialChars(text); - 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) => { + const processNode = (node: Node): string => { if (node.nodeType === Node.TEXT_NODE) { - return cleanText(node.textContent); + return cleanText(node.textContent || ''); } if (node.nodeType === Node.ELEMENT_NODE) { - const tag = node.tagName.toLowerCase(); + const el = node as HTMLElement; + const tag = el.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] : ''; + const children = childResults + .reduce((acc: string[], current: string, i: number) => { + if (!current) return acc; - if (current) { - if ( + const previous = i > 0 ? childResults[i - 1] : ''; + const needsSpace = previous && !previous.match(/[\s\n]$/) && !current.match(/^[\s\n]/) && previous.trim() && - current.trim() - ) { - children += ' '; + current.trim(); + + if (needsSpace && acc.length > 0) { + acc.push(' '); } - children += current; - } - } + acc.push(current); + return acc; + }, []) + .join(''); switch (tag) { case 'h1': @@ -141,97 +238,23 @@ export default function CopyPageButton({ case 'i': return `*${children}*`; case 'code': - if (node.parentElement?.tagName.toLowerCase() === 'pre') { + if (el.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}\``; + return `\`${cleanSpecialChars(children)}\``; case 'pre': - const codeElement = node.querySelector('code'); + const codeElement = el.querySelector('code'); if (codeElement) { const language = (codeElement.className?.match(/language-(\w+)/) || - node.className?.match(/language-(\w+)/) || + el.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 || ''; - } + const codeContent = cleanSpecialChars( + extractCodeContent(codeElement), + ).replace(/^\n+|\n+$/g, ''); return `\n\`\`\`${language}\n${codeContent}\n\`\`\`\n\n`; } @@ -239,7 +262,7 @@ export default function CopyPageButton({ case 'ul': return `\n${children}`; case 'ol': - const items = Array.from(node.querySelectorAll('li')); + const items = Array.from(el.querySelectorAll('li')); return ( '\n' + items @@ -254,7 +277,7 @@ export default function CopyPageButton({ case 'li': return `- ${children.trim()}\n`; case 'a': - const href = node.getAttribute('href'); + const href = el.getAttribute('href'); if (href && !href.startsWith('#') && children.trim()) { return `[${children.trim()}](${href})`; } @@ -271,16 +294,15 @@ export default function CopyPageButton({ case 'td': return `| ${children.trim()} `; case 'img': - const src = node.getAttribute('src'); - const alt = node.getAttribute('alt') || ''; + const src = el.getAttribute('src'); + const alt = el.getAttribute('alt') || ''; return src ? `![${alt}](${src})` : ''; case 'div': case 'section': case 'article': - // Handle admonitions - if (node.classList?.contains('admonition')) { + if (el.classList?.contains('admonition')) { const type = - Array.from(node.classList) + Array.from(el.classList) .find((cls) => cls.startsWith('alert--')) ?.replace('alert--', '') || 'note'; return `\n> **${type.toUpperCase()}**: ${children.trim()}\n\n`; @@ -295,98 +317,74 @@ export default function CopyPageButton({ }; return processNode(element) - .replace(/\n{3,}/g, '\n\n') // Limit multiple newlines - .replace(/^\n+|\n+$/g, '') // Trim newlines + .replace(/\n{3,}/g, '\n\n') + .replace(/^\n+|\n+$/g, '') .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); + const extractPageContent = useCallback(() => { + log('Extracting page content...'); + const mainContent = findContentElement(); 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 ''; + log('No main content found on page'); + return ''; } - const targetElement = - mainContent || - document.querySelector('main') || - document.querySelector('article'); - const clone = targetElement.cloneNode(true); + const clone = mainContent.cloneNode(true) as HTMLElement; - // Remove unwanted elements - SELECTORS_TO_REMOVE.forEach((selector) => { - clone.querySelectorAll(selector).forEach((el) => el.remove()); + DEFAULT_SELECTORS_TO_REMOVE.forEach((selector) => { + try { + clone.querySelectorAll(selector).forEach((el) => el.remove()); + } catch (err) { + log('Error removing selector:', selector, err); + } }); - // 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); + const title = firstH1?.textContent?.trim() || 'Documentation Page'; + 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; - } + + return sanitizeContent(finalContent); + }, [convertToMarkdown]); + + const getPageContent = useCallback(() => { + return extractPageContent(); + }, [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(text); - console.log('Content copied to clipboard successfully'); + await navigator.clipboard.writeText(content); + log('Content copied to clipboard successfully'); } else { - // Fallback for older browsers const textArea = document.createElement('textarea'); - textArea.value = text; + textArea.value = content; document.body.appendChild(textArea); textArea.select(); document.execCommand('copy'); document.body.removeChild(textArea); - console.log('Content copied to clipboard using fallback method'); + log('Content copied to clipboard using fallback method'); } } catch (err) { - console.error('Failed to copy to clipboard:', err); + log('Failed to copy to clipboard:', err); } - }; + }, [getPageContent]); - const openInAI = (baseUrl) => { + const openInAI = useCallback((baseUrl: string) => { try { const currentUrl = window.location.href; const prompt = encodeURIComponent( @@ -395,45 +393,31 @@ export default function CopyPageButton({ 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'); + log('Opened AI tool with prompt'); } catch (err) { - console.error('Failed to open AI tool:', err); + log('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; - } + }, []); + + const viewAsMarkdown = useCallback(() => { + const content = getPageContent(); + + if (!content || content.trim() === '') { + log('Failed to extract content'); + return; } try { - const blob = new Blob([contentToView], { type: 'text/plain' }); + const blob = new Blob([content], { type: 'text/plain' }); const url = URL.createObjectURL(blob); window.open(url, '_blank'); - console.log('Opened markdown view'); + log('Opened markdown view'); } catch (err) { - console.error('Failed to open markdown view:', err); + log('Failed to open markdown view:', err); } - }; + }, [getPageContent]); - const allDropdownItems = [ + const dropdownItems: DropdownItem[] = useMemo(() => [ { id: 'copy', title: 'Copy page', @@ -451,7 +435,7 @@ Please provide a clear summary and help me understand the key concepts covered i ), - action: () => copyToClipboard(pageContent), + action: copyToClipboard, }, { id: 'view', @@ -513,16 +497,11 @@ Please provide a clear summary and help me understand the key concepts covered i ), action: () => openInAI('https://claude.ai/new'), }, - ]; - - // Filter dropdown items based on enabled actions - const dropdownItems = allDropdownItems.filter((item) => - enabledActions.includes(item.id), - ); + ], [copyToClipboard, viewAsMarkdown, openInAI]); return ( <> -
+
- {mounted && isOpen && - createPortal( -
- {dropdownItems.map((item) => ( - - ))} -
, - // Ensure body exists - (typeof document !== 'undefined' && document.body) ? document.body : null - )} - + {isOpen && ( +
+ {dropdownItems.map((item) => ( + + ))} +
+ )} ); } diff --git a/website/plugins/copy-page-button/styles.module.css b/website/src/css/copy-page-button.module.scss similarity index 73% rename from website/plugins/copy-page-button/styles.module.css rename to website/src/css/copy-page-button.module.scss index c6d6c6ece..5fe899321 100644 --- a/website/plugins/copy-page-button/styles.module.css +++ b/website/src/css/copy-page-button.module.scss @@ -1,8 +1,4 @@ -#copy-page-button-container > * { - pointer-events: auto; -} - -.copyPageContainer { +.copyPageButtonContainer { position: relative; display: inline-block; } @@ -12,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; @@ -142,36 +137,3 @@ [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 */ - } -} diff --git a/website/src/theme/Heading/index.tsx b/website/src/theme/Heading/index.tsx new file mode 100644 index 000000000..65d883abf --- /dev/null +++ b/website/src/theme/Heading/index.tsx @@ -0,0 +1,60 @@ +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..2e915c85c --- /dev/null +++ b/website/src/theme/Heading/styles.module.css @@ -0,0 +1,37 @@ +: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-h1-wrapper) { + display: flex; + 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) { + :global(.custom-h1-wrapper) { + display: block; + } +} 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) {