399 lines
11 KiB
TypeScript
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;
|
|
}
|