From 47a5d388bff0d91a30b6984d22af542476a4f3f0 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 26 May 2026 18:23:36 +0000 Subject: [PATCH 1/5] [lexical-website] Feature: Server-rendered "Copy page" Markdown button Adds an in-repo "Copy page" button to documentation pages (Copy / View as Markdown / Open in ChatGPT, Claude, Perplexity, Gemini). The button is rendered server-side in a persistent right-hand column on every doc page so it never flashes in after hydration and stays in a consistent location even on pages without a table of contents. A build-time plugin emits a clean Markdown copy of each doc page under /llms/.md so the actions use real Markdown instead of scraping the DOM on the client. A webpack alias dedupes @docusaurus/plugin-content-docs so the swizzled DocItem/Layout shares the DocProvider context with theme-classic. --- packages/lexical-website/.gitignore | 1 + packages/lexical-website/docusaurus.config.ts | 17 + .../plugins/copy-page-button/index.mjs | 125 +++++++ .../src/components/CopyPageButton/index.tsx | 339 ++++++++++++++++++ .../CopyPageButton/styles.module.css | 104 ++++++ .../src/theme/DocItem/Layout/index.tsx | 90 +++++ .../theme/DocItem/Layout/styles.module.css | 41 +++ 7 files changed, 717 insertions(+) create mode 100644 packages/lexical-website/plugins/copy-page-button/index.mjs create mode 100644 packages/lexical-website/src/components/CopyPageButton/index.tsx create mode 100644 packages/lexical-website/src/components/CopyPageButton/styles.module.css create mode 100644 packages/lexical-website/src/theme/DocItem/Layout/index.tsx create mode 100644 packages/lexical-website/src/theme/DocItem/Layout/styles.module.css diff --git a/packages/lexical-website/.gitignore b/packages/lexical-website/.gitignore index ed4a6c45f70..8b457f67449 100644 --- a/packages/lexical-website/.gitignore +++ b/packages/lexical-website/.gitignore @@ -9,6 +9,7 @@ .cache-loader /docs/api /docs/packages +/static/llms # Misc .DS_Store diff --git a/packages/lexical-website/docusaurus.config.ts b/packages/lexical-website/docusaurus.config.ts index 61ea6059d40..cde9de83568 100644 --- a/packages/lexical-website/docusaurus.config.ts +++ b/packages/lexical-website/docusaurus.config.ts @@ -17,6 +17,7 @@ import {fileURLToPath} from 'node:url'; import {themes} from 'prism-react-renderer'; import {packagesManager} from '../../scripts/shared/packagesManager.mjs'; +import copyPageButtonPlugin from './plugins/copy-page-button/index.mjs'; import packageDocsPlugin from './plugins/package-docs/index.mjs'; import slugifyPlugin from './src/plugins/lexical-remark-slugify-anchors/index.js'; @@ -345,11 +346,27 @@ const config: Config = { }, ], './plugins/webpack-buffer', + copyPageButtonPlugin, async function webpackLexicalModules() { return { configureWebpack() { const alias: Record = { ...buildLexicalWebpackAliases(), + // Dedupe the docs client module so swizzled theme components under + // src/theme share the same DocProvider React context as + // @docusaurus/theme-classic. pnpm can otherwise resolve a second + // copy of @docusaurus/plugin-content-docs, which would make + // useDoc() throw a ReactContextError during SSG. + '@docusaurus/plugin-content-docs/client$': require.resolve( + '@docusaurus/plugin-content-docs/client', + { + paths: [ + path.dirname( + require.resolve('@docusaurus/theme-classic/package.json'), + ), + ], + }, + ), '@examples/agent-example': path.resolve( __dirname, '../../examples/agent-example/src', diff --git a/packages/lexical-website/plugins/copy-page-button/index.mjs b/packages/lexical-website/plugins/copy-page-button/index.mjs new file mode 100644 index 00000000000..4a4ac5568ba --- /dev/null +++ b/packages/lexical-website/plugins/copy-page-button/index.mjs @@ -0,0 +1,125 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import fs from 'node:fs'; +import path from 'node:path'; + +const SITE_ALIAS = '@site'; + +/** + * Path namespace (under `static/`) where a Markdown copy of every doc page is + * emitted, e.g. the page `/docs/intro` is written to `static/llms/docs/intro.md` + * and served at `/llms/docs/intro.md`. Must stay in sync with the + * `MARKDOWN_NAMESPACE` constant in `src/theme/CopyPageButton/index.tsx`. + */ +const OUTPUT_NAMESPACE = 'llms'; + +function stripFrontMatter(raw) { + return raw.replace(/^\uFEFF?---\r?\n[\s\S]*?\r?\n---\r?\n?/, ''); +} + +/** + * Drop the leading block of MDX `import`/`export` statements (and blank lines) + * that appear before the first piece of real content. Only the leading block is + * removed so `import`/`export` lines inside code fences are left untouched. + */ +function stripLeadingMdxStatements(body) { + const lines = body.split('\n'); + let index = 0; + for (; index < lines.length; index++) { + const trimmed = lines[index].trim(); + if (trimmed === '' || /^(?:import|export)\b/.test(trimmed)) { + continue; + } + break; + } + return lines.slice(index).join('\n'); +} + +function relativePermalink(permalink, baseUrl) { + let rel = permalink; + if (baseUrl && rel.startsWith(baseUrl)) { + rel = rel.slice(baseUrl.length); + } + rel = rel.replace(/^\/+/, '').replace(/\/+$/, ''); + return rel || 'index'; +} + +/** + * Emit a clean Markdown copy of every doc page at build time so the + * server-rendered CopyPageButton can link to / copy / hand off real Markdown + * without any client-side DOM scraping. + * + * @type {import('@docusaurus/types').PluginModule} + */ +const copyPageButtonPlugin = async function (context) { + const {siteDir, siteConfig} = context; + const {baseUrl, url: siteUrl} = siteConfig; + const outputRoot = path.join(siteDir, 'static', OUTPUT_NAMESPACE); + + const resolveSource = source => { + if (source.startsWith(SITE_ALIAS)) { + return path.join(siteDir, source.slice(SITE_ALIAS.length)); + } + return path.isAbsolute(source) ? source : path.join(siteDir, source); + }; + + return { + // Runs in both dev and production, after every plugin has loaded its + // content, so we have the authoritative permalink -> source mapping for + // every doc (including the generated API reference). + allContentLoaded({allContent}) { + const docsContent = allContent['docusaurus-plugin-content-docs']; + if (!docsContent) { + return; + } + + // Regenerate from scratch so renamed/removed pages don't leave orphans. + fs.rmSync(outputRoot, {force: true, recursive: true}); + + const normalizedSiteUrl = String(siteUrl || '').replace(/\/$/, ''); + + for (const instance of Object.values(docsContent)) { + const loadedVersions = (instance && instance.loadedVersions) || []; + for (const version of loadedVersions) { + for (const doc of version.docs || []) { + const sourcePath = resolveSource(doc.source); + let raw; + try { + raw = fs.readFileSync(sourcePath, 'utf-8'); + } catch { + continue; + } + + let body = stripFrontMatter(raw); + if (sourcePath.endsWith('.mdx')) { + body = stripLeadingMdxStatements(body); + } + body = body.trim(); + + const pageUrl = `${normalizedSiteUrl}${doc.permalink}`; + const header = /^#\s/.test(body) + ? `URL: ${pageUrl}\n\n` + : `# ${doc.title}\n\nURL: ${pageUrl}\n\n`; + + const outputPath = path.join( + outputRoot, + `${relativePermalink(doc.permalink, baseUrl)}.md`, + ); + fs.mkdirSync(path.dirname(outputPath), {recursive: true}); + fs.writeFileSync(outputPath, `${header}${body}\n`); + } + } + } + }, + + name: 'copy-page-button', + }; +}; + +export default copyPageButtonPlugin; diff --git a/packages/lexical-website/src/components/CopyPageButton/index.tsx b/packages/lexical-website/src/components/CopyPageButton/index.tsx new file mode 100644 index 00000000000..d26a934740b --- /dev/null +++ b/packages/lexical-website/src/components/CopyPageButton/index.tsx @@ -0,0 +1,339 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import {useDoc} from '@docusaurus/plugin-content-docs/client'; +import useBaseUrl from '@docusaurus/useBaseUrl'; +import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; +import clsx from 'clsx'; +import React, {useCallback, useEffect, useRef, useState} from 'react'; + +import styles from './styles.module.css'; + +/** + * The path namespace under which the build-time `copy-page-button` plugin emits + * a Markdown copy of every doc page. Must stay in sync with the plugin's + * `OUTPUT_NAMESPACE`. + */ +const MARKDOWN_NAMESPACE = 'llms'; + +function CopyIcon() { + return ( + + ); +} + +function CheckIcon() { + return ( + + ); +} + +function ViewIcon() { + return ( + + ); +} + +function ChatGPTIcon() { + return ( + + ); +} + +function ClaudeIcon() { + return ( + + ); +} + +function PerplexityIcon() { + return ( + + ); +} + +function GeminiIcon() { + return ( + + ); +} + +function ChevronIcon({open}: {open: boolean}) { + return ( + + ); +} + +type MenuItem = { + id: string; + title: string; + description: string; + icon: React.ReactNode; + href?: string; + onSelect?: () => void; +}; + +export default function CopyPageButton(): React.ReactNode { + const {metadata} = useDoc(); + const {siteConfig} = useDocusaurusContext(); + + // `permalink` already includes the site baseUrl. Strip it so useBaseUrl can + // re-add it, keeping the namespace path correct under any baseUrl. + const baseUrl = siteConfig.baseUrl; + const relativePermalink = metadata.permalink.startsWith(baseUrl) + ? metadata.permalink.slice(baseUrl.length) + : metadata.permalink.replace(/^\/+/, ''); + const markdownPath = useBaseUrl( + `${MARKDOWN_NAMESPACE}/${relativePermalink.replace(/^\/+/, '')}.md`, + ); + + const [open, setOpen] = useState(false); + const [copied, setCopied] = useState(false); + const containerRef = useRef(null); + + useEffect(() => { + if (!open) { + return; + } + const onPointerDown = (event: MouseEvent) => { + if ( + containerRef.current && + !containerRef.current.contains(event.target as Node) + ) { + setOpen(false); + } + }; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + setOpen(false); + } + }; + document.addEventListener('mousedown', onPointerDown); + document.addEventListener('keydown', onKeyDown); + return () => { + document.removeEventListener('mousedown', onPointerDown); + document.removeEventListener('keydown', onKeyDown); + }; + }, [open]); + + const copyMarkdown = useCallback(async () => { + try { + const response = await fetch(markdownPath); + const text = await response.text(); + await navigator.clipboard.writeText(text); + setCopied(true); + window.setTimeout(() => setCopied(false), 2000); + } catch { + // Clipboard or fetch unavailable; fail silently. + } + }, [markdownPath]); + + const openInAI = useCallback( + (base: string, extraParams: Record = {}) => { + // Resolve the Markdown URL against the live origin so it points at the + // current deployment (production, preview, or local) rather than a + // hard-coded host. + const absoluteMarkdownUrl = new URL( + markdownPath, + window.location.origin, + ).toString(); + const prompt = `Please read and explain this documentation page: ${absoluteMarkdownUrl}\n\nPlease provide a clear summary and help me understand the key concepts covered in this documentation.`; + const params = new URLSearchParams({q: prompt, ...extraParams}); + window.open(`${base}?${params.toString()}`, '_blank', 'noopener'); + }, + [markdownPath], + ); + + const items: MenuItem[] = [ + { + description: copied + ? 'Copied to clipboard' + : 'Copy this page as Markdown for LLMs', + icon: copied ? : , + id: 'copy', + onSelect: copyMarkdown, + title: copied ? 'Copied!' : 'Copy as Markdown', + }, + { + description: 'View this page as plain Markdown', + href: markdownPath, + icon: , + id: 'view', + title: 'View as Markdown', + }, + { + description: 'Ask ChatGPT about this page', + icon: , + id: 'chatgpt', + onSelect: () => openInAI('https://chatgpt.com/'), + title: 'Open in ChatGPT', + }, + { + description: 'Ask Claude about this page', + icon: , + id: 'claude', + onSelect: () => openInAI('https://claude.ai/new'), + title: 'Open in Claude', + }, + { + description: 'Ask Perplexity about this page', + icon: , + id: 'perplexity', + onSelect: () => openInAI('https://www.perplexity.ai/search'), + title: 'Open in Perplexity', + }, + { + description: 'Ask Gemini about this page', + icon: , + id: 'gemini', + onSelect: () => openInAI('https://www.google.com/search', {udm: '50'}), + title: 'Open in Gemini', + }, + ]; + + return ( +
+ + {open && ( +
+ {items.map(item => { + const content = ( + <> + {item.icon} + + {item.title} + + {item.description} + + + + ); + if (item.href) { + return ( + setOpen(false)}> + {content} + + ); + } + return ( + + ); + })} +
+ )} +
+ ); +} diff --git a/packages/lexical-website/src/components/CopyPageButton/styles.module.css b/packages/lexical-website/src/components/CopyPageButton/styles.module.css new file mode 100644 index 00000000000..1510696c060 --- /dev/null +++ b/packages/lexical-website/src/components/CopyPageButton/styles.module.css @@ -0,0 +1,104 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +.copyPage { + position: relative; + display: flex; + justify-content: flex-end; +} + +.trigger { + display: inline-flex; + align-items: center; + gap: 0.4rem; + padding: 0.35rem 0.65rem; + font-size: 0.8rem; + font-weight: 500; + line-height: 1.2; + color: var(--ifm-font-color-base); + background: var(--ifm-background-surface-color); + border: 1px solid var(--ifm-color-emphasis-300); + border-radius: var(--ifm-global-radius); + cursor: pointer; + transition: + background var(--ifm-transition-fast) ease, + border-color var(--ifm-transition-fast) ease; +} + +.trigger:hover { + background: var(--ifm-color-emphasis-100); + border-color: var(--ifm-color-emphasis-400); +} + +.triggerLabel { + white-space: nowrap; +} + +.chevron { + transition: transform var(--ifm-transition-fast) ease; +} + +.chevronOpen { + transform: rotate(180deg); +} + +.menu { + position: absolute; + top: calc(100% + 0.4rem); + right: 0; + z-index: var(--ifm-z-index-dropdown, 200); + min-width: 280px; + padding: 0.3rem; + background: var(--ifm-background-surface-color); + border: 1px solid var(--ifm-color-emphasis-200); + border-radius: var(--ifm-global-radius); + box-shadow: var(--ifm-global-shadow-md); +} + +.item { + display: flex; + align-items: flex-start; + gap: 0.6rem; + width: 100%; + padding: 0.5rem 0.6rem; + text-align: left; + color: var(--ifm-font-color-base); + background: transparent; + border: none; + border-radius: var(--ifm-global-radius); + cursor: pointer; + font: inherit; +} + +.item:hover { + background: var(--ifm-color-emphasis-100); + color: var(--ifm-font-color-base); + text-decoration: none; +} + +.itemIcon { + display: flex; + flex-shrink: 0; + margin-top: 0.1rem; + color: var(--ifm-color-emphasis-700); +} + +.itemText { + display: flex; + flex-direction: column; + gap: 0.1rem; +} + +.itemTitle { + font-size: 0.85rem; + font-weight: 500; +} + +.itemDescription { + font-size: 0.75rem; + color: var(--ifm-color-emphasis-600); +} diff --git a/packages/lexical-website/src/theme/DocItem/Layout/index.tsx b/packages/lexical-website/src/theme/DocItem/Layout/index.tsx new file mode 100644 index 00000000000..64ec110bb83 --- /dev/null +++ b/packages/lexical-website/src/theme/DocItem/Layout/index.tsx @@ -0,0 +1,90 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +/** + * Swizzled (ejected) from `@docusaurus/theme-classic` to render a + * server-side `CopyPageButton` in a persistent right-hand column. + * + * Unlike the upstream layout, the right column is rendered on every doc page + * (not only when a table of contents exists) so the "Copy page" button always + * appears in the same place. On narrow viewports the right column is hidden and + * the button falls back to the top of the article. Because the button is part + * of the static HTML, it never flashes in after hydration. + */ + +import type {Props} from '@theme/DocItem/Layout'; + +import {useDoc} from '@docusaurus/plugin-content-docs/client'; +import {useWindowSize} from '@docusaurus/theme-common'; +import ContentVisibility from '@theme/ContentVisibility'; +import DocBreadcrumbs from '@theme/DocBreadcrumbs'; +import DocItemContent from '@theme/DocItem/Content'; +import DocItemFooter from '@theme/DocItem/Footer'; +import DocItemPaginator from '@theme/DocItem/Paginator'; +import DocItemTOCDesktop from '@theme/DocItem/TOC/Desktop'; +import DocItemTOCMobile from '@theme/DocItem/TOC/Mobile'; +import DocVersionBadge from '@theme/DocVersionBadge'; +import DocVersionBanner from '@theme/DocVersionBanner'; +import clsx from 'clsx'; +import React, {type ReactNode} from 'react'; + +import CopyPageButton from '../../../components/CopyPageButton'; +import styles from './styles.module.css'; + +function useDocTOC() { + const {frontMatter, toc} = useDoc(); + const windowSize = useWindowSize(); + + const hidden = frontMatter.hide_table_of_contents; + const canRender = !hidden && toc.length > 0; + + const mobile = canRender ? : undefined; + + const desktop = + canRender && (windowSize === 'desktop' || windowSize === 'ssr') ? ( + + ) : undefined; + + return { + desktop, + hidden, + mobile, + }; +} + +export default function DocItemLayout({children}: Props): ReactNode { + const docTOC = useDocTOC(); + const {metadata} = useDoc(); + return ( +
+
+ + +
+
+ + +
+ +
+ {docTOC.mobile} + {children} + +
+ +
+
+
+
+ +
+ {docTOC.desktop} +
+
+ ); +} diff --git a/packages/lexical-website/src/theme/DocItem/Layout/styles.module.css b/packages/lexical-website/src/theme/DocItem/Layout/styles.module.css new file mode 100644 index 00000000000..7275df6877e --- /dev/null +++ b/packages/lexical-website/src/theme/DocItem/Layout/styles.module.css @@ -0,0 +1,41 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +.docItemContainer header + *, +.docItemContainer article > *:first-child { + margin-top: 0; +} + +/* Always reserve space for the right column on desktop so the layout (and the + * Copy page button) stays consistent across pages with and without a TOC. */ +@media (min-width: 997px) { + .docItemCol { + max-width: 75% !important; + } +} + +/* The right column always renders on desktop but is hidden on mobile, where the + * button falls back to the top of the article. */ +@media (max-width: 996px) { + .tocCol { + display: none; + } +} + +.copyPageDesktop { + margin-bottom: 1rem; +} + +.copyPageMobile { + margin-bottom: 0.75rem; +} + +@media (min-width: 997px) { + .copyPageMobile { + display: none; + } +} From 6082991708cd46000d42b8763e7d15896e42e040 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 26 May 2026 19:00:02 +0000 Subject: [PATCH 2/5] [lexical-website] Fix: Copy page button alignment and "Open in AI" links Left-align the button to match the previous client-side placement, and make the "Open in ChatGPT/Claude/Perplexity/Gemini" actions plain anchors instead of window.open() calls, which were being silently swallowed by popup blockers. The absolute Markdown URL handed to AI tools now defaults to the configured Docusaurus site URL (stable for SSR/hydration) and reconciles to the live window origin on the client via useSyncExternalStore, so links are correct on production, preview, and local builds. --- .../src/components/CopyPageButton/index.tsx | 58 ++++++++++++------- .../CopyPageButton/styles.module.css | 4 +- 2 files changed, 40 insertions(+), 22 deletions(-) diff --git a/packages/lexical-website/src/components/CopyPageButton/index.tsx b/packages/lexical-website/src/components/CopyPageButton/index.tsx index d26a934740b..26e30a12049 100644 --- a/packages/lexical-website/src/components/CopyPageButton/index.tsx +++ b/packages/lexical-website/src/components/CopyPageButton/index.tsx @@ -10,7 +10,13 @@ import {useDoc} from '@docusaurus/plugin-content-docs/client'; import useBaseUrl from '@docusaurus/useBaseUrl'; import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; import clsx from 'clsx'; -import React, {useCallback, useEffect, useRef, useState} from 'react'; +import React, { + useCallback, + useEffect, + useRef, + useState, + useSyncExternalStore, +} from 'react'; import styles from './styles.module.css'; @@ -21,6 +27,25 @@ import styles from './styles.module.css'; */ const MARKDOWN_NAMESPACE = 'llms'; +// location.origin can't change without a full navigation (which remounts), so +// there is nothing to subscribe to. +const subscribeToOrigin = () => () => {}; +const getClientOrigin = () => window.location.origin; + +/** + * The origin to build absolute Markdown URLs from. Renders with the configured + * Docusaurus site URL on the server (stable for hydration) and reconciles to + * the real browser origin on the client, so links are correct on production, + * preview, and local deployments alike. + */ +function useOrigin(serverOrigin: string): string { + return useSyncExternalStore( + subscribeToOrigin, + getClientOrigin, + () => serverOrigin, + ); +} + function CopyIcon() { return ( = {}) => { - // Resolve the Markdown URL against the live origin so it points at the - // current deployment (production, preview, or local) rather than a - // hard-coded host. - const absoluteMarkdownUrl = new URL( - markdownPath, - window.location.origin, - ).toString(); - const prompt = `Please read and explain this documentation page: ${absoluteMarkdownUrl}\n\nPlease provide a clear summary and help me understand the key concepts covered in this documentation.`; - const params = new URLSearchParams({q: prompt, ...extraParams}); - window.open(`${base}?${params.toString()}`, '_blank', 'noopener'); - }, - [markdownPath], - ); + // Absolute URL of the Markdown file for AI tools to fetch. + const origin = useOrigin(new URL(siteConfig.url).origin); + const aiPrompt = `Please read and explain this documentation page: ${origin}${markdownPath}\n\nPlease provide a clear summary and help me understand the key concepts covered in this documentation.`; + const aiHref = (base: string, extraParams: Record = {}) => + `${base}?${new URLSearchParams({q: aiPrompt, ...extraParams}).toString()}`; + // "Open in " links are plain anchors (target=_blank) rather than + // window.open() calls: anchors are treated as user-initiated navigations and + // aren't silently swallowed by popup blockers. const items: MenuItem[] = [ { description: copied @@ -245,30 +263,30 @@ export default function CopyPageButton(): React.ReactNode { }, { description: 'Ask ChatGPT about this page', + href: aiHref('https://chatgpt.com/'), icon: , id: 'chatgpt', - onSelect: () => openInAI('https://chatgpt.com/'), title: 'Open in ChatGPT', }, { description: 'Ask Claude about this page', + href: aiHref('https://claude.ai/new'), icon: , id: 'claude', - onSelect: () => openInAI('https://claude.ai/new'), title: 'Open in Claude', }, { description: 'Ask Perplexity about this page', + href: aiHref('https://www.perplexity.ai/search'), icon: , id: 'perplexity', - onSelect: () => openInAI('https://www.perplexity.ai/search'), title: 'Open in Perplexity', }, { description: 'Ask Gemini about this page', + href: aiHref('https://www.google.com/search', {udm: '50'}), icon: , id: 'gemini', - onSelect: () => openInAI('https://www.google.com/search', {udm: '50'}), title: 'Open in Gemini', }, ]; diff --git a/packages/lexical-website/src/components/CopyPageButton/styles.module.css b/packages/lexical-website/src/components/CopyPageButton/styles.module.css index 1510696c060..772db61c0b5 100644 --- a/packages/lexical-website/src/components/CopyPageButton/styles.module.css +++ b/packages/lexical-website/src/components/CopyPageButton/styles.module.css @@ -8,7 +8,7 @@ .copyPage { position: relative; display: flex; - justify-content: flex-end; + justify-content: flex-start; } .trigger { @@ -49,7 +49,7 @@ .menu { position: absolute; top: calc(100% + 0.4rem); - right: 0; + left: 0; z-index: var(--ifm-z-index-dropdown, 200); min-width: 280px; padding: 0.3rem; From 7b8c6255f623ce520c2ddcd584c0658926785a38 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 26 May 2026 19:04:13 +0000 Subject: [PATCH 3/5] [lexical-website] Fix: Share plugin-content-docs copy via direct dependency Replaces the webpack resolve.alias workaround with the idiomatic fix: declare @docusaurus/plugin-content-docs as a direct dependency of the site. Because the website swizzles theme-classic components that import its client entry, pnpm otherwise resolved a separate peer-variant copy for src/ than the one theme-classic's DocProvider uses, giving two React contexts and a ReactContextError from useDoc() during SSG. With the direct dependency both resolve the same copy, so the alias is no longer needed. --- packages/lexical-website/docusaurus.config.ts | 15 --------------- packages/lexical-website/package.json | 1 + pnpm-lock.yaml | 5 ++++- 3 files changed, 5 insertions(+), 16 deletions(-) diff --git a/packages/lexical-website/docusaurus.config.ts b/packages/lexical-website/docusaurus.config.ts index cde9de83568..5c9ca6755ef 100644 --- a/packages/lexical-website/docusaurus.config.ts +++ b/packages/lexical-website/docusaurus.config.ts @@ -352,21 +352,6 @@ const config: Config = { configureWebpack() { const alias: Record = { ...buildLexicalWebpackAliases(), - // Dedupe the docs client module so swizzled theme components under - // src/theme share the same DocProvider React context as - // @docusaurus/theme-classic. pnpm can otherwise resolve a second - // copy of @docusaurus/plugin-content-docs, which would make - // useDoc() throw a ReactContextError during SSG. - '@docusaurus/plugin-content-docs/client$': require.resolve( - '@docusaurus/plugin-content-docs/client', - { - paths: [ - path.dirname( - require.resolve('@docusaurus/theme-classic/package.json'), - ), - ], - }, - ), '@examples/agent-example': path.resolve( __dirname, '../../examples/agent-example/src', diff --git a/packages/lexical-website/package.json b/packages/lexical-website/package.json index aab569d6d57..f2f509f4c3c 100644 --- a/packages/lexical-website/package.json +++ b/packages/lexical-website/package.json @@ -18,6 +18,7 @@ "@docusaurus/core": "^3.10.1", "@docusaurus/faster": "^3.10.1", "@docusaurus/plugin-client-redirects": "^3.10.1", + "@docusaurus/plugin-content-docs": "^3.10.1", "@docusaurus/preset-classic": "^3.10.1", "@docusaurus/theme-common": "^3.10.1", "@docusaurus/theme-mermaid": "^3.10.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f1fc84a23b9..e4f49b60c4e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -944,6 +944,9 @@ importers: '@docusaurus/plugin-client-redirects': specifier: ^3.10.1 version: 3.10.1(@docusaurus/faster@3.10.1(@docusaurus/types@3.10.1(@swc/core@1.15.24(@swc/helpers@0.5.21))(esbuild@0.27.7)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(@swc/helpers@0.5.21)(esbuild@0.27.7))(@mdx-js/react@3.1.1(@types/react@19.2.15)(react@19.2.5))(@rspack/core@1.7.11(@swc/helpers@0.5.21))(@swc/core@1.15.24(@swc/helpers@0.5.21))(esbuild@0.27.7)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(typescript@6.0.3) + '@docusaurus/plugin-content-docs': + specifier: ^3.10.1 + version: 3.10.1(@docusaurus/faster@3.10.1(@docusaurus/types@3.10.1(@swc/core@1.15.24(@swc/helpers@0.5.21))(esbuild@0.27.7)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(@swc/helpers@0.5.21)(esbuild@0.27.7))(@mdx-js/react@3.1.1(@types/react@19.2.15)(react@19.2.5))(@rspack/core@1.7.11(@swc/helpers@0.5.21))(@swc/core@1.15.24(@swc/helpers@0.5.21))(esbuild@0.27.7)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(typescript@6.0.3) '@docusaurus/preset-classic': specifier: ^3.10.1 version: 3.10.1(@algolia/client-search@5.46.0)(@docusaurus/faster@3.10.1(@docusaurus/types@3.10.1(@swc/core@1.15.24(@swc/helpers@0.5.21))(esbuild@0.27.7)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(@swc/helpers@0.5.21)(esbuild@0.27.7))(@mdx-js/react@3.1.1(@types/react@19.2.15)(react@19.2.5))(@rspack/core@1.7.11(@swc/helpers@0.5.21))(@swc/core@1.15.24(@swc/helpers@0.5.21))(@types/react@19.2.15)(esbuild@0.27.7)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(search-insights@2.17.3)(typescript@6.0.3) @@ -14706,7 +14709,7 @@ snapshots: '@docusaurus/react-loadable@6.0.0(react@19.2.5)': dependencies: - '@types/react': 19.2.14 + '@types/react': 19.2.15 react: 19.2.5 '@docusaurus/theme-classic@3.10.1(@docusaurus/faster@3.10.1(@docusaurus/types@3.10.1(@swc/core@1.15.24(@swc/helpers@0.5.21))(esbuild@0.27.7)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(@swc/helpers@0.5.21)(esbuild@0.27.7))(@rspack/core@1.7.11(@swc/helpers@0.5.21))(@swc/core@1.15.24(@swc/helpers@0.5.21))(@types/react@19.2.15)(esbuild@0.27.7)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(typescript@6.0.3)': From d88509b0b5ad2b3ae3cc31e08678fc297a00496f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 26 May 2026 20:02:25 +0000 Subject: [PATCH 4/5] [lexical-website] Update: Show only Copy/View Markdown items in Copy page menu Gates the "Open in ChatGPT/Claude/Perplexity/Gemini" links behind a disabled ENABLE_AI_TOOL_LINKS flag, leaving the menu with just "Copy as Markdown" and "View as Markdown". The assistants tend to hallucinate having read the linked Markdown rather than actually fetching it; the link handlers are kept so they can be re-enabled by flipping the flag. --- .../src/components/CopyPageButton/index.tsx | 49 ++++++++++++------- 1 file changed, 31 insertions(+), 18 deletions(-) diff --git a/packages/lexical-website/src/components/CopyPageButton/index.tsx b/packages/lexical-website/src/components/CopyPageButton/index.tsx index 26e30a12049..9a071181ae6 100644 --- a/packages/lexical-website/src/components/CopyPageButton/index.tsx +++ b/packages/lexical-website/src/components/CopyPageButton/index.tsx @@ -27,6 +27,14 @@ import styles from './styles.module.css'; */ const MARKDOWN_NAMESPACE = 'llms'; +/** + * Whether to show the "Open in " menu items. Disabled because the + * assistants tend to hallucinate having read the linked Markdown rather than + * actually fetching it. The handlers are kept (see `aiToolItems`) so they can + * be restored by flipping this flag. + */ +const ENABLE_AI_TOOL_LINKS: boolean = false; + // location.origin can't change without a full navigation (which remounts), so // there is nothing to subscribe to. const subscribeToOrigin = () => () => {}; @@ -243,24 +251,9 @@ export default function CopyPageButton(): React.ReactNode { // "Open in " links are plain anchors (target=_blank) rather than // window.open() calls: anchors are treated as user-initiated navigations and - // aren't silently swallowed by popup blockers. - const items: MenuItem[] = [ - { - description: copied - ? 'Copied to clipboard' - : 'Copy this page as Markdown for LLMs', - icon: copied ? : , - id: 'copy', - onSelect: copyMarkdown, - title: copied ? 'Copied!' : 'Copy as Markdown', - }, - { - description: 'View this page as plain Markdown', - href: markdownPath, - icon: , - id: 'view', - title: 'View as Markdown', - }, + // aren't silently swallowed by popup blockers. Currently gated off by + // ENABLE_AI_TOOL_LINKS. + const aiToolItems: MenuItem[] = [ { description: 'Ask ChatGPT about this page', href: aiHref('https://chatgpt.com/'), @@ -291,6 +284,26 @@ export default function CopyPageButton(): React.ReactNode { }, ]; + const items: MenuItem[] = [ + { + description: copied + ? 'Copied to clipboard' + : 'Copy this page as Markdown for LLMs', + icon: copied ? : , + id: 'copy', + onSelect: copyMarkdown, + title: copied ? 'Copied!' : 'Copy as Markdown', + }, + { + description: 'View this page as plain Markdown', + href: markdownPath, + icon: , + id: 'view', + title: 'View as Markdown', + }, + ...(ENABLE_AI_TOOL_LINKS ? aiToolItems : []), + ]; + return (