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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@
"sass": "^1.83.4",
"stream-chain": "^2.2.5",
"stream-json": "^1.7.5",
"turndown": "^7.2.0",
"turndown-plugin-gfm": "^1.0.2",
"uuid": "^13.0.0",
"weaviate-agents": "^1.5.0",
"weaviate-client": "^3.12.1",
Expand Down
3 changes: 3 additions & 0 deletions src/components/AcademyBadge/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,13 @@ const AcademyBadge = ({
iconOnly = false,
className = ""
}) => {
// data-copy-exclude marks this badge as UI chrome so the "Copy page" markdown
// export (src/components/ContextualMenu) strips it out via [data-copy-exclude].
return (
<span
className={`${styles.academyBadge} ${compact ? styles.compact : ""} ${iconOnly ? styles.iconOnly : ""} ${className}`}
title={iconOnly ? "Weaviate Academy" : undefined}
data-copy-exclude=""
>
<img src="/img/graduation-cap-icon.svg" alt="" className={styles.academyIcon} />
{!iconOnly && <span>{compact ? compactText : text}</span>}
Expand Down
3 changes: 3 additions & 0 deletions src/components/CloudOnlyBadge/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,13 @@ const CloudOnlyBadge = ({
iconOnly = false,
className = ""
}) => {
// data-copy-exclude marks this badge as UI chrome so the "Copy page" markdown
// export (src/components/ContextualMenu) strips it out via [data-copy-exclude].
return (
<span
className={`${styles.cloudOnlyBadge} ${compact ? styles.compact : ""} ${iconOnly ? styles.iconOnly : ""} ${className}`}
title={iconOnly ? "Weaviate Cloud only" : undefined}
data-copy-exclude=""
>
<img src="/img/cloud-icon.svg" alt="" className={styles.cloudIcon} />
{!iconOnly && <span>{compact ? compactText : text}</span>}
Expand Down
151 changes: 125 additions & 26 deletions src/components/ContextualMenu/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ export default function ContextualMenu({
promptName = "",
}) {
const [isOpen, setIsOpen] = useState(false);
const [copyStatus, setCopyStatus] = useState("idle"); // idle, copying, success
const [copyStatus, setCopyStatus] = useState("idle"); // idle, copying, success, error
const [selectedLanguage, setSelectedLanguage] = useState(
languages[0] || "python",
);
Expand Down Expand Up @@ -42,29 +42,123 @@ export default function ContextualMenu({
const copyPageAsMarkdown = async () => {
setCopyStatus("copying");
try {
// Get the main article content
const articleElement = document.querySelector("article");
if (!articleElement) {
// turndown touches the DOM, and Docusaurus server-renders this component
// at build time, so it must never be imported at module scope. Load it
// lazily inside this browser-only async handler instead.
const { default: TurndownService } = await import("turndown");
const { gfm } = await import("turndown-plugin-gfm");

// Scope to the rendered MDX body only. `.theme-doc-markdown` is the inner
// wrapper from @theme/DocItem/Content; the breadcrumbs, the ContextualMenu
// button, the version badge, the mobile TOC and the footer are all
// siblings OUTSIDE it (see src/theme/DocItem/Layout/index.js), so scoping
// here drops all the page chrome. Fall back to <article> for safety.
const contentRoot =
document.querySelector(".theme-doc-markdown") ||
document.querySelector("article");
if (!contentRoot) {
throw new Error("Article content not found");
}

// Get page title and metadata
const title = metadata.title || frontMatter.title || "Untitled";
const pageUrl = getCurrentPageUrl();

// Extract text content from the article
const content = articleElement.innerText;

// Format as markdown with metadata
const markdown = `# ${title}

Source: ${pageUrl}

---

${content}`;

await navigator.clipboard.writeText(markdown);
// Work on a clone so the live page is never mutated.
const clone = contentRoot.cloneNode(true);

// Strip non-prose chrome from the clone before converting. Only stable
// (non-CSS-module-hashed) selectors are used here.
// [aria-hidden="true"] - our custom code Tabs (src/theme/Tabs/index.js)
// render EVERY language panel and hide the non-selected ones with
// display:none + aria-hidden="true"; removing them leaves only the
// selected language. (Also drops decorative aria-hidden icons.)
// [role="tablist"] - the clickable label strip of DEFAULT Docusaurus
// <Tabs> (used for non-code content, e.g. Docker/Kubernetes steps),
// which would otherwise leak as a bullet list of tab labels.
// [hidden] - inactive DEFAULT-tab panels mark themselves with
// the `hidden` attribute (not aria-hidden); removing them gives the
// same selected-only behavior for non-code tabs. (Custom code tabs use
// style+aria-hidden, never `hidden`/role=tablist, so no code regression.)
// select - the per-tab language dropdown.
// .badge - FilteredTextBlock's badge row (stable Infima
// class, see FilteredTextBlock.js:198,222).
// button, nav - stray controls.
// img[alt=""] - decorative empty-alt icons (e.g. logo-py.svg);
// real content images keep their meaningful alt and survive.
// [data-copy-exclude] - explicit opt-out marker on shared components
// whose chrome leaks but isn't otherwise stably targetable (currently
// CloudOnlyBadge / AcademyBadge).
clone
.querySelectorAll(
'[aria-hidden="true"], [role="tablist"], [hidden], select, .badge, button, nav, img[alt=""], [data-copy-exclude]',
)
.forEach((node) => node.remove());

// Drop the code-tab HEADER chrome (language dropdown, the "API docs" link
// + icon, the "More info" tooltip) while KEEPING the code content. Our
// code blocks are authored as <Tabs className="code"> (src/theme/Tabs/
// index.js:464-474), which renders a container carrying the literal,
// stable `code` class whose FIRST child is the header and second is the
// code content (Tabs/index.js:333-460). The bare `code` class is used
// ONLY for these containers (verified: no other component or global CSS
// uses it), so removing each container's first element child strips the
// header without ever touching the code.
clone
.querySelectorAll(".code")
.forEach((container) => container.firstElementChild?.remove());

const turndownService = new TurndownService({
headingStyle: "atx",
codeBlockStyle: "fenced",
bulletListMarker: "-",
});
turndownService.use(gfm);
turndownService.remove(["select", "button", "nav", "script", "style"]);

// Docusaurus/Prism renders code as <pre class="language-xxx"> with a
// <code> child whose lines are <span class="token-line"> separated by
// <br>. turndown's default fenced-code rule reads the language off <code>
// (Docusaurus puts it on <pre>) and drops the <br> line breaks, so emit
// our own fenced block: language from the language-xxx class, content
// rebuilt from the token-line spans (falling back to textContent).
turndownService.addRule("docusaurusCodeBlock", {
filter: (node) =>
node.nodeName === "PRE" &&
(node.querySelector("code") !== null ||
node.classList.contains("prism-code")),
replacement: (_content, node) => {
const code = node.querySelector("code") || node;
const classAttr = `${node.className} ${code.className}`;
const langMatch = classAttr.match(/language-([\w-]+)/);
const language = langMatch ? langMatch[1] : "";
const lines = code.querySelectorAll(".token-line");
const text = (
lines.length
? Array.from(lines)
.map((line) => line.textContent)
.join("\n")
: code.textContent
).replace(/\n+$/, "");
return `\n\n\`\`\`${language}\n${text}\n\`\`\`\n\n`;
},
});

const markdown = turndownService.turndown(clone);

// No synthetic `# title` — the page H1 already lives inside
// .theme-doc-markdown, so prepending one would duplicate it. Keep the
// Source line for LLM context.
const output = `Source: ${pageUrl}\n\n---\n\n${markdown}`;

// Guard against insecure contexts where the Clipboard API is missing,
// rather than throwing an opaque error into the catch.
if (!navigator.clipboard?.writeText) {
throw new Error(
"Clipboard API is unavailable (a secure context is required)",
);
}
await navigator.clipboard.writeText(output);

// Track successful copy
analytics.contextualMenu.copyPage(pageUrl, title);
Expand All @@ -75,7 +169,10 @@ ${content}`;
}, 1500);
} catch (error) {
console.error("Failed to copy page:", error);
setCopyStatus("idle");
setCopyStatus("error");
setTimeout(() => {
setCopyStatus("idle");
}, 2000);
}
};

Expand Down Expand Up @@ -200,13 +297,15 @@ ${content}`;

const showLanguageSelector = variant === "prompts" && languages.length > 1;
const mainButtonLabel =
variant === "prompts"
? copyStatus === "success"
? "Copied!"
: "Copy prompt"
: copyStatus === "success"
? "Copied!"
: "Copy page";
copyStatus === "error"
? "Copy failed"
: variant === "prompts"
? copyStatus === "success"
? "Copied!"
: "Copy prompt"
: copyStatus === "success"
? "Copied!"
: "Copy page";
const mainButtonHandler =
variant === "prompts" ? copyPromptFromFile : copyPageAsMarkdown;

Expand Down Expand Up @@ -303,7 +402,7 @@ ${content}`;
</>
)}
</svg>
<span>{mainButtonLabel}</span>
<span aria-live="polite">{mainButtonLabel}</span>
</button>
<div className={styles.separator}></div>
<button
Expand Down
17 changes: 17 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -2695,6 +2695,11 @@
dependencies:
langium "3.3.1"

"@mixmark-io/domino@^2.2.0":
version "2.2.0"
resolved "https://registry.yarnpkg.com/@mixmark-io/domino/-/domino-2.2.0.tgz#4e8ec69bf1afeb7a14f0628b7e2c0f35bdb336c3"
integrity sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==

"@netlify/binary-info@^1.0.0":
version "1.0.0"
resolved "https://registry.npmjs.org/@netlify/binary-info/-/binary-info-1.0.0.tgz"
Expand Down Expand Up @@ -14953,6 +14958,18 @@ tunnel-agent@^0.6.0:
dependencies:
safe-buffer "^5.0.1"

turndown-plugin-gfm@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/turndown-plugin-gfm/-/turndown-plugin-gfm-1.0.2.tgz#6f8678a361f35220b2bdf5619e6049add75bf1c7"
integrity sha512-vwz9tfvF7XN/jE0dGoBei3FXWuvll78ohzCZQuOb+ZjWrs3a0XhQVomJEb2Qh4VHTPNRO4GPZh0V7VRbiWwkRg==

turndown@^7.2.0:
version "7.2.4"
resolved "https://registry.yarnpkg.com/turndown/-/turndown-7.2.4.tgz#42d98202aefa8c188c997b586bc6da78bdf27ea2"
integrity sha512-I8yFsfRzmzK0WV1pNNOA4A7y4RDfFxPRxb3t+e3ui14qSGOxGtiSP6GjeX+Y6CHb7HYaFj7ECUD7VE5kQMZWGQ==
dependencies:
"@mixmark-io/domino" "^2.2.0"

type-fest@^0.21.3:
version "0.21.3"
resolved "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz"
Expand Down
Loading