Files
hbm-foundry-lore/scripts/parsers/journal-parser.ts
octoturge 7dd2af1ec5 Rebuild split-rulebook journal parsing (vault sync 2026-08-18 to 08-23)
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>
2026-08-23 01:15:51 +02:00

444 lines
15 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
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 }];
}