feat: implement bff layer
This commit is contained in:
+368
@@ -0,0 +1,368 @@
|
||||
/**
|
||||
* 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Chapters — if parts exist, group chapters under part headers
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user