-
-
Notifications
You must be signed in to change notification settings - Fork 128
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 2 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,162 @@ | ||
| --- | ||
| import DefaultPageTitle from "@astrojs/starlight/components/PageTitle.astro" | ||
| import { getMarkdownById } from "../../lib/markdown-map" | ||
| import copyScriptUrl from "./copy-page.client.ts?url" | ||
|
|
||
| const route = Astro.locals.starlightRoute | ||
| const entry = route.entry | ||
|
|
||
| const rawMarkdown = getMarkdownById(entry.id) | ||
|
|
||
| const title = typeof entry.data?.title === "string" ? entry.data.title.trim() : undefined | ||
| const normalizedTitle = title?.toLowerCase() | ||
|
|
||
| const ensureTitleHeading = (markdown: string | undefined) => { | ||
| if (!markdown) return undefined | ||
| if (!title || !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}` | ||
| } | ||
|
|
||
| const markdownForCopy = ensureTitleHeading(rawMarkdown) | ||
|
|
||
| const slug = entry.slug || entry.id || "page" | ||
| const controlId = `copy-page-${slug.replace(/[^a-z0-9]+/gi, "-").replace(/^-+|-+$/g, "") || "doc"}` | ||
kitlangton marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| const templateId = `${controlId}-markdown` | ||
| const hasCopyAction = Boolean(markdownForCopy) | ||
| 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-copy-source={templateId} | ||
| > | ||
| <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> | ||
| <template id={templateId} data-copy-content set:text={markdownForCopy}></template> | ||
| </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,108 @@ | ||
| const STATUS_RESET_DELAY = 3000 | ||
| const successLabel = "Copied!" | ||
| const failureLabel = "Copy failed" | ||
| const unavailableMessage = "Markdown unavailable." | ||
| const copiedMessage = "Markdown copied to clipboard." | ||
| const failedMessage = "Unable to copy markdown." | ||
|
|
||
| type CopyState = "idle" | "copied" | "error" | ||
|
|
||
| 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 readMarkdown = (templateId: string): string | null => { | ||
| const node = document.getElementById(templateId) | ||
| if (!node) return null | ||
|
|
||
| if (node instanceof HTMLTemplateElement) { | ||
| return node.content.textContent ?? "" | ||
| } | ||
|
|
||
| return node.textContent ?? "" | ||
| } | ||
|
|
||
| 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 templateId = button.dataset.copySource | ||
| if (!templateId) return | ||
|
|
||
| const label = button.querySelector<HTMLElement>("[data-copy-label]") | ||
| const status = button.parentElement?.querySelector<HTMLElement>("[data-copy-status]") ?? null | ||
| button.dataset.copyDefaultLabel = label?.textContent ?? "Copy page" | ||
|
|
||
| button.addEventListener("click", async () => { | ||
| const markdown = readMarkdown(templateId) | ||
| if (markdown == null) { | ||
| setState(button, label, status, "error", unavailableMessage) | ||
| return | ||
| } | ||
|
|
||
| try { | ||
| await writeToClipboard(markdown) | ||
| setState(button, label, status, "copied", copiedMessage) | ||
| } catch (error) { | ||
| setState(button, label, status, "error", failedMessage) | ||
| return | ||
| } | ||
|
|
||
| window.setTimeout(() => { | ||
| setState(button, label, status, "idle") | ||
| }, STATUS_RESET_DELAY) | ||
| }) | ||
|
|
||
| button.dataset.copyReady = "true" | ||
| } | ||
|
|
||
| const initialize = () => { | ||
| document | ||
| .querySelectorAll<HTMLButtonElement>("button[data-copy-source]") | ||
| .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,61 @@ | ||
| // Build-time markdown bundling for PageTitle copy functionality | ||
| // This avoids runtime file I/O by importing all markdown content at build time | ||
|
|
||
| // Import all markdown files as raw strings | ||
| const markdownFiles = import.meta.glob('/src/content/**/*.{md,mdx}', { | ||
| eager: true, | ||
| as: 'raw' | ||
| }) as Record<string, string> | ||
|
|
||
| /** | ||
| * Strip YAML frontmatter from markdown content | ||
| * Frontmatter is the metadata between --- lines at the start of files | ||
| */ | ||
| function stripFrontmatter(content: string): string { | ||
| // Match frontmatter pattern: starts with ---, has content, ends with --- | ||
| const frontmatterRegex = /^---\s*\r?\n(.*?)\r?\n---\s*\r?\n/s | ||
| return content.replace(frontmatterRegex, '') | ||
| } | ||
|
|
||
| /** | ||
| * 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) { | ||
| // Strip frontmatter before returning | ||
| return stripFrontmatter(content) | ||
| } | ||
| } | ||
|
|
||
| // 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) | ||
| } | ||
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.