Add a footer and an in-app docs viewer
Footer (in the authenticated app layout, matching where Nav lives): copyright line, a Docs link, and a link to the project repo. Docs viewer: /docs lists docs/API.md, CONFIG_REFERENCE.md, and ARCHITECTURE.md; /docs/[slug] renders one via react-markdown + remark-gfm (tables, fenced code) inside a Tailwind Typography `prose` block, dark-mode aware via prose-invert. The markdown files themselves stay the single source of truth at the repo's docs/ - the app reads them at request time rather than duplicating their content, resolved from the app's cwd the same way config paths already are, since a compiled Route Handler's module graph doesn't preserve source-relative paths. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -38,6 +38,8 @@
|
|||||||
"react": "19.2.8",
|
"react": "19.2.8",
|
||||||
"react-dom": "19.2.8",
|
"react-dom": "19.2.8",
|
||||||
"react-hook-form": "^7.85.0",
|
"react-hook-form": "^7.85.0",
|
||||||
|
"react-markdown": "^10.1.0",
|
||||||
|
"remark-gfm": "^4.0.1",
|
||||||
"shadcn": "^4.18.0",
|
"shadcn": "^4.18.0",
|
||||||
"sonner": "^2.0.8",
|
"sonner": "^2.0.8",
|
||||||
"tailwind-merge": "^3.6.0",
|
"tailwind-merge": "^3.6.0",
|
||||||
@@ -48,6 +50,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@tailwindcss/typography": "^0.5.20",
|
||||||
"@types/better-sqlite3": "^9.6.0",
|
"@types/better-sqlite3": "^9.6.0",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
|
|||||||
Generated
+890
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,41 @@
|
|||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { ArrowLeft } from "lucide-react";
|
||||||
|
import { getDocMeta, readDocContent } from "@/lib/docs";
|
||||||
|
import { MarkdownViewer } from "@/components/docs/markdown-viewer";
|
||||||
|
|
||||||
|
interface DocPageProps {
|
||||||
|
params: Promise<{ slug: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateMetadata({
|
||||||
|
params,
|
||||||
|
}: DocPageProps): Promise<Metadata> {
|
||||||
|
const { slug } = await params;
|
||||||
|
return { title: getDocMeta(slug)?.title ?? "Doc not found" };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function DocPage({ params }: DocPageProps) {
|
||||||
|
const { slug } = await params;
|
||||||
|
const doc = getDocMeta(slug);
|
||||||
|
if (!doc) notFound();
|
||||||
|
|
||||||
|
const content = readDocContent(doc);
|
||||||
|
if (content === null) notFound();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto flex max-w-3xl flex-col gap-6">
|
||||||
|
<Link
|
||||||
|
href="/docs"
|
||||||
|
className="text-muted-foreground hover:text-foreground flex w-fit items-center gap-1 text-sm"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="size-3.5" />
|
||||||
|
All docs
|
||||||
|
</Link>
|
||||||
|
<MarkdownViewer content={content} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { BookOpen, ChevronRight } from "lucide-react";
|
||||||
|
import { DOCS } from "@/lib/docs";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Docs" };
|
||||||
|
|
||||||
|
export default function DocsIndexPage() {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">Docs</h1>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
{DOCS.map((doc) => (
|
||||||
|
<Link key={doc.slug} href={`/docs/${doc.slug}`}>
|
||||||
|
<Card className="hover:border-foreground/30 h-full transition-colors">
|
||||||
|
<CardHeader className="flex flex-row items-start justify-between gap-2 space-y-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<BookOpen className="text-muted-foreground size-4.5 shrink-0" />
|
||||||
|
<CardTitle className="text-base">{doc.title}</CardTitle>
|
||||||
|
</div>
|
||||||
|
<ChevronRight className="text-muted-foreground size-4 shrink-0" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="text-muted-foreground text-sm">
|
||||||
|
{doc.description}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ export const dynamic = "force-dynamic";
|
|||||||
import { getConfig } from "@/lib/config/load";
|
import { getConfig } from "@/lib/config/load";
|
||||||
import { requireAuth } from "@/lib/auth/guard";
|
import { requireAuth } from "@/lib/auth/guard";
|
||||||
import { Nav } from "@/components/layout/nav";
|
import { Nav } from "@/components/layout/nav";
|
||||||
|
import { Footer } from "@/components/layout/footer";
|
||||||
|
|
||||||
export default async function AppLayout({
|
export default async function AppLayout({
|
||||||
children,
|
children,
|
||||||
@@ -18,6 +19,7 @@ export default async function AppLayout({
|
|||||||
<main className="mx-auto w-full max-w-5xl flex-1 px-4 py-8">
|
<main className="mx-auto w-full max-w-5xl flex-1 px-4 py-8">
|
||||||
{children}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
|
<Footer />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
@import "tw-animate-css";
|
@import "tw-animate-css";
|
||||||
@import "shadcn/tailwind.css";
|
@import "shadcn/tailwind.css";
|
||||||
|
@plugin "@tailwindcss/typography";
|
||||||
|
|
||||||
@custom-variant dark (&:is(.dark *));
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import ReactMarkdown from "react-markdown";
|
||||||
|
import remarkGfm from "remark-gfm";
|
||||||
|
|
||||||
|
export function MarkdownViewer({ content }: { content: string }) {
|
||||||
|
return (
|
||||||
|
<div className="prose prose-neutral dark:prose-invert max-w-none prose-pre:bg-muted prose-pre:text-foreground">
|
||||||
|
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
const PROJECT_URL = "https://dev.pivoine.art/valknar/triggershell";
|
||||||
|
|
||||||
|
export function Footer() {
|
||||||
|
return (
|
||||||
|
<footer className="border-t">
|
||||||
|
<div className="text-muted-foreground mx-auto flex w-full max-w-5xl flex-col items-center gap-2 px-4 py-4 text-xs sm:flex-row sm:justify-between">
|
||||||
|
<span>© {new Date().getFullYear()} TriggerShell contributors</span>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Link href="/docs" className="hover:text-foreground">
|
||||||
|
Docs
|
||||||
|
</Link>
|
||||||
|
<a
|
||||||
|
href={PROJECT_URL}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="hover:text-foreground"
|
||||||
|
>
|
||||||
|
Project
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
export interface DocMeta {
|
||||||
|
slug: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
file: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DOCS: DocMeta[] = [
|
||||||
|
{
|
||||||
|
slug: "api",
|
||||||
|
title: "API Reference",
|
||||||
|
description: "REST and WebSocket endpoints, auth, and request/response shapes.",
|
||||||
|
file: "API.md",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: "config",
|
||||||
|
title: "Config Reference",
|
||||||
|
description: "Every field in triggershell.yml: server, auth, scripts, and variables.",
|
||||||
|
file: "CONFIG_REFERENCE.md",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: "architecture",
|
||||||
|
title: "Architecture",
|
||||||
|
description: "How the CLI, custom server, runner, and web app fit together.",
|
||||||
|
file: "ARCHITECTURE.md",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function getDocMeta(slug: string): DocMeta | undefined {
|
||||||
|
return DOCS.find((doc) => doc.slug === slug);
|
||||||
|
}
|
||||||
|
|
||||||
|
// docs/ ships at the repo root, one level up from this Next app - resolved at runtime rather
|
||||||
|
// than imported, since the compiled Route Handler module graph doesn't preserve source-relative
|
||||||
|
// paths (same reasoning as config path resolution in lib/config/load.ts).
|
||||||
|
function docsDir(): string {
|
||||||
|
return path.resolve(process.cwd(), "..", "docs");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readDocContent(doc: DocMeta): string | null {
|
||||||
|
const filePath = path.join(docsDir(), doc.file);
|
||||||
|
if (!fs.existsSync(/*turbopackIgnore: true*/ filePath)) return null;
|
||||||
|
return fs.readFileSync(/*turbopackIgnore: true*/ filePath, "utf-8");
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user