Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
84 changes: 84 additions & 0 deletions src/components/CopyPageDropdown/index.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<svg className={styles.icon} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.25" aria-hidden="true">
<path d="M4 1.75h5l3 3v9.5H4z" />
<path d="M9 1.75v3h3M6 8h4M6 10.5h4" />
</svg>
);
}

function CopyIcon() {
return (
<svg className={styles.icon} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.25" aria-hidden="true">
<rect x="5.25" y="5.25" width="7" height="8" rx="1" />
<path d="M10.75 5.25V3.5a1 1 0 0 0-1-1h-5a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h.5" />
</svg>
);
}

export default function CopyPageDropdown({ markdownUrl, className }: CopyPageDropdownProps) {
const [copied, setCopied] = useState(false);
const timeoutRef = useRef<number | undefined>(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 (
<MenuTrigger>
<Button className={clsx(styles.button, className)}>{copied ? 'Copied' : 'Copy page'}</Button>
<Popover className={styles.popover} placement="bottom end" offset={4}>
<Menu className={styles.menu}>
<MenuItem
href={markdownUrl}
target="_blank"
rel="noopener"
className={({ isFocused }) => clsx('dropdown__link', { focused: isFocused }, styles.item)}
>
<span className={styles.itemContent} title="Open this page as plain text">
<DocumentIcon />
View as Markdown
</span>
</MenuItem>
<MenuItem onAction={copyMarkdown} className={({ isFocused }) => clsx('dropdown__link', { focused: isFocused }, styles.item)}>
<span className={styles.itemContent} title="Copy this page as Markdown for LLMs">
<CopyIcon />
Copy as Markdown
</span>
</MenuItem>
</Menu>
</Popover>
</MenuTrigger>
);
}
90 changes: 90 additions & 0 deletions src/components/CopyPageDropdown/styles.module.css
Original file line number Diff line number Diff line change
@@ -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;
}
12 changes: 12 additions & 0 deletions src/plugin/llmsTxt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/**`. */
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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);
Expand Down
20 changes: 20 additions & 0 deletions src/plugin/llmsTxtClient.ts
Original file line number Diff line number Diff line change
@@ -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;
}
64 changes: 39 additions & 25 deletions src/theme/DocBreadcrumbs/index.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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';

Expand Down Expand Up @@ -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 (
<>
<DocBreadcrumbsStructuredData breadcrumbs={breadcrumbs} />
<nav
className={clsx(ThemeClassNames.docs.docBreadcrumbs, styles.breadcrumbsContainer)}
aria-label={translate({
id: 'theme.docs.breadcrumbs.navAriaLabel',
message: 'Breadcrumbs',
description: 'The ARIA label for the breadcrumbs',
})}
>
<ul className="breadcrumbs">
{homePageRoute && <HomeBreadcrumbItem />}
{breadcrumbs.map((item, idx) => {
const isLast = idx === breadcrumbs.length - 1;
const href = item.type === 'category' && item.linkUnlisted ? undefined : item.href;
return (
<BreadcrumbsItem key={idx} active={isLast}>
<BreadcrumbsItemLink href={href} isLast={isLast}>
{item.label}
</BreadcrumbsItemLink>
</BreadcrumbsItem>
);
})}
</ul>
</nav>
{markdownUrl && (
<Head>
<link rel="alternate" type="text/markdown" href={markdownUrl} />
</Head>
)}
{breadcrumbs && <DocBreadcrumbsStructuredData breadcrumbs={breadcrumbs} />}
<div className={styles.breadcrumbsRow}>
{breadcrumbs && (
<nav
className={clsx(ThemeClassNames.docs.docBreadcrumbs, styles.breadcrumbsContainer)}
aria-label={translate({
id: 'theme.docs.breadcrumbs.navAriaLabel',
message: 'Breadcrumbs',
description: 'The ARIA label for the breadcrumbs',
})}
>
<ul className="breadcrumbs">
{homePageRoute && <HomeBreadcrumbItem />}
{breadcrumbs.map((item, idx) => {
const isLast = idx === breadcrumbs.length - 1;
const href = item.type === 'category' && item.linkUnlisted ? undefined : item.href;
return (
<BreadcrumbsItem key={idx} active={isLast}>
<BreadcrumbsItemLink href={href} isLast={isLast}>
{item.label}
</BreadcrumbsItemLink>
</BreadcrumbsItem>
);
})}
</ul>
</nav>
)}
{markdownUrl && <CopyPageDropdown markdownUrl={markdownUrl} />}
</div>
</>
);
}
9 changes: 8 additions & 1 deletion src/theme/DocBreadcrumbs/styles.module.css
Original file line number Diff line number Diff line change
@@ -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;
}