so only their inline children remain in the cell.
+ cell.querySelectorAll("ul, ol").forEach((list) => {
+ Array.from(list.querySelectorAll(":scope > li")).forEach((li, i) => {
+ if (i > 0) li.before(document.createTextNode(" • "));
+ while (li.firstChild) li.before(li.firstChild);
+ li.remove();
+ });
+ while (list.firstChild) list.before(list.firstChild);
+ list.remove();
+ });
+ // Unwrap remaining block wrappers (incl. that "loose" list items
+ // leave behind) so nothing forces a line break.
+ cell.querySelectorAll("p, div").forEach((block) => {
+ block.before(document.createTextNode(" ")); // keep a word boundary
+ while (block.firstChild) block.before(block.firstChild);
+ block.remove();
+ });
+ });
+
+ // Admonitions -> GitHub callouts. Pre-process: read the type from the
+ // class, capture a custom title (when the heading text isn't just the type
+ // word), stash both on data-* attrs, and drop the heading chrome (icon +
+ // type label). The turndown rule below quotes the remaining body.
+ const ADMONITION_CALLOUT = {
+ note: "NOTE",
+ tip: "TIP",
+ info: "NOTE",
+ caution: "CAUTION",
+ warning: "WARNING",
+ danger: "CAUTION",
+ };
+ clone
+ .querySelectorAll(".theme-admonition, .admonition")
+ .forEach((adm) => {
+ const m = (adm.className || "").match(/admonition-([a-z]+)/i);
+ const rawType = (m ? m[1] : "note").toLowerCase();
+ const callout = ADMONITION_CALLOUT[rawType] || "NOTE";
+ const heading = adm.querySelector('[class*="admonitionHeading"]');
+ if (heading) {
+ const headingText = (heading.textContent || "")
+ .replace(/\s+/g, " ")
+ .trim();
+ // A custom title is a heading whose text isn't just the type word.
+ if (
+ headingText &&
+ headingText.toLowerCase() !== rawType &&
+ headingText.toLowerCase() !== callout.toLowerCase()
+ ) {
+ adm.setAttribute("data-callout-title", headingText);
+ }
+ heading.remove();
+ }
+ adm.setAttribute("data-callout", callout);
+ });
+
+ // / -> bold summary + body (see rule). Stash the summary
+ // text and remove the so the rule's `content` is body-only.
+ clone.querySelectorAll("details").forEach((d) => {
+ const summary = d.querySelector("summary");
+ if (summary) {
+ const t = (summary.textContent || "").replace(/\s+/g, " ").trim();
+ if (t) d.setAttribute("data-summary", t);
+ summary.remove();
+ }
+ });
+
+ const turndownService = new TurndownService({
+ headingStyle: "atx",
+ codeBlockStyle: "fenced",
+ bulletListMarker: "-",
+ });
+ turndownService.use(gfm);
+ // Note: "button" is intentionally absent — inline content buttons (KapaAI's
+ // "Ask AI") must survive; code-block chrome buttons are stripped from the
+ // clone above.
+ turndownService.remove(["select", "nav", "script", "style"]);
+
+ // Docusaurus/Prism renders code as with a
+ // child whose lines are separated by
+ //
. turndown's default fenced-code rule reads the language off
+ // (Docusaurus puts it on ) and drops the
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`;
+ },
+ });
+
+ // Admonitions -> GitHub-flavored callout blockquotes. Type + optional title
+ // were stashed on data-* attrs during pre-processing; here we quote the
+ // body content line by line.
+ turndownService.addRule("admonitionCallout", {
+ filter: (node) =>
+ node.nodeType === 1 && node.getAttribute("data-callout") !== null,
+ replacement: (content, node) => {
+ const callout = node.getAttribute("data-callout") || "NOTE";
+ const title = node.getAttribute("data-callout-title");
+ const body = content.replace(/^\n+|\n+$/g, "");
+ const lines = [`[!${callout}]`];
+ if (title) lines.push(`**${title}**`);
+ body.split("\n").forEach((line) => lines.push(line));
+ return `\n\n${lines
+ .map((l) => (l ? `> ${l}` : ">"))
+ .join("\n")}\n\n`;
+ },
+ });
+
+ // -> bold summary followed by the (expanded) body content. The
+ // was removed in pre-processing, so `content` is body-only.
+ turndownService.addRule("detailsBlock", {
+ filter: (node) => node.nodeName === "DETAILS",
+ replacement: (content, node) => {
+ const summary = node.getAttribute("data-summary");
+ const body = content.replace(/^\n+|\n+$/g, "");
+ return `\n\n${summary ? `**${summary}**\n\n` : ""}${body}\n\n`;
+ },
+ });
+
+ // Squeeze runs of blank lines the conversion can leave behind (e.g. from
+ // emptied wrappers) down to a single blank line. The Source/--- prefix is
+ // added afterwards, so its structure is unaffected.
+ const markdown = turndownService
+ .turndown(clone)
+ .replace(/\n{3,}/g, "\n\n");
+
+ // 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);
@@ -75,7 +404,10 @@ ${content}`;
}, 1500);
} catch (error) {
console.error("Failed to copy page:", error);
- setCopyStatus("idle");
+ setCopyStatus("error");
+ setTimeout(() => {
+ setCopyStatus("idle");
+ }, 2000);
}
};
@@ -186,7 +518,10 @@ ${content}`;
}, 1500);
} catch (error) {
console.error("Failed to copy prompt:", error);
- setCopyStatus("idle");
+ setCopyStatus("error");
+ setTimeout(() => {
+ setCopyStatus("idle");
+ }, 2000);
}
};
@@ -200,13 +535,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;
@@ -303,7 +640,7 @@ ${content}`;
>
)}
- {mainButtonLabel}
+ {mainButtonLabel}