diff --git a/docusaurus.config.js b/docusaurus.config.js index 6da1beb..9a5dea5 100644 --- a/docusaurus.config.js +++ b/docusaurus.config.js @@ -65,6 +65,8 @@ const config = { ], ], + plugins: ['./plugins/doc-page-markdown.js'], + presets: [ [ 'classic', diff --git a/plugins/doc-page-markdown.js b/plugins/doc-page-markdown.js new file mode 100644 index 0000000..577c929 --- /dev/null +++ b/plugins/doc-page-markdown.js @@ -0,0 +1,48 @@ +/** + * Injects globalData.markdownByPermalink for client-side copy / view-as-markdown. + * @param {import('@docusaurus/types').LoadContext} context + */ +module.exports = function docPageMarkdownPlugin(context) { + const {siteDir, siteConfig} = context; + + return { + name: 'docusaurus-plugin-doc-page-markdown', + async allContentLoaded({allContent, actions}) { + const docsContent = + allContent['docusaurus-plugin-content-docs']?.default; + if (!docsContent?.loadedVersions) { + actions.setGlobalData({markdownByPermalink: {}}); + return; + } + + const {aliasedSitePathToRelativePath, parseMarkdownFile} = + require('@docusaurus/utils'); + const fs = require('fs/promises'); + const path = require('path'); + const parseFrontMatter = siteConfig.markdown.parseFrontMatter; + + /** @type {Record} */ + const markdownByPermalink = {}; + + for (const version of docsContent.loadedVersions) { + for (const doc of version.docs) { + try { + const rel = aliasedSitePathToRelativePath(doc.source); + const absPath = path.join(siteDir, rel); + const fileContent = await fs.readFile(absPath, 'utf-8'); + const {content} = await parseMarkdownFile({ + filePath: absPath, + fileContent, + parseFrontMatter, + }); + markdownByPermalink[doc.permalink] = content.trimEnd(); + } catch { + // Skip unreadable or invalid files + } + } + } + + actions.setGlobalData({markdownByPermalink}); + }, + }; +}; diff --git a/src/components/DocPageCopyDropdown/index.js b/src/components/DocPageCopyDropdown/index.js new file mode 100644 index 0000000..0f92295 --- /dev/null +++ b/src/components/DocPageCopyDropdown/index.js @@ -0,0 +1,308 @@ +import React, { useCallback, useEffect, useId, useRef, useState } from "react"; +import { useDoc } from "@docusaurus/plugin-content-docs/client"; +import { usePluginData } from "@docusaurus/useGlobalData"; +import useBaseUrl from "@docusaurus/useBaseUrl"; +import { AI_PROVIDERS, buildDocPageAiPrompt } from "@site/src/data/aiProviders"; + +function IconCopy(props) { + return ( + + + + + ); +} + +function IconCheck(props) { + return ( + + + + ); +} + +function IconChevron(props) { + return ( + + + + ); +} + +function IconMarkdown(props) { + return ( + + + + + + + ); +} + +function IconLink(props) { + return ( + + + + + ); +} + +const menuItemCls = + "flex w-full items-center group gap-3 rounded-md px-2.5 py-2 text-left no-underline! cursor-pointer border-none bg-transparent text-inherit font-inherit hover:bg-neutral-100 dark:hover:bg-neutral-900 disabled:opacity-45 disabled:cursor-not-allowed"; + +const ListItem = ({ icon, title, description, asLink = false, ...props }) => { + const Component = asLink ? "a" : "button"; + return ( +
  • + +
    {icon}
    +
    +

    + {title} +

    +

    + {description} +

    +
    +
    +
  • + ); +}; + +export default function DocPageCopyDropdown() { + const { metadata } = useDoc(); + const pluginData = usePluginData( + "docusaurus-plugin-doc-page-markdown", + "default", + ); + const markdown = pluginData?.markdownByPermalink?.[metadata.permalink] ?? ""; + const hasMarkdown = Boolean(markdown?.trim()); + + const pageUrl = useBaseUrl(metadata.permalink, { absolute: true }); + const aiMessage = buildDocPageAiPrompt(metadata.title, pageUrl); + + const [menuOpen, setMenuOpen] = useState(false); + const [copiedMd, setCopiedMd] = useState(false); + const [copiedLink, setCopiedLink] = useState(false); + const wrapRef = useRef(null); + const menuId = useId(); + + const copyMarkdown = useCallback(async () => { + if (!hasMarkdown) return; + try { + await navigator.clipboard.writeText(markdown); + setCopiedMd(true); + setTimeout(() => setCopiedMd(false), 1500); + } catch { + // ignore + } + }, [hasMarkdown, markdown]); + + const copyLink = useCallback(async () => { + try { + await navigator.clipboard.writeText(pageUrl); + setCopiedLink(true); + setMenuOpen(false); + setTimeout(() => setCopiedLink(false), 1500); + } catch { + // ignore + } + }, [pageUrl]); + + const viewAsMarkdown = useCallback(() => { + if (!hasMarkdown) return; + const blob = new Blob([markdown], { type: "text/markdown;charset=utf-8" }); + const url = URL.createObjectURL(blob); + window.open(url, "_blank", "noopener,noreferrer"); + setTimeout(() => URL.revokeObjectURL(url), 60_000); + setMenuOpen(false); + }, [hasMarkdown, markdown]); + + useEffect(() => { + if (!menuOpen) return undefined; + const onDocMouseDown = (e) => { + if (wrapRef.current && !wrapRef.current.contains(e.target)) { + setMenuOpen(false); + } + }; + const onKey = (e) => { + if (e.key === "Escape") { + setMenuOpen(false); + } + }; + document.addEventListener("mousedown", onDocMouseDown); + document.addEventListener("keydown", onKey); + return () => { + document.removeEventListener("mousedown", onDocMouseDown); + document.removeEventListener("keydown", onKey); + }; + }, [menuOpen]); + + const dropdownItems = [ + { + icon: , + title: "Copy page as Markdown", + description: "Copy page as Markdown for LLMs", + onClick: copyMarkdown, + }, + { + icon: , + title: "View as Markdown", + description: "View this page as plain text", + onClick: viewAsMarkdown, + }, + ...AI_PROVIDERS.map(({ name, icon: BrandIcon, buildUrl }) => ({ + icon: , + title: `Open in ${name}`, + description: "Ask questions about this page", + href: buildUrl(aiMessage), + target: "_blank", + rel: "noopener noreferrer", + })), + { + icon: , + title: "Copy link", + description: "Copy page URL to clipboard", + onClick: copyLink, + }, + ]; + + return ( +
    + {/* Split button */} +
    + + + +
    + + {/* Dropdown menu */} + {menuOpen && ( + + )} +
    + ); +} diff --git a/src/components/SidebarAskAI/index.js b/src/components/SidebarAskAI/index.js deleted file mode 100644 index 5f736b5..0000000 --- a/src/components/SidebarAskAI/index.js +++ /dev/null @@ -1,141 +0,0 @@ -import React, {useState, useCallback} from 'react'; -import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; - -function IconCopy(props) { - return ( - - - - - ); -} - -function IconCheck(props) { - return ( - - - - ); -} - -function IconExternal(props) { - return ( - - - - - - ); -} - -const AI_PROVIDERS = [ - { - name: 'ChatGPT', - buildUrl: (msg) => - `https://chatgpt.com/?q=${encodeURIComponent(msg)}`, - }, - { - name: 'Claude', - buildUrl: (msg) => - `https://claude.ai/new?q=${encodeURIComponent(msg)}`, - }, - { - name: 'Gemini', - buildUrl: (msg) => - `https://gemini.google.com/app?q=${encodeURIComponent(msg)}`, - }, -]; - -export default function SidebarAskAI() { - const [showOther, setShowOther] = useState(false); - const [copied, setCopied] = useState(false); - const {siteConfig} = useDocusaurusContext(); - - const llmsTxtUrl = `${siteConfig.url}${siteConfig.baseUrl}llms.txt`; - const message = `Here is the LLMs.txt for QuilrAI LLM and MCP Gateways: ${llmsTxtUrl}\n\nUnderstand this and answer any related questions.`; - - const copyMessage = useCallback(async () => { - try { - await navigator.clipboard.writeText(message); - setCopied(true); - setTimeout(() => setCopied(false), 1500); - } catch {} - }, [message]); - - return ( -
    - Ask about QuilrAI Gateway -
    - {AI_PROVIDERS.map(({name, buildUrl}) => ( - - {name} - - - ))} - -
    - {showOther && ( -
    -

    - Copy and paste this into your AI: -

    -
    - {message} -
    - -
    - )} -
    - ); -} diff --git a/src/components/SidebarLlmsTxt/index.js b/src/components/SidebarLlmsTxt/index.js deleted file mode 100644 index bdce48d..0000000 --- a/src/components/SidebarLlmsTxt/index.js +++ /dev/null @@ -1,70 +0,0 @@ -import React, {useState, useCallback} from 'react'; -import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; - -function IconLink(props) { - return ( - - - - - ); -} - -function IconCheck(props) { - return ( - - - - ); -} - -export default function SidebarLlmsTxt() { - const [copied, setCopied] = useState(false); - const {siteConfig} = useDocusaurusContext(); - - const fullUrl = `${siteConfig.url}${siteConfig.baseUrl}llms.txt`; - - const copyUrl = useCallback(async () => { - try { - await navigator.clipboard.writeText(fullUrl); - setCopied(true); - setTimeout(() => setCopied(false), 1500); - } catch {} - }, [fullUrl]); - - return ( -
    - LLMs.txt - -
    - ); -} diff --git a/src/css/custom.css b/src/css/custom.css index 765e3e1..c54d8d1 100644 --- a/src/css/custom.css +++ b/src/css/custom.css @@ -14,6 +14,9 @@ @plugin "@tailwindcss/typography"; @import "tailwindcss/utilities.css" layer(utilities); +/* Docusaurus uses [data-theme="dark"] on , not a class or media query */ +@custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *)); + /* ────────────────────────────────── Light mode variables ─────────────── */ :root { --ifm-color-primary: #059669; @@ -315,6 +318,28 @@ main > .container.padding-top--md.padding-bottom--lg, font-weight: 700; } +/* Doc title copy dropdown: neutralize prose list markers and link underlines */ +.theme-doc-markdown.markdown [data-doc-copy-dropdown] :is(ul, ol) { + list-style: none; + list-style-type: none; + padding-inline-start: 0; + margin-inline-start: 0; +} +.theme-doc-markdown.markdown [data-doc-copy-dropdown] li { + list-style: none; + list-style-type: none; +} +.theme-doc-markdown.markdown [data-doc-copy-dropdown] li::marker { + content: none; + font-size: 0; +} +.theme-doc-markdown.markdown [data-doc-copy-dropdown] a, +.theme-doc-markdown.markdown [data-doc-copy-dropdown] a:hover, +.theme-doc-markdown.markdown [data-doc-copy-dropdown] a:focus, +.theme-doc-markdown.markdown [data-doc-copy-dropdown] a:visited { + text-decoration: none; +} + /* Inline code (Typography backticks off; violet) — fenced blocks reset below */ .theme-doc-markdown.markdown code { color: #7c3aed; diff --git a/src/data/aiProviders.js b/src/data/aiProviders.js new file mode 100644 index 0000000..1ad1cc2 --- /dev/null +++ b/src/data/aiProviders.js @@ -0,0 +1,100 @@ +import React from "react"; + +function IconChatGPT(props) { + return ( + + + + ); +} + +function IconClaude(props) { + return ( + + + + ); +} + +function IconPerplexity(props) { + return ( + + + + ); +} + +function IconGemini(props) { + return ( + + + + ); +} + +/** + * Shared AI assistant deep-link builders (sidebar + doc copy dropdown). + * @typedef {{ name: string; buildUrl: (message: string) => string; icon: React.ComponentType }} AiProvider + */ + +/** @type {AiProvider[]} */ +export const AI_PROVIDERS = [ + { + name: "ChatGPT", + icon: IconChatGPT, + buildUrl: (msg) => `https://chatgpt.com/?q=${encodeURIComponent(msg)}`, + }, + { + name: "Claude", + icon: IconClaude, + buildUrl: (msg) => `https://claude.ai/new?q=${encodeURIComponent(msg)}`, + }, + { + name: "Perplexity", + icon: IconPerplexity, + buildUrl: (msg) => + `https://www.perplexity.ai/search?q=${encodeURIComponent(msg)}`, + }, + { + name: "Gemini", + icon: IconGemini, + buildUrl: (msg) => + `https://gemini.google.com/app?q=${encodeURIComponent(msg)}`, + }, +]; + +/** + * @param {string} pageTitle + * @param {string} canonicalPageUrl + */ +export function buildDocPageAiPrompt(pageTitle, canonicalPageUrl) { + return `Please read this documentation page and help me with questions about it.\n\nTitle: ${pageTitle}\nURL: ${canonicalPageUrl}`; +} diff --git a/src/theme/DocItem/Content/index.js b/src/theme/DocItem/Content/index.js new file mode 100644 index 0000000..9853c27 --- /dev/null +++ b/src/theme/DocItem/Content/index.js @@ -0,0 +1,32 @@ +import React from "react"; +import clsx from "clsx"; +import { ThemeClassNames } from "@docusaurus/theme-common"; +import { useDoc } from "@docusaurus/plugin-content-docs/client"; +import Heading from "@theme/Heading"; +import MDXContent from "@theme/MDXContent"; +import DocPageCopyDropdown from "@site/src/components/DocPageCopyDropdown"; + +function useSyntheticTitle() { + const { metadata, frontMatter, contentTitle } = useDoc(); + const shouldRender = + !frontMatter.hide_title && typeof contentTitle === "undefined"; + if (!shouldRender) { + return null; + } + return metadata.title; +} + +export default function DocItemContent({ children }) { + const syntheticTitle = useSyntheticTitle(); + + return ( +
    +
    +
    + +
    + {children} +
    +
    + ); +} diff --git a/src/theme/DocSidebar/Desktop/Content/index.js b/src/theme/DocSidebar/Desktop/Content/index.js index 3122a1f..a227d37 100644 --- a/src/theme/DocSidebar/Desktop/Content/index.js +++ b/src/theme/DocSidebar/Desktop/Content/index.js @@ -1,33 +1,31 @@ -import React from 'react'; -import clsx from 'clsx'; -import {ThemeClassNames} from '@docusaurus/theme-common'; -import DocSidebarItems from '@theme/DocSidebarItems'; -import SidebarThemeToggle from '@site/src/components/SidebarThemeToggle'; -import SidebarLlmsTxt from '@site/src/components/SidebarLlmsTxt'; -import SidebarAskAI from '@site/src/components/SidebarAskAI'; +import React from "react"; +import clsx from "clsx"; +import { ThemeClassNames } from "@docusaurus/theme-common"; +import DocSidebarItems from "@theme/DocSidebarItems"; +import SidebarThemeToggle from "@site/src/components/SidebarThemeToggle"; -export default function DocSidebarDesktopContent({path, sidebar, className}) { +export default function DocSidebarDesktopContent({ path, sidebar, className }) { return ( diff --git a/src/theme/DocSidebar/Mobile/index.js b/src/theme/DocSidebar/Mobile/index.js index 398f6c5..af916e2 100644 --- a/src/theme/DocSidebar/Mobile/index.js +++ b/src/theme/DocSidebar/Mobile/index.js @@ -1,36 +1,32 @@ -import React from 'react'; -import clsx from 'clsx'; +import React from "react"; +import clsx from "clsx"; import { NavbarSecondaryMenuFiller, ThemeClassNames, -} from '@docusaurus/theme-common'; -import {useNavbarMobileSidebar} from '@docusaurus/theme-common/internal'; -import DocSidebarItems from '@theme/DocSidebarItems'; -import SidebarThemeToggle from '@site/src/components/SidebarThemeToggle'; -import SidebarAskAI from '@site/src/components/SidebarAskAI'; +} from "@docusaurus/theme-common"; +import { useNavbarMobileSidebar } from "@docusaurus/theme-common/internal"; +import DocSidebarItems from "@theme/DocSidebarItems"; +import SidebarThemeToggle from "@site/src/components/SidebarThemeToggle"; -const DocSidebarMobileSecondaryMenu = ({sidebar, path}) => { +const DocSidebarMobileSecondaryMenu = ({ sidebar, path }) => { const mobileSidebar = useNavbarMobileSidebar(); return (
    -
      +
        { - if (item.type === 'category' && item.href) { + if (item.type === "category" && item.href) { mobileSidebar.toggle(); } - if (item.type === 'link') { + if (item.type === "link") { mobileSidebar.toggle(); } }} level={1} />
      -
      - -