diff --git a/.github/ISSUE_TEMPLATE/docs_feedback.yml b/.github/ISSUE_TEMPLATE/docs_feedback.yml new file mode 100644 index 000000000..f97c87668 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/docs_feedback.yml @@ -0,0 +1,31 @@ +name: Docs feedback +description: Report a docs typo, gap, or confusing page +title: "[docs] " +labels: ["docs"] +body: + - type: markdown + attributes: + value: | + Use this form for documentation fixes, confusing examples, stale commands, or missing setup details. + - type: input + id: page + attributes: + label: Documentation page + description: The page where you found the issue. + validations: + required: true + - type: input + id: source + attributes: + label: Source file + description: The docs source file, if known. + validations: + required: false + - type: textarea + id: feedback + attributes: + label: What should change? + description: Tell us what is wrong, missing, stale, or confusing. + placeholder: This page says X, but it should say Y. + validations: + required: true diff --git a/docs-site/app/docs/[[...slug]]/page.tsx b/docs-site/app/docs/[[...slug]]/page.tsx index e0677c9e9..7a6409db0 100644 --- a/docs-site/app/docs/[[...slug]]/page.tsx +++ b/docs-site/app/docs/[[...slug]]/page.tsx @@ -8,8 +8,13 @@ import { import { notFound, redirect } from "next/navigation"; import defaultMdxComponents from "fumadocs-ui/mdx"; import { CodeBlock } from "@/components/code-block"; -import { DocsPageActions } from "@/components/docs-page-actions"; -import { readDocsPageMarkdown } from "@/lib/docs-markdown"; +import { + DocsPageFooter, + DocsPageFooterProvider, +} from "@/components/docs-page-footer"; +import { readDocsPageMarkdownFile } from "@/lib/docs-markdown"; +import { absoluteUrl } from "@/lib/llm-docs"; +import { relative } from "node:path"; const docsIndexPath = "/docs/getting-started/introduction"; const docsIndexSlug = ["getting-started", "introduction"] as const; @@ -22,6 +27,28 @@ function isHeroPage(slug: string[] | undefined) { return slug?.join("/") === "getting-started/introduction"; } +function toRepositoryPath(sourcePath: string) { + return `docs-site/${relative(process.cwd(), sourcePath).replaceAll("\\", "/")}`; +} + +function buildSourceEditUrl(sourcePath: string) { + return `https://github.com/Kaelio/ktx/edit/main/${toRepositoryPath(sourcePath)}`; +} + +function buildIssueUrl(pageTitle: string, sourcePath: string, pageUrl: string) { + const title = `[docs] ${pageTitle}`; + const repositoryPath = toRepositoryPath(sourcePath); + + const params = new URLSearchParams({ + template: "docs_feedback.yml", + title, + page: pageUrl, + source: repositoryPath, + }); + + return `https://github.com/Kaelio/ktx/issues/new?${params.toString()}`; +} + export default async function Page(props: { params: Promise<{ slug?: string[] }>; }) { @@ -34,34 +61,41 @@ export default async function Page(props: { if (!page) notFound(); const MDX = page.data.body; - const mdxSource = await readDocsPageMarkdown(page.slugs); - + const { content: mdxSource, path: sourcePath } = + await readDocsPageMarkdownFile(page.slugs); + const pageUrl = absoluteUrl(page.url); const hero = isHeroPage(params.slug); return ( - - {!hero && ( - <> -
+ + {!hero && ( + <> {page.data.title} - -
- - {page.data.description} - - - )} - - - -
+ + {page.data.description} + + + )} + + + + + ); } diff --git a/docs-site/components/docs-page-actions.tsx b/docs-site/components/docs-page-actions.tsx index ce85aca05..f308b2497 100644 --- a/docs-site/components/docs-page-actions.tsx +++ b/docs-site/components/docs-page-actions.tsx @@ -1,19 +1,76 @@ "use client"; -import { useState } from "react"; +import { useState, type SVGProps } from "react"; type Props = { - mdxSource: string; + mdxSource?: string; + issueUrl?: string; + sourceEditUrl?: string; }; function stripFrontmatter(source: string) { return source.trim().replace(/^---\n[\s\S]*?\n---\n?/, "").trim(); } -export function DocsPageActions({ mdxSource }: Props) { +function CopyIcon(props: SVGProps) { + return ( + + + + + ); +} + +function EditIcon(props: SVGProps) { + return ( + + + + + ); +} + +function MessageIcon(props: SVGProps) { + return ( + + + + ); +} + +const actionClassName = + "inline-flex h-8 items-center gap-1.5 rounded-md border border-fd-border bg-fd-background px-3 font-medium text-fd-muted-foreground transition-colors hover:border-fd-primary/40 hover:text-fd-foreground"; + +export function DocsPageActions({ mdxSource, issueUrl, sourceEditUrl }: Props) { const [copied, setCopied] = useState(false); const onCopy = async () => { + if (mdxSource === undefined) return; + try { await navigator.clipboard.writeText(stripFrontmatter(mdxSource)); setCopied(true); @@ -25,14 +82,39 @@ export function DocsPageActions({ mdxSource }: Props) { return (
- + {mdxSource !== undefined && ( + + )} + {sourceEditUrl !== undefined && ( + + + )} + {issueUrl !== undefined && ( + + + )}
); } diff --git a/docs-site/components/docs-page-footer.tsx b/docs-site/components/docs-page-footer.tsx new file mode 100644 index 000000000..69cb4deb0 --- /dev/null +++ b/docs-site/components/docs-page-footer.tsx @@ -0,0 +1,46 @@ +"use client"; + +import { createContext, useContext, type ReactNode } from "react"; +import { PageFooter, type FooterProps } from "fumadocs-ui/layouts/docs/page"; +import { DocsPageActions } from "@/components/docs-page-actions"; + +type DocsPageFooterActions = { + issueUrl: string; + mdxSource: string; + sourceEditUrl: string; +}; + +const docsPageFooterContext = createContext(null); + +export function DocsPageFooterProvider({ + actions, + children, +}: { + actions: DocsPageFooterActions; + children: ReactNode; +}) { + return ( + + {children} + + ); +} + +export function DocsPageFooter(props: FooterProps) { + const actions = useContext(docsPageFooterContext); + + return ( + <> + {actions !== null && ( +
+ +
+ )} + + + ); +} diff --git a/docs-site/lib/docs-markdown.ts b/docs-site/lib/docs-markdown.ts index 603126277..ced0ee6ad 100644 --- a/docs-site/lib/docs-markdown.ts +++ b/docs-site/lib/docs-markdown.ts @@ -2,6 +2,10 @@ import { readFile } from "node:fs/promises"; import { join } from "node:path"; export async function readDocsPageMarkdown(slugs: string[]) { + return (await readDocsPageMarkdownFile(slugs)).content; +} + +export async function readDocsPageMarkdownFile(slugs: string[]) { if ( slugs.length === 0 || slugs.some((segment) => segment.includes("/") || segment.includes("..")) @@ -13,14 +17,15 @@ export async function readDocsPageMarkdown(slugs: string[]) { const directPath = join(docsRoot, `${slugs.join("/")}.mdx`); try { - return await readFile(directPath, "utf8"); + return { path: directPath, content: await readFile(directPath, "utf8") }; } catch (error) { if (!isNotFoundError(error)) { throw error; } } - return readFile(join(docsRoot, slugs.join("/"), "index.mdx"), "utf8"); + const indexPath = join(docsRoot, slugs.join("/"), "index.mdx"); + return { path: indexPath, content: await readFile(indexPath, "utf8") }; } function isNotFoundError(error: unknown) { diff --git a/docs-site/lib/llm-docs.ts b/docs-site/lib/llm-docs.ts index 1f5766e1c..649ac465e 100644 --- a/docs-site/lib/llm-docs.ts +++ b/docs-site/lib/llm-docs.ts @@ -139,7 +139,7 @@ ${links}`; .join("\n\n"); } -function absoluteUrl(path: string) { +export function absoluteUrl(path: string) { return `${siteOrigin}${path}`; } diff --git a/docs-site/tests/docs-index-route.test.mjs b/docs-site/tests/docs-index-route.test.mjs index e2ab24f0c..7c3fb78d3 100644 --- a/docs-site/tests/docs-index-route.test.mjs +++ b/docs-site/tests/docs-index-route.test.mjs @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import { spawn } from "node:child_process"; import { once } from "node:events"; -import { readFile, writeFile } from "node:fs/promises"; +import { readdir, readFile, writeFile } from "node:fs/promises"; import http from "node:http"; import https from "node:https"; import { dirname, join } from "node:path"; @@ -17,6 +17,8 @@ let docsServer; let docsServerOutput = ""; let nextEnvPath; let nextEnvContents; +const docsSiteDir = join(dirname(fileURLToPath(import.meta.url)), ".."); +const pnpmExecPath = process.env.npm_execpath; async function getAvailablePort() { const server = createServer(); @@ -64,30 +66,72 @@ before(async () => { return; } - const docsSiteDir = join( - dirname(fileURLToPath(import.meta.url)), - "..", - ); nextEnvPath = join(docsSiteDir, "next-env.d.ts"); nextEnvContents = await readFile(nextEnvPath, "utf8"); const port = await getAvailablePort(); docsSiteUrl = `http://127.0.0.1:${port}`; - docsServer = spawn( - "pnpm", - ["exec", "next", "dev", "--hostname", "127.0.0.1", "--port", `${port}`], - { - cwd: docsSiteDir, - env: { ...process.env, NEXT_TELEMETRY_DISABLED: "1" }, - stdio: ["ignore", "pipe", "pipe"], - }, - ); + const pnpmArgs = [ + "exec", + "next", + "dev", + "--hostname", + "127.0.0.1", + "--port", + `${port}`, + ]; + const command = + pnpmExecPath === undefined + ? process.platform === "win32" + ? (process.env.ComSpec ?? "cmd.exe") + : "pnpm" + : process.execPath; + const args = + pnpmExecPath === undefined + ? process.platform === "win32" + ? ["/d", "/s", "/c", "pnpm", ...pnpmArgs] + : pnpmArgs + : [pnpmExecPath, ...pnpmArgs]; + + docsServer = spawn(command, args, { + cwd: docsSiteDir, + env: { ...process.env, NEXT_TELEMETRY_DISABLED: "1" }, + stdio: ["ignore", "pipe", "pipe"], + }); docsServer.stdout.on("data", appendDocsServerOutput); docsServer.stderr.on("data", appendDocsServerOutput); await waitForDocsServer(); }); +async function listDocsPages(dir = join(docsSiteDir, "content", "docs")) { + const entries = await readdir(dir, { withFileTypes: true }); + const pages = await Promise.all( + entries.map(async (entry) => { + const entryPath = join(dir, entry.name); + + if (entry.isDirectory()) { + return listDocsPages(entryPath); + } + + if (!entry.isFile() || !entry.name.endsWith(".mdx")) { + return []; + } + + const sourcePath = entryPath + .slice(docsSiteDir.length + 1) + .replaceAll("\\", "/"); + const routePath = sourcePath + .replace(/^content\/docs\//, "") + .replace(/(?:\/index)?\.mdx$/, ""); + + return [{ routePath, sourcePath: `docs-site/${sourcePath}` }]; + }), + ); + + return pages.flat(); +} + after(async () => { if (docsServer && docsServer.exitCode === null) { docsServer.kill("SIGTERM"); @@ -222,6 +266,54 @@ test("/ktx/api/search returns docs search results", async () => { ); }); +test("docs pages render source edit and issue actions", async () => { + const pages = await listDocsPages(); + assert.ok(pages.length > 0, "docs pages should be discovered from content"); + + const sampledPages = [ + pages.find((page) => page.routePath === "getting-started/introduction"), + pages.find((page) => page.routePath === "cli-reference/ktx"), + ]; + + for (const page of sampledPages) { + assert.ok(page, "sample docs page should exist"); + + const response = await fetch( + `${docsSiteUrl}${docsBasePath}/docs/${page.routePath}`, + ); + assert.equal(response.status, 200, page.routePath); + + const html = await response.text(); + assert.match(html, />Copy as MarkdownSuggest editsRaise issue { const root = await requestWithHost("ktx.sh", "/"); assert.equal(root.status, 308);