Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions content/astro.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ export default defineConfig({
ContentPanel: "./src/components/starlight/ContentPanel.astro",
Head: "./src/components/starlight/Head.astro",
MarkdownContent: "./src/components/starlight/MarkdownContent.astro",
PageTitle: "./src/components/starlight/PageTitle.astro",
SocialIcons: "./src/components/starlight/SocialIcons.astro",
ThemeSelect: "./src/components/starlight/ThemeSelect.astro"
},
Expand Down
10 changes: 10 additions & 0 deletions content/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,16 @@
"pnpm": {
"patchedDependencies": {
"astro-tweet": "patches/astro-tweet.patch"
},
"onlyBuiltDependencies": [
"@mixedbread/cli",
"@parcel/watcher",
"@vercel/speed-insights",
"esbuild",
"msgpackr-extract",
"protobufjs",
"sharp"
]
}
},
"dependencies": {
Expand Down
162 changes: 162 additions & 0 deletions content/src/components/starlight/PageTitle.astro
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"}`
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>
108 changes: 108 additions & 0 deletions content/src/components/starlight/copy-page.client.ts
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)
}

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)
61 changes: 61 additions & 0 deletions content/src/lib/markdown-map.ts
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}`)
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)
}