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>
347 lines
12 KiB
TypeScript
347 lines
12 KiB
TypeScript
/**
|
|
* Build LevelDB JournalEntry packs from book chapters.
|
|
*
|
|
* One pack per book / topic (mapped via PACK_CONFIGS + TOPICAL_CONFIGS).
|
|
*
|
|
* Split-book packs accept multiple source files: a list of `SourceFile`
|
|
* entries each pointing at a `rules/` or `lore/` markdown file (instead of a
|
|
* single monolithic `_books/` file). An optional `prefaceTitle` per source
|
|
* captures the introductory content before the first chapter heading.
|
|
*
|
|
* Run: `bun scripts/build-journal-packs.ts` from `.src/foundry-lore/`.
|
|
*/
|
|
|
|
import { mkdirSync, rmSync, writeFileSync, existsSync, readdirSync, statSync, readFileSync } from 'node:fs';
|
|
import { dirname, resolve, basename, extname } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { compilePack } from '@foundryvtt/foundryvtt-cli';
|
|
import { parseJournalBook, parseSplitBookFolder, type JournalDoc, type JournalPage } from './parsers/journal-parser';
|
|
import { parseTopicalFolder } from './parsers/topical-parser';
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
const moduleRoot = resolve(__dirname, '..');
|
|
const repoRoot = resolve(moduleRoot, '..', '..');
|
|
const booksDir = resolve(repoRoot, '_books');
|
|
const packsSrcDir = resolve(moduleRoot, 'packs-src');
|
|
const packsOutDir = resolve(moduleRoot, 'packs');
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Types
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface SourceFile {
|
|
/** Path relative to repoRoot. */
|
|
path: string;
|
|
/**
|
|
* If set, lines before the first chapter heading are collected and emitted
|
|
* 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 {
|
|
pack: string;
|
|
/** For provenance / flags. */
|
|
sourceBook: string;
|
|
/** One or more source files that together make up this pack. */
|
|
sources: SourceFile[];
|
|
/** Skip chapters whose title matches this regex. */
|
|
skip?: RegExp;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Pack configurations
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const PACK_CONFIGS: PackConfig[] = [
|
|
{
|
|
pack: 'core-rules-lore',
|
|
sourceBook: 'core-rules',
|
|
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', prefaceTitle: 'Wstęp', splitFolder: 'ObsidianNotes/rules/06. Bestiariusz' },
|
|
],
|
|
skip: /^Talenty/i,
|
|
},
|
|
|
|
// ── Split books: combine lore/ chapters + Wstęp intro from rules/ ──
|
|
{
|
|
pack: 'magic-book-lore',
|
|
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', splitFolder: 'ObsidianNotes/rules/01. Księga Magii' },
|
|
],
|
|
skip: /^(Zaklęcia|Lista zaklęć)/i,
|
|
},
|
|
{
|
|
pack: 'abyss',
|
|
sourceBook: 'abyss-curse',
|
|
sources: [
|
|
// 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', splitFolder: 'ObsidianNotes/rules/02. Klątwa Otchłani' },
|
|
],
|
|
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', splitFolder: 'ObsidianNotes/rules/04. Arcanum Sanguinis' },
|
|
],
|
|
skip: /^Talenty/i,
|
|
},
|
|
{
|
|
pack: 'crimson-cult',
|
|
sourceBook: 'crimson-cult',
|
|
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). 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,
|
|
},
|
|
{
|
|
pack: 'economy',
|
|
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', splitFolder: 'ObsidianNotes/rules/03. Złoto, Stal i Magia' },
|
|
],
|
|
skip: /^(Nowe Talenty|Przedmioty)/i,
|
|
},
|
|
|
|
// ── New: Humanity Guide ──
|
|
{
|
|
pack: 'humanity-guide',
|
|
sourceBook: 'humanity-guide',
|
|
sources: [{ path: 'ObsidianNotes/lore/10. Przewodnik Ludzkości po Magicznym Świecie.md' }],
|
|
},
|
|
];
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Topical vault-folder configurations
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface TopicalConfig {
|
|
/** Folder path relative to repoRoot. */
|
|
folder: string;
|
|
/** Foundry pack id. */
|
|
pack: string;
|
|
}
|
|
|
|
const TOPICAL_CONFIGS: TopicalConfig[] = [
|
|
{ folder: 'ObsidianNotes/disciplines', pack: 'disciplines-lore' },
|
|
{ folder: 'ObsidianNotes/organizations', pack: 'organizations' },
|
|
{ folder: 'ObsidianNotes/races', pack: 'races-lore' },
|
|
{ folder: 'ObsidianNotes/classes', pack: 'classes' },
|
|
{ folder: 'ObsidianNotes/concepts', pack: 'concepts' },
|
|
{ folder: 'ObsidianNotes/locations', pack: 'locations' },
|
|
{ folder: 'ObsidianNotes/npcs', pack: 'npcs' },
|
|
{ folder: 'ObsidianNotes/conflicts', pack: 'conflicts' },
|
|
{ folder: 'ObsidianNotes/player-characters', pack: 'player-characters' },
|
|
{ folder: 'ObsidianNotes/adventures', pack: 'adventures' },
|
|
];
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Main
|
|
// ---------------------------------------------------------------------------
|
|
|
|
console.log(`[lore] repo root: ${repoRoot}`);
|
|
|
|
const allDocs: JournalDoc[] = [];
|
|
|
|
// ── Book packs ──
|
|
for (const cfg of PACK_CONFIGS) {
|
|
let packDocs: JournalDoc[] = [];
|
|
for (const src of cfg.sources) {
|
|
const bookPath = resolve(repoRoot, src.path);
|
|
if (!existsSync(bookPath)) {
|
|
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,
|
|
sourceBook: cfg.sourceBook,
|
|
chapterSkip: cfg.skip,
|
|
prefaceTitle: src.prefaceTitle,
|
|
});
|
|
|
|
packDocs.push(...docs);
|
|
}
|
|
console.log(` · ${cfg.pack}: ${packDocs.length} entries`);
|
|
allDocs.push(...packDocs);
|
|
}
|
|
|
|
// ── Topical packs ──
|
|
for (const top of TOPICAL_CONFIGS) {
|
|
const folderPath = resolve(repoRoot, top.folder);
|
|
if (!existsSync(folderPath)) {
|
|
console.warn(` · skip topical ${top.pack}: folder not found (${top.folder})`);
|
|
continue;
|
|
}
|
|
const docs = parseTopicalFolder({ folderPath, pack: top.pack });
|
|
console.log(` · ${top.pack}: ${docs.length} entries`);
|
|
allDocs.push(...docs);
|
|
}
|
|
|
|
console.log(`[lore] parsed ${allDocs.length} journal entries total`);
|
|
|
|
if (existsSync(packsSrcDir)) rmSync(packsSrcDir, { recursive: true });
|
|
const byPack = new Map<string, JournalDoc[]>();
|
|
for (const doc of allDocs) {
|
|
const list = byPack.get(doc.pack) ?? [];
|
|
list.push(doc);
|
|
byPack.set(doc.pack, list);
|
|
}
|
|
|
|
for (const [pack, docs] of byPack) {
|
|
const dir = resolve(packsSrcDir, pack);
|
|
mkdirSync(dir, { recursive: true });
|
|
let sort = 0;
|
|
for (const doc of docs) {
|
|
sort += 100;
|
|
const foundryDoc = toFoundryJournal(doc, sort);
|
|
writeFileSync(resolve(dir, `${doc.id}.json`), `${JSON.stringify(foundryDoc, null, 2)}\n`, 'utf8');
|
|
}
|
|
console.log(` · wrote ${pack}: ${docs.length} entries`);
|
|
}
|
|
|
|
if (existsSync(packsOutDir)) rmSync(packsOutDir, { recursive: true });
|
|
for (const pack of byPack.keys()) {
|
|
const src = resolve(packsSrcDir, pack);
|
|
const dest = resolve(packsOutDir, pack);
|
|
await compilePack(src, dest, {
|
|
recursive: false,
|
|
log: false,
|
|
transformEntry: (doc: any, context: any) => {
|
|
if (!doc._key) {
|
|
console.error(`[build-journal-packs] ERROR: Document missing _key! Name: ${doc.name}, ID: ${doc._id}`);
|
|
}
|
|
if (doc.pages) {
|
|
for (const page of doc.pages) {
|
|
if (!page._key) {
|
|
console.error(`[build-journal-packs] ERROR: Page missing _key! Journal: ${doc.name}, Page: ${page.name}, Page ID: ${page._id}`);
|
|
}
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
});
|
|
console.log(` ✓ compiled ${pack}`);
|
|
}
|
|
|
|
console.log(`[lore] done - ${byPack.size} packs in packs/`);
|
|
|
|
function toFoundryJournal(doc: JournalDoc, sort: number): Record<string, unknown> {
|
|
const fId = makeFoundryId(doc.id);
|
|
return {
|
|
_key: `!journal!${fId}`,
|
|
_id: fId,
|
|
name: doc.name,
|
|
folder: null,
|
|
sort,
|
|
pages: doc.pages.map((page, i) => {
|
|
const pageFId = makeFoundryId(`${doc.id}-page-${i}`);
|
|
return {
|
|
_key: `!journal.pages!${fId}.${pageFId}`,
|
|
_id: pageFId,
|
|
name: page.name,
|
|
type: 'text',
|
|
title: { show: true, level: 1 },
|
|
text: { content: page.html, format: 1 /* HTML */ },
|
|
sort: page.sort,
|
|
};
|
|
}),
|
|
flags: {
|
|
'hbm-rpg-v3-lore': {
|
|
slug: doc.id,
|
|
sourceBook: doc.source.book,
|
|
sourceChapter: doc.source.chapter,
|
|
sourceLine: doc.source.line,
|
|
},
|
|
},
|
|
_stats: {},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Derive a stable 12-character Foundry document ID from a slug.
|
|
* Uses two independent FNV-1a 32-bit hashes to spread entropy across the
|
|
* *entire* slug (not just the first 12 bytes), avoiding collisions between
|
|
* slugs that share a long common prefix (e.g. page-0 vs page-1).
|
|
*/
|
|
function makeFoundryId(slug: string): string {
|
|
const alphabet = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
|
// FNV-1a 32-bit, two independent passes with different offsets
|
|
let h1 = 0x811c9dc5;
|
|
let h2 = 0x4b9ace3f;
|
|
for (let i = 0; i < slug.length; i++) {
|
|
const c = slug.charCodeAt(i);
|
|
h1 = Math.imul(h1 ^ c, 0x01000193) >>> 0;
|
|
h2 = Math.imul(h2 ^ (c + i + 1), 0x01000193) >>> 0;
|
|
}
|
|
// Encode 64 bits (two 32-bit hashes) into 12 base-62 chars
|
|
let out = '';
|
|
let lo = h1;
|
|
let hi = h2;
|
|
for (let i = 0; i < 12; i++) {
|
|
// Combine both halves cycling
|
|
const combined = (i % 2 === 0 ? lo : hi) >>> 0;
|
|
out += alphabet[combined % alphabet.length];
|
|
lo = Math.imul(lo, 1664525) + 1013904223 >>> 0;
|
|
hi = Math.imul(hi, 22695477) + 1013904223 >>> 0;
|
|
}
|
|
return out;
|
|
}
|