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>
This commit is contained in:
2026-08-23 01:15:51 +02:00
parent 447b90dbb7
commit 7dd2af1ec5
2 changed files with 201 additions and 15 deletions
+55 -12
View File
@@ -15,7 +15,7 @@ import { mkdirSync, rmSync, writeFileSync, existsSync, readdirSync, statSync, re
import { dirname, resolve, basename, extname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { compilePack } from '@foundryvtt/foundryvtt-cli';
import { parseJournalBook, type JournalDoc, type JournalPage } from './parsers/journal-parser';
import { parseJournalBook, parseSplitBookFolder, type JournalDoc, type JournalPage } from './parsers/journal-parser';
import { parseTopicalFolder } from './parsers/topical-parser';
const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -37,6 +37,14 @@ interface SourceFile {
* as a synthetic journal entry with this title (e.g. "Wstęp").
*/
prefaceTitle?: string;
/**
* Since `bab5a62`, `path`'s chapter bodies were replaced with links into
* this per-topic subfolder (relative to repoRoot). When set, chapters are
* read from that folder instead of sliced out of `path` - `path` is then
* used only to extract `prefaceTitle`'s intro paragraph, which is still
* real prose in the hub file.
*/
splitFolder?: string;
}
interface PackConfig {
@@ -57,13 +65,17 @@ const PACK_CONFIGS: PackConfig[] = [
{
pack: 'core-rules-lore',
sourceBook: 'core-rules',
sources: [{ path: 'ObsidianNotes/rules/00. Podręcznik Gry.md' }],
sources: [
{ path: 'ObsidianNotes/rules/00. Podręcznik Gry.md', prefaceTitle: 'Wstęp', splitFolder: 'ObsidianNotes/rules/00. Podręcznik Gry' },
],
skip: /^(Talenty|Zaklęcia|Atrybuty|Umiejętności|Wyposażenie)/i,
},
{
pack: 'bestiary-lore',
sourceBook: 'bestiary',
sources: [{ path: 'ObsidianNotes/rules/06. Bestiariusz.md' }],
sources: [
{ path: 'ObsidianNotes/rules/06. Bestiariusz.md', prefaceTitle: 'Wstęp', splitFolder: 'ObsidianNotes/rules/06. Bestiariusz' },
],
skip: /^Talenty/i,
},
@@ -73,9 +85,9 @@ const PACK_CONFIGS: PackConfig[] = [
sourceBook: 'magic-book',
sources: [
// Lore intro lives inside the rules file (no dedicated lore/ file).
{ path: 'ObsidianNotes/rules/01. Księga Magii.md', prefaceTitle: 'Wstęp' },
{ path: 'ObsidianNotes/rules/01. Księga Magii.md', prefaceTitle: 'Wstęp', splitFolder: 'ObsidianNotes/rules/01. Księga Magii' },
],
skip: /^(Zaklęcia|Lista zaklęć|Rozdział V)/i,
skip: /^(Zaklęcia|Lista zaklęć)/i,
},
{
pack: 'abyss',
@@ -84,17 +96,17 @@ const PACK_CONFIGS: PackConfig[] = [
// Pure lore narrative.
{ path: 'ObsidianNotes/lore/02. Otchłań i Magia.md' },
// Rules intro (Wstęp only - mechanics chapters are skipped).
{ path: 'ObsidianNotes/rules/02. Klątwa Otchłani.md', prefaceTitle: 'Wstęp' },
{ path: 'ObsidianNotes/rules/02. Klątwa Otchłani.md', prefaceTitle: 'Wstęp', splitFolder: 'ObsidianNotes/rules/02. Klątwa Otchłani' },
],
skip: /^(Rozdział II - Talenty|Rozdział III - Dary|Rozdział IV - Choroby|Rozdział V - Zaklęcia|Rozdział X - Artefakty)/i,
skip: /^(Talenty|Dary|Choroby|Zaklęcia|Artefakty)/i,
},
{
pack: 'blood-magic-history',
sourceBook: 'arcanum-sanguinis',
sources: [
{ path: 'ObsidianNotes/rules/04. Arcanum Sanguinis.md', prefaceTitle: 'Wstęp' },
{ path: 'ObsidianNotes/rules/04. Arcanum Sanguinis.md', prefaceTitle: 'Wstęp', splitFolder: 'ObsidianNotes/rules/04. Arcanum Sanguinis' },
],
skip: /^Rozdział IV - Talenty/i,
skip: /^Talenty/i,
},
{
pack: 'crimson-cult',
@@ -102,7 +114,9 @@ const PACK_CONFIGS: PackConfig[] = [
sources: [
// Lore narrative (former Chwała Szkarłatnemu Kultowi lore section).
{ path: 'ObsidianNotes/lore/05. Szkarłatny Kult.md' },
// Rules intro only (spells/artifacts skipped).
// Rules intro only (spells/artifacts skipped). This book was NOT split
// into a per-topic subfolder (bab5a62) - it stayed a monolith, so the
// old heading-slicing approach still applies unmodified.
{ path: 'ObsidianNotes/rules/05. Vivat Patriarcha coccineus!.md', prefaceTitle: 'Wstęp' },
],
skip: /^(Rozdział I - Zaklęcia|Rozdział II - Artefakty)/i,
@@ -112,9 +126,9 @@ const PACK_CONFIGS: PackConfig[] = [
sourceBook: 'gold-steel-magic',
sources: [
{ path: 'ObsidianNotes/lore/03. Ekonomia Magicznego Świata.md' },
{ path: 'ObsidianNotes/rules/03. Złoto, Stal i Magia.md', prefaceTitle: 'Wstęp' },
{ path: 'ObsidianNotes/rules/03. Złoto, Stal i Magia.md', prefaceTitle: 'Wstęp', splitFolder: 'ObsidianNotes/rules/03. Złoto, Stal i Magia' },
],
skip: /^(Aneks A - Nowe Talenty|Aneks B - Przedmioty)/i,
skip: /^(Nowe Talenty|Przedmioty)/i,
},
// ── New: Humanity Guide ──
@@ -166,6 +180,35 @@ for (const cfg of PACK_CONFIGS) {
console.warn(` · skip source ${src.path}: file not found`);
continue;
}
if (src.splitFolder) {
const folderPath = resolve(repoRoot, src.splitFolder);
if (!existsSync(folderPath)) {
console.warn(` · skip splitFolder ${src.splitFolder}: folder not found`);
} else {
if (src.prefaceTitle) {
// Preface only - skip every real chapter (they come from the folder walk below).
packDocs.push(
...parseJournalBook({
bookPath,
pack: cfg.pack,
sourceBook: cfg.sourceBook,
prefaceTitle: src.prefaceTitle,
chapterSkip: /./,
}),
);
}
packDocs.push(
...parseSplitBookFolder({
folderPath,
pack: cfg.pack,
sourceBook: cfg.sourceBook,
chapterSkip: cfg.skip,
}),
);
}
continue;
}
const docs = parseJournalBook({
bookPath,
pack: cfg.pack,
+146 -3
View File
@@ -22,7 +22,8 @@
* Output: `JournalDoc[]` consumed by `build-journal-packs.ts`.
*/
import { readFileSync } from 'node:fs';
import { readFileSync, readdirSync, statSync } from 'node:fs';
import { resolve, basename, extname } from 'node:path';
export interface JournalDoc {
/** Stable kebab-case slug. */
@@ -199,7 +200,7 @@ function trimEdges(arr: string[]): string[] {
/** 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() ?? '';
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;
}
@@ -292,9 +293,151 @@ function slugify(s: string): string {
return s
.toLowerCase()
.normalize('NFKD')
.replace(/[\u0300-\u036f]/g, '')
.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 }];
}