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
21 changes: 21 additions & 0 deletions app/docs-raw/[...slug]/route.dev.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { getDoc } from "@/lib/docs"
import { parseDocSlug } from "@/lib/docs-config"

// In production, .md files are served as static files written by scripts/copy-docs-md.ts.
// This route only runs in dev (output: "export" is not set in dev).
export const dynamic = "force-dynamic"

export async function GET(
_request: Request,
{ params }: { params: Promise<{ slug: string[] }> }
) {
const { slug } = await params
const { version, docSlug } = parseDocSlug(slug)
const doc = getDoc(version, docSlug)

if (!doc) return new Response("Not found", { status: 404 })

return new Response(doc.content, {
headers: { "Content-Type": "text/markdown; charset=utf-8" },
})
}
2 changes: 1 addition & 1 deletion app/docs/[...slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ export default async function DocPage({ params }: PageProps) {
{/* Table of Contents - right sidebar */}
{headings.length > 0 && (
<aside className="sticky top-14 hidden h-[calc(100vh-3.5rem)] w-56 shrink-0 overflow-y-auto py-8 pr-4 xl:block">
<TableOfContents headings={headings} />
<TableOfContents headings={headings} rawContent={doc.content} />
</aside>
Comment on lines 105 to 108
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Passing the full doc.content string into a client component means the entire raw doc source is serialized into the RSC payload and sent to the browser on every docs page view (even if the user never clicks copy). To avoid a potentially large page payload, consider fetching the raw markdown on-demand from the new *.md endpoint when the button is clicked instead of embedding it in props.

Copilot uses AI. Check for mistakes.
)}
Comment on lines 104 to 109
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TableOfContents is only rendered when headings.length > 0, but the new “Copy as Markdown” UI lives inside that component. This means docs pages without headings can’t show the copy button even though doc.content is available. If the copy feature should work for all docs, render the sidebar (or at least the copy control) regardless of headings length.

Copilot uses AI. Check for mistakes.
</div>
Expand Down
30 changes: 29 additions & 1 deletion components/table-of-contents.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,30 @@

import { useEffect, useState } from "react"
import { cn } from "@/lib/utils"
import { CopyIcon, CheckIcon } from "lucide-react"

interface TocEntry {
depth: number
text: string
id: string
}

export function TableOfContents({ headings }: { headings: TocEntry[] }) {
export function TableOfContents({
headings,
rawContent,
}: {
headings: TocEntry[]
rawContent?: string
}) {
const [activeId, setActiveId] = useState<string>("")
const [copied, setCopied] = useState(false)

async function copyMarkdown() {
if (!rawContent) return
await navigator.clipboard.writeText(rawContent)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
Comment on lines +25 to +27
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

navigator.clipboard.writeText(...) can throw/reject (permission denied, insecure context, etc.). Right now that would result in an unhandled rejection and copied state never resetting. Wrap this in try/catch (and optionally surface a non-intrusive error state) so the UI doesn’t silently fail.

Suggested change
await navigator.clipboard.writeText(rawContent)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
if (!navigator?.clipboard?.writeText) {
console.error("Clipboard API is not available in this environment.")
return
}
try {
await navigator.clipboard.writeText(rawContent)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
} catch (error) {
console.error("Failed to copy markdown to clipboard:", error)
}

Copilot uses AI. Check for mistakes.
}
Comment on lines +26 to +28
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The setTimeout started after copying is never cleared. If the user navigates away within 2s, this can trigger a state update on an unmounted component. Store the timeout id (e.g., in a ref) and clear it in an effect cleanup (and/or before setting a new timeout).

Copilot uses AI. Check for mistakes.

useEffect(() => {
if (headings.length === 0) return
Expand Down Expand Up @@ -46,6 +61,19 @@ export function TableOfContents({ headings }: { headings: TocEntry[] }) {

return (
<nav aria-label="Table of contents">
{rawContent && (
<button
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This <button> doesn’t specify type. If this component is ever rendered inside a <form>, the default type="submit" can cause unintended submissions. Set type="button" to make the click behavior unambiguous.

Suggested change
<button
<button
type="button"

Copilot uses AI. Check for mistakes.
onClick={copyMarkdown}
className="mb-4 flex w-full items-center gap-1.5 text-sm text-muted-foreground transition-colors hover:text-foreground"
>
{copied ? (
<CheckIcon className="size-3.5 text-green-500" />
) : (
<CopyIcon className="size-3.5" />
)}
{copied ? "Copied!" : "Copy as Markdown"}
</button>
)}
<p className="mb-3 text-sm font-medium">On this page</p>
<ul className="space-y-1.5 text-sm">
{headings.map((heading) => (
Expand Down
15 changes: 14 additions & 1 deletion next.config.mjs
Original file line number Diff line number Diff line change
@@ -1,9 +1,22 @@
/** @type {import('next').NextConfig} */
const isDev = process.env.NODE_ENV === "development"

const nextConfig = {
output: "export",
output: isDev ? undefined : "export",
pageExtensions: isDev
? ["tsx", "ts", "jsx", "js", "dev.ts", "dev.tsx"]
: ["tsx", "ts", "jsx", "js"],
images: {
unoptimized: true,
},
async rewrites() {
return [
{
source: "/docs/:path*.md",
destination: "/docs-raw/:path*",
},
]
},
}

export default nextConfig
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"private": true,
"scripts": {
"dev": "next dev --turbopack",
"build": "next build && bun run scripts/generate-rss.ts",
"build": "next build && bun run scripts/generate-rss.ts && bun run scripts/copy-docs-md.ts",
"start": "next start",
"lint": "eslint",
"format": "prettier --write \"**/*.{ts,tsx}\"",
Expand Down
47 changes: 47 additions & 0 deletions scripts/copy-docs-md.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import fs from "fs"
import path from "path"
import matter from "gray-matter"
import { VERSIONS, DEFAULT_VERSION, type Version } from "../lib/docs-config"
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

type Version is imported but never used in this script. This will typically fail lint/typecheck with unused-import rules; remove it (or use it) to keep the script clean.

Suggested change
import { VERSIONS, DEFAULT_VERSION, type Version } from "../lib/docs-config"
import { VERSIONS, DEFAULT_VERSION } from "../lib/docs-config"

Copilot uses AI. Check for mistakes.

const CONTENT_DIR = path.join(process.cwd(), "content/docs")
const OUT_DIR = path.join(process.cwd(), "out/docs")

function copyMarkdownFiles() {
let count = 0

for (const version of VERSIONS) {
const versionDir = path.join(CONTENT_DIR, version)
if (!fs.existsSync(versionDir)) continue

const targetDir =
version === DEFAULT_VERSION ? OUT_DIR : path.join(OUT_DIR, version)

function walk(dir: string, relativePath: string = "") {
const entries = fs.readdirSync(dir, { withFileTypes: true })

for (const entry of entries) {
const fullPath = path.join(dir, entry.name)

if (entry.isDirectory()) {
walk(fullPath, path.join(relativePath, entry.name))
} else if (entry.name.endsWith(".mdx") || entry.name.endsWith(".md")) {
const rawContent = fs.readFileSync(fullPath, "utf-8")
const { content } = matter(rawContent)

const mdName = entry.name.replace(/\.(mdx|md)$/, ".md")
const outPath = path.join(targetDir, relativePath, mdName)

fs.mkdirSync(path.dirname(outPath), { recursive: true })
fs.writeFileSync(outPath, content.trim() + "\n")
count++
Comment on lines +35 to +36
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Writing content.trim() will remove leading whitespace/newlines from the document body, which can change Markdown semantics (e.g., leading-indented code blocks) and produce output that doesn't match the source. Prefer preserving the content as-is and only normalize the trailing newline (e.g., ensure it ends with exactly one \n).

Copilot uses AI. Check for mistakes.
}
}
}

walk(versionDir)
}

console.log(`Copied ${count} markdown files → out/docs/`)
}

copyMarkdownFiles()
Loading