From a3f1dda5dee58623f8ff457d08b73f09ce9ac81c Mon Sep 17 00:00:00 2001 From: ajspig Date: Wed, 10 Dec 2025 17:14:54 -0500 Subject: [PATCH] feature: adding copy markdown button to individual blog pages. --- quartz.layout.ts | 13 +- quartz/components/CopyPageMarkdown.tsx | 169 +++++++++++++++++++ quartz/components/index.ts | 2 + quartz/components/scripts/copypage.inline.ts | 108 ++++++++++++ quartz/components/scripts/popover.inline.ts | 2 +- quartz/components/styles/copypage.scss | 133 +++++++++++++++ 6 files changed, 425 insertions(+), 2 deletions(-) create mode 100644 quartz/components/CopyPageMarkdown.tsx create mode 100644 quartz/components/scripts/copypage.inline.ts create mode 100644 quartz/components/styles/copypage.scss diff --git a/quartz.layout.ts b/quartz.layout.ts index c2d1aacd0..0bed580f7 100644 --- a/quartz.layout.ts +++ b/quartz.layout.ts @@ -23,7 +23,18 @@ export const defaultContentPageLayout: PageLayout = { component: Component.Breadcrumbs(), condition: (page) => page.fileData.slug !== "index", }), - Component.ArticleTitle(), + Component.Flex({ + components: [ + { Component: Component.ArticleTitle(), grow: true, align: "start" }, + { + Component: Component.ConditionalRender({ + component: Component.CopyPageMarkdown(), + condition: (page) => page.fileData.slug !== "index", + }), + align: "start", + }, + ], + }), Component.ArticleSubtitle(), Component.ContentMeta(), Component.TagList(), diff --git a/quartz/components/CopyPageMarkdown.tsx b/quartz/components/CopyPageMarkdown.tsx new file mode 100644 index 000000000..d6e1eae49 --- /dev/null +++ b/quartz/components/CopyPageMarkdown.tsx @@ -0,0 +1,169 @@ +// @ts-ignore +import copypageScript from "./scripts/copypage.inline" +import styles from "./styles/copypage.scss" +import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types" +import { classNames } from "../util/lang" + +const CopyPageMarkdown: QuartzComponent = ({ fileData, displayClass }: QuartzComponentProps) => { + const slug = fileData.slug + + return ( +
+ + +
+ ) +} + +CopyPageMarkdown.afterDOMLoaded = copypageScript +CopyPageMarkdown.css = styles + +export default (() => CopyPageMarkdown) satisfies QuartzComponentConstructor + + diff --git a/quartz/components/index.ts b/quartz/components/index.ts index c7dfc5ef3..3f1111c3a 100644 --- a/quartz/components/index.ts +++ b/quartz/components/index.ts @@ -24,6 +24,7 @@ import Breadcrumbs from "./Breadcrumbs" import Comments from "./Comments" import Flex from "./Flex" import ConditionalRender from "./ConditionalRender" +import CopyPageMarkdown from "./CopyPageMarkdown" export { ArticleTitle, @@ -52,4 +53,5 @@ export { Comments, Flex, ConditionalRender, + CopyPageMarkdown, } diff --git a/quartz/components/scripts/copypage.inline.ts b/quartz/components/scripts/copypage.inline.ts new file mode 100644 index 000000000..506fb45d5 --- /dev/null +++ b/quartz/components/scripts/copypage.inline.ts @@ -0,0 +1,108 @@ +const svgCopy = + '' +const svgCheck = + '' + +async function copyPageMarkdown(slug: string): Promise { + try { + const llmsUrl = `/${slug}/llms.txt` + const response = await fetch(llmsUrl) + if (!response.ok) { + console.error("Failed to fetch markdown:", response.statusText) + return false + } + const markdown = await response.text() + await navigator.clipboard.writeText(markdown) + return true + } catch (error) { + console.error("Failed to copy page markdown:", error) + return false + } +} + +function getLlmsUrl(slug: string): string { + return `${window.location.origin}/${slug}/llms.txt` +} + +document.addEventListener("nav", () => { + const containers = document.querySelectorAll(".copy-page-container") + + containers.forEach((container) => { + const mainButton = container.querySelector(".copy-page-button") + const dropdown = container.querySelector(".copy-page-dropdown") + const copyBtn = container.querySelector(".copy-markdown-btn") + const honchoLink = container.querySelector(".honcho-link") + const chatgptLink = container.querySelector(".chatgpt-link") + const claudeLink = container.querySelector(".claude-link") + const textSpan = mainButton?.querySelector(".copy-page-text") + const iconSpan = mainButton?.querySelector("svg:first-child") + + if (!mainButton || !dropdown || !copyBtn) return + + const slug = mainButton.dataset.slug || "" + const llmsUrl = getLlmsUrl(slug) + + // Set the correct hrefs for Honcho, ChatGPT and Claude links + if (honchoLink) { + const prompt = `Read this page and answer questions about it: ${llmsUrl}` + honchoLink.href = `https://honcho.chat/?q=${encodeURIComponent(prompt)}` + } + if (chatgptLink) { + const prompt = `Read this page and answer questions about it: ${llmsUrl}` + chatgptLink.href = `https://chatgpt.com/?hints=search&q=${encodeURIComponent(prompt)}` + } + if (claudeLink) { + const prompt = `Read this page and answer questions about it: ${llmsUrl}` + claudeLink.href = `https://claude.ai/new?q=${encodeURIComponent(prompt)}` + } + + // Toggle dropdown on main button click + function toggleDropdown(e: MouseEvent) { + e.stopPropagation() + const isOpen = dropdown!.classList.contains("show") + // Close all other dropdowns first + document.querySelectorAll(".copy-page-dropdown.show").forEach((d) => { + if (d !== dropdown) d.classList.remove("show") + }) + dropdown!.classList.toggle("show", !isOpen) + } + + // Copy markdown from dropdown button + async function handleCopy(e: MouseEvent) { + e.stopPropagation() + const success = await copyPageMarkdown(slug) + if (success && textSpan && iconSpan) { + textSpan.textContent = "Copied!" + iconSpan.outerHTML = svgCheck + dropdown!.classList.remove("show") + + setTimeout(() => { + textSpan.textContent = "Copy page" + const newIcon = mainButton!.querySelector("svg:first-child") + if (newIcon) { + newIcon.outerHTML = svgCopy + } + }, 2000) + } + } + + // Close dropdown when clicking outside + function closeDropdown(e: MouseEvent) { + if (!container.contains(e.target as Node)) { + dropdown!.classList.remove("show") + } + } + + mainButton.addEventListener("click", toggleDropdown) + copyBtn.addEventListener("click", handleCopy) + document.addEventListener("click", closeDropdown) + + window.addCleanup(() => { + mainButton.removeEventListener("click", toggleDropdown) + copyBtn.removeEventListener("click", handleCopy) + document.removeEventListener("click", closeDropdown) + }) + }) +}) + + diff --git a/quartz/components/scripts/popover.inline.ts b/quartz/components/scripts/popover.inline.ts index 989af7ee8..16d093d86 100644 --- a/quartz/components/scripts/popover.inline.ts +++ b/quartz/components/scripts/popover.inline.ts @@ -121,7 +121,7 @@ function clearActivePopover() { } document.addEventListener("nav", () => { - const links = [...document.querySelectorAll("a.internal")] as HTMLAnchorElement[] + const links = [...document.querySelectorAll("a.internal:not([data-no-popover])")] as HTMLAnchorElement[] for (const link of links) { link.addEventListener("mouseenter", mouseEnterHandler) link.addEventListener("mouseleave", clearActivePopover) diff --git a/quartz/components/styles/copypage.scss b/quartz/components/styles/copypage.scss new file mode 100644 index 000000000..145cd7e43 --- /dev/null +++ b/quartz/components/styles/copypage.scss @@ -0,0 +1,133 @@ +// Copy page markdown button styles +.copy-page-container { + position: relative; + display: inline-block; + flex-shrink: 0; + margin-top: 2rem; // Match article title margin +} + +.copy-page-button { + display: inline-flex; + align-items: center; + gap: 0.4rem; + padding: 0.4rem 0.6rem; + font-size: 0.85rem; + font-family: var(--codeFont); + color: var(--darkgray); + background-color: var(--highlight); + border: 1px solid var(--lightgray); + border-radius: 5px; + cursor: pointer; + transition: all 0.15s ease; + + &:hover { + background-color: var(--highlight); + border-color: var(--secondary); + } + + svg { + fill: currentColor; + } + + .dropdown-arrow { + margin-left: 0.2rem; + fill: currentColor; + opacity: 0.6; + } +} + +.copy-page-dropdown { + position: absolute; + top: calc(100% + 4px); + right: 0; + width: 280px; + background: var(--light); + border: 1px solid var(--lightgray); + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + z-index: 100; + opacity: 0; + visibility: hidden; + transform: translateY(-4px); + transition: all 0.15s ease; + overflow: hidden; + padding: 0; + + &.show { + opacity: 1; + visibility: visible; + transform: translateY(0); + } +} + +// Reset styles for all dropdown items (both a and button) +// Need to override a.internal and a.external styles that get added by Quartz link processor +.copy-page-dropdown > a.dropdown-item, +.copy-page-dropdown > a.dropdown-item.internal, +.copy-page-dropdown > a.dropdown-item.external, +.copy-page-dropdown > button.dropdown-item { + display: block !important; + padding: 0.5rem 1rem !important; + margin: 0 !important; + background: transparent !important; + background-color: transparent !important; + border: none !important; + border-radius: 0 !important; + box-sizing: border-box !important; + width: 100% !important; + text-align: left; + cursor: pointer; + text-decoration: none !important; + color: var(--dark) !important; + font-weight: normal !important; + line-height: 1.4 !important; + + &:hover { + background-color: var(--highlight) !important; + color: var(--dark) !important; + } + + svg, img { + display: inline-block; + vertical-align: top; + margin-right: 0.6rem; + margin-top: 0.1rem; + width: 16px; + height: 16px; + } + + svg { + fill: var(--darkgray); + } + + .dropdown-item-content { + display: inline-block; + vertical-align: top; + } +} + +.dropdown-item-content { + display: flex; + flex-direction: column; + gap: 0.15rem; +} + +.dropdown-item-title { + font-size: 0.85rem; + font-family: var(--codeFont); + font-weight: 500; + color: var(--dark); +} + +.dropdown-item-desc { + font-size: 0.75rem; + font-family: var(--codeFont); + color: var(--gray); +} + +.external-arrow { + font-size: 0.75rem; + opacity: 0.7; +} + +