From 9d509bdd82edca3413cd4bb0b01e1c6756736ab1 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 10 Sep 2026 09:53:44 +0000 Subject: [PATCH 1/5] feat: add "Copy page" dropdown and Markdown alternate link Co-Authored-By: Mattias Buelens --- package-lock.json | 19 ++++ package.json | 2 + src/components/CopyPageDropdown/index.tsx | 86 ++++++++++++++++ .../CopyPageDropdown/styles.module.css | 97 +++++++++++++++++++ src/plugin/llmsTxt.ts | 12 +++ src/plugin/llmsTxtClient.ts | 18 ++++ src/theme/DocBreadcrumbs/index.tsx | 64 +++++++----- src/theme/DocBreadcrumbs/styles.module.css | 9 +- 8 files changed, 281 insertions(+), 26 deletions(-) create mode 100644 src/components/CopyPageDropdown/index.tsx create mode 100644 src/components/CopyPageDropdown/styles.module.css create mode 100644 src/plugin/llmsTxtClient.ts diff --git a/package-lock.json b/package-lock.json index 43f59a392be5..ebc4dd2f1bce 100644 --- a/package-lock.json +++ b/package-lock.json @@ -37,6 +37,7 @@ "@docusaurus/types": "^3.10.2", "@eslint/js": "~9.39.5", "@types/lodash": "^4.17.25", + "@types/micromatch": "^4.0.10", "dictionary-en": "^4.0.0", "dotenv": "^17.4.2", "eslint": "~9.39.5", @@ -45,6 +46,7 @@ "globals": "^17.11.0", "lodash": "^4.18.1", "mdxlint": "^1.0.0", + "micromatch": "^4.0.8", "prettier": "^3.9.6", "raw-loader": "^4.0.2", "remark": "^15.0.1", @@ -7258,6 +7260,13 @@ "@types/node": "*" } }, + "node_modules/@types/braces": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/braces/-/braces-3.0.5.tgz", + "integrity": "sha512-SQFof9H+LXeWNz8wDe7oN5zu7ket0qwMu5vZubW4GCJ8Kkeh6nBWUz87+KTz/G3Kqsrp0j/W253XJb3KMEeg3w==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/concat-stream": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@types/concat-stream/-/concat-stream-2.0.3.tgz", @@ -7456,6 +7465,16 @@ "integrity": "sha512-+OWZQfAYyio6YkJb3HLxDrvnx6SWWDbC0zVPfBRzUk0/nqoDyf6dNxQi3eArPe8rJ473nobTMQ/8Zk+LxJ+Yuw==", "license": "MIT" }, + "node_modules/@types/micromatch": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/@types/micromatch/-/micromatch-4.0.10.tgz", + "integrity": "sha512-5jOhFDElqr4DKTrTEbnW8DZ4Hz5LRUEmyrGpCMrD/NphYv3nUnaF08xmSLx1rGGnyEs/kFnhiw6dCgcDqMr5PQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/braces": "*" + } + }, "node_modules/@types/mime": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", diff --git a/package.json b/package.json index 1697f8b3aefa..d2d266ff641d 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ "@docusaurus/types": "^3.10.2", "@eslint/js": "~9.39.5", "@types/lodash": "^4.17.25", + "@types/micromatch": "^4.0.10", "dictionary-en": "^4.0.0", "dotenv": "^17.4.2", "eslint": "~9.39.5", @@ -62,6 +63,7 @@ "globals": "^17.11.0", "lodash": "^4.18.1", "mdxlint": "^1.0.0", + "micromatch": "^4.0.8", "prettier": "^3.9.6", "raw-loader": "^4.0.2", "remark": "^15.0.1", diff --git a/src/components/CopyPageDropdown/index.tsx b/src/components/CopyPageDropdown/index.tsx new file mode 100644 index 000000000000..beb0e0ceef41 --- /dev/null +++ b/src/components/CopyPageDropdown/index.tsx @@ -0,0 +1,86 @@ +import { useEffect, useRef, useState } from 'react'; +import { Button, Menu, MenuItem, MenuTrigger, Popover, Text } from 'react-aria-components'; +import clsx from 'clsx'; +import styles from './styles.module.css'; + +export interface CopyPageDropdownProps { + markdownUrl: string; + className?: string; +} + +function DocumentIcon() { + return ( + + ); +} + +function CopyIcon() { + return ( + + ); +} + +export default function CopyPageDropdown({ markdownUrl, className }: CopyPageDropdownProps) { + const [copied, setCopied] = useState(false); + const timeoutRef = useRef(undefined); + + useEffect(() => { + return () => { + if (timeoutRef.current !== undefined) { + window.clearTimeout(timeoutRef.current); + } + }; + }, []); + + const copyMarkdown = async () => { + try { + const response = await fetch(markdownUrl); + if (!response.ok) { + throw new Error(`Failed to fetch Markdown: ${response.status}`); + } + await navigator.clipboard.writeText(await response.text()); + setCopied(true); + if (timeoutRef.current !== undefined) { + window.clearTimeout(timeoutRef.current); + } + timeoutRef.current = window.setTimeout(() => setCopied(false), 2000); + } catch (error) { + console.error(error); + } + }; + + return ( + + + + + clsx('dropdown__link', { focused: isFocused }, styles.item)} + > + + + View as Markdown + Open this page as plain text + + + clsx('dropdown__link', { focused: isFocused }, styles.item)}> + + + Copy as Markdown + Copy this page as Markdown for LLMs + + + + + + ); +} diff --git a/src/components/CopyPageDropdown/styles.module.css b/src/components/CopyPageDropdown/styles.module.css new file mode 100644 index 000000000000..6585f7f15994 --- /dev/null +++ b/src/components/CopyPageDropdown/styles.module.css @@ -0,0 +1,97 @@ +.button { + appearance: none; + background: var(--ifm-background-color); + border: 1px solid var(--ifm-color-emphasis-300); + border-radius: var(--ifm-global-radius); + color: var(--ifm-font-color-base); + font-family: inherit; + font-size: 0.8rem; + padding: 0.25rem 0.6rem; +} + +.button:hover { + background: var(--ifm-hover-overlay); +} + +.button::after { + display: inline; + content: ''; + border-color: currentColor transparent; + border-style: solid; + border-width: 0.35em 0.35em 0; + margin-left: 0.6em; +} + +.popover { + background-color: var(--ifm-dropdown-background-color); + border-radius: var(--ifm-global-radius); + box-shadow: var(--ifm-global-shadow-md); + list-style: none; + max-height: 80vh; + min-width: 16rem; + max-width: min(25rem, 90vw); + overflow-y: auto; + padding: 0.5rem; +} + +.popover[data-placement='top'] { + --origin: translateY(0.625rem); +} + +.popover[data-placement='bottom'] { + --origin: translateY(-0.625rem); +} + +.popover[data-entering], +.popover[data-exiting] { + animation-name: popover-slide; + animation-duration: var(--ifm-transition-fast); + animation-timing-function: var(--ifm-transition-timing-default); +} + +.popover[data-exiting] { + animation-direction: reverse; +} + +@keyframes popover-slide { + from { + transform: var(--origin); + opacity: 0; + } + + to { + transform: translateY(0); + opacity: 1; + } +} + +.menu { + list-style: none; + margin: 0; + padding: 0; +} + +.item { + display: flex; + flex-direction: row; + gap: 0.6rem; + align-items: flex-start; +} + +.icon { + flex: 0 0 auto; + height: 16px; + margin-top: 0.1rem; + width: 16px; +} + +.itemText { + display: flex; + flex-direction: column; + gap: 0.1rem; +} + +.itemText [data-slot='description'] { + color: var(--ifm-color-emphasis-600); + font-size: 0.75rem; +} diff --git a/src/plugin/llmsTxt.ts b/src/plugin/llmsTxt.ts index d71592d85545..0dae5d14a723 100644 --- a/src/plugin/llmsTxt.ts +++ b/src/plugin/llmsTxt.ts @@ -3,8 +3,10 @@ import path from 'node:path'; import type { LoadContext, Plugin } from '@docusaurus/types'; import { normalizeUrl } from '@docusaurus/utils'; import llmsTxtPlugin, { type PluginOptions as LlmsTxtPluginOptions } from '@signalwire/docusaurus-plugin-llms-txt'; +import micromatch from 'micromatch'; import { rehypeDocusaurusMarkdown, remarkAbsoluteLinks } from './llmsTxtMarkdown'; import { locateDocs, type DocLocation } from './llmsTxtSidebars'; +import type { LlmsTxtGlobalData } from './llmsTxtClient'; export interface Product { /** First path segment of the product's docs, e.g. `theoplayer` for `/docs/theoplayer/**`. */ @@ -86,6 +88,10 @@ function header(title: string, description: string | undefined): string { const OVERVIEW_SECTION = 'Overview'; +function excludeRoutesRegExp(patterns: string[]): string { + return patterns.map((pattern) => micromatch.makeRe(pattern).source).join('|'); +} + /** * Section of the product index for a page, based on where the page appears in the sidebars. * Ranks order the sections: overview, one platform, shared between platforms; older versions last. @@ -203,6 +209,12 @@ export default function llmsTxt(context: LoadContext, options: Options): Plugin< }); return { name: 'llms-txt', + async contentLoaded({ actions }) { + // Serialize patterns as a regex so the client uses the same matcher as the plugin for the current route. + actions.setGlobalData({ + excludeRoutes: excludeRoutesRegExp([...(options.llmsTxt.content?.excludeRoutes ?? [])]), + } satisfies LlmsTxtGlobalData); + }, async postBuild(props) { await inner.postBuild?.(props); const locations = locateDocs(props.plugins, siteConfig.baseUrl); diff --git a/src/plugin/llmsTxtClient.ts b/src/plugin/llmsTxtClient.ts new file mode 100644 index 000000000000..7c4336661300 --- /dev/null +++ b/src/plugin/llmsTxtClient.ts @@ -0,0 +1,18 @@ +import { useLocation } from '@docusaurus/router'; +import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; +import { usePluginData } from '@docusaurus/useGlobalData'; + +export interface LlmsTxtGlobalData { + excludeRoutes: string; +} + +export function useMarkdownUrl(): string | undefined { + const { pathname } = useLocation(); + const { siteConfig } = useDocusaurusContext(); + const { excludeRoutes } = usePluginData('llms-txt') as LlmsTxtGlobalData; + + if (excludeRoutes !== '' && new RegExp(excludeRoutes).test(pathname)) { + return undefined; + } + return new URL(`${pathname.replace(/\/$/, '')}.md`, siteConfig.url).href; +} diff --git a/src/theme/DocBreadcrumbs/index.tsx b/src/theme/DocBreadcrumbs/index.tsx index 514c72198846..186933c5451d 100644 --- a/src/theme/DocBreadcrumbs/index.tsx +++ b/src/theme/DocBreadcrumbs/index.tsx @@ -1,5 +1,6 @@ import React, { type ReactNode } from 'react'; import clsx from 'clsx'; +import Head from '@docusaurus/Head'; import { ThemeClassNames } from '@docusaurus/theme-common'; import { useActiveDocContext, useActivePlugin, useSidebarBreadcrumbs } from '@docusaurus/plugin-content-docs/client'; import type { PropSidebarBreadcrumbsItem, PropSidebarItemLink } from '@docusaurus/plugin-content-docs/lib/sidebars/types.js'; @@ -9,6 +10,8 @@ import Link from '@docusaurus/Link'; import { translate } from '@docusaurus/Translate'; import HomeBreadcrumbItem from '@theme/DocBreadcrumbs/Items/Home'; import DocBreadcrumbsStructuredData from '@theme/DocBreadcrumbs/StructuredData'; +import CopyPageDropdown from '@site/src/components/CopyPageDropdown'; +import { useMarkdownUrl } from '@site/src/plugin/llmsTxtClient'; import styles from './styles.module.css'; @@ -81,37 +84,48 @@ function useSidebarBreadcrumbsWithMainDoc(): PropSidebarBreadcrumbsItem[] | null export default function DocBreadcrumbs(): ReactNode { const breadcrumbs = useSidebarBreadcrumbsWithMainDoc(); const homePageRoute = useHomePageRoute(); + const markdownUrl = useMarkdownUrl(); - if (!breadcrumbs) { + if (!breadcrumbs && !markdownUrl) { return null; } return ( <> - - + {markdownUrl && ( + + + + )} + {breadcrumbs && } +
+ {breadcrumbs && ( + + )} + {markdownUrl && } +
); } diff --git a/src/theme/DocBreadcrumbs/styles.module.css b/src/theme/DocBreadcrumbs/styles.module.css index 72da4671a455..a3a9d51ded84 100644 --- a/src/theme/DocBreadcrumbs/styles.module.css +++ b/src/theme/DocBreadcrumbs/styles.module.css @@ -1,4 +1,11 @@ +.breadcrumbsRow { + align-items: flex-start; + display: flex; + gap: 1rem; + justify-content: space-between; + margin-bottom: 0.8rem; +} + .breadcrumbsContainer { --ifm-breadcrumb-size-multiplier: 0.8; - margin-bottom: 0.8rem; } From 51426f397eec01f86bc1177d834f1d90d03efbce Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 10 Sep 2026 09:59:20 +0000 Subject: [PATCH 2/5] fix: align dropdown chevron and popover placement Co-Authored-By: Mattias Buelens --- src/components/CopyPageDropdown/index.tsx | 2 +- src/components/CopyPageDropdown/styles.module.css | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/components/CopyPageDropdown/index.tsx b/src/components/CopyPageDropdown/index.tsx index beb0e0ceef41..2a2e9173f1d0 100644 --- a/src/components/CopyPageDropdown/index.tsx +++ b/src/components/CopyPageDropdown/index.tsx @@ -58,7 +58,7 @@ export default function CopyPageDropdown({ markdownUrl, className }: CopyPageDro return ( - + Date: Thu, 10 Sep 2026 13:06:31 +0000 Subject: [PATCH 3/5] fix: compact menu items with tooltips and pointer cursor Co-Authored-By: Mattias Buelens --- src/components/CopyPageDropdown/index.tsx | 19 +++++++++---------- .../CopyPageDropdown/styles.module.css | 17 +++-------------- 2 files changed, 12 insertions(+), 24 deletions(-) diff --git a/src/components/CopyPageDropdown/index.tsx b/src/components/CopyPageDropdown/index.tsx index 2a2e9173f1d0..154f14084aa8 100644 --- a/src/components/CopyPageDropdown/index.tsx +++ b/src/components/CopyPageDropdown/index.tsx @@ -1,5 +1,5 @@ import { useEffect, useRef, useState } from 'react'; -import { Button, Menu, MenuItem, MenuTrigger, Popover, Text } from 'react-aria-components'; +import { Button, Menu, MenuItem, MenuTrigger, Popover } from 'react-aria-components'; import clsx from 'clsx'; import styles from './styles.module.css'; @@ -64,20 +64,19 @@ export default function CopyPageDropdown({ markdownUrl, className }: CopyPageDro href={markdownUrl} target="_blank" rel="noopener" + title="Open this page as plain text" className={({ isFocused }) => clsx('dropdown__link', { focused: isFocused }, styles.item)} > - - View as Markdown - Open this page as plain text - + View as Markdown - clsx('dropdown__link', { focused: isFocused }, styles.item)}> + clsx('dropdown__link', { focused: isFocused }, styles.item)} + > - - Copy as Markdown - Copy this page as Markdown for LLMs - + Copy as Markdown diff --git a/src/components/CopyPageDropdown/styles.module.css b/src/components/CopyPageDropdown/styles.module.css index 3cd49e831fd7..40bd4d3da7a9 100644 --- a/src/components/CopyPageDropdown/styles.module.css +++ b/src/components/CopyPageDropdown/styles.module.css @@ -29,7 +29,7 @@ box-shadow: var(--ifm-global-shadow-md); list-style: none; max-height: 80vh; - min-width: 16rem; + min-width: 12rem; max-width: min(25rem, 90vw); overflow-y: auto; padding: 0.5rem; @@ -74,26 +74,15 @@ } .item { + align-items: center; + cursor: pointer; display: flex; flex-direction: row; gap: 0.6rem; - align-items: flex-start; } .icon { flex: 0 0 auto; height: 16px; - margin-top: 0.1rem; width: 16px; } - -.itemText { - display: flex; - flex-direction: column; - gap: 0.1rem; -} - -.itemText [data-slot='description'] { - color: var(--ifm-color-emphasis-600); - font-size: 0.75rem; -} From e531d6999635b4bbce6eb93a675bad2e88471c52 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 10 Sep 2026 13:07:12 +0000 Subject: [PATCH 4/5] fix: set tooltips on menu item content Co-Authored-By: Mattias Buelens --- src/components/CopyPageDropdown/index.tsx | 19 +++++++++---------- .../CopyPageDropdown/styles.module.css | 6 ++++-- 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/src/components/CopyPageDropdown/index.tsx b/src/components/CopyPageDropdown/index.tsx index 154f14084aa8..51f70b2db2f1 100644 --- a/src/components/CopyPageDropdown/index.tsx +++ b/src/components/CopyPageDropdown/index.tsx @@ -64,19 +64,18 @@ export default function CopyPageDropdown({ markdownUrl, className }: CopyPageDro href={markdownUrl} target="_blank" rel="noopener" - title="Open this page as plain text" className={({ isFocused }) => clsx('dropdown__link', { focused: isFocused }, styles.item)} > - - View as Markdown + + + View as Markdown + - clsx('dropdown__link', { focused: isFocused }, styles.item)} - > - - Copy as Markdown + clsx('dropdown__link', { focused: isFocused }, styles.item)}> + + + Copy as Markdown + diff --git a/src/components/CopyPageDropdown/styles.module.css b/src/components/CopyPageDropdown/styles.module.css index 40bd4d3da7a9..c0b32e37c1ec 100644 --- a/src/components/CopyPageDropdown/styles.module.css +++ b/src/components/CopyPageDropdown/styles.module.css @@ -74,10 +74,12 @@ } .item { - align-items: center; cursor: pointer; +} + +.itemContent { + align-items: center; display: flex; - flex-direction: row; gap: 0.6rem; } From 17ea0ce5807943092071c3bc7ed2732ac0b41113 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 10 Sep 2026 13:37:09 +0000 Subject: [PATCH 5/5] refactor: memoize excludeRoutes RegExp Co-Authored-By: Mattias Buelens --- src/plugin/llmsTxtClient.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/plugin/llmsTxtClient.ts b/src/plugin/llmsTxtClient.ts index 7c4336661300..02b2980bc109 100644 --- a/src/plugin/llmsTxtClient.ts +++ b/src/plugin/llmsTxtClient.ts @@ -1,3 +1,4 @@ +import { useMemo } from 'react'; import { useLocation } from '@docusaurus/router'; import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; import { usePluginData } from '@docusaurus/useGlobalData'; @@ -10,8 +11,9 @@ export function useMarkdownUrl(): string | undefined { const { pathname } = useLocation(); const { siteConfig } = useDocusaurusContext(); const { excludeRoutes } = usePluginData('llms-txt') as LlmsTxtGlobalData; + const excludeRoutesRegExp = useMemo(() => (excludeRoutes === '' ? undefined : new RegExp(excludeRoutes)), [excludeRoutes]); - if (excludeRoutes !== '' && new RegExp(excludeRoutes).test(pathname)) { + if (excludeRoutesRegExp?.test(pathname)) { return undefined; } return new URL(`${pathname.replace(/\/$/, '')}.md`, siteConfig.url).href;