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..51f70b2db2f1
--- /dev/null
+++ b/src/components/CopyPageDropdown/index.tsx
@@ -0,0 +1,84 @@
+import { useEffect, useRef, useState } from 'react';
+import { Button, Menu, MenuItem, MenuTrigger, Popover } 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 (
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/CopyPageDropdown/styles.module.css b/src/components/CopyPageDropdown/styles.module.css
new file mode 100644
index 000000000000..c0b32e37c1ec
--- /dev/null
+++ b/src/components/CopyPageDropdown/styles.module.css
@@ -0,0 +1,90 @@
+.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-block;
+ content: '';
+ border-color: currentColor transparent;
+ border-style: solid;
+ border-width: 0.35em 0.35em 0;
+ margin-left: 0.5em;
+ vertical-align: middle;
+}
+
+.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: 12rem;
+ 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;
+ outline: none;
+ padding: 0;
+}
+
+.item {
+ cursor: pointer;
+}
+
+.itemContent {
+ align-items: center;
+ display: flex;
+ gap: 0.6rem;
+}
+
+.icon {
+ flex: 0 0 auto;
+ height: 16px;
+ width: 16px;
+}
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..02b2980bc109
--- /dev/null
+++ b/src/plugin/llmsTxtClient.ts
@@ -0,0 +1,20 @@
+import { useMemo } from 'react';
+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;
+ const excludeRoutesRegExp = useMemo(() => (excludeRoutes === '' ? undefined : new RegExp(excludeRoutes)), [excludeRoutes]);
+
+ if (excludeRoutesRegExp?.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;
}