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:
@@ -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 }];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user