From 8ab277b07058891bd8c235f77fb7005a869e5205 Mon Sep 17 00:00:00 2001 From: TomShawn <41534398+TomShawn@users.noreply.github.com> Date: Wed, 5 Aug 2026 13:55:22 +0800 Subject: [PATCH 1/2] 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 `
` 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 ``, ``, `` placeholders and Rust generics like `` 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) --- docusaurus.config.ts | 27 ++ src/plugins/markdown-export/index.js | 611 ++++++++++++++++++++++++ src/plugins/markdown-export/registry.js | 55 +++ 3 files changed, 693 insertions(+) create mode 100644 src/plugins/markdown-export/index.js create mode 100644 src/plugins/markdown-export/registry.js diff --git a/docusaurus.config.ts b/docusaurus.config.ts index 223d45fd37..e21924fdfd 100644 --- a/docusaurus.config.ts +++ b/docusaurus.config.ts @@ -22,6 +22,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"] }, diff --git a/src/plugins/markdown-export/index.js b/src/plugins/markdown-export/index.js new file mode 100644 index 0000000000..5d8537d0a7 --- /dev/null +++ b/src/plugins/markdown-export/index.js @@ -0,0 +1,611 @@ +/* + * 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*]*/?>\\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 `text` 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( + `]*/?>`, + "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 `
` / `` 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( + /]*\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 `` 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 + * `` 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 (``, ``, ``) and Rust + * generics (``, ``) 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(` 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` -> `/docs/introduction/cbdb-overview.md` + * `/docs/` -> `/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; +} + +// --------------------------------------------------------------------------- +// Plugin +// --------------------------------------------------------------------------- + +/** + * @param {import('@docusaurus/types').LoadContext} context + * @param {{ + * docs?: boolean, + * blog?: boolean, + * pages?: boolean, + * excludeVersions?: Record, + * excludeFromSitemap?: Record, + * }} 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 + * ``. `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); + } + } + }, + + async postBuild({ outDir, siteDir, siteConfig }) { + if (entries.length === 0) { + logger.warn(`[${PLUGIN_NAME}] no pages collected; nothing exported.`); + return; + } + + 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 raw = await fs.readFile(resolveSource(source, siteDir), "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})`); + } + }), + ); + + 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} 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 }; From 43f7be29beb73f763cf731f87128844f08c4fde4 Mon Sep 17 00:00:00 2001 From: TomShawn <41534398+TomShawn@users.noreply.github.com> Date: Wed, 5 Aug 2026 13:55:55 +0800 Subject: [PATCH 2/2] 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, `` 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) --- docusaurus.config.ts | 43 +++++ src/components/common/AiActions/index.tsx | 174 ++++++++++++++++++ .../common/AiActions/styles.module.scss | 141 ++++++++++++++ src/components/common/markdownTwin/index.tsx | 45 +++++ 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 + 9 files changed, 491 insertions(+), 2 deletions(-) create mode 100644 src/components/common/AiActions/index.tsx create mode 100644 src/components/common/AiActions/styles.module.scss create mode 100644 src/components/common/markdownTwin/index.tsx diff --git a/docusaurus.config.ts b/docusaurus.config.ts index e21924fdfd..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.", @@ -96,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 && ( + + ); +} 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