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:
2026-08-16 00:54:15 +02:00
co-authored by Claude Sonnet 5
parent d7c0be6b22
commit 1b415a7957
9 changed files with 1055 additions and 0 deletions
+3
View File
@@ -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",
+890
View File
File diff suppressed because it is too large Load Diff
+41
View File
@@ -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>
);
}
+35
View File
@@ -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>
);
}
+2
View File
@@ -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
View File
@@ -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>
);
}
+26
View File
@@ -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>
);
}
+47
View File
@@ -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");
}