7dd2af1ec5
The bab5a62 vault commit split 9 rulebook monoliths into hub/ToC pages plus per-topic-file subfolders (rules/NN. <Book>/<Chapter>/<Topic>.md). The existing chapter-slicing parser (parseJournalBook) relied on finding a duplicate TOC-vs-body heading pair per chapter; after the split each hub heading only occurs once (in the ToC), so it was silently treating the ToC's link-list body as if it were real chapter content. Added parseSplitBookFolder (journal-parser.ts): walks a book's split subfolder directly, one JournalDoc per Rozdzial/Aneks subfolder-or-file, one JournalPage per topic file (or a single page for a standalone chapter file). The hub's own Wstep intro paragraph is unaffected by the split (it's still real prose, not a link) and continues to come from the existing prefaceTitle preface-extraction logic. Wired into build-journal-packs.ts for the 6 affected packs: core-rules-lore, bestiary-lore, magic-book-lore, abyss, blood-magic-history, economy. crimson-cult's rules source was never split into a subfolder, so it's left on the old code path unchanged. Also fixed a pre-existing derivePageTitle bug (page titles kept a leading markdown heading marker, e.g. "## Wstep" instead of "Wstep") to match the already-correct behavior in topical-parser.ts. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
444 lines
15 KiB
TypeScript
444 lines
15 KiB
TypeScript
/**
|
||
* Journal parser for HbM RPG v3 books.
|
||
*
|
||
* Strategy
|
||
* ────────
|
||
* Supports two file formats:
|
||
*
|
||
* A. Legacy Google-Docs export (`_books/`):
|
||
* - No frontmatter.
|
||
* - TOC is a plain-text block that reuses the same `Rozdział X - Title`
|
||
* headings. The parser deduplicates: the SECOND occurrence of each
|
||
* heading is the real body.
|
||
*
|
||
* B. Native Obsidian/repo files (`rules/`, `lore/`):
|
||
* - Has YAML frontmatter (--- ... ---), stripped before parsing.
|
||
* - TOC is Obsidian bullet-links (`- [[#Anchor|Label]]`), not bare
|
||
* `Rozdział X` headings, so no duplicates exist and dedup is skipped.
|
||
* - Chapters may be prefixed with markdown heading markers (`## `).
|
||
* - An optional `prefaceTitle` captures content before the first chapter
|
||
* as a synthetic "Wstęp" entry.
|
||
*
|
||
* Output: `JournalDoc[]` consumed by `build-journal-packs.ts`.
|
||
*/
|
||
|
||
import { readFileSync, readdirSync, statSync } from 'node:fs';
|
||
import { resolve, basename, extname } from 'node:path';
|
||
|
||
export interface JournalDoc {
|
||
/** Stable kebab-case slug. */
|
||
id: string;
|
||
/** Display title (Polish, as in book). */
|
||
name: string;
|
||
/** Pack id this entry belongs to (e.g. `abyss`). */
|
||
pack: string;
|
||
/** Source provenance for traceability. */
|
||
source: { book: string; chapter: string; line: number };
|
||
/** Pages - each becomes a `JournalEntryPage`. */
|
||
pages: JournalPage[];
|
||
}
|
||
|
||
export interface JournalPage {
|
||
name: string;
|
||
/** Content as HTML (paragraph-split markdown, basic conversion). */
|
||
html: string;
|
||
/** Hint for sort order. */
|
||
sort: number;
|
||
}
|
||
|
||
export interface JournalParseConfig {
|
||
/** Absolute path to the markdown book. */
|
||
bookPath: string;
|
||
/** Pack id to assign (e.g. `abyss`). */
|
||
pack: string;
|
||
/** Source-book id for `source.book`. */
|
||
sourceBook: string;
|
||
/** Optional: only emit chapters whose title matches this regex. */
|
||
chapterFilter?: RegExp;
|
||
/** Optional: skip chapters whose title matches this regex (e.g. spell tables). */
|
||
chapterSkip?: RegExp;
|
||
/**
|
||
* If set, content between the frontmatter/start and the first chapter is
|
||
* collected and emitted as a synthetic entry with this title (e.g. "Wstęp").
|
||
* Useful for the intro paragraphs of split `rules/` files.
|
||
*/
|
||
prefaceTitle?: string;
|
||
}
|
||
|
||
// Matches both plain-text (`Rozdział IV - Title`) and heading-prefixed
|
||
// (`## Rozdział IV - Title`) chapter lines. Optional trailing page number.
|
||
const CHAPTER_RE = /^(?:#{1,6}\s+)?Rozdzia[łl]\s+([IVXLC]+)\s*[:.\-–]\s*(.+?)(?:\s+\d+)?$/;
|
||
const SEPARATOR = /^_{3,}$/;
|
||
const FRONTMATTER_FENCE = /^---\s*$/;
|
||
// Obsidian TOC bullets: `- [[#Anchor|Label]]` - skip in body content too
|
||
const OBSIDIAN_TOC_BULLET = /^[-*]\s+\[\[#/;
|
||
|
||
/** Returns the first non-frontmatter line index (skips `--- ... ---` block). */
|
||
function skipFrontmatter(lines: string[]): number {
|
||
if (lines.length === 0 || !FRONTMATTER_FENCE.test(lines[0].trim())) return 0;
|
||
for (let i = 1; i < lines.length; i++) {
|
||
if (FRONTMATTER_FENCE.test(lines[i].trim())) return i + 1;
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
export function parseJournalBook(cfg: JournalParseConfig): JournalDoc[] {
|
||
const text = readFileSync(cfg.bookPath, 'utf8');
|
||
const rawLines = text.split(/\r?\n/);
|
||
const startIdx = skipFrontmatter(rawLines);
|
||
const lines = rawLines.slice(startIdx);
|
||
|
||
// Pass 1: find every chapter occurrence.
|
||
const occurrences: Array<{ idx: number; roman: string; title: string; raw: string }> = [];
|
||
for (let i = 0; i < lines.length; i++) {
|
||
const m = lines[i].trim().match(CHAPTER_RE);
|
||
if (m) occurrences.push({ idx: i, roman: m[1], title: m[2].trim(), raw: lines[i].trim() });
|
||
}
|
||
if (occurrences.length === 0) {
|
||
// No chapters found - if there's a prefaceTitle, still try to emit preface.
|
||
if (cfg.prefaceTitle) {
|
||
const pages = bodyToPages(lines);
|
||
if (pages.length > 0) {
|
||
const slug = slugify(cfg.prefaceTitle);
|
||
return [{ id: `${cfg.pack}-${slug}`, name: cfg.prefaceTitle, pack: cfg.pack, source: { book: cfg.sourceBook, chapter: cfg.prefaceTitle, line: startIdx + 1 }, pages }];
|
||
}
|
||
}
|
||
return [];
|
||
}
|
||
|
||
// Identify TOC entries: a chapter occurrence is part of the TOC if the
|
||
// SAME (roman, title) pair occurs again later in the file.
|
||
const seen = new Map<string, number>();
|
||
const bodyChapters: typeof occurrences = [];
|
||
for (const occ of occurrences) {
|
||
const key = `${occ.roman}|${occ.title}`;
|
||
if (!seen.has(key)) {
|
||
seen.set(key, occ.idx);
|
||
continue;
|
||
}
|
||
bodyChapters.push(occ);
|
||
}
|
||
// If no duplicates were found (no plain-text TOC - typical of native files),
|
||
// treat the entire list as body chapters.
|
||
const chapters = bodyChapters.length > 0 ? bodyChapters : occurrences;
|
||
|
||
const docs: JournalDoc[] = [];
|
||
|
||
// Emit preface entry (content before first chapter heading).
|
||
if (cfg.prefaceTitle && chapters.length > 0) {
|
||
const prefaceBody = lines.slice(0, chapters[0].idx);
|
||
// Filter out Obsidian TOC bullets so they don't become page content.
|
||
const prefaceClean = prefaceBody.filter((l) => !OBSIDIAN_TOC_BULLET.test(l.trim()));
|
||
const pages = bodyToPages(prefaceClean);
|
||
if (pages.length > 0) {
|
||
const slug = slugify(cfg.prefaceTitle);
|
||
docs.push({
|
||
id: `${cfg.pack}-${slug}`,
|
||
name: cfg.prefaceTitle,
|
||
pack: cfg.pack,
|
||
source: { book: cfg.sourceBook, chapter: cfg.prefaceTitle, line: startIdx + 1 },
|
||
pages,
|
||
});
|
||
}
|
||
}
|
||
|
||
for (let c = 0; c < chapters.length; c++) {
|
||
const ch = chapters[c];
|
||
if (cfg.chapterSkip && cfg.chapterSkip.test(ch.title)) continue;
|
||
if (cfg.chapterFilter && !cfg.chapterFilter.test(ch.title)) continue;
|
||
|
||
const start = ch.idx + 1;
|
||
const end = c + 1 < chapters.length ? chapters[c + 1].idx : lines.length;
|
||
const bodyRaw = lines.slice(start, end);
|
||
// Filter out Obsidian TOC bullets.
|
||
const body = bodyRaw.filter((l) => !OBSIDIAN_TOC_BULLET.test(l.trim()));
|
||
|
||
const pages = bodyToPages(body);
|
||
if (pages.length === 0) continue;
|
||
|
||
const slug = slugify(`${ch.roman}-${ch.title}`);
|
||
docs.push({
|
||
id: `${cfg.pack}-${slug}`,
|
||
name: `Rozdział ${ch.roman} - ${ch.title}`,
|
||
pack: cfg.pack,
|
||
source: { book: cfg.sourceBook, chapter: ch.title, line: startIdx + ch.idx + 1 },
|
||
pages,
|
||
});
|
||
}
|
||
return docs;
|
||
}
|
||
|
||
/** Split a chapter body by `____` separators into pages, converting each to HTML. */
|
||
function bodyToPages(body: string[]): JournalPage[] {
|
||
const segments: string[][] = [[]];
|
||
for (const line of body) {
|
||
if (SEPARATOR.test(line.trim())) {
|
||
if (segments[segments.length - 1].length > 0) segments.push([]);
|
||
} else {
|
||
segments[segments.length - 1].push(line);
|
||
}
|
||
}
|
||
const pages: JournalPage[] = [];
|
||
let pageNo = 1;
|
||
for (const seg of segments) {
|
||
const cleaned = trimEdges(seg);
|
||
if (cleaned.length === 0) continue;
|
||
const name = derivePageTitle(cleaned, pageNo);
|
||
pages.push({ name, html: linesToHtml(cleaned), sort: pageNo * 100 });
|
||
pageNo++;
|
||
}
|
||
return pages;
|
||
}
|
||
|
||
function trimEdges(arr: string[]): string[] {
|
||
let lo = 0;
|
||
let hi = arr.length;
|
||
while (lo < hi && arr[lo].trim() === '') lo++;
|
||
while (hi > lo && arr[hi - 1].trim() === '') hi--;
|
||
return arr.slice(lo, hi);
|
||
}
|
||
|
||
/** First non-empty line if it looks short and title-like, else "Część N". */
|
||
function derivePageTitle(lines: string[], pageNo: number): string {
|
||
const first = (lines.find((l) => l.trim().length > 0)?.trim() ?? '').replace(/^#{1,6}\s+/, '');
|
||
if (first.length > 0 && first.length <= 80 && !/[.!?]$/.test(first)) {
|
||
return first;
|
||
}
|
||
return `Część ${pageNo}`;
|
||
}
|
||
|
||
/** Conservative markdown → HTML conversion. Paragraphs, bold, italic, lists. */
|
||
function linesToHtml(lines: string[]): string {
|
||
const out: string[] = [];
|
||
let para: string[] = [];
|
||
let listType: 'ul' | 'ol' | null = null;
|
||
|
||
const flushPara = () => {
|
||
if (para.length === 0) return;
|
||
out.push(`<p>${inline(para.join(' ').trim())}</p>`);
|
||
para = [];
|
||
};
|
||
const flushList = () => {
|
||
if (listType) {
|
||
out.push(`</${listType}>`);
|
||
listType = null;
|
||
}
|
||
};
|
||
|
||
for (const raw of lines) {
|
||
const line = raw.trimEnd();
|
||
const trimmed = line.trim();
|
||
|
||
if (trimmed === '') {
|
||
flushPara();
|
||
flushList();
|
||
continue;
|
||
}
|
||
|
||
const ulMatch = trimmed.match(/^[*\-]\s+(.+)$/);
|
||
const olMatch = trimmed.match(/^\d+\.\s+(.+)$/);
|
||
if (ulMatch) {
|
||
flushPara();
|
||
if (listType !== 'ul') {
|
||
flushList();
|
||
listType = 'ul';
|
||
out.push('<ul>');
|
||
}
|
||
out.push(`<li>${inline(ulMatch[1])}</li>`);
|
||
continue;
|
||
}
|
||
if (olMatch) {
|
||
flushPara();
|
||
if (listType !== 'ol') {
|
||
flushList();
|
||
listType = 'ol';
|
||
out.push('<ol>');
|
||
}
|
||
out.push(`<li>${inline(olMatch[1])}</li>`);
|
||
continue;
|
||
}
|
||
|
||
// Sub-heading: stand-alone short bold-ish line
|
||
if (/^#{1,6}\s+/.test(trimmed)) {
|
||
flushPara();
|
||
flushList();
|
||
const lvl = Math.min(6, (trimmed.match(/^#+/)?.[0].length ?? 2) + 1);
|
||
out.push(`<h${lvl}>${inline(trimmed.replace(/^#+\s+/, ''))}</h${lvl}>`);
|
||
continue;
|
||
}
|
||
|
||
flushList();
|
||
para.push(line);
|
||
}
|
||
flushPara();
|
||
flushList();
|
||
return out.join('\n');
|
||
}
|
||
|
||
function inline(s: string): string {
|
||
// Order matters: escape first, then markup.
|
||
let r = s
|
||
.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>');
|
||
r = r.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
|
||
r = r.replace(/(^|[\s(])\*([^*\n]+)\*/g, '$1<em>$2</em>');
|
||
r = r.replace(/\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g, (_m, target, label) => {
|
||
return `<a class="lore-link" data-target="${target.trim()}">${(label ?? target).trim()}</a>`;
|
||
});
|
||
return r;
|
||
}
|
||
|
||
function slugify(s: string): string {
|
||
return s
|
||
.toLowerCase()
|
||
.normalize('NFKD')
|
||
.replace(/[̀-ͯ]/g, '')
|
||
.replace(/ł/g, 'l')
|
||
.replace(/[^a-z0-9]+/g, '-')
|
||
.replace(/^-|-$/g, '')
|
||
.slice(0, 60);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Split-book folder parser
|
||
// ---------------------------------------------------------------------------
|
||
//
|
||
// The `bab5a62` vault split turned each `rules/NN. <Book>.md` monolith into a
|
||
// hub/ToC page plus a `rules/NN. <Book>/<Chapter>/<Topic>.md` folder tree.
|
||
// `parseJournalBook`'s heading-slicing approach can no longer find real
|
||
// chapter content in the hub (its former body text is now just links), so
|
||
// split books read their chapters directly from that folder tree instead:
|
||
// one `JournalDoc` per immediate child (a `Rozdział`/`Aneks` subfolder or a
|
||
// standalone chapter file), one `JournalPage` per topic file inside it (or a
|
||
// single page for a standalone chapter file). The hub's own `Wstęp` intro
|
||
// (still real prose, not a link) is unaffected and continues to come from
|
||
// `parseJournalBook`'s existing `prefaceTitle` handling.
|
||
|
||
export interface SplitBookFolderConfig {
|
||
/** Absolute path to the book's split subfolder (e.g. `.../00. Podręcznik Gry`). */
|
||
folderPath: string;
|
||
pack: string;
|
||
sourceBook: string;
|
||
/** Skip chapters whose folder/file name OR extracted title matches this regex. */
|
||
chapterSkip?: RegExp;
|
||
/** Only include chapters whose folder/file name OR extracted title matches this regex. */
|
||
chapterFilter?: RegExp;
|
||
}
|
||
|
||
export function parseSplitBookFolder(cfg: SplitBookFolderConfig): JournalDoc[] {
|
||
let entries: string[];
|
||
try {
|
||
entries = readdirSync(cfg.folderPath);
|
||
} catch {
|
||
return [];
|
||
}
|
||
|
||
const chapters = entries
|
||
.filter((e) => !e.startsWith('.'))
|
||
.map((e) => ({ entry: e, full: resolve(cfg.folderPath, e) }))
|
||
.filter(({ full }) => {
|
||
let s;
|
||
try {
|
||
s = statSync(full);
|
||
} catch {
|
||
return false;
|
||
}
|
||
return s.isDirectory() || extname(full).toLowerCase() === '.md';
|
||
});
|
||
|
||
chapters.sort((a, b) => {
|
||
const ka = chapterSortKey(chapterBaseName(a.entry));
|
||
const kb = chapterSortKey(chapterBaseName(b.entry));
|
||
return ka[0] - kb[0] || ka[1] - kb[1] || ka[2].localeCompare(kb[2]);
|
||
});
|
||
|
||
const docs: JournalDoc[] = [];
|
||
for (const { entry, full } of chapters) {
|
||
const name = chapterBaseName(entry);
|
||
const title = chapterTitle(name);
|
||
if (cfg.chapterSkip && (cfg.chapterSkip.test(name) || cfg.chapterSkip.test(title))) continue;
|
||
if (cfg.chapterFilter && !(cfg.chapterFilter.test(name) || cfg.chapterFilter.test(title))) continue;
|
||
|
||
const isDir = statSync(full).isDirectory();
|
||
const pages = isDir ? topicFilesToPages(full) : singleFileToPages(full);
|
||
if (pages.length === 0) continue;
|
||
|
||
docs.push({
|
||
id: `${cfg.pack}-${slugify(name)}`,
|
||
name,
|
||
pack: cfg.pack,
|
||
source: { book: cfg.sourceBook, chapter: title, line: 0 },
|
||
pages,
|
||
});
|
||
}
|
||
return docs;
|
||
}
|
||
|
||
function chapterBaseName(entry: string): string {
|
||
return extname(entry).toLowerCase() === '.md' ? basename(entry, '.md') : entry;
|
||
}
|
||
|
||
/**
|
||
* Strips a `Rozdział <roman> - ` or `Aneks <letter> - ` prefix, e.g.
|
||
* "Rozdział II - Talenty" -> "Talenty", "Aneks B - Przedmioty" -> "Przedmioty".
|
||
*/
|
||
function chapterTitle(name: string): string {
|
||
const m = name.match(/^(?:Rozdzia[łl]\s+[IVXLC]+|Aneks\s+[A-Za-z])\s*[-–:.]\s*(.+)$/i);
|
||
return m ? m[1].trim() : name;
|
||
}
|
||
|
||
function romanToInt(r: string): number {
|
||
const vals: Record<string, number> = { I: 1, V: 5, X: 10, L: 50, C: 100 };
|
||
const up = r.toUpperCase();
|
||
let total = 0;
|
||
for (let i = 0; i < up.length; i++) {
|
||
const cur = vals[up[i]] ?? 0;
|
||
const next = vals[up[i + 1]] ?? 0;
|
||
total += cur < next ? -cur : cur;
|
||
}
|
||
return total;
|
||
}
|
||
|
||
/** Rozdział chapters first (by numeral), then Aneks appendices (by letter), then anything else. */
|
||
function chapterSortKey(name: string): [number, number, string] {
|
||
const rz = name.match(/^Rozdzia[łl]\s+([IVXLC]+)/i);
|
||
if (rz) return [0, romanToInt(rz[1]), name];
|
||
const an = name.match(/^Aneks\s+([A-Za-z])/i);
|
||
if (an) return [1, an[1].toUpperCase().charCodeAt(0), name];
|
||
return [2, 0, name];
|
||
}
|
||
|
||
function fileBody(filePath: string): string[] {
|
||
const raw = readFileSync(filePath, 'utf8').split(/\r?\n/);
|
||
return raw.slice(skipFrontmatter(raw));
|
||
}
|
||
|
||
function cleanBody(lines: string[]): string[] {
|
||
return trimEdges(lines.filter((l) => !SEPARATOR.test(l.trim()) && !OBSIDIAN_TOC_BULLET.test(l.trim())));
|
||
}
|
||
|
||
/** One topic file per page, sorted by filename. */
|
||
function topicFilesToPages(dirPath: string): JournalPage[] {
|
||
const files = readdirSync(dirPath)
|
||
.filter((f) => extname(f).toLowerCase() === '.md')
|
||
.sort((a, b) => a.localeCompare(b));
|
||
|
||
const pages: JournalPage[] = [];
|
||
let sort = 100;
|
||
for (const file of files) {
|
||
const cleaned = cleanBody(fileBody(resolve(dirPath, file)));
|
||
if (cleaned.length === 0) continue;
|
||
pages.push({ name: basename(file, '.md'), html: linesToHtml(cleaned), sort });
|
||
sort += 100;
|
||
}
|
||
return pages;
|
||
}
|
||
|
||
/** A standalone chapter file (no subfolder) becomes a single page. */
|
||
function singleFileToPages(filePath: string): JournalPage[] {
|
||
const cleaned = cleanBody(fileBody(filePath));
|
||
if (cleaned.length === 0) return [];
|
||
return [{ name: basename(filePath, '.md'), html: linesToHtml(cleaned), sort: 100 }];
|
||
}
|