diff --git a/app/books/kb/[volume]/[slug]/page.tsx b/app/books/kb/[volume]/[slug]/page.tsx
new file mode 100644
index 00000000..7efcdf66
--- /dev/null
+++ b/app/books/kb/[volume]/[slug]/page.tsx
@@ -0,0 +1,205 @@
+import Link from "next/link";
+import { notFound } from "next/navigation";
+import { getProseChapter, getProseChapters } from "@/lib/api";
+import { PageHeader } from "@/components/page-header";
+import { Card, CardContent } from "@/components/ui/card";
+import { buttonVariants } from "@/components/ui/button";
+import { ChevronLeft, ChevronRight } from "lucide-react";
+import { cn } from "@/lib/utils";
+
+export default async function KbChapterPage({
+ params,
+}: {
+ params: Promise<{ volume: string; slug: string }>;
+}) {
+ const { volume, slug } = await params;
+ const vol = parseInt(volume);
+ if (isNaN(vol) || vol < 1 || vol > 2) notFound();
+
+ const chapter = await getProseChapter("kb", vol, slug);
+ if (!chapter) notFound();
+
+ // Fetch all chapters/segments in this volume for sidebar + prev/next
+ const allChapters = await getProseChapters("kb", vol);
+ const currentIdx = allChapters.findIndex((ch) => ch.slug === slug);
+
+ const prev = currentIdx > 0 ? allChapters[currentIdx - 1] : null;
+ const next =
+ currentIdx < allChapters.length - 1 ? allChapters[currentIdx + 1] : null;
+
+ const chapterLabel =
+ chapter.type === "chapter" && chapter.chapter != null
+ ? `Chapter ${chapter.chapter}`
+ : null;
+
+ return (
+
+
+
+ ← Volume {vol}
+
+
+ {chapterLabel && (
+ {chapterLabel}:
+ )}
+ {chapter.title}
+
+
+
+
+ {/* Left sidebar — chapter index */}
+
+
+ {/* Main content */}
+
+ {/* Top navigation */}
+
+
+ {/* Chapter content */}
+
+
+
+ {chapter.paragraphs.map((paragraph, i) => (
+
+ ))}
+
+
+
+
+ {/* Bottom navigation */}
+
+
+
+
+ );
+}
+
+// ---------------------------------------------------------------------------
+// Sidebar — chapter index
+// ---------------------------------------------------------------------------
+
+type SidebarChapter = {
+ slug: string;
+ title: string;
+ type: string;
+ chapter: number | null;
+};
+
+function ChapterSidebar({
+ chapters,
+ currentSlug,
+ volume,
+}: {
+ chapters: SidebarChapter[];
+ currentSlug: string;
+ volume: number;
+}) {
+ return (
+
+ );
+}
+
+// ---------------------------------------------------------------------------
+// Prev / Next navigation
+// ---------------------------------------------------------------------------
+
+type NavChapter = {
+ slug: string;
+ title: string;
+ type: string;
+ chapter: number | null;
+};
+
+function ChapterNav({
+ prev,
+ next,
+ volume,
+}: {
+ prev: NavChapter | null;
+ next: NavChapter | null;
+ volume: number;
+}) {
+ return (
+
+ {prev ? (
+
+
+
+ {prev.type === "chapter" && prev.chapter != null
+ ? `Ch. ${prev.chapter}`
+ : prev.title}
+
+
Previous
+
+ ) : (
+
+ )}
+
+ {next ? (
+
+
+ {next.type === "chapter" && next.chapter != null
+ ? `Ch. ${next.chapter}`
+ : next.title}
+
+
Next
+
+
+ ) : (
+
+ )}
+
+ );
+}
diff --git a/app/books/kb/[volume]/page.tsx b/app/books/kb/[volume]/page.tsx
new file mode 100644
index 00000000..eb5592e7
--- /dev/null
+++ b/app/books/kb/[volume]/page.tsx
@@ -0,0 +1,108 @@
+import Link from "next/link";
+import { notFound } from "next/navigation";
+import { getBookMeta, getProseChapters } from "@/lib/api";
+import { PageHeader } from "@/components/page-header";
+import { Card, CardContent } from "@/components/ui/card";
+
+export default async function KbVolumePage({
+ params,
+}: {
+ params: Promise<{ volume: string }>;
+}) {
+ const { volume } = await params;
+ const vol = parseInt(volume);
+ if (isNaN(vol) || vol < 1 || vol > 2) notFound();
+
+ const meta = await getBookMeta("kb");
+ if (!meta) notFound();
+
+ const title = meta.title as string;
+
+ const [introSegments, chapters] = await Promise.all([
+ getProseChapters("kb", vol, "intro-segment"),
+ getProseChapters("kb", vol, "chapter"),
+ ]);
+
+ return (
+
+
+
+ ← {title}
+
+
+ Volume {vol}
+
+ {chapters.length} chapters
+
+
+ {/* Front Matter */}
+ {introSegments.length > 0 && (
+
+
+ Front Matter
+
+
+
+
+ {introSegments.map((seg) => (
+ -
+
+
+
+ {seg.paragraphs.length} paragraphs
+
+
+
+ ))}
+
+
+
+
+ )}
+
+ {/* Chapters */}
+
+
+ Chapters
+
+
+
+
+ {chapters.map((ch) => (
+ -
+
+
+
+ Chapter {ch.chapter}
+
+
+ {ch.title}
+
+
+
+ {ch.paragraphs.length} paragraphs
+
+
+
+ ))}
+
+
+
+
+
+
+ );
+}
diff --git a/app/books/kb/page.tsx b/app/books/kb/page.tsx
new file mode 100644
index 00000000..123ad0e6
--- /dev/null
+++ b/app/books/kb/page.tsx
@@ -0,0 +1,64 @@
+import Link from "next/link";
+import { notFound } from "next/navigation";
+import { getBookMeta } from "@/lib/api";
+import { PageHeader } from "@/components/page-header";
+import { Card, CardContent } from "@/components/ui/card";
+import { Badge } from "@/components/ui/badge";
+
+type VolumeInfo = {
+ number: number;
+ totalChapters: number;
+};
+
+export default async function KbPage() {
+ const meta = await getBookMeta("kb");
+ if (!meta) notFound();
+
+ const title = meta.title as string;
+ const author = meta.author as string;
+ const description = meta.description as string | undefined;
+ const volumes = meta.volumes as VolumeInfo[];
+
+ return (
+
+
+
+ ← Books
+
+ {title}
+ {author}
+
+
+ {description && (
+ {description}
+ )}
+
+
+
+ {volumes.map((vol) => (
+ -
+
+
+
+ Volume {vol.number}
+
+
+ {vol.totalChapters} chapters
+
+
+
+
+ ))}
+
+
+
+
+
+ );
+}
diff --git a/app/books/page.tsx b/app/books/page.tsx
index c8b8db26..9a68e0dc 100644
--- a/app/books/page.tsx
+++ b/app/books/page.tsx
@@ -21,6 +21,12 @@ interface ChapterBookMeta extends BookMeta {
chapters: ChapterEntry[];
}
+interface KbMeta extends BookMeta {
+ totalChapters: number;
+ description: string;
+ volumes: { number: number; totalChapters: number }[];
+}
+
interface SbMeta extends BookMeta {
totalCantos: number;
cantos: CantoEntry[];
@@ -112,6 +118,16 @@ function buildBookList(allBooks: BookMeta[]): BookInfo[] {
totalVerses,
href: "/books/cc",
});
+ } else if (slug === "kb") {
+ const kb = data as KbMeta;
+ books.push({
+ slug,
+ title,
+ author,
+ description: `${kb.totalChapters} chapters · 2 volumes`,
+ totalVerses: 0,
+ href: "/books/kb",
+ });
}
}
@@ -156,11 +172,13 @@ export default async function BooksPage() {
{book.description}
-
+ {book.totalVerses > 0 && (
+
+ )}
diff --git a/lib/api.ts b/lib/api.ts
index aa58efa2..e97d2a6f 100644
--- a/lib/api.ts
+++ b/lib/api.ts
@@ -3,10 +3,11 @@
*
* These functions query MongoDB via Prisma and are called directly by
* Next.js server components. The /api/* routes exist for external consumers
- * and for client-side infinite-scroll fetching.
+ * and for client-side verse-navigation fetching.
*/
import { prisma } from "@/lib/db";
+import type { JsonValue } from "@prisma/client/runtime/library";
// ---------------------------------------------------------------------------
// Types
@@ -56,6 +57,22 @@ export type GlossaryRecord = {
definitionHtml: string;
};
+// ---------------------------------------------------------------------------
+// Prose chapter types (for KB and other prose books)
+// ---------------------------------------------------------------------------
+
+export type ProseChapterRecord = {
+ id: string;
+ book: string;
+ volume: number;
+ chapter: number | null;
+ title: string;
+ type: string;
+ slug: string;
+ order: number;
+ paragraphs: string[];
+};
+
// ---------------------------------------------------------------------------
// Book meta
// ---------------------------------------------------------------------------
@@ -174,6 +191,117 @@ export async function getSingleVerse(
return result as unknown as VerseRecord | null;
}
+// ---------------------------------------------------------------------------
+// Prose chapters (KB and other prose books)
+// ---------------------------------------------------------------------------
+
+/**
+ * Get all prose chapters/segments for a given book and volume,
+ * optionally filtered by type ("chapter" | "intro-segment").
+ * Ordered by the `order` field.
+ */
+export async function getProseChapters(
+ book: string,
+ volume: number,
+ type?: string,
+): Promise {
+ const where: Record = { book, volume };
+ if (type) where.type = type;
+
+ const results = await prisma.proseChapter.findMany({
+ where,
+ orderBy: { order: "asc" },
+ });
+
+ return results.map(normalizeProse);
+}
+
+/**
+ * Get a single prose chapter by book + volume + slug.
+ */
+export async function getProseChapter(
+ book: string,
+ volume: number,
+ slug: string,
+): Promise {
+ const result = await prisma.proseChapter.findUnique({
+ where: {
+ book_volume_slug: { book, volume, slug },
+ },
+ });
+
+ return result ? normalizeProse(result) : null;
+}
+
+/**
+ * Normalize a prose chapter from the DB.
+ * Some chapters were stored with paragraphs as a JSON string (fallback
+ * for BSON wire-protocol corruption). This normalizes them back to arrays.
+ */
+function normalizeProse(doc: {
+ id: string;
+ book: string;
+ volume: number;
+ chapter: number | null;
+ title: string;
+ type: string;
+ slug: string;
+ order: number;
+ paragraphs: string[] | JsonValue;
+}): ProseChapterRecord {
+ let paragraphs: string[];
+
+ if (typeof doc.paragraphs === "string") {
+ // Was stored as stringified JSON — parse it back
+ try {
+ paragraphs = JSON.parse(doc.paragraphs);
+ } catch {
+ paragraphs = [doc.paragraphs];
+ }
+ } else if (Array.isArray(doc.paragraphs)) {
+ paragraphs = doc.paragraphs as string[];
+ } else {
+ paragraphs = [];
+ }
+
+ // Sanitize HTML — strip dangerous tags and event handlers
+ paragraphs = paragraphs.map(sanitizeHtml);
+
+ return {
+ id: doc.id,
+ book: doc.book,
+ volume: doc.volume,
+ chapter: doc.chapter,
+ title: doc.title,
+ type: doc.type,
+ slug: doc.slug,
+ order: doc.order,
+ paragraphs,
+ };
+}
+
+/**
+ * Sanitize an HTML string by stripping dangerous tags and attributes.
+ * Keeps safe formatting tags: , , , ,
, , .
+ */
+function sanitizeHtml(html: string): string {
+ // Remove , ,
+ let clean = html.replace(/