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 ( + + + + + clsx('dropdown__link', { focused: isFocused }, styles.item)} + > + + + View 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 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; }