-
-
Notifications
You must be signed in to change notification settings - Fork 129
Implement copy as markdown button #1218
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
kitlangton
wants to merge
4
commits into
main
Choose a base branch
from
add-copy-as-markdown-button
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+409
−1
Open
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,138 @@ | ||
| --- | ||
| import DefaultPageTitle from "@astrojs/starlight/components/PageTitle.astro" | ||
| import copyScriptUrl from "./copy-page.client.ts?url" | ||
|
|
||
| const route = Astro.locals.starlightRoute | ||
| const entry = route.entry | ||
|
|
||
| const title = typeof entry.data?.title === "string" ? entry.data.title.trim() : undefined | ||
|
|
||
| const slug = entry.slug || entry.id || "page" | ||
| const controlId = `copy-page-${slug.replace(/[^a-z0-9]+/gi, "-").replace(/^-+|-+$/g, "") || "doc"}` | ||
| const docSlug = entry.slug?.startsWith("docs") ? entry.slug : undefined | ||
| const markdownUrl = docSlug ? `/${docSlug}.md` : undefined | ||
| const hasCopyAction = Boolean(markdownUrl) | ||
| const copyScriptSrc = hasCopyAction ? copyScriptUrl : undefined | ||
| --- | ||
|
|
||
| <div class="page-title"> | ||
| <DefaultPageTitle /> | ||
| {hasCopyAction && ( | ||
| <div class="page-title__actions"> | ||
| <button | ||
| id={controlId} | ||
| type="button" | ||
| class="copy-page-button" | ||
| data-copy-state="idle" | ||
| data-markdown-url={markdownUrl} | ||
| data-doc-title={title ?? undefined} | ||
| > | ||
| <span class="copy-page-button__icon" aria-hidden="true"> | ||
| <svg viewBox="0 0 24 24" role="presentation" focusable="false"> | ||
| <rect width="14" height="14" x="8" y="8" rx="2" ry="2" fill="none" stroke="currentColor" stroke-width="2"/> | ||
| <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"/> | ||
| </svg> | ||
| </span> | ||
| <span class="copy-page-button__label" data-copy-label>Copy markdown</span> | ||
| </button> | ||
| <span class="copy-page-button__status" aria-live="polite" data-copy-status></span> | ||
| </div> | ||
| )} | ||
| </div> | ||
|
|
||
| {hasCopyAction && copyScriptSrc && ( | ||
| <script type="module" src={copyScriptSrc} /> | ||
| )} | ||
|
|
||
| <style> | ||
| .page-title { | ||
| display: flex; | ||
| align-items: center; | ||
| justify-content: space-between; | ||
| gap: 1rem; | ||
| flex-wrap: wrap; | ||
| } | ||
|
|
||
| .page-title h1 { | ||
| margin: 0; | ||
| } | ||
|
|
||
| .page-title__actions { | ||
| display: flex; | ||
| align-items: center; | ||
| gap: 0.5rem; | ||
| position: relative; | ||
| } | ||
|
|
||
| .copy-page-button { | ||
| display: inline-flex; | ||
| align-items: center; | ||
| gap: 0.4rem; | ||
| font-size: 0.9rem; | ||
| font-weight: 500; | ||
| line-height: 1.4; | ||
| color: var(--sl-color-text); | ||
| background: var(--sl-color-bg); | ||
| border: 1px solid var(--sl-color-hairline); | ||
| border-radius: 999px; | ||
| padding: 0.35rem 0.9rem; | ||
| cursor: pointer; | ||
| transition: color 150ms ease, background 150ms ease, border-color 150ms ease; | ||
| } | ||
|
|
||
| .copy-page-button:hover, | ||
| .copy-page-button:focus-visible { | ||
| background: var(--sl-color-bg-nav); | ||
| border-color: var(--sl-color-accent); | ||
| color: var(--sl-color-accent); | ||
| outline: none; | ||
| } | ||
|
|
||
| .copy-page-button:focus-visible { | ||
| box-shadow: 0 0 0 2px color-mix(in srgb, var(--sl-color-accent) 35%, transparent); | ||
| } | ||
|
|
||
| .copy-page-button[data-copy-state="copied"] { | ||
| border-color: color-mix(in srgb, var(--sl-color-accent) 65%, transparent); | ||
| color: var(--sl-color-accent); | ||
| } | ||
|
|
||
| .copy-page-button[data-copy-state="error"] { | ||
| border-color: color-mix(in srgb, var(--sl-color-red) 70%, transparent); | ||
| color: var(--sl-color-red); | ||
| } | ||
|
|
||
| .copy-page-button__icon { | ||
| display: grid; | ||
| place-items: center; | ||
| } | ||
|
|
||
| .copy-page-button__icon svg { | ||
| width: 1rem; | ||
| height: 1rem; | ||
| } | ||
|
|
||
| .copy-page-button__status { | ||
| position: absolute; | ||
| width: 1px; | ||
| height: 1px; | ||
| padding: 0; | ||
| margin: -1px; | ||
| overflow: hidden; | ||
| clip: rect(0, 0, 0, 0); | ||
| white-space: nowrap; | ||
| border: 0; | ||
| } | ||
|
|
||
| @media (max-width: 40rem) { | ||
| .page-title { | ||
| flex-direction: column; | ||
| align-items: flex-start; | ||
| } | ||
|
|
||
| .copy-page-button { | ||
| width: 100%; | ||
| justify-content: center; | ||
| } | ||
| } | ||
| </style> | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,123 @@ | ||
| import { ensureTitleHeading } from "@/lib/markdown-heading" | ||
|
|
||
| const STATUS_RESET_DELAY = 3000 | ||
| const successLabel = "Copied!" | ||
| const failureLabel = "Copy failed" | ||
| const copiedMessage = "Markdown copied to clipboard." | ||
| const failedMessage = "Unable to copy markdown." | ||
|
|
||
| type CopyState = "idle" | "copied" | "error" | ||
|
|
||
| const markdownCache = new Map<string, string>() | ||
|
|
||
| const writeToClipboard = async (text: string) => { | ||
| if (navigator.clipboard?.writeText) { | ||
| await navigator.clipboard.writeText(text) | ||
| return | ||
| } | ||
|
|
||
| const area = document.createElement("textarea") | ||
| area.value = text | ||
| area.setAttribute("readonly", "") | ||
| area.style.position = "absolute" | ||
| area.style.left = "-9999px" | ||
| document.body.appendChild(area) | ||
| area.select() | ||
| document.execCommand("copy") | ||
| document.body.removeChild(area) | ||
kitlangton marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| } | ||
|
|
||
| const fetchMarkdown = async (url: string) => { | ||
| if (markdownCache.has(url)) { | ||
| return markdownCache.get(url) as string | ||
| } | ||
|
|
||
| const response = await fetch(url, { | ||
| headers: { | ||
| Accept: "text/markdown,text/plain;q=0.9,*/*;q=0.8" | ||
| } | ||
| }) | ||
|
|
||
| if (!response.ok) { | ||
| throw new Error(`Failed to fetch markdown: ${response.status}`) | ||
| } | ||
|
|
||
| const text = await response.text() | ||
| markdownCache.set(url, text) | ||
| return text | ||
| } | ||
|
|
||
| const setState = ( | ||
| button: HTMLButtonElement, | ||
| label: HTMLElement | null, | ||
| status: HTMLElement | null, | ||
| state: CopyState, | ||
| message?: string | ||
| ) => { | ||
| button.dataset.copyState = state | ||
|
|
||
| if (label) { | ||
| if (state === "copied") { | ||
| label.textContent = successLabel | ||
| } else if (state === "error") { | ||
| label.textContent = failureLabel | ||
| } else { | ||
| label.textContent = button.dataset.copyDefaultLabel || "Copy markdown" | ||
| } | ||
| } | ||
|
|
||
| if (status) { | ||
| status.textContent = message ?? "" | ||
| } | ||
| } | ||
|
|
||
| const attachHandler = (button: HTMLButtonElement) => { | ||
| if (button.dataset.copyReady === "true") return | ||
|
|
||
| const markdownUrl = button.dataset.markdownUrl | ||
| if (!markdownUrl) return | ||
|
|
||
| const label = button.querySelector<HTMLElement>("[data-copy-label]") | ||
| const status = button.parentElement?.querySelector<HTMLElement>("[data-copy-status]") ?? null | ||
| const docTitle = button.dataset.docTitle?.trim() | ||
| button.dataset.copyDefaultLabel = label?.textContent ?? "Copy page" | ||
|
|
||
| button.addEventListener("click", async () => { | ||
| try { | ||
| const markdown = await fetchMarkdown(markdownUrl) | ||
| const prepared = ensureTitleHeading(markdown, docTitle) | ||
| await writeToClipboard(prepared) | ||
| setState(button, label, status, "copied", copiedMessage) | ||
| } catch (error) { | ||
| setState( | ||
| button, | ||
| label, | ||
| status, | ||
| "error", | ||
| error instanceof Error ? error.message : failedMessage | ||
| ) | ||
| return | ||
| } | ||
|
|
||
| window.setTimeout(() => { | ||
| setState(button, label, status, "idle") | ||
| }, STATUS_RESET_DELAY) | ||
| }) | ||
|
|
||
| button.dataset.copyReady = "true" | ||
| } | ||
|
|
||
| const initialize = () => { | ||
| document | ||
| .querySelectorAll<HTMLButtonElement>("button[data-markdown-url]") | ||
| .forEach((button) => attachHandler(button)) | ||
| } | ||
|
|
||
| if (document.readyState !== "loading") { | ||
| initialize() | ||
| } else { | ||
| document.addEventListener("DOMContentLoaded", initialize) | ||
| } | ||
|
|
||
| document.addEventListener("astro:page-load", initialize) | ||
| document.addEventListener("astro:after-swap", initialize) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,19 @@ | ||
| export const ensureTitleHeading = (markdown: string, title?: string) => { | ||
| if (!title) return markdown | ||
| const normalizedTitle = title.trim().toLowerCase() | ||
| if (!normalizedTitle) return markdown | ||
|
|
||
| const trimmed = markdown.trimStart() | ||
| if (trimmed.length > 0) { | ||
| const firstLine = trimmed.split(/\r?\n/, 1)[0] | ||
| if (firstLine) { | ||
| const normalizedFirstLine = firstLine.replace(/^#+\s*/, "").trim().toLowerCase() | ||
| if (firstLine.startsWith("#") && normalizedFirstLine === normalizedTitle) { | ||
| return markdown | ||
| } | ||
| } | ||
| } | ||
|
|
||
| const body = trimmed.length > 0 ? `\n\n${trimmed}` : "" | ||
| return `# ${title}${body}` | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,83 @@ | ||
| // Build-time markdown bundling for PageTitle copy functionality | ||
| // This avoids runtime file I/O by importing all markdown content at build time | ||
|
|
||
| import { ensureTitleHeading } from "./markdown-heading" | ||
|
|
||
| // Import all markdown files as raw strings | ||
| const markdownFiles = import.meta.glob("/src/content/**/*.{md,mdx}", { | ||
| eager: true, | ||
| as: "raw" | ||
| }) as Record<string, string> | ||
|
|
||
| const frontmatterRegex = /^---\s*\r?\n([\s\S]*?)\r?\n---\s*\r?\n/ | ||
|
|
||
| const splitFrontmatter = (content: string) => { | ||
| const match = frontmatterRegex.exec(content) | ||
| if (!match) { | ||
| return { markdown: content, frontmatter: undefined } | ||
| } | ||
|
|
||
| const [fullMatch, frontmatter] = match | ||
| const markdown = content.slice(fullMatch.length) | ||
| return { markdown, frontmatter } | ||
| } | ||
|
|
||
| const stripWrappingQuotes = (value: string) => value.replace(/^['"]+|['"]+$/g, "").trim() | ||
|
|
||
| const extractTitle = (frontmatter?: string): string | undefined => { | ||
| if (!frontmatter) return undefined | ||
|
|
||
| const lines = frontmatter.split(/\r?\n/) | ||
| for (const line of lines) { | ||
| const match = /^title\s*:\s*(.+)$/i.exec(line.trim()) | ||
| if (match?.[1]) { | ||
| return stripWrappingQuotes(match[1].trim()) | ||
| } | ||
| } | ||
|
|
||
| return undefined | ||
| } | ||
|
|
||
| /** | ||
| * Get the raw markdown content for a Starlight entry ID | ||
| * Maps Starlight's route ID format to actual file paths and strips frontmatter | ||
| */ | ||
| export function getMarkdownById(entryId: string): string | undefined { | ||
| // Remove leading slash if present | ||
| const normalizedId = entryId.replace(/^\//, "") | ||
|
|
||
| // Try different path patterns that match Starlight's routing | ||
| const candidates = [ | ||
| `/src/content/docs/${normalizedId}.md`, | ||
| `/src/content/docs/${normalizedId}.mdx`, | ||
| `/src/content/${normalizedId}.md`, | ||
| `/src/content/${normalizedId}.mdx`, | ||
| // Handle index files | ||
| `/src/content/docs/${normalizedId}/index.md`, | ||
| `/src/content/docs/${normalizedId}/index.mdx` | ||
| ] | ||
|
|
||
| for (const candidatePath of candidates) { | ||
| const content = markdownFiles[candidatePath] | ||
| if (content) { | ||
| const { markdown, frontmatter } = splitFrontmatter(content) | ||
| const title = extractTitle(frontmatter) | ||
| return ensureTitleHeading(markdown, title) | ||
| } | ||
| } | ||
|
|
||
| // Debug: log available files if we can't find a match | ||
| if (import.meta.env.DEV) { | ||
| console.warn(`No markdown found for entry ID: ${entryId}`) | ||
kitlangton marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| console.warn("Available files:", Object.keys(markdownFiles).slice(0, 10)) | ||
| } | ||
|
|
||
| return undefined | ||
| } | ||
|
|
||
| /** | ||
| * Get all available markdown file paths (for debugging) | ||
| */ | ||
| export function getAvailablePaths(): string[] { | ||
| return Object.keys(markdownFiles) | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.