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;
+}
+
+