-
Notifications
You must be signed in to change notification settings - Fork 5
feat: add raw markdown endpoint and Copy as Markdown button #9
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
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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" }, | ||
| }) | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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
104
to
109
|
||
| </div> | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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
|
||||||||||||||||||||||||||||||||||
| 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
AI
Mar 12, 2026
There was a problem hiding this comment.
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
AI
Mar 12, 2026
There was a problem hiding this comment.
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.
| <button | |
| <button | |
| type="button" |
| 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 |
| 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" | ||||||
|
||||||
| import { VERSIONS, DEFAULT_VERSION, type Version } from "../lib/docs-config" | |
| import { VERSIONS, DEFAULT_VERSION } from "../lib/docs-config" |
Copilot
AI
Mar 12, 2026
There was a problem hiding this comment.
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).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Passing the full
doc.contentstring 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*.mdendpoint when the button is clicked instead of embedding it in props.