Files
2026-05-10 20:41:58 +05:30

399 lines
11 KiB
TypeScript

/**
* 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(/<script[\s>][\s\S]*?<\/script>/gi, "");
clean = clean.replace(/<style[\s>][\s\S]*?<\/style>/gi, "");
clean = clean.replace(/<iframe[\s>][\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<BookMeta | null> {
const raw = await bff<{ book: string; data: Record<string, unknown> }>(
`/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<IntroSegment[]> {
const chapters = await bffList<BffChapter>(`/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<IntroSegment | null> {
const dbSlug = slug.startsWith("intro-") ? slug : `intro-${slug}`;
const chapter = await bff<BffChapter>(`/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<Chapter | null> {
const chapter = await bff<BffChapter>(`/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<Chapter | IntroSegment | null> {
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<SidebarItem[]> {
const [meta, allChapters] = await Promise.all([
getBookMeta(code),
bffList<BffChapter>(`/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;
}