Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
11 changes: 10 additions & 1 deletion content/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,16 @@
"pnpm": {
"patchedDependencies": {
"astro-tweet": "patches/astro-tweet.patch"
}
},
"onlyBuiltDependencies": [
"@mixedbread/cli",
"@parcel/watcher",
"@vercel/speed-insights",
"esbuild",
"msgpackr-extract",
"protobufjs",
"sharp"
]
},
"dependencies": {
"neverthrow": "^8.2.0"
Expand Down
138 changes: 138 additions & 0 deletions content/src/components/starlight/PageTitle.astro
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>
123 changes: 123 additions & 0 deletions content/src/components/starlight/copy-page.client.ts
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)
}

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