diff --git a/docusaurus.config.ts b/docusaurus.config.ts index 223d45fd37..8e7a11867a 100644 --- a/docusaurus.config.ts +++ b/docusaurus.config.ts @@ -1,6 +1,8 @@ import type * as Preset from "@docusaurus/preset-classic"; import type { Config } from "@docusaurus/types"; import { themes as prismThemes } from "prism-react-renderer"; + +import markdownExportRegistry from "./src/plugins/markdown-export/registry"; const config: Config = { title: "Apache Cloudberry (Incubating)", tagline: "One advanced and mature open-source MPP (Massively Parallel Processing) database. Open source alternative to Greenplum Database.", @@ -22,6 +24,33 @@ const config: Config = { plugins: [ "docusaurus-plugin-sass", 'docusaurus-plugin-matomo', + // Emits a plain-Markdown twin of every page, reachable by appending `.md` + // to its URL. Build-output only; does not affect the rendered site. + [ + "./src/plugins/markdown-export", + { + // Both lists are keyed by docs plugin id: the unreleased version of + // *every* instance is named `current`, so a flat list would silently + // take PXF down with `docs/next`. + + // Skipped outright. `/docs/1.x/**.md` returns 404 and those pages get + // no Copy page menu; their HTML is untouched. 1.x is legacy and not + // worth the weight it adds to every asf-site commit. + excludeVersions: { + default: ["1.x"], + }, + + // Exported and linked from the page, but kept out of sitemap.xml -- + // the two entry points serve different audiences. A contributor + // reading the dev docs should get the dev docs when they hit Copy + // page; a crawler should not be answering user questions out of an + // unreleased version. Keeping `docs/next` out also spares crawlers 516 + // files whose prose is byte-identical to 2.x on 491 of them. + excludeFromSitemap: { + default: ["current"], + }, + }, + ], [ "@easyops-cn/docusaurus-search-local", { hashed: true, indexPages: true, language: ["en"] }, @@ -69,6 +98,47 @@ const config: Config = { "Apache Cloudberry (Incubating) is one advanced and mature open-source MPP (Massively Parallel Processing) databases available.", }, }, + sitemap: { + // List the Markdown twins alongside the HTML pages. `` already announces them per page, but sitemap.xml + // is the one discovery file AI crawlers reliably fetch, and nothing + // else on the site links to a `.md` URL. + // + // Tradeoff: every page now appears twice, and ASF's static hosting + // gives us no way to send `X-Robots-Tag: noindex` on the Markdown + // half. Delete this block to go back to HTML-only. + createSitemapItems: async ({ + defaultCreateSitemapItems, + ...params + }) => { + const items = await defaultCreateSitemapItems(params); + + // Reuse each page's own lastmod so the twin is never treated as + // fresher (or staler) than the page it mirrors. A no-op today -- + // the plugin's `lastmod` option defaults to null, so no entry + // carries one -- but it keeps the two halves in step if that is + // ever switched on. + const lastmodByPath = new Map( + items.map((item) => [ + new URL(item.url).pathname.replace(/\/$/, ""), + item.lastmod, + ]), + ); + + // Populated by `markdown-export` in `allContentLoaded`, which + // always runs before any `postBuild`. See registry.js. + const twins = [...markdownExportRegistry.sitemapPermalinks].map( + (permalink) => ({ + url: `${params.siteConfig.url}${markdownExportRegistry.markdownPathFor( + permalink, + )}`, + lastmod: lastmodByPath.get(permalink.replace(/\/$/, "")), + }), + ); + + return [...items, ...twins]; + }, + }, theme: { customCss: [ "./src/css/custom.scss", diff --git a/src/components/common/AiActions/index.tsx b/src/components/common/AiActions/index.tsx new file mode 100644 index 0000000000..980361fcb9 --- /dev/null +++ b/src/components/common/AiActions/index.tsx @@ -0,0 +1,174 @@ +import React, { useCallback, useEffect, useRef, useState } from "react"; +import { useClickAway } from "ahooks"; +import useDocusaurusContext from "@docusaurus/useDocusaurusContext"; +import clsx from "clsx"; + +import { markdownPathFor } from "@site/src/components/common/markdownTwin"; + +import styles from "./styles.module.scss"; + +function promptFor(markdownUrl: string): string { + return `Read ${markdownUrl} — I have questions about this Apache Cloudberry documentation page.`; +} + +type CopyState = "idle" | "busy" | "done" | "error"; + +const COPY_LABEL: Record = { + idle: "Copy page", + busy: "Copying…", + done: "Copied!", + error: "Copy failed", +}; + +export interface Props { + /** Permalink of the current page, as produced by the content plugin. */ + permalink: string; + className?: string; + /** + * Edge the dropdown grows from. Must match how the trigger itself is aligned + * in its container -- otherwise the panel opens past the viewport edge on + * narrow screens. + */ + align?: "start" | "end"; +} + +export default function AiActions({ + permalink, + className, + align = "end", +}: Props): JSX.Element { + const { siteConfig } = useDocusaurusContext(); + const [open, setOpen] = useState(false); + const [copyState, setCopyState] = useState("idle"); + const containerRef = useRef(null); + + const markdownPath = markdownPathFor(permalink); + // Deep links are resolved by a third party, so they need the canonical + // origin -- a dev-server URL would be unreachable to them anyway. + const prompt = encodeURIComponent(promptFor(`${siteConfig.url}${markdownPath}`)); + + useClickAway(() => setOpen(false), containerRef); + + useEffect(() => { + if (!open) { + return undefined; + } + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + setOpen(false); + } + }; + document.addEventListener("keydown", onKeyDown); + return () => document.removeEventListener("keydown", onKeyDown); + }, [open]); + + // Let the transient copy result fall back to the resting label. + useEffect(() => { + if (copyState !== "done" && copyState !== "error") { + return undefined; + } + const timer = window.setTimeout(() => setCopyState("idle"), 2200); + return () => window.clearTimeout(timer); + }, [copyState]); + + const handleCopy = useCallback(async () => { + setOpen(false); + setCopyState("busy"); + try { + const response = await fetch(markdownPath); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + // Unavailable outside secure contexts (plain-HTTP dev hosts); the catch + // below surfaces that rather than failing silently. + await navigator.clipboard.writeText(await response.text()); + setCopyState("done"); + } catch { + setCopyState("error"); + } + }, [markdownPath]); + + return ( +
+ + + {open && ( +
+ + + + View as Markdown + Open the plain-text source + + + + )} +
+ ); +} diff --git a/src/components/common/AiActions/styles.module.scss b/src/components/common/AiActions/styles.module.scss new file mode 100644 index 0000000000..5b858281f8 --- /dev/null +++ b/src/components/common/AiActions/styles.module.scss @@ -0,0 +1,141 @@ +/* ------------------------------------------------------------------ */ +/* AiActions — per-page menu exposing the page's Markdown twin */ +/* ------------------------------------------------------------------ */ + +.root { + position: relative; + display: inline-flex; + flex-shrink: 0; +} + +/* ---- Trigger ----------------------------------------------------- */ +.trigger { + display: inline-flex; + align-items: center; + gap: 7px; + padding: 5px 10px; + font-size: 0.8125rem; + line-height: 1.4; + font-weight: 500; + color: var(--color-text-muted); + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: 6px; + cursor: pointer; + transition: color 0.15s ease, border-color 0.15s ease, background 0.15s ease; + + &:hover { + color: var(--color-text); + border-color: var(--color-border-strong); + background: var(--color-bg-subtle); + } + + /* Keep keyboard focus obvious; the trigger is small and easy to lose. */ + &:focus-visible { + outline: 2px solid var(--color-accent); + outline-offset: 2px; + } +} + +.triggerLabel { + white-space: nowrap; +} + +.chevron { + flex-shrink: 0; + transition: transform 0.15s ease; +} + +.chevronOpen { + transform: rotate(180deg); +} + +/* ---- Menu -------------------------------------------------------- */ +.menu { + position: absolute; + top: calc(100% + 6px); + right: 0; + z-index: 20; + min-width: 240px; + /* Never wider than the viewport, however narrow the screen gets. */ + max-width: calc(100vw - 24px); + padding: 6px; + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: 10px; + box-shadow: 0 8px 28px rgba(0, 0, 0, 0.14); +} + +.menuStart { + right: auto; + left: 0; +} + +html[data-theme="dark"] .menu { + box-shadow: 0 8px 28px rgba(0, 0, 0, 0.55); +} + +.item { + display: flex; + flex-direction: column; + gap: 2px; + width: 100%; + /* Reset the shared