Skip to content

Commit 6985502

Browse files
committed
implement copy as markdown button in docs
1 parent 046c4f0 commit 6985502

File tree

5 files changed

+342
-1
lines changed

5 files changed

+342
-1
lines changed

content/astro.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@ export default defineConfig({
122122
ContentPanel: "./src/components/starlight/ContentPanel.astro",
123123
Head: "./src/components/starlight/Head.astro",
124124
MarkdownContent: "./src/components/starlight/MarkdownContent.astro",
125+
PageTitle: "./src/components/starlight/PageTitle.astro",
125126
SocialIcons: "./src/components/starlight/SocialIcons.astro",
126127
ThemeSelect: "./src/components/starlight/ThemeSelect.astro"
127128
},

content/package.json

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,15 @@
106106
"pnpm": {
107107
"patchedDependencies": {
108108
"astro-tweet": "patches/astro-tweet.patch"
109-
}
109+
},
110+
"onlyBuiltDependencies": [
111+
"@mixedbread/cli",
112+
"@parcel/watcher",
113+
"@vercel/speed-insights",
114+
"esbuild",
115+
"msgpackr-extract",
116+
"protobufjs",
117+
"sharp"
118+
]
110119
}
111120
}
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
---
2+
import DefaultPageTitle from "@astrojs/starlight/components/PageTitle.astro"
3+
import { getMarkdownById } from "../../lib/markdown-map"
4+
import copyScriptUrl from "./copy-page.client.ts?url"
5+
6+
const route = Astro.locals.starlightRoute
7+
const entry = route.entry
8+
9+
const rawMarkdown = getMarkdownById(entry.id)
10+
11+
const title = typeof entry.data?.title === "string" ? entry.data.title.trim() : undefined
12+
const normalizedTitle = title?.toLowerCase()
13+
14+
const ensureTitleHeading = (markdown: string | undefined) => {
15+
if (!markdown) return undefined
16+
if (!title || !normalizedTitle) return markdown
17+
18+
const trimmed = markdown.trimStart()
19+
if (trimmed.length > 0) {
20+
const firstLine = trimmed.split(/\r?\n/, 1)[0]
21+
if (firstLine) {
22+
const normalizedFirstLine = firstLine.replace(/^#+\s*/, "").trim().toLowerCase()
23+
if (firstLine.startsWith("#") && normalizedFirstLine === normalizedTitle) {
24+
return markdown
25+
}
26+
}
27+
}
28+
29+
const body = trimmed.length > 0 ? `\n\n${trimmed}` : ""
30+
return `# ${title}${body}`
31+
}
32+
33+
const markdownForCopy = ensureTitleHeading(rawMarkdown)
34+
35+
const slug = entry.slug || entry.id || "page"
36+
const controlId = `copy-page-${slug.replace(/[^a-z0-9]+/gi, "-").replace(/^-+|-+$/g, "") || "doc"}`
37+
const templateId = `${controlId}-markdown`
38+
const hasCopyAction = Boolean(markdownForCopy)
39+
const copyScriptSrc = hasCopyAction ? copyScriptUrl : undefined
40+
---
41+
42+
<div class="page-title">
43+
<DefaultPageTitle />
44+
{hasCopyAction && (
45+
<div class="page-title__actions">
46+
<button
47+
id={controlId}
48+
type="button"
49+
class="copy-page-button"
50+
data-copy-state="idle"
51+
data-copy-source={templateId}
52+
>
53+
<span class="copy-page-button__icon" aria-hidden="true">
54+
<svg viewBox="0 0 24 24" role="presentation" focusable="false">
55+
<rect width="14" height="14" x="8" y="8" rx="2" ry="2" fill="none" stroke="currentColor" stroke-width="2"/>
56+
<path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" fill="none" stroke="currentColor" stroke-width="2"/>
57+
</svg>
58+
</span>
59+
<span class="copy-page-button__label" data-copy-label>Copy markdown</span>
60+
</button>
61+
<span class="copy-page-button__status" aria-live="polite" data-copy-status></span>
62+
<template id={templateId} data-copy-content set:text={markdownForCopy}></template>
63+
</div>
64+
)}
65+
</div>
66+
67+
{hasCopyAction && copyScriptSrc && (
68+
<script type="module" src={copyScriptSrc} />
69+
)}
70+
71+
<style>
72+
.page-title {
73+
display: flex;
74+
align-items: center;
75+
justify-content: space-between;
76+
gap: 1rem;
77+
flex-wrap: wrap;
78+
}
79+
80+
.page-title h1 {
81+
margin: 0;
82+
}
83+
84+
.page-title__actions {
85+
display: flex;
86+
align-items: center;
87+
gap: 0.5rem;
88+
position: relative;
89+
}
90+
91+
.copy-page-button {
92+
display: inline-flex;
93+
align-items: center;
94+
gap: 0.4rem;
95+
font-size: 0.9rem;
96+
font-weight: 500;
97+
line-height: 1.4;
98+
color: var(--sl-color-text);
99+
background: var(--sl-color-bg);
100+
border: 1px solid var(--sl-color-hairline);
101+
border-radius: 999px;
102+
padding: 0.35rem 0.9rem;
103+
cursor: pointer;
104+
transition: color 150ms ease, background 150ms ease, border-color 150ms ease;
105+
}
106+
107+
.copy-page-button:hover,
108+
.copy-page-button:focus-visible {
109+
background: var(--sl-color-bg-nav);
110+
border-color: var(--sl-color-accent);
111+
color: var(--sl-color-accent);
112+
outline: none;
113+
}
114+
115+
.copy-page-button:focus-visible {
116+
box-shadow: 0 0 0 2px color-mix(in srgb, var(--sl-color-accent) 35%, transparent);
117+
}
118+
119+
.copy-page-button[data-copy-state="copied"] {
120+
border-color: color-mix(in srgb, var(--sl-color-accent) 65%, transparent);
121+
color: var(--sl-color-accent);
122+
}
123+
124+
.copy-page-button[data-copy-state="error"] {
125+
border-color: color-mix(in srgb, var(--sl-color-red) 70%, transparent);
126+
color: var(--sl-color-red);
127+
}
128+
129+
.copy-page-button__icon {
130+
display: grid;
131+
place-items: center;
132+
}
133+
134+
.copy-page-button__icon svg {
135+
width: 1rem;
136+
height: 1rem;
137+
}
138+
139+
.copy-page-button__status {
140+
position: absolute;
141+
width: 1px;
142+
height: 1px;
143+
padding: 0;
144+
margin: -1px;
145+
overflow: hidden;
146+
clip: rect(0, 0, 0, 0);
147+
white-space: nowrap;
148+
border: 0;
149+
}
150+
151+
@media (max-width: 40rem) {
152+
.page-title {
153+
flex-direction: column;
154+
align-items: flex-start;
155+
}
156+
157+
.copy-page-button {
158+
width: 100%;
159+
justify-content: center;
160+
}
161+
}
162+
</style>
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
const STATUS_RESET_DELAY = 3000
2+
const successLabel = "Copied!"
3+
const failureLabel = "Copy failed"
4+
const unavailableMessage = "Markdown unavailable."
5+
const copiedMessage = "Markdown copied to clipboard."
6+
const failedMessage = "Unable to copy markdown."
7+
8+
type CopyState = "idle" | "copied" | "error"
9+
10+
const writeToClipboard = async (text: string) => {
11+
if (navigator.clipboard?.writeText) {
12+
await navigator.clipboard.writeText(text)
13+
return
14+
}
15+
16+
const area = document.createElement("textarea")
17+
area.value = text
18+
area.setAttribute("readonly", "")
19+
area.style.position = "absolute"
20+
area.style.left = "-9999px"
21+
document.body.appendChild(area)
22+
area.select()
23+
document.execCommand("copy")
24+
document.body.removeChild(area)
25+
}
26+
27+
const readMarkdown = (templateId: string): string | null => {
28+
const node = document.getElementById(templateId)
29+
if (!node) return null
30+
31+
if (node instanceof HTMLTemplateElement) {
32+
return node.content.textContent ?? ""
33+
}
34+
35+
return node.textContent ?? ""
36+
}
37+
38+
const setState = (
39+
button: HTMLButtonElement,
40+
label: HTMLElement | null,
41+
status: HTMLElement | null,
42+
state: CopyState,
43+
message?: string
44+
) => {
45+
button.dataset.copyState = state
46+
47+
if (label) {
48+
if (state === "copied") {
49+
label.textContent = successLabel
50+
} else if (state === "error") {
51+
label.textContent = failureLabel
52+
} else {
53+
label.textContent = button.dataset.copyDefaultLabel || "Copy markdown"
54+
}
55+
}
56+
57+
if (status) {
58+
status.textContent = message ?? ""
59+
}
60+
}
61+
62+
const attachHandler = (button: HTMLButtonElement) => {
63+
if (button.dataset.copyReady === "true") return
64+
65+
const templateId = button.dataset.copySource
66+
if (!templateId) return
67+
68+
const label = button.querySelector<HTMLElement>("[data-copy-label]")
69+
const status = button.parentElement?.querySelector<HTMLElement>("[data-copy-status]") ?? null
70+
button.dataset.copyDefaultLabel = label?.textContent ?? "Copy page"
71+
72+
button.addEventListener("click", async () => {
73+
const markdown = readMarkdown(templateId)
74+
if (markdown == null) {
75+
setState(button, label, status, "error", unavailableMessage)
76+
return
77+
}
78+
79+
try {
80+
await writeToClipboard(markdown)
81+
setState(button, label, status, "copied", copiedMessage)
82+
} catch (error) {
83+
setState(button, label, status, "error", failedMessage)
84+
return
85+
}
86+
87+
window.setTimeout(() => {
88+
setState(button, label, status, "idle")
89+
}, STATUS_RESET_DELAY)
90+
})
91+
92+
button.dataset.copyReady = "true"
93+
}
94+
95+
const initialize = () => {
96+
document
97+
.querySelectorAll<HTMLButtonElement>("button[data-copy-source]")
98+
.forEach((button) => attachHandler(button))
99+
}
100+
101+
if (document.readyState !== "loading") {
102+
initialize()
103+
} else {
104+
document.addEventListener("DOMContentLoaded", initialize)
105+
}
106+
107+
document.addEventListener("astro:page-load", initialize)
108+
document.addEventListener("astro:after-swap", initialize)

content/src/lib/markdown-map.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
// Build-time markdown bundling for PageTitle copy functionality
2+
// This avoids runtime file I/O by importing all markdown content at build time
3+
4+
// Import all markdown files as raw strings
5+
const markdownFiles = import.meta.glob('/src/content/**/*.{md,mdx}', {
6+
eager: true,
7+
as: 'raw'
8+
}) as Record<string, string>
9+
10+
/**
11+
* Strip YAML frontmatter from markdown content
12+
* Frontmatter is the metadata between --- lines at the start of files
13+
*/
14+
function stripFrontmatter(content: string): string {
15+
// Match frontmatter pattern: starts with ---, has content, ends with ---
16+
const frontmatterRegex = /^---\s*\r?\n(.*?)\r?\n---\s*\r?\n/s
17+
return content.replace(frontmatterRegex, '')
18+
}
19+
20+
/**
21+
* Get the raw markdown content for a Starlight entry ID
22+
* Maps Starlight's route ID format to actual file paths and strips frontmatter
23+
*/
24+
export function getMarkdownById(entryId: string): string | undefined {
25+
// Remove leading slash if present
26+
const normalizedId = entryId.replace(/^\//, '')
27+
28+
// Try different path patterns that match Starlight's routing
29+
const candidates = [
30+
`/src/content/docs/${normalizedId}.md`,
31+
`/src/content/docs/${normalizedId}.mdx`,
32+
`/src/content/${normalizedId}.md`,
33+
`/src/content/${normalizedId}.mdx`,
34+
// Handle index files
35+
`/src/content/docs/${normalizedId}/index.md`,
36+
`/src/content/docs/${normalizedId}/index.mdx`,
37+
]
38+
39+
for (const candidatePath of candidates) {
40+
const content = markdownFiles[candidatePath]
41+
if (content) {
42+
// Strip frontmatter before returning
43+
return stripFrontmatter(content)
44+
}
45+
}
46+
47+
// Debug: log available files if we can't find a match
48+
if (import.meta.env.DEV) {
49+
console.warn(`No markdown found for entry ID: ${entryId}`)
50+
console.warn('Available files:', Object.keys(markdownFiles).slice(0, 10))
51+
}
52+
53+
return undefined
54+
}
55+
56+
/**
57+
* Get all available markdown file paths (for debugging)
58+
*/
59+
export function getAvailablePaths(): string[] {
60+
return Object.keys(markdownFiles)
61+
}

0 commit comments

Comments
 (0)