This is an automated email from the ASF dual-hosted git repository.
TomShawn pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/cloudberry-site.git
The following commit(s) were added to refs/heads/main by this push:
new d656c4b095 Feat/ai page access (#392)
d656c4b095 is described below
commit d656c4b0959e152891a6b383807b2691c8b8926f
Author: TomShawn <[email protected]>
AuthorDate: Tue Aug 18 10:57:21 2026 +0800
Feat/ai page access (#392)
* Docs: emit a plain-Markdown twin of every page
Appending `.md` to any page URL now returns clean Markdown instead of an
82 KB HTML document. Measured over the 503 released doc pages, that is
38.4 MB of HTML against 3.5 MB of Markdown -- 9%, and closer to 2% on the
short reference pages, where a 79 KB page carries 1.6 KB of content. The
rest is navigation, scripts and styling that an LLM pays for and cannot
use.
The output comes from the Markdown source rather than from the rendered
HTML, so tables, admonitions and code samples survive verbatim. That
matters here: the docs contain shell samples with `export VAR=...`, Java
samples with `import java.sql.*;`, and pg_filedump output with literal
`<Header>` markers, all of which a line-oriented stripper corrupts. The
sanitiser tracks code fences and passes them through untouched, treating
only Docusaurus' `mdx-code-block` fences as transparent, since their
contents are evaluated rather than displayed.
A build-time self-check flags components that survive sanitising. It
derives the component list from each file's own imports, so a component
introduced later is audited without touching this plugin. Scanning for
bare capitalised tags instead is unusable -- the docs are full of
`<SEGID>`, `<PID>`, `<YYYYMMDD>` placeholders and Rust generics like
`<T>` that are prose, not JSX.
Two exclusion lists, both keyed by docs plugin id because version names
are only unique within an instance -- the unreleased version of every
instance is named `current`, so a flat list would take PXF down with
`docs/next`. `excludeVersions` skips a version outright (1.x, legacy);
`excludeFromSitemap` still exports and links the twin but keeps it out of
sitemap.xml, wired up in the next commit.
Nothing about the rendered site changes; this only adds files to the
build output.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
* Docs: surface each page's Markdown twin to readers and crawlers
The twins added in the previous commit had no entry point: nothing on the
site linked to a `.md` URL, and sitemap.xml listed only HTML. This adds
both, for the two audiences separately.
For readers, a "Copy page" menu on every doc, PXF and blog page: copy the
Markdown to the clipboard, open the plain-text source, or hand the page to
Claude or ChatGPT as context. The deep links use the canonical origin,
since a dev-server URL would be unreachable to a third party.
For crawlers, `<link rel="alternate" type="text/markdown">` in the head of
every page that has a twin, plus the `.md` URLs in sitemap.xml. The head
link is what makes discovery possible at all -- the menu is behind an
`open &&` guard, so its links never reach the server-rendered HTML.
`docs/next` gets the menu but stays out of sitemap.xml. A contributor
reading the dev docs should get the dev docs; a crawler should not be
answering user questions out of an unreleased version, and 491 of its 516
pages are byte-identical to 2.x anyway. Cost of listing the twins at all:
sitemap.xml grows from 1456 entries to 2086, and ASF's static hosting
gives us no way to send `X-Robots-Tag: noindex` on the Markdown half.
Deleting the `sitemap` block returns to HTML-only.
The sitemap reads the exported permalinks through a module-level set
rather than a file, because `postBuild` hooks run concurrently and would
race; `allContentLoaded` strictly precedes all of them.
Two layout notes: on narrow viewports the actions wrap onto their own line
instead of being pushed out of the viewport, and they stay flush right
once wrapped -- the dropdown is anchored to the trigger's right edge, so a
left-aligned trigger would send the panel off-screen. Blog pages align the
panel from the left instead, where the trigger sits.
Verified at 375/768/1280/1440/1728/1920 px: the panel stays inside the
viewport and the actions never overflow it.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
* Docs: make the Markdown twins work under `docusaurus start`
The twins are written in `postBuild`, which never runs in dev mode, so
`npm start` served no `.md` files at all. Worse than a 404: webpack-dev-
server's history fallback answers those requests with the SPA shell at
200, so "View as Markdown" opened a blank page and "Copy as Markdown"
copied 2 KB of HTML while reporting success. Reported by a contributor who
pulled the branch; it never showed up locally because every check here ran
against a production build.
Dev mode now exports the twins into `.docusaurus/markdown-export/` from
`allContentLoaded` and serves them by appending that directory to
`devServer.static`. `static` is an array, so webpack-merge appends to
Docusaurus' own entries; overriding `setupMiddlewares` was the other
option, but that key merges by replacement and would drop the middleware
behind the dev error overlay. Re-exports skip files whose twin is newer
than its source, so content reloads do not rewrite all 1146 each time.
The copy handler now rejects an HTML response instead of putting it on the
clipboard. Any host without a route for `.md` can answer 200 with a shell
rather than 404, and copying that looks like success and pastes as
garbage.
Verified: `npm start` serves all 1146 twins as `text/markdown`, and the
production build is unchanged -- 1146 files, sitemap at 2086 entries, no
dev directory in the output. ASF's httpd already serves `.md` as
`text/markdown`, confirmed against an existing file on the live site.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
---------
Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
---
docusaurus.config.ts | 70 ++
src/components/common/AiActions/index.tsx | 184 ++++++
src/components/common/AiActions/styles.module.scss | 141 ++++
src/components/common/markdownTwin/index.tsx | 45 ++
src/plugins/markdown-export/index.js | 714 +++++++++++++++++++++
src/plugins/markdown-export/registry.js | 55 ++
src/theme/BlogPostItem/index.tsx | 12 +
src/theme/BlogPostItem/styles.module.scss | 5 +
src/theme/DocItem/Layout/index.tsx | 40 +-
src/theme/DocItem/Layout/styles.module.css | 27 +
src/theme/MDXPage/index.tsx | 6 +
11 files changed, 1297 insertions(+), 2 deletions(-)
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. `<link
+ // rel="alternate">` 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..fef22052e7
--- /dev/null
+++ b/src/components/common/AiActions/index.tsx
@@ -0,0 +1,184 @@
+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<CopyState, string> = {
+ 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<CopyState>("idle");
+ const containerRef = useRef<HTMLDivElement>(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}`);
+ }
+
+ const text = await response.text();
+ // A server with no route for `.md` may answer 200 with the SPA shell
+ // rather than 404 -- webpack-dev-server's history fallback does exactly
+ // that. Copying that HTML would look like success and paste as garbage.
+ const contentType = response.headers.get("content-type") ?? "";
+ if (contentType.startsWith("text/html") ||
/^\s*<(?:!doctype|html)\b/i.test(text)) {
+ throw new Error("endpoint returned HTML, not Markdown");
+ }
+
+ // Unavailable outside secure contexts (plain-HTTP dev hosts); the catch
+ // below surfaces that rather than failing silently.
+ await navigator.clipboard.writeText(text);
+ setCopyState("done");
+ } catch {
+ setCopyState("error");
+ }
+ }, [markdownPath]);
+
+ return (
+ <div className={clsx(styles.root, className)} ref={containerRef}>
+ <button
+ type="button"
+ className={styles.trigger}
+ aria-haspopup="menu"
+ aria-expanded={open}
+ onClick={() => setOpen((value) => !value)}
+ >
+ <span className={styles.triggerLabel}>{COPY_LABEL[copyState]}</span>
+ <svg
+ className={clsx(styles.chevron, open && styles.chevronOpen)}
+ width="10"
+ height="10"
+ viewBox="0 0 10 10"
+ aria-hidden="true"
+ >
+ <path
+ d="M2 3.5L5 6.5L8 3.5"
+ fill="none"
+ stroke="currentColor"
+ strokeWidth="1.5"
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ />
+ </svg>
+ </button>
+
+ {open && (
+ <div
+ className={clsx(styles.menu, align === "start" && styles.menuStart)}
+ role="menu"
+ >
+ <button
+ type="button"
+ role="menuitem"
+ className={styles.item}
+ onClick={handleCopy}
+ >
+ <span className={styles.itemLabel}>Copy as Markdown</span>
+ <span className={styles.itemHint}>
+ Clean page source, ready to paste into a chat
+ </span>
+ </button>
+
+ <a
+ role="menuitem"
+ className={styles.item}
+ href={markdownPath}
+ target="_blank"
+ rel="noreferrer"
+ >
+ <span className={styles.itemLabel}>View as Markdown</span>
+ <span className={styles.itemHint}>Open the plain-text source</span>
+ </a>
+
+ <div className={styles.separator} role="separator" />
+
+ <a
+ role="menuitem"
+ className={styles.item}
+ href={`https://claude.ai/new?q=${prompt}`}
+ target="_blank"
+ rel="noreferrer"
+ >
+ <span className={styles.itemLabel}>Open in Claude</span>
+ <span className={styles.itemHint}>Ask with this page as
context</span>
+ </a>
+
+ <a
+ role="menuitem"
+ className={styles.item}
+ href={`https://chatgpt.com/?q=${prompt}`}
+ target="_blank"
+ rel="noreferrer"
+ >
+ <span className={styles.itemLabel}>Open in ChatGPT</span>
+ <span className={styles.itemHint}>Ask with this page as
context</span>
+ </a>
+ </div>
+ )}
+ </div>
+ );
+}
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 <button>/<a> differences so both rows render alike. */
+ padding: 8px 10px;
+ text-align: left;
+ background: none;
+ border: none;
+ border-radius: 6px;
+ cursor: pointer;
+ transition: background 0.15s ease;
+
+ &:hover {
+ background: var(--color-bg-muted);
+ text-decoration: none;
+ }
+
+ &:focus-visible {
+ outline: 2px solid var(--color-accent);
+ outline-offset: -2px;
+ }
+}
+
+.itemLabel {
+ font-size: 0.875rem;
+ font-weight: 500;
+ color: var(--color-text);
+}
+
+.itemHint {
+ font-size: 0.75rem;
+ line-height: 1.35;
+ color: var(--color-text-soft);
+}
+
+.separator {
+ height: 1px;
+ margin: 6px 4px;
+ background: var(--color-border);
+}
+
+/* Anchor rows are links, so both the theme's link colouring and the blog
+ content's underlines would otherwise leak in. */
+a.item,
+a.item:hover,
+a.item:visited {
+ color: inherit;
+ text-decoration: none;
+
+ .itemLabel,
+ .itemHint {
+ text-decoration: none;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .trigger,
+ .chevron,
+ .item {
+ transition: none;
+ }
+}
diff --git a/src/components/common/markdownTwin/index.tsx
b/src/components/common/markdownTwin/index.tsx
new file mode 100644
index 0000000000..85346a2835
--- /dev/null
+++ b/src/components/common/markdownTwin/index.tsx
@@ -0,0 +1,45 @@
+import Head from "@docusaurus/Head";
+import useDocusaurusContext from "@docusaurus/useDocusaurusContext";
+
+/**
+ * Maps a page permalink onto the Markdown twin emitted at build time by the
+ * `markdown-export` plugin.
+ *
+ * Must stay in step with `permalinkToFile()` in
+ * `src/plugins/markdown-export/index.js` -- if the two disagree, every link
+ * built from this 404s.
+ */
+export function markdownPathFor(permalink: string): string {
+ return permalink.endsWith("/") ? `${permalink}index.md` : `${permalink}.md`;
+}
+
+export interface Props {
+ /** Permalink of the current page, as produced by the content plugin. */
+ permalink: string;
+}
+
+/**
+ * Advertises the page's Markdown twin from `<head>`.
+ *
+ * This is the only way a crawler can find those files: the "View as Markdown"
+ * menu item is behind an `open &&` guard, so it never reaches the server-
+ * rendered HTML, and nothing else on the page links to a `.md` URL. Unlike
+ * that menu, this tag is unconditional -- it is the discovery surface.
+ *
+ * Absolute URLs, because a `rel="alternate"` resolved against the document is
+ * technically fine but needlessly fragile for third-party consumers.
+ */
+export default function MarkdownAlternate({ permalink }: Props): JSX.Element {
+ const { siteConfig } = useDocusaurusContext();
+
+ return (
+ <Head>
+ <link
+ rel="alternate"
+ type="text/markdown"
+ title="Markdown source"
+ href={`${siteConfig.url}${markdownPathFor(permalink)}`}
+ />
+ </Head>
+ );
+}
diff --git a/src/plugins/markdown-export/index.js
b/src/plugins/markdown-export/index.js
new file mode 100644
index 0000000000..db10cfe5ef
--- /dev/null
+++ b/src/plugins/markdown-export/index.js
@@ -0,0 +1,714 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * markdown-export
+ *
+ * Emits a plain-Markdown twin next to every generated HTML page, so that any
+ * page URL answers with clean Markdown when `.md` is appended:
+ *
+ * /docs/introduction/cbdb-overview -> /docs/introduction/cbdb-overview.md
+ *
+ * The output is aimed at LLM consumption: React/MDX machinery is stripped, but
+ * the prose, tables and code samples are kept verbatim. Nothing about the
+ * rendered site changes -- this plugin only adds files to the build output.
+ *
+ * Companion pieces (not implemented here): an llms.txt index, and the
+ * per-page "Copy as Markdown" menu that links to these URLs.
+ */
+
+const fs = require("fs/promises");
+const path = require("path");
+const logger = require("@docusaurus/logger");
+
+const { sitemapPermalinks, markdownPathFor } = require("./registry");
+
+const PLUGIN_NAME = "markdown-export";
+
+/** Components that carry no prose of their own; drop the tag, keep children.
*/
+const STRUCTURAL_TAGS = ["Tabs", "TabItem"];
+/** Components whose content lives in JS, not Markdown; nothing to salvage. */
+const OPAQUE_TAGS = ["DocCardList", "Timeline", "Contributors"];
+
+const DROP_TAG_RE = new RegExp(
+ `^\\s*</?(?:${[...STRUCTURAL_TAGS,
...OPAQUE_TAGS].join("|")})\\b[^>]*/?>\\s*$`,
+);
+
+/**
+ * The same structural tags, but wherever they sit on a line. Authors usually
+ * give them their own line, in which case `DROP_TAG_RE` has already handled
+ * them; this catches `<TabItem label="x">text</TabItem>` written inline, whose
+ * closing tag would otherwise survive into the Markdown.
+ *
+ * Only the structural tags: dropping an opaque tag inline would leave its
+ * children behind, and those are JS expressions, not prose.
+ */
+const STRUCTURAL_INLINE_RE = new RegExp(
+ `</?(?:${STRUCTURAL_TAGS.join("|")})\\b[^>]*/?>`,
+ "g",
+);
+
+/**
+ * ESM imports only. Deliberately requires a `from "..."` clause (or a bare
+ * side-effect import) so that Java's `import java.sql.Connection;` and
+ * Python's `import pyodbc` are never matched -- those appear in code samples.
+ */
+const ESM_IMPORT_RE =
+
/^import\s+(?:[^;'"]*\s+from\s+)?['"][^'"]+['"];?\s*$|^import\s+[\w*{][^;]*\s+from\s+['"][^'"]+['"];?\s*$/;
+
+/** `export const history = [ ... ]` and friends. Never matches `export
FOO=bar`. */
+const ESM_EXPORT_RE =
/^export\s+(?:const|let|var|default|function|class|\{)\b/;
+
+const FENCE_RE = /^\s*(`{3,}|~{3,})/;
+
+/**
+ * Docusaurus' escape hatch for putting JSX where MDX would not otherwise allow
+ * it. It looks like a code fence but its contents are *evaluated*, not
+ * displayed, so it must be unwrapped rather than passed through verbatim.
+ * @see
https://docusaurus.io/docs/markdown-features/react#markdown-and-jsx-interoperability
+ */
+const MDX_CODE_BLOCK = "mdx-code-block";
+
+// ---------------------------------------------------------------------------
+// Front matter
+// ---------------------------------------------------------------------------
+
+const FRONT_MATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---[ \t]*\r?\n?/;
+
+function splitFrontMatter(raw) {
+ const match = FRONT_MATTER_RE.exec(raw);
+ if (!match) {
+ return { fields: {}, body: raw };
+ }
+ const fields = {};
+ for (const line of match[1].split(/\r?\n/)) {
+ // Top-level scalars only; nested YAML is irrelevant to what we emit.
+ const kv = /^([A-Za-z_][\w-]*):[ \t]*(.*)$/.exec(line);
+ if (kv) {
+ fields[kv[1]] = kv[2].trim().replace(/^["'](.*)["']$/, "$1");
+ }
+ }
+ return { fields, body: raw.slice(match[0].length) };
+}
+
+// ---------------------------------------------------------------------------
+// Body sanitiser
+// ---------------------------------------------------------------------------
+
+/**
+ * Strip MDX/JSX constructs while leaving fenced code blocks completely
+ * untouched. Fence tracking is what makes this safe: the docs contain shell
+ * samples with `export VAR=...`, Java samples with `import java.sql.*;`, and
+ * pg_filedump output containing literal `<Header>` / `<Data>` markers -- all
of
+ * which a line-oriented stripper would happily corrupt.
+ *
+ * @returns {{body: string, hasH1: boolean, prose: string}}
+ * `prose` is the emitted text minus every fenced block -- the only region
+ * where a leaked component tag would be a real defect. Collected here rather
+ * than by a second pass so the two can never disagree about fence state.
+ */
+function sanitizeBody(body) {
+ const lines = body.split("\n");
+ const out = [];
+ const prose = [];
+
+ let fence = null; // {char: '`'|'~', len: number}
+ let inMdxComment = false;
+ let exportDepth = null; // bracket balance while consuming an ESM export
+
+ let hasH1 = false;
+
+ for (const line of lines) {
+ // --- fenced code: verbatim passthrough, and the only place we track state
+ const fenceMatch = FENCE_RE.exec(line);
+ if (fenceMatch) {
+ const marker = fenceMatch[1];
+ if (!fence) {
+ const info = line.slice(line.indexOf(marker) + marker.length).trim();
+ // A transparent fence contributes no delimiters of its own; its body
+ // falls through to the MDX handling below.
+ fence = {
+ char: marker[0],
+ len: marker.length,
+ transparent: info === MDX_CODE_BLOCK,
+ };
+ if (!fence.transparent) {
+ out.push(line);
+ }
+ continue;
+ }
+ if (marker[0] === fence.char && marker.length >= fence.len) {
+ const { transparent } = fence;
+ fence = null;
+ if (!transparent) {
+ out.push(line);
+ }
+ continue;
+ }
+ out.push(line);
+ continue;
+ }
+ if (fence && !fence.transparent) {
+ out.push(line);
+ continue;
+ }
+
+ // --- multi-line constructs opened on an earlier line
+ if (inMdxComment) {
+ if (line.includes("*/}")) {
+ inMdxComment = false;
+ }
+ continue;
+ }
+ if (exportDepth !== null) {
+ exportDepth += bracketDelta(line);
+ if (exportDepth <= 0) {
+ exportDepth = null;
+ }
+ continue;
+ }
+
+ // --- single-line MDX machinery
+ if (ESM_IMPORT_RE.test(line)) {
+ continue;
+ }
+ if (ESM_EXPORT_RE.test(line)) {
+ const delta = bracketDelta(line);
+ if (delta > 0) {
+ exportDepth = delta;
+ }
+ continue;
+ }
+ if (line.includes("{/*")) {
+ if (!line.includes("*/}")) {
+ inMdxComment = true;
+ continue;
+ }
+ const stripped = line.replace(/\{\/\*[\s\S]*?\*\/\}/g, "").trim();
+ if (stripped === "") {
+ continue;
+ }
+ out.push(stripped);
+ continue;
+ }
+
+ // --- components
+ // A tab's `label` is real prose (e.g. "For Rocky Linux 8"); promote it to
+ // bold text so the branch each code block belongs to survives.
+ let text = line.replace(
+ /<TabItem\b[^>]*\blabel=(["'])(.*?)\1[^>]*>/g,
+ (_all, _quote, label) => `**${label}**\n`,
+ );
+ if (DROP_TAG_RE.test(text)) {
+ continue;
+ }
+ const withoutStructural = text.replace(STRUCTURAL_INLINE_RE, "");
+ if (withoutStructural.trim() === "" && text.trim() !== "") {
+ continue;
+ }
+ text = withoutStructural;
+
+ if (!hasH1 && /^#\s+\S/.test(text)) {
+ hasH1 = true;
+ }
+ out.push(text);
+ prose.push(text);
+ }
+
+ const collapsed = out
+ .join("\n")
+ .replace(/\n{3,}/g, "\n\n")
+ .trim();
+
+ return { body: collapsed, hasH1, prose: prose.join("\n") };
+}
+
+function bracketDelta(line) {
+ let delta = 0;
+ for (const ch of line) {
+ if (ch === "(" || ch === "[" || ch === "{") delta++;
+ else if (ch === ")" || ch === "]" || ch === "}") delta--;
+ }
+ return delta;
+}
+
+// ---------------------------------------------------------------------------
+// Self-check
+// ---------------------------------------------------------------------------
+
+/**
+ * Every capitalised binding an ESM import brings into scope -- i.e. every name
+ * that could legally appear as `<Name />` further down the file.
+ */
+const IMPORT_BINDINGS_RE =
+
/^import\s+(?:(\w+)\s*(?:,\s*\{([^}]*)\})?|\{([^}]*)\})\s+from\s+['"][^'"]+['"]/gm;
+
+/**
+ * @param {string} raw the untouched source file
+ * @returns {string[]} component names this file could render
+ */
+function importedComponents(raw) {
+ const names = new Set();
+
+ for (const match of raw.matchAll(IMPORT_BINDINGS_RE)) {
+ const [, defaultBinding, ...namedGroups] = match;
+ const candidates = [defaultBinding];
+ for (const group of namedGroups) {
+ if (group) {
+ // `{ Foo, Bar as Baz }` -- the local name is what gets rendered.
+ candidates.push(...group.split(",").map((part) =>
part.trim().split(/\s+/).pop()));
+ }
+ }
+ for (const name of candidates) {
+ if (name && /^[A-Z]/.test(name)) {
+ names.add(name);
+ }
+ }
+ }
+ return [...names];
+}
+
+/**
+ * Flags components that survived sanitising.
+ *
+ * Derived from each file's own imports rather than from a hard-coded list, so
+ * this check covers components that do not exist yet: introduce
+ * `<Admonition />` tomorrow and it is audited without touching this plugin.
+ *
+ * Scanning for bare capitalised tags instead would be unusable -- the docs are
+ * full of placeholder notation (`<SEGID>`, `<PID>`, `<YYYYMMDD>`) and Rust
+ * generics (`<T>`, `<AnyRange>`) that are prose, not JSX.
+ *
+ * @returns {string[]} names still present in the emitted prose
+ */
+function leakedComponents(raw, prose) {
+ return importedComponents(raw).filter(
+ (name) =>
+ new RegExp(`</?${name}\\b`).test(prose) ||
+ new RegExp(`^import\\s[^\\n]*\\b${name}\\b`, "m").test(prose),
+ );
+}
+
+// ---------------------------------------------------------------------------
+// Rendering
+// ---------------------------------------------------------------------------
+
+function yamlQuote(value) {
+ return `"${String(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
+}
+
+function renderMarkdown({ fields, body, hasH1, canonicalUrl }) {
+ const header = ["---"];
+ if (fields.title) {
+ header.push(`title: ${yamlQuote(fields.title)}`);
+ }
+ if (fields.description) {
+ header.push(`description: ${yamlQuote(fields.description)}`);
+ }
+ header.push(`source: ${canonicalUrl}`);
+ header.push("---");
+
+ // Docusaurus synthesises the <h1> from front matter when the body has none;
+ // replay that here so the Markdown twin is not left title-less.
+ if (!hasH1 && fields.title) {
+ header.push("", `# ${fields.title}`);
+ }
+
+ return `${header.join("\n")}\n\n${body}\n`;
+}
+
+// ---------------------------------------------------------------------------
+// Path mapping
+// ---------------------------------------------------------------------------
+
+/** `@site/docs/foo.md` -> absolute path. */
+function resolveSource(source, siteDir) {
+ return source.startsWith("@site/")
+ ? path.join(siteDir, source.slice("@site/".length))
+ : path.resolve(siteDir, source);
+}
+
+/**
+ * `/docs/introduction/cbdb-overview` ->
`<outDir>/docs/introduction/cbdb-overview.md`
+ * `/docs/` -> `<outDir>/docs/index.md`
+ */
+function permalinkToFile(permalink, baseUrl, outDir) {
+ let rel = markdownPathFor(permalink);
+ if (baseUrl && baseUrl !== "/" && rel.startsWith(baseUrl)) {
+ rel = rel.slice(baseUrl.length);
+ }
+ rel = rel.replace(/^\/+/, "");
+
+ const target = path.join(outDir, ...rel.split("/"));
+
+ // Refuse to escape the build directory, whatever a permalink claims.
+ const resolved = path.resolve(target);
+ if (resolved !== path.resolve(outDir) &&
!resolved.startsWith(path.resolve(outDir) + path.sep)) {
+ return null;
+ }
+ return resolved;
+}
+
+// ---------------------------------------------------------------------------
+// Content collection
+// ---------------------------------------------------------------------------
+
+function collectDocs(allContent, excludeVersions) {
+ const entries = [];
+ const instances = allContent["docusaurus-plugin-content-docs"] ?? {};
+
+ for (const [pluginId, content] of Object.entries(instances)) {
+ const excluded = excludeVersions[pluginId] ?? [];
+ for (const version of content?.loadedVersions ?? []) {
+ if (excluded.includes(version.versionName)) {
+ continue;
+ }
+ for (const doc of version.docs ?? []) {
+ // `drafts` live in a separate array, but guard anyway.
+ if (doc.draft) {
+ continue;
+ }
+ entries.push({
+ permalink: doc.permalink,
+ source: doc.source,
+ pluginId,
+ version: version.versionName,
+ });
+ }
+ }
+ }
+ return entries;
+}
+
+function collectBlog(allContent) {
+ const entries = [];
+ const instances = allContent["docusaurus-plugin-content-blog"] ?? {};
+
+ for (const content of Object.values(instances)) {
+ for (const post of content?.blogPosts ?? []) {
+ const meta = post?.metadata ?? post;
+ if (!meta?.permalink || !meta?.source) {
+ continue;
+ }
+ entries.push({ permalink: meta.permalink, source: meta.source });
+ }
+ }
+ return entries;
+}
+
+/**
+ * The pages plugin does not expose its Markdown sources through `allContent`,
+ * but its routing is a plain mirror of the filesystem, so walk it directly.
+ * Only `.md` pages qualify -- `.tsx` pages have no Markdown to export.
+ */
+async function collectPages(pagesDir, baseUrl) {
+ const entries = [];
+
+ async function walk(dir) {
+ let dirents;
+ try {
+ dirents = await fs.readdir(dir, { withFileTypes: true });
+ } catch {
+ return;
+ }
+ for (const dirent of dirents) {
+ const abs = path.join(dir, dirent.name);
+ if (dirent.isDirectory()) {
+ await walk(abs);
+ continue;
+ }
+ // Leading `_` marks a partial; Docusaurus does not route those.
+ if (!dirent.name.endsWith(".md") || dirent.name.startsWith("_")) {
+ continue;
+ }
+
+ const rel = path.relative(pagesDir, abs).split(path.sep).join("/");
+ const routePath = rel.replace(/\.md$/, "").replace(/(^|\/)index$/, "$1");
+ entries.push({
+ permalink: `${baseUrl}${routePath}`,
+ source: abs,
+ });
+ }
+ }
+
+ await walk(pagesDir);
+ return entries;
+}
+
+// ---------------------------------------------------------------------------
+// Export
+// ---------------------------------------------------------------------------
+
+/**
+ * Where the dev server's twins live. Inside `.docusaurus`, which is already
+ * git-ignored and cleared by `docusaurus clear`.
+ */
+function devOutDir(siteDir) {
+ return path.join(siteDir, ".docusaurus", PLUGIN_NAME);
+}
+
+/**
+ * Writes the Markdown twin for every entry.
+ *
+ * @param {object} args
+ * @param {Array<{permalink: string, source: string}>} args.entries
+ * @param {string} args.outDir where the twins go
+ * @param {string} args.siteDir
+ * @param {{baseUrl: string, url: string}} args.siteConfig
+ * @param {boolean} [args.skipUnchanged]
+ * Skip an entry whose twin is newer than its source. Used by the dev server,
+ * which re-exports on every content reload; a production build always starts
+ * from an empty output directory, so there is nothing to skip.
+ * @returns {Promise<{written: number, bytes: number, failures: string[],
leaks: string[]}>}
+ */
+async function exportTwins({
+ entries,
+ outDir,
+ siteDir,
+ siteConfig,
+ skipUnchanged = false,
+}) {
+ let written = 0;
+ let bytes = 0;
+ const failures = [];
+ const leaks = [];
+
+ await Promise.all(
+ entries.map(async ({ permalink, source }) => {
+ const target = permalinkToFile(permalink, siteConfig.baseUrl, outDir);
+ if (!target) {
+ failures.push(`${permalink} (refused: escapes outDir)`);
+ return;
+ }
+
+ try {
+ const sourcePath = resolveSource(source, siteDir);
+
+ if (skipUnchanged) {
+ const [sourceStat, targetStat] = await Promise.all([
+ fs.stat(sourcePath),
+ fs.stat(target).catch(() => null),
+ ]);
+ if (targetStat && targetStat.mtimeMs >= sourceStat.mtimeMs) {
+ return;
+ }
+ }
+
+ const raw = await fs.readFile(sourcePath, "utf8");
+ const { fields, body } = splitFrontMatter(raw);
+ const sanitized = sanitizeBody(body);
+ const markdown = renderMarkdown({
+ fields,
+ body: sanitized.body,
+ hasH1: sanitized.hasH1,
+ canonicalUrl: `${siteConfig.url}${permalink}`,
+ });
+
+ await fs.mkdir(path.dirname(target), { recursive: true });
+ await fs.writeFile(target, markdown, "utf8");
+
+ written++;
+ bytes += Buffer.byteLength(markdown);
+
+ const leaked = leakedComponents(raw, sanitized.prose);
+ if (leaked.length > 0) {
+ leaks.push(`${permalink} -> ${leaked.join(", ")}`);
+ }
+ } catch (err) {
+ failures.push(`${permalink} (${err.message})`);
+ }
+ }),
+ );
+
+ return { written, bytes, failures, leaks };
+}
+
+// ---------------------------------------------------------------------------
+// Plugin
+// ---------------------------------------------------------------------------
+
+/**
+ * @param {import('@docusaurus/types').LoadContext} context
+ * @param {{
+ * docs?: boolean,
+ * blog?: boolean,
+ * pages?: boolean,
+ * excludeVersions?: Record<string, string[]>,
+ * excludeFromSitemap?: Record<string, string[]>,
+ * }} options
+ * Both exclusion lists are keyed by docs plugin id, because version names
are
+ * only unique within an instance: the unreleased version of every instance
is
+ * called `current`, so a flat list would take PXF down with `docs/next`.
+ *
+ * `excludeVersions` skips a version entirely -- no file, no menu, no
+ * `<link rel="alternate">`. `excludeFromSitemap` is narrower: the twin is
+ * written and linked from the page, it simply is not advertised to crawlers.
+ */
+module.exports = function markdownExportPlugin(context, options = {}) {
+ const {
+ docs = true,
+ blog = true,
+ pages = true,
+ excludeVersions = {},
+ excludeFromSitemap = {},
+ } = options;
+
+ /**
+ * @type {Array<{
+ * permalink: string, source: string, pluginId?: string, version?: string,
+ * }>}
+ * `pluginId`/`version` are absent for blog posts and standalone pages,
+ * which are unversioned and therefore never excluded.
+ */
+ let entries = [];
+
+ return {
+ name: PLUGIN_NAME,
+
+ async allContentLoaded({ allContent, actions }) {
+ const { siteDir, baseUrl } = context;
+ const collected = [];
+
+ // Single source of truth for the UI: the per-page menu must not offer a
+ // Markdown link on versions we skip. Shipping the whole permalink list
+ // would bloat every bundle, so publish just the exclusion list.
+ actions.setGlobalData({ excludeVersions });
+
+ if (docs) {
+ collected.push(...collectDocs(allContent, excludeVersions));
+ }
+ if (blog) {
+ collected.push(...collectBlog(allContent));
+ }
+ if (pages) {
+ collected.push(
+ ...(await collectPages(path.join(siteDir, "src", "pages"), baseUrl)),
+ );
+ }
+
+ // Same source can be routed twice (e.g. a version alias); keep one file
+ // per permalink.
+ const seen = new Set();
+ entries = collected.filter(({ permalink }) => {
+ if (seen.has(permalink)) {
+ return false;
+ }
+ seen.add(permalink);
+ return true;
+ });
+
+ // Hand the sitemap-eligible subset to the sitemap plugin. Rebuilt from
+ // scratch each time so the dev server's repeated reloads cannot
+ // accumulate stale permalinks.
+ sitemapPermalinks.clear();
+ for (const { permalink, pluginId, version } of entries) {
+ const quiet =
+ version !== undefined &&
+ (excludeFromSitemap[pluginId] ?? []).includes(version);
+ if (!quiet) {
+ sitemapPermalinks.add(permalink);
+ }
+ }
+
+ // Under `docusaurus start` this is the only chance to write the twins --
+ // see `configureWebpack` below for how they get served.
+ if (process.env.NODE_ENV !== "production") {
+ const { written, failures } = await exportTwins({
+ entries,
+ outDir: devOutDir(siteDir),
+ siteDir,
+ siteConfig: context.siteConfig,
+ skipUnchanged: true,
+ });
+ if (written > 0) {
+ logger.info(
+ `[${PLUGIN_NAME}] ${written} Markdown twin(s) written for the dev
server.`,
+ );
+ }
+ if (failures.length > 0) {
+ logger.warn(
+ `[${PLUGIN_NAME}] ${failures.length} twin(s) failed:\n
${failures.join("\n ")}`,
+ );
+ }
+ }
+ },
+
+ /**
+ * `postBuild` never runs under `docusaurus start`, so without this the
+ * dev server has no twins to serve: the menu is visible but every link
+ * resolves to the SPA shell, which renders blank and copies HTML.
+ *
+ * `devServer.static` is an array, so webpack-merge appends to Docusaurus'
+ * own entries instead of replacing them. Overriding `setupMiddlewares`
+ * would have been the other option, but that key merges by replacement and
+ * would drop the middleware behind the dev error overlay.
+ */
+ configureWebpack(_config, isServer) {
+ if (isServer || process.env.NODE_ENV === "production") {
+ return {};
+ }
+ return {
+ devServer: {
+ static: [
+ {
+ publicPath: context.baseUrl,
+ directory: devOutDir(context.siteDir),
+ },
+ ],
+ },
+ };
+ },
+
+ async postBuild({ outDir, siteDir, siteConfig }) {
+ if (entries.length === 0) {
+ logger.warn(`[${PLUGIN_NAME}] no pages collected; nothing exported.`);
+ return;
+ }
+
+ const { written, bytes, failures, leaks } = await exportTwins({
+ entries,
+ outDir,
+ siteDir,
+ siteConfig,
+ });
+
+ logger.success(
+ `[${PLUGIN_NAME}] exported ${written} Markdown files (${(
+ bytes /
+ 1024 /
+ 1024
+ ).toFixed(1)} MB).`,
+ );
+
+ if (failures.length > 0) {
+ logger.warn(
+ `[${PLUGIN_NAME}] ${failures.length} page(s) failed:\n
${failures.join("\n ")}`,
+ );
+ }
+
+ // Not fatal: a leaked tag makes one page's Markdown uglier, which is no
+ // reason to block a site publish. It does mean STRUCTURAL_TAGS /
+ // OPAQUE_TAGS above need a new entry.
+ if (leaks.length > 0) {
+ logger.warn(
+ `[${PLUGIN_NAME}] ${leaks.length} page(s) leaked component markup; `
+
+ `add the component to STRUCTURAL_TAGS or OPAQUE_TAGS:\n
${leaks.join("\n ")}`,
+ );
+ }
+ },
+ };
+};
diff --git a/src/plugins/markdown-export/registry.js
b/src/plugins/markdown-export/registry.js
new file mode 100644
index 0000000000..758276fe2b
--- /dev/null
+++ b/src/plugins/markdown-export/registry.js
@@ -0,0 +1,55 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * Cross-plugin handoff: which Markdown twins sitemap.xml should advertise.
+ *
+ * The sitemap plugin cannot read a manifest written by `markdown-export`,
+ * because `postBuild` hooks run *concurrently* -- see the `Promise.all` in
+ * `@docusaurus/core/lib/commands/build/buildLocale.js` -- so the two would
+ * race. `allContentLoaded` does strictly precede every `postBuild`, so a
+ * module-level set filled there is reliably populated by the time
+ * `createSitemapItems` is called.
+ *
+ * Both sides run in the same Node process, so this is shared state rather
+ * than serialised data. It is deliberately the only such coupling.
+ *
+ * Note this is a subset of what gets exported: a page can have a twin that the
+ * sitemap deliberately stays quiet about. See `excludeFromSitemap` in
+ * index.js.
+ */
+
+/** @type {Set<string>} permalinks, exactly as the content plugins report them
*/
+const sitemapPermalinks = new Set();
+
+/**
+ * The one rule mapping a permalink onto its Markdown twin, shared by the
+ * exporter (which turns it into a file path) and the sitemap (which turns it
+ * into a URL).
+ *
+ * `markdownPathFor()` in `src/components/common/markdownTwin/index.tsx`
+ * repeats it for the browser bundle, deliberately: pulling this build-time
+ * module into the client would ship the permalink set to every visitor.
+ *
+ * @param {string} permalink
+ * @returns {string}
+ */
+function markdownPathFor(permalink) {
+ return permalink.endsWith("/") ? `${permalink}index.md` : `${permalink}.md`;
+}
+
+module.exports = { sitemapPermalinks, markdownPathFor };
diff --git a/src/theme/BlogPostItem/index.tsx b/src/theme/BlogPostItem/index.tsx
index e4109a809d..32f0fde035 100644
--- a/src/theme/BlogPostItem/index.tsx
+++ b/src/theme/BlogPostItem/index.tsx
@@ -9,6 +9,8 @@ import BlogItemDesc from "./components/Desc";
import BlogItemTags from "./components/Tags";
import BlogItemTitle from "./components/Title";
+import AiActions from "@site/src/components/common/AiActions";
+import MarkdownAlternate from "@site/src/components/common/markdownTwin";
import LinkWithBaseUrl from "@site/src/components/common/LinkWithBaseUrl";
import styles from "./styles.module.scss";
@@ -36,11 +38,21 @@ const BlogListItem = () => {
);
};
const BlogDetailItem = ({ children }) => {
+ const {
+ metadata: { permalink },
+ } = useBlogPost();
+
return (
<BlogPostItemContainer className={styles["blogDetail"]}>
+ <MarkdownAlternate permalink={permalink} />
<header>
<BlogItemTitle />
<BlogPostItemAuthors />
+ <AiActions
+ permalink={permalink}
+ align="start"
+ className={styles["blogAiActions"]}
+ />
</header>
{/* only show blog detail */}
<BlogPostItemContent>{children}</BlogPostItemContent>
diff --git a/src/theme/BlogPostItem/styles.module.scss
b/src/theme/BlogPostItem/styles.module.scss
index 6a24216140..cae1816f2f 100644
--- a/src/theme/BlogPostItem/styles.module.scss
+++ b/src/theme/BlogPostItem/styles.module.scss
@@ -35,6 +35,11 @@
header {
margin-bottom: 32px;
}
+ /* Sits below the author row rather than beside the title, which already
+ competes for width on narrow viewports. */
+ .blogAiActions {
+ margin-top: 20px;
+ }
.tags {
display: flex;
align-items: center;
diff --git a/src/theme/DocItem/Layout/index.tsx
b/src/theme/DocItem/Layout/index.tsx
index b9f95aef41..d966ffcba5 100644
--- a/src/theme/DocItem/Layout/index.tsx
+++ b/src/theme/DocItem/Layout/index.tsx
@@ -1,7 +1,10 @@
import React from "react";
import clsx from "clsx";
import { useWindowSize } from "@docusaurus/theme-common";
-import { useDoc } from "@docusaurus/plugin-content-docs/client";
+import {
+ useActivePlugin,
+ useDoc,
+} from "@docusaurus/plugin-content-docs/client";
import DocItemPaginator from "@theme/DocItem/Paginator";
import DocVersionBanner from "@theme/DocVersionBanner";
import DocVersionBadge from "@theme/DocVersionBadge";
@@ -11,10 +14,34 @@ import DocItemTOCDesktop from "@theme/DocItem/TOC/Desktop";
import DocItemContent from "@theme/DocItem/Content";
import DocBreadcrumbs from "@theme/DocBreadcrumbs";
import ContentVisibility from "@theme/ContentVisibility";
+import { usePluginData } from "@docusaurus/useGlobalData";
import type { Props } from "@theme/DocItem/Layout";
+import AiActions from "@site/src/components/common/AiActions";
+import MarkdownAlternate from "@site/src/components/common/markdownTwin";
+
import styles from "./styles.module.css";
+/**
+ * Whether this doc has a Markdown twin to link to. Versions excluded from the
+ * `markdown-export` plugin have none, so the menu and the `<link
+ * rel="alternate">` must stay hidden there rather than pointing at a 404.
+ *
+ * Scoped by plugin id, because `current` names the unreleased version of every
+ * docs instance -- PXF's only version is also `current`, and it is exported.
+ */
+function useHasMarkdownExport(version: string): boolean {
+ const activePlugin = useActivePlugin();
+ const data = usePluginData("markdown-export") as
+ | { excludeVersions?: Record<string, string[]> }
+ | undefined;
+
+ const excluded = activePlugin
+ ? data?.excludeVersions?.[activePlugin.pluginId]
+ : undefined;
+ return !(excluded ?? []).includes(version);
+}
+
/**
* Decide if the toc should be rendered, on mobile or desktop viewports
*/
@@ -42,14 +69,23 @@ function useDocTOC() {
export default function DocItemLayout({ children }: Props): JSX.Element {
const docTOC = useDocTOC();
const { metadata } = useDoc();
+ const hasMarkdownExport = useHasMarkdownExport(metadata.version);
return (
<div className="row">
<div className={clsx('col',!docTOC.hidden && styles.docItemCol)}>
<ContentVisibility metadata={metadata} />
+ {hasMarkdownExport && (
+ <MarkdownAlternate permalink={metadata.permalink} />
+ )}
<DocVersionBanner />
<div className={styles.docItemContainer}>
<article>
- <DocBreadcrumbs />
+ <div className={styles.docItemTopBar}>
+ <DocBreadcrumbs />
+ {hasMarkdownExport && (
+ <AiActions permalink={metadata.permalink} />
+ )}
+ </div>
<DocVersionBadge />
{docTOC.mobile}
<DocItemContent>{children}</DocItemContent>
diff --git a/src/theme/DocItem/Layout/styles.module.css
b/src/theme/DocItem/Layout/styles.module.css
index 35940c2b7e..fef89e0096 100644
--- a/src/theme/DocItem/Layout/styles.module.css
+++ b/src/theme/DocItem/Layout/styles.module.css
@@ -3,6 +3,33 @@
margin-top: 0;
}
+/* Breadcrumbs on the left, the page's AI actions pinned to the right. On
narrow
+ viewports the actions wrap onto their own line instead of being pushed out
of
+ the viewport. */
+.docItemTopBar {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 12px 16px;
+ margin-bottom: var(--ifm-spacing-vertical);
+}
+
+/* Breadcrumbs bring their own bottom margin; the row now owns that spacing, so
+ drop it to avoid a doubled gap. `min-width: 0` lets a long trail shrink (it
+ scrolls on its own) rather than shoving the actions off-screen. */
+.docItemTopBar > nav {
+ margin-bottom: 0;
+ min-width: 0;
+}
+
+/* Keep the actions flush right even once they wrap onto their own line: the
+ dropdown is anchored to the trigger's right edge, so a left-aligned trigger
+ would send the panel off the left of the viewport. */
+.docItemTopBar > :last-child {
+ margin-left: auto;
+}
+
/* 1280px is where design-style.scss stops hiding the TOC column, so the two
have to agree. When they disagreed (1280 vs 1440), everything between those
two widths rendered the TOC alongside an article still claiming 100%, which
diff --git a/src/theme/MDXPage/index.tsx b/src/theme/MDXPage/index.tsx
index b3d12a0e44..f53655f15f 100644
--- a/src/theme/MDXPage/index.tsx
+++ b/src/theme/MDXPage/index.tsx
@@ -8,6 +8,7 @@ import MDXContent from "@theme/MDXContent";
import type { Props } from "@theme/MDXPage";
import TOC from "@theme/TOC";
import ContentVisibility from "@theme/ContentVisibility";
+import MarkdownAlternate from "@site/src/components/common/markdownTwin";
import clsx from "clsx";
import styles from "./styles.module.css";
@@ -25,6 +26,10 @@ export default function MDXPage(props: Props): JSX.Element {
const showTOC =
!hideTableOfContents && MDXPageContent.toc && MDXPageContent.toc.length >
0;
+ // `markdown-export` only twins `.md` pages -- an `.mdx` one would get a link
+ // to a file that was never written.
+ const hasMarkdownTwin = metadata.source?.endsWith(".md") ?? false;
+
return (
<HtmlClassNameProvider
className={clsx(
@@ -33,6 +38,7 @@ export default function MDXPage(props: Props): JSX.Element {
)}
>
<CommonLayout>
+ {hasMarkdownTwin && <MarkdownAlternate permalink={metadata.permalink}
/>}
<ColorCard subText={description} titleText={title} />
<div className={styles.mdxPageWrapper}>
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]