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-dom": "19.2.8",
|
||||
"react-hook-form": "^7.85.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"shadcn": "^4.18.0",
|
||||
"sonner": "^2.0.8",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
@@ -48,6 +50,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@tailwindcss/typography": "^0.5.20",
|
||||
"@types/better-sqlite3": "^9.6.0",
|
||||
"@types/node": "^20",
|
||||
"@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 { requireAuth } from "@/lib/auth/guard";
|
||||
import { Nav } from "@/components/layout/nav";
|
||||
import { Footer } from "@/components/layout/footer";
|
||||
|
||||
export default async function AppLayout({
|
||||
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">
|
||||
{children}
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import "shadcn/tailwind.css";
|
||||
@plugin "@tailwindcss/typography";
|
||||
|
||||
@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