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..5c9ca6755ef 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,6 +346,7 @@ const config: Config = { }, ], './plugins/webpack-buffer', + copyPageButtonPlugin, async function webpackLexicalModules() { return { configureWebpack() { 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/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..c2e02d53b7e --- /dev/null +++ b/packages/lexical-website/plugins/copy-page-button/index.mjs @@ -0,0 +1,110 @@ +/** + * 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'; + +import {MARKDOWN_NAMESPACE, relativeMarkdownPath} from './markdownPath.mjs'; + +const SITE_ALIAS = '@site'; + +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'); +} + +/** + * 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', MARKDOWN_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, + `${relativeMarkdownPath(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/plugins/copy-page-button/markdownPath.mjs b/packages/lexical-website/plugins/copy-page-button/markdownPath.mjs new file mode 100644 index 00000000000..546c8fd288e --- /dev/null +++ b/packages/lexical-website/plugins/copy-page-button/markdownPath.mjs @@ -0,0 +1,35 @@ +/** + * 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. + * + */ + +/** + * Path namespace (under `static/`) where the build-time plugin emits a Markdown + * copy of every doc page, e.g. the page `/docs/intro` is served at + * `/llms/docs/intro.md`. + */ +export const MARKDOWN_NAMESPACE = 'llms'; + +/** + * Map a doc permalink to the namespace-relative path of its generated Markdown + * file (without the `MARKDOWN_NAMESPACE` prefix or surrounding slashes), e.g. + * `/docs/api/` -> `docs/api`. + * + * Shared by the plugin that writes the file and the CopyPageButton that links + * to it so the two normalizations (notably trailing-slash handling for index + * pages) can never drift. + * + * @param {string} permalink Doc permalink, including baseUrl (e.g. `/docs/api/`). + * @param {string} baseUrl Site baseUrl (e.g. `/`). + * @returns {string} + */ +export function relativeMarkdownPath(permalink, baseUrl) { + let rel = permalink; + if (baseUrl && rel.startsWith(baseUrl)) { + rel = rel.slice(baseUrl.length); + } + return rel.replace(/^\/+/, '').replace(/\/+$/, '') || 'index'; +} 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..536412439e3 --- /dev/null +++ b/packages/lexical-website/src/components/CopyPageButton/index.tsx @@ -0,0 +1,364 @@ +/** + * 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, + useSyncExternalStore, +} from 'react'; + +import { + MARKDOWN_NAMESPACE, + relativeMarkdownPath, +} from '../../../plugins/copy-page-button/markdownPath.mjs'; +import styles from './styles.module.css'; + +/** + * 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 = () => () => {}; +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 ( + + ); +} + +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(); + + // useBaseUrl re-adds the baseUrl that relativeMarkdownPath stripped, keeping + // the path correct under any baseUrl. The shared helper is also what the + // build-time plugin uses to name the file, so the two always agree. + const markdownPath = useBaseUrl( + `${MARKDOWN_NAMESPACE}/${relativeMarkdownPath(metadata.permalink, siteConfig.baseUrl)}.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]); + + // 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. Currently gated off by + // ENABLE_AI_TOOL_LINKS. + const aiToolItems: MenuItem[] = [ + { + description: 'Ask ChatGPT about this page', + href: aiHref('https://chatgpt.com/'), + icon: , + id: 'chatgpt', + title: 'Open in ChatGPT', + }, + { + description: 'Ask Claude about this page', + href: aiHref('https://claude.ai/new'), + icon: , + id: 'claude', + title: 'Open in Claude', + }, + { + description: 'Ask Perplexity about this page', + href: aiHref('https://www.perplexity.ai/search'), + icon: , + id: 'perplexity', + title: 'Open in Perplexity', + }, + { + description: 'Ask Gemini about this page', + href: aiHref('https://www.google.com/search', {udm: '50'}), + icon: , + id: 'gemini', + title: 'Open in Gemini', + }, + ]; + + 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 ( +
+ + {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..772db61c0b5 --- /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-start; +} + +.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); + left: 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; + } +} 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)':