/** * Generic book data layer — replaces individual book files (bbd.ts, ej.ts, etc.) * * Calls the BFF API for book metadata, chapters, and intro segments. * All prose books share the same BFF endpoints: * GET /api/v1/books/{code} → book meta * GET /api/v1/books/{code}/chapters → all chapters (with optional ?type= filter) * GET /api/v1/books/{code}/chapters/{slug} → single chapter */ import { bff, bffList } from "@/lib/bff"; // --------------------------------------------------------------------------- // Types — generic, work for any prose book // --------------------------------------------------------------------------- export type BookMeta = { book: string; title: string; author: string; year?: number; subtitle?: string; description?: string; totalChapters?: number; chapters: ChapterEntry[]; /** Volume info for multi-volume books like KB */ volumes?: { number: number; title: string; chapters: ChapterEntry[] }[]; /** Parent chapters for hierarchical books (SSR, DS, SVA, NOD) — always [] for non-hierarchical books */ parts: { number: number; title: string; subChapters: { slug: string; title: string }[]; /** NOD-style: array of chapter numbers belonging to this part */ chapters: number[]; }[]; /** For ISO — mantra entries (always [] for non-ISO books) */ mantras: { number: number; slug: string; title: string }[]; /** For BS/NOI — text entries (always [] for non-BS/NOI books) */ texts: { number: number; slug: string; title: string }[]; /** Total mantras (ISO) */ totalMantras?: number; /** Total texts (BS, NOI) */ totalTexts?: number; }; export type ChapterEntry = { number: number; title: string; slug?: string; subChapters: { slug: string; title: string }[]; }; export type IntroSegment = { slug: string; title: string; type: string; book: string; order: number; paragraphs: string[]; }; export type Chapter = { book: string; chapter: number | null; title: string; type: string; slug: string; order: number; paragraphs: string[]; /** For hierarchical books (SSR, DS, SVA) — the parent chapter number */ parentChapter?: number; /** For hierarchical sub-chapters — the sub-chapter number */ subChapter?: number; }; export type SidebarItem = { slug: string; label: string; type: string; chapter: number | null; /** For hierarchical books (SSR, DS, SVA) — the parent chapter number */ parentChapter?: number; /** Sub-chapters within a parent chapter */ subChapters?: { slug: string; title: string }[]; /** For verse books (ISO) — mantra number */ mantra?: number; /** For verse books (BS, NOI) — text number */ text?: number; /** Part number this item belongs to (for books with parts like TLC, NOD) */ partNumber?: number; /** Part title (e.g., "Part I", "Part One") */ partTitle?: string; }; // --------------------------------------------------------------------------- // HTML sanitisation // --------------------------------------------------------------------------- function sanitizeHtml(html: string): string { let clean = html.replace(/][\s\S]*?<\/script>/gi, ""); clean = clean.replace(/][\s\S]*?<\/style>/gi, ""); clean = clean.replace(/][\s\S]*?<\/iframe>/gi, ""); clean = clean.replace( /<\/?(?:script|style|iframe|object|embed|form|input|textarea|select|button)[^>]*>/gi, "", ); clean = clean.replace(/\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, ""); clean = clean.replace(/href\s*=\s*"javascript:[^"]*"/gi, 'href=""'); clean = clean.replace(/href\s*=\s*'javascript:[^']*'/gi, "href=''"); return clean; } // --------------------------------------------------------------------------- // BFF response types (snake_case from Go) // --------------------------------------------------------------------------- type BffChapter = { id: string; book: string; volume: number; chapter: number | null; title: string; type: string; slug: string; order: number; paragraphs: string[]; mantra?: number; text?: number; }; // --------------------------------------------------------------------------- // Mappers // --------------------------------------------------------------------------- function mapChapter(c: BffChapter): Chapter { return { book: c.book, chapter: c.chapter, title: c.title, type: c.type, slug: c.slug, order: c.order, paragraphs: (c.paragraphs ?? []).map(sanitizeHtml), }; } function mapIntro(c: BffChapter): IntroSegment { return { slug: c.slug, title: c.title, type: c.type, book: c.book, order: c.order, paragraphs: (c.paragraphs ?? []).map(sanitizeHtml), }; } // --------------------------------------------------------------------------- // Generic book functions // --------------------------------------------------------------------------- /** * Get book metadata. */ export async function getBookMeta(code: string): Promise { const raw = await bff<{ book: string; data: Record }>( `/books/${code}`, { tags: [`book-${code}`] }, ); if (!raw) return null; // Flatten: BFF returns { id, book, data: { title, chapters, parts, ... } } const data = raw.data && typeof raw.data === "object" ? raw.data : {}; const meta = { book: raw.book, ...data } as BookMeta; // Ensure arrays are always present if (!meta.chapters) meta.chapters = []; for (const ch of meta.chapters) { if (!ch.subChapters) ch.subChapters = []; } if (!meta.mantras) meta.mantras = []; if (!meta.texts) meta.texts = []; if (!meta.parts) meta.parts = []; for (const part of meta.parts) { if (!part.subChapters) part.subChapters = []; if (!part.chapters) part.chapters = []; } return meta; } /** * Get all intro segments for a book. */ export async function getIntroSegments(code: string): Promise { const chapters = await bffList(`/books/${code}/chapters`, { tags: [`book-${code}`], }); return chapters.filter((c) => c.type === "intro-segment").map(mapIntro); } /** * Get a single intro segment by slug. */ export async function getIntroSegment( code: string, slug: string, ): Promise { const dbSlug = slug.startsWith("intro-") ? slug : `intro-${slug}`; const chapter = await bff(`/books/${code}/chapters/${dbSlug}`, { tags: [`book-${code}`], }); return chapter ? mapIntro(chapter) : null; } /** * Get a single chapter by slug (numeric or named). */ export async function getChapter( code: string, slug: string, ): Promise { const chapter = await bff(`/books/${code}/chapters/${slug}`, { tags: [`book-${code}`], }); return chapter ? mapChapter(chapter) : null; } /** * Get content by URL slug — routes to intro segment or chapter. */ export async function getContent( code: string, slug: string, ): Promise { if (slug.startsWith("intro-")) { return getIntroSegment(code, slug); } // Try as chapter return getChapter(code, slug); } /** * Build sidebar navigation items for a book. * Returns intro segments first, then chapters. */ export async function getSidebarItems(code: string): Promise { const [meta, allChapters] = await Promise.all([ getBookMeta(code), bffList(`/books/${code}/chapters`, { tags: [`book-${code}`] }), ]); if (!meta) return []; const items: SidebarItem[] = []; // Intro segments for (const ch of allChapters) { if (ch.type === "intro-segment") { items.push({ slug: ch.slug, label: ch.title, type: "intro-segment", chapter: null, }); } } // Verse entries (mantras for ISO, texts for BS/NOI) for (const ch of allChapters) { if (ch.type === "verse") { if (ch.mantra != null) { items.push({ slug: ch.slug, label: ch.mantra === 0 ? "Invocation" : `Mantra ${ch.mantra}`, type: "verse", chapter: null, mantra: ch.mantra, }); } else if (ch.text != null) { items.push({ slug: ch.slug, label: `Text ${ch.text}`, type: "verse", chapter: null, text: ch.text, }); } } } // Hierarchical books (SVA, SSR, DS) — chapters have nested subChapters const hasHierarchical = meta.chapters && meta.chapters.length > 0 && meta.chapters.some((ch) => ch.subChapters && ch.subChapters.length > 0); if (hasHierarchical && meta.chapters) { for (const ch of meta.chapters) { // Parent chapter heading (not navigable on its own) items.push({ slug: `parent-${ch.number}`, label: `${ch.number}. ${ch.title}`, type: "parent", chapter: ch.number, parentChapter: ch.number, }); // Sub-chapters if (ch.subChapters) { for (const sub of ch.subChapters) { items.push({ slug: sub.slug, label: sub.title, type: "sub-chapter", chapter: ch.number, parentChapter: ch.number, }); } } } } // Chapters — if parts exist, group chapters under part headers else if ( meta.parts && meta.parts.length > 0 && meta.chapters && meta.chapters.length > 0 ) { // Build a lookup: chapter number → which part it belongs to const chapterToPart = new Map< number, { partNumber: number; partTitle: string } >(); for (const part of meta.parts) { for (const chNum of part.chapters) { chapterToPart.set(chNum, { partNumber: part.number, partTitle: part.title, }); } } for (const ch of meta.chapters) { const partInfo = chapterToPart.get(ch.number); items.push({ slug: ch.slug ?? String(ch.number), label: `${ch.number}. ${ch.title}`, type: "chapter", chapter: ch.number, partNumber: partInfo?.partNumber, partTitle: partInfo?.partTitle, }); } } else if (meta.chapters && meta.chapters.length > 0) { // No parts — flat chapter list for (const ch of meta.chapters) { items.push({ slug: ch.slug ?? String(ch.number), label: `${ch.number}. ${ch.title}`, type: "chapter", chapter: ch.number, }); } } else { // Fallback: use chapters from the DB ordered by order field for (const ch of allChapters) { if (ch.type === "chapter") { items.push({ slug: ch.slug, label: ch.title, type: "chapter", chapter: ch.chapter, }); } } } // Conclusion segments (if any) for (const ch of allChapters) { if (ch.type === "conclusion") { items.push({ slug: ch.slug, label: ch.title, type: "conclusion", chapter: null, }); } } return items; } /** * For hierarchical books (SSR, DS) — get the navigable items * (intro segments + parent chapters that have subChapters). * This matches the getNavigableItems() functions in ssr.ts and ds.ts. */ export function getNavigableItems(items: SidebarItem[]): SidebarItem[] { return items; }