Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
070d702ef9 | ||
|
|
1bd8e634dd | ||
|
|
3078230258 | ||
|
|
7fe6205f34 | ||
|
|
509ec5454e | ||
|
|
7dd2af1ec5 |
@@ -2,6 +2,15 @@
|
|||||||
|
|
||||||
All notable changes to the **HbM RPG v3 - Lore** Foundry VTT module.
|
All notable changes to the **HbM RPG v3 - Lore** Foundry VTT module.
|
||||||
|
|
||||||
|
## [1.4.0] - 2026-10-20
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Two new journal compendia built from the vault: **Ściągawka Gracza** (visible to players) and **Ściągawka Mistrza Gry** (GM only), one page per section, in the "Ściągawki - HbM RPG v3" pack folder.
|
||||||
|
- `scripts/parsers/cheatsheet-parser.ts`: markdown renderer with tables, code blocks, callouts, nested lists and checkboxes.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- All vault-derived journal packs recompiled from the 2026-10-20 vault state.
|
||||||
|
|
||||||
## [1.1.0] - 2026-04-30
|
## [1.1.0] - 2026-04-30
|
||||||
|
|
||||||
Version bump to track the companion system release. No content changes.
|
Version bump to track the companion system release. No content changes.
|
||||||
|
|||||||
+40
-2
@@ -2,9 +2,9 @@
|
|||||||
"id": "hbm-rpg-v3-lore",
|
"id": "hbm-rpg-v3-lore",
|
||||||
"title": "Homebrew Magic: RPG v3 - Lore",
|
"title": "Homebrew Magic: RPG v3 - Lore",
|
||||||
"description": "Companion lore module for the HbM RPG v3 system. Provides journal entries for setting, deities, history, and narrative chapters from the official books.",
|
"description": "Companion lore module for the HbM RPG v3 system. Provides journal entries for setting, deities, history, and narrative chapters from the official books.",
|
||||||
"version": "1.3.19",
|
"version": "1.4.0",
|
||||||
"manifest": "https://vtt-content.octoturge.com/packages/module/hbm-rpg-v3-lore.json",
|
"manifest": "https://vtt-content.octoturge.com/packages/module/hbm-rpg-v3-lore.json",
|
||||||
"download": "https://vtt-content.octoturge.com/packages/module/hbm-rpg-v3-lore-v1.3.19.zip",
|
"download": "https://vtt-content.octoturge.com/packages/module/hbm-rpg-v3-lore-v1.4.0.zip",
|
||||||
"compatibility": {
|
"compatibility": {
|
||||||
"minimum": "13",
|
"minimum": "13",
|
||||||
"verified": "14"
|
"verified": "14"
|
||||||
@@ -39,6 +39,36 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"packs": [
|
"packs": [
|
||||||
|
{
|
||||||
|
"name": "cheatsheet-player",
|
||||||
|
"label": "Ściągawka Gracza",
|
||||||
|
"path": "packs/cheatsheet-player",
|
||||||
|
"type": "JournalEntry",
|
||||||
|
"ownership": {
|
||||||
|
"PLAYER": "OBSERVER",
|
||||||
|
"ASSISTANT": "OWNER"
|
||||||
|
},
|
||||||
|
"flags": {
|
||||||
|
"hbm-rpg-v3-lore": {
|
||||||
|
"category": "cheat-sheet"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "cheatsheet-gm",
|
||||||
|
"label": "Ściągawka Mistrza Gry",
|
||||||
|
"path": "packs/cheatsheet-gm",
|
||||||
|
"type": "JournalEntry",
|
||||||
|
"ownership": {
|
||||||
|
"PLAYER": "NONE",
|
||||||
|
"ASSISTANT": "OWNER"
|
||||||
|
},
|
||||||
|
"flags": {
|
||||||
|
"hbm-rpg-v3-lore": {
|
||||||
|
"category": "cheat-sheet"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "abyss",
|
"name": "abyss",
|
||||||
"label": "Otchłań",
|
"label": "Otchłań",
|
||||||
@@ -319,6 +349,14 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"packFolders": [
|
"packFolders": [
|
||||||
|
{
|
||||||
|
"name": "Ściągawki - HbM RPG v3",
|
||||||
|
"sorting": "a",
|
||||||
|
"packs": [
|
||||||
|
"cheatsheet-player",
|
||||||
|
"cheatsheet-gm"
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "Lore - HbM RPG v3",
|
"name": "Lore - HbM RPG v3",
|
||||||
"sorting": "a",
|
"sorting": "a",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "hbm-rpg-v3-lore",
|
"name": "hbm-rpg-v3-lore",
|
||||||
"version": "1.3.19",
|
"version": "1.4.0",
|
||||||
"description": "Foundry VTT lore companion module for Homebrew Magic: RPG v3.",
|
"description": "Foundry VTT lore companion module for Homebrew Magic: RPG v3.",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -15,8 +15,9 @@ import { mkdirSync, rmSync, writeFileSync, existsSync, readdirSync, statSync, re
|
|||||||
import { dirname, resolve, basename, extname } from 'node:path';
|
import { dirname, resolve, basename, extname } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { compilePack } from '@foundryvtt/foundryvtt-cli';
|
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';
|
import { parseTopicalFolder } from './parsers/topical-parser';
|
||||||
|
import { parseCheatsheet } from './parsers/cheatsheet-parser';
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
const moduleRoot = resolve(__dirname, '..');
|
const moduleRoot = resolve(__dirname, '..');
|
||||||
@@ -37,6 +38,14 @@ interface SourceFile {
|
|||||||
* as a synthetic journal entry with this title (e.g. "Wstęp").
|
* as a synthetic journal entry with this title (e.g. "Wstęp").
|
||||||
*/
|
*/
|
||||||
prefaceTitle?: string;
|
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 {
|
interface PackConfig {
|
||||||
@@ -57,13 +66,17 @@ const PACK_CONFIGS: PackConfig[] = [
|
|||||||
{
|
{
|
||||||
pack: 'core-rules-lore',
|
pack: 'core-rules-lore',
|
||||||
sourceBook: 'core-rules',
|
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,
|
skip: /^(Talenty|Zaklęcia|Atrybuty|Umiejętności|Wyposażenie)/i,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
pack: 'bestiary-lore',
|
pack: 'bestiary-lore',
|
||||||
sourceBook: 'bestiary',
|
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,
|
skip: /^Talenty/i,
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -73,9 +86,9 @@ const PACK_CONFIGS: PackConfig[] = [
|
|||||||
sourceBook: 'magic-book',
|
sourceBook: 'magic-book',
|
||||||
sources: [
|
sources: [
|
||||||
// Lore intro lives inside the rules file (no dedicated lore/ file).
|
// 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',
|
pack: 'abyss',
|
||||||
@@ -84,17 +97,17 @@ const PACK_CONFIGS: PackConfig[] = [
|
|||||||
// Pure lore narrative.
|
// Pure lore narrative.
|
||||||
{ path: 'ObsidianNotes/lore/02. Otchłań i Magia.md' },
|
{ path: 'ObsidianNotes/lore/02. Otchłań i Magia.md' },
|
||||||
// Rules intro (Wstęp only - mechanics chapters are skipped).
|
// 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',
|
pack: 'blood-magic-history',
|
||||||
sourceBook: 'arcanum-sanguinis',
|
sourceBook: 'arcanum-sanguinis',
|
||||||
sources: [
|
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',
|
pack: 'crimson-cult',
|
||||||
@@ -102,7 +115,9 @@ const PACK_CONFIGS: PackConfig[] = [
|
|||||||
sources: [
|
sources: [
|
||||||
// Lore narrative (former Chwała Szkarłatnemu Kultowi lore section).
|
// Lore narrative (former Chwała Szkarłatnemu Kultowi lore section).
|
||||||
{ path: 'ObsidianNotes/lore/05. Szkarłatny Kult.md' },
|
{ 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' },
|
{ path: 'ObsidianNotes/rules/05. Vivat Patriarcha coccineus!.md', prefaceTitle: 'Wstęp' },
|
||||||
],
|
],
|
||||||
skip: /^(Rozdział I - Zaklęcia|Rozdział II - Artefakty)/i,
|
skip: /^(Rozdział I - Zaklęcia|Rozdział II - Artefakty)/i,
|
||||||
@@ -112,9 +127,9 @@ const PACK_CONFIGS: PackConfig[] = [
|
|||||||
sourceBook: 'gold-steel-magic',
|
sourceBook: 'gold-steel-magic',
|
||||||
sources: [
|
sources: [
|
||||||
{ path: 'ObsidianNotes/lore/03. Ekonomia Magicznego Świata.md' },
|
{ 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 ──
|
// ── New: Humanity Guide ──
|
||||||
@@ -149,6 +164,12 @@ const TOPICAL_CONFIGS: TopicalConfig[] = [
|
|||||||
{ folder: 'ObsidianNotes/adventures', pack: 'adventures' },
|
{ folder: 'ObsidianNotes/adventures', pack: 'adventures' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** Single-page vault cheat sheets → one pack each (page per `##` section). */
|
||||||
|
const CHEATSHEET_CONFIGS: Array<{ file: string; pack: string; id: string }> = [
|
||||||
|
{ file: 'ObsidianNotes/rules/Ściągawka Gracza.md', pack: 'cheatsheet-player', id: 'sciagawka-gracza' },
|
||||||
|
{ file: 'ObsidianNotes/rules/Ściągawka Mistrza Gry.md', pack: 'cheatsheet-gm', id: 'sciagawka-mistrza-gry' },
|
||||||
|
];
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Main
|
// Main
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -166,6 +187,35 @@ for (const cfg of PACK_CONFIGS) {
|
|||||||
console.warn(` · skip source ${src.path}: file not found`);
|
console.warn(` · skip source ${src.path}: file not found`);
|
||||||
continue;
|
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({
|
const docs = parseJournalBook({
|
||||||
bookPath,
|
bookPath,
|
||||||
pack: cfg.pack,
|
pack: cfg.pack,
|
||||||
@@ -192,6 +242,18 @@ for (const top of TOPICAL_CONFIGS) {
|
|||||||
allDocs.push(...docs);
|
allDocs.push(...docs);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Cheat sheets (one entry per pack, one page per section) ──
|
||||||
|
for (const cs of CHEATSHEET_CONFIGS) {
|
||||||
|
const filePath = resolve(repoRoot, cs.file);
|
||||||
|
if (!existsSync(filePath)) {
|
||||||
|
console.warn(` · skip cheat sheet ${cs.pack}: file not found (${cs.file})`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const docs = parseCheatsheet({ filePath, pack: cs.pack, id: cs.id, sourceBook: 'cheat-sheet' });
|
||||||
|
console.log(` · ${cs.pack}: ${docs[0]?.pages.length ?? 0} pages`);
|
||||||
|
allDocs.push(...docs);
|
||||||
|
}
|
||||||
|
|
||||||
console.log(`[lore] parsed ${allDocs.length} journal entries total`);
|
console.log(`[lore] parsed ${allDocs.length} journal entries total`);
|
||||||
|
|
||||||
if (existsSync(packsSrcDir)) rmSync(packsSrcDir, { recursive: true });
|
if (existsSync(packsSrcDir)) rmSync(packsSrcDir, { recursive: true });
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
/**
|
||||||
|
* Cheat-sheet parser: turns a single self-contained vault page (`rules/Ściągawka *.md`)
|
||||||
|
* into one JournalEntry with one page per `##` section.
|
||||||
|
*
|
||||||
|
* Unlike `journal-parser.ts` it renders the full Obsidian dialect these pages use:
|
||||||
|
* tables, fenced code blocks, `> [!callout]` blocks, nested lists, task checkboxes,
|
||||||
|
* horizontal rules, and inline bold / italic / code / links. Links point at vault
|
||||||
|
* files that don't exist in Foundry, so they are rendered as plain text.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import type { JournalDoc, JournalPage } from './journal-parser';
|
||||||
|
|
||||||
|
function esc(s: string): string {
|
||||||
|
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
}
|
||||||
|
|
||||||
|
function inline(raw: string): string {
|
||||||
|
const codes: string[] = [];
|
||||||
|
let s = raw.replace(/`([^`]+)`/g, (_m, c: string) => {
|
||||||
|
codes.push(`<code>${esc(c)}</code>`);
|
||||||
|
return `\u0000${codes.length - 1}\u0000`;
|
||||||
|
});
|
||||||
|
s = esc(s);
|
||||||
|
s = s.replace(/\[\[([^\]|]+)\|([^\]]+)\]\]/g, '$2').replace(/\[\[([^\]]+)\]\]/g, '$1');
|
||||||
|
s = s.replace(/\[([^\]]+)\]\((?:[^()]|\([^)]*\))*\)/g, '$1');
|
||||||
|
s = s.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
|
||||||
|
s = s.replace(/(^|[^*])\*([^*\s][^*]*?)\*(?!\*)/g, '$1<em>$2</em>');
|
||||||
|
return s.replace(/\u0000(\d+)\u0000/g, (_m, i: string) => codes[Number(i)]!);
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitRow(line: string): string[] {
|
||||||
|
return line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|').map((c) => c.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTable(rows: string[]): string {
|
||||||
|
const head = splitRow(rows[0]!);
|
||||||
|
const body = rows.slice(2).map(splitRow);
|
||||||
|
const th = head.map((c) => `<th>${inline(c)}</th>`).join('');
|
||||||
|
const tr = body.map((r) => `<tr>${r.map((c) => `<td>${inline(c)}</td>`).join('')}</tr>`).join('');
|
||||||
|
return `<table><thead><tr>${th}</tr></thead><tbody>${tr}</tbody></table>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ListItem { text: string; children: ListItem[]; ordered: boolean }
|
||||||
|
|
||||||
|
function renderList(items: ListItem[]): string {
|
||||||
|
if (!items.length) return '';
|
||||||
|
const tag = items[0]!.ordered ? 'ol' : 'ul';
|
||||||
|
const lis = items.map((i) => `<li>${inline(i.text)}${renderList(i.children)}</li>`).join('');
|
||||||
|
return `<${tag}>${lis}</${tag}>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Render markdown (no frontmatter, no leading H1) to Foundry-safe HTML. */
|
||||||
|
export function markdownToHtml(md: string): string {
|
||||||
|
const lines = md.replace(/\r\n/g, '\n').split('\n');
|
||||||
|
const out: string[] = [];
|
||||||
|
let i = 0;
|
||||||
|
while (i < lines.length) {
|
||||||
|
const line = lines[i]!;
|
||||||
|
if (!line.trim()) { i++; continue; }
|
||||||
|
|
||||||
|
// fenced code
|
||||||
|
if (/^```/.test(line)) {
|
||||||
|
const buf: string[] = [];
|
||||||
|
i++;
|
||||||
|
while (i < lines.length && !/^```/.test(lines[i]!)) buf.push(lines[i++]!);
|
||||||
|
i++;
|
||||||
|
out.push(`<pre><code>${esc(buf.join('\n'))}</code></pre>`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// heading (### and deeper; ## is split into pages by the caller)
|
||||||
|
const h = /^(#{2,6})\s+(.*)$/.exec(line);
|
||||||
|
if (h) { out.push(`<h${h[1]!.length}>${inline(h[2]!)}</h${h[1]!.length}>`); i++; continue; }
|
||||||
|
if (/^-{3,}\s*$/.test(line)) { out.push('<hr>'); i++; continue; }
|
||||||
|
|
||||||
|
// table
|
||||||
|
if (/^\|/.test(line) && /^\|?\s*:?-{3,}/.test(lines[i + 1] ?? '')) {
|
||||||
|
const rows: string[] = [];
|
||||||
|
while (i < lines.length && /^\|/.test(lines[i]!)) rows.push(lines[i++]!);
|
||||||
|
out.push(renderTable(rows));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// blockquote / callout
|
||||||
|
if (/^>/.test(line)) {
|
||||||
|
const buf: string[] = [];
|
||||||
|
while (i < lines.length && /^>/.test(lines[i]!)) buf.push(lines[i++]!.replace(/^>\s?/, ''));
|
||||||
|
const co = /^\[!(\w+)\][+-]?\s*(.*)$/.exec(buf[0]!);
|
||||||
|
if (co) {
|
||||||
|
const title = co[2] || co[1]!;
|
||||||
|
out.push(`<blockquote class="callout callout-${co[1]!.toLowerCase()}"><p><strong>${inline(title)}</strong></p>${markdownToHtml(buf.slice(1).join('\n'))}</blockquote>`);
|
||||||
|
} else {
|
||||||
|
out.push(`<blockquote>${markdownToHtml(buf.join('\n'))}</blockquote>`);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// list
|
||||||
|
if (/^\s*([-*]|\d+\.)\s+/.test(line)) {
|
||||||
|
const root: ListItem[] = [];
|
||||||
|
const stack: Array<{ indent: number; list: ListItem[] }> = [{ indent: -1, list: root }];
|
||||||
|
while (i < lines.length && /^\s*([-*]|\d+\.)\s+/.test(lines[i]!)) {
|
||||||
|
const m = /^(\s*)([-*]|\d+\.)\s+(.*)$/.exec(lines[i]!)!;
|
||||||
|
const indent = m[1]!.replace(/\t/g, ' ').length;
|
||||||
|
const text = m[3]!.replace(/^\[( |x)\]\s+/i, (_x, c: string) => (c === ' ' ? '☐ ' : '☑ '));
|
||||||
|
const item: ListItem = { text, children: [], ordered: /\d/.test(m[2]!) };
|
||||||
|
while (stack.length > 1 && stack[stack.length - 1]!.indent >= indent) stack.pop();
|
||||||
|
stack[stack.length - 1]!.list.push(item);
|
||||||
|
stack.push({ indent, list: item.children });
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
out.push(renderList(root));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// paragraph
|
||||||
|
const buf: string[] = [];
|
||||||
|
while (i < lines.length && lines[i]!.trim() && !/^(#{2,6}\s|```|>|\||-{3,}\s*$|\s*([-*]|\d+\.)\s+)/.test(lines[i]!)) buf.push(lines[i++]!);
|
||||||
|
if (!buf.length) { buf.push(lines[i++]!); }
|
||||||
|
out.push(`<p>${inline(buf.join(' '))}</p>`);
|
||||||
|
}
|
||||||
|
return out.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CheatsheetConfig {
|
||||||
|
/** Absolute path to the vault markdown file. */
|
||||||
|
filePath: string;
|
||||||
|
/** Pack id (e.g. `cheatsheet-player`). */
|
||||||
|
pack: string;
|
||||||
|
/** Journal entry id / slug. */
|
||||||
|
id: string;
|
||||||
|
/** Source book id for flags. */
|
||||||
|
sourceBook: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse a cheat sheet into one JournalDoc (intro page + one page per `##` section). */
|
||||||
|
export function parseCheatsheet(cfg: CheatsheetConfig): JournalDoc[] {
|
||||||
|
const raw = readFileSync(cfg.filePath, 'utf8').replace(/\r\n/g, '\n');
|
||||||
|
const body = raw.replace(/^---\n[\s\S]*?\n---\n/, '');
|
||||||
|
const lines = body.split('\n');
|
||||||
|
const titleIdx = lines.findIndex((l) => /^#\s+/.test(l));
|
||||||
|
const title = titleIdx >= 0 ? lines[titleIdx]!.replace(/^#\s+/, '').trim() : cfg.id;
|
||||||
|
const rest = lines.slice(titleIdx + 1);
|
||||||
|
|
||||||
|
const sections: Array<{ name: string; lines: string[] }> = [{ name: title, lines: [] }];
|
||||||
|
let inFence = false;
|
||||||
|
for (const l of rest) {
|
||||||
|
if (/^```/.test(l)) inFence = !inFence;
|
||||||
|
const h2 = !inFence ? /^##\s+(.*)$/.exec(l) : null;
|
||||||
|
if (h2) sections.push({ name: h2[1]!.trim(), lines: [] });
|
||||||
|
else sections[sections.length - 1]!.lines.push(l);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pages: JournalPage[] = sections
|
||||||
|
.filter((s) => s.lines.some((l) => l.trim()))
|
||||||
|
.map((s, idx) => ({ name: s.name, html: markdownToHtml(s.lines.join('\n')), sort: (idx + 1) * 100 }));
|
||||||
|
|
||||||
|
return [{
|
||||||
|
id: cfg.id,
|
||||||
|
name: title,
|
||||||
|
pack: cfg.pack,
|
||||||
|
source: { book: cfg.sourceBook, chapter: title, line: 1 },
|
||||||
|
pages,
|
||||||
|
}];
|
||||||
|
}
|
||||||
@@ -22,7 +22,8 @@
|
|||||||
* Output: `JournalDoc[]` consumed by `build-journal-packs.ts`.
|
* 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 {
|
export interface JournalDoc {
|
||||||
/** Stable kebab-case slug. */
|
/** 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". */
|
/** First non-empty line if it looks short and title-like, else "Część N". */
|
||||||
function derivePageTitle(lines: string[], pageNo: number): string {
|
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)) {
|
if (first.length > 0 && first.length <= 80 && !/[.!?]$/.test(first)) {
|
||||||
return first;
|
return first;
|
||||||
}
|
}
|
||||||
@@ -292,9 +293,151 @@ function slugify(s: string): string {
|
|||||||
return s
|
return s
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.normalize('NFKD')
|
.normalize('NFKD')
|
||||||
.replace(/[\u0300-\u036f]/g, '')
|
.replace(/[̀-ͯ]/g, '')
|
||||||
.replace(/ł/g, 'l')
|
.replace(/ł/g, 'l')
|
||||||
.replace(/[^a-z0-9]+/g, '-')
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
.replace(/^-|-$/g, '')
|
.replace(/^-|-$/g, '')
|
||||||
.slice(0, 60);
|
.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