diff --git a/CHANGELOG.md b/CHANGELOG.md
index 70ed11d..5537d87 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,15 @@
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
Version bump to track the companion system release. No content changes.
diff --git a/module.json b/module.json
index 2b23a29..032ca7d 100644
--- a/module.json
+++ b/module.json
@@ -2,9 +2,9 @@
"id": "hbm-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.",
- "version": "1.3.21",
+ "version": "1.4.0",
"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.21.zip",
+ "download": "https://vtt-content.octoturge.com/packages/module/hbm-rpg-v3-lore-v1.4.0.zip",
"compatibility": {
"minimum": "13",
"verified": "14"
@@ -39,6 +39,36 @@
}
],
"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",
"label": "Otchłań",
@@ -319,6 +349,14 @@
}
],
"packFolders": [
+ {
+ "name": "Ściągawki - HbM RPG v3",
+ "sorting": "a",
+ "packs": [
+ "cheatsheet-player",
+ "cheatsheet-gm"
+ ]
+ },
{
"name": "Lore - HbM RPG v3",
"sorting": "a",
diff --git a/package.json b/package.json
index 66c6e5b..f9d287d 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "hbm-rpg-v3-lore",
- "version": "1.3.21",
+ "version": "1.4.0",
"description": "Foundry VTT lore companion module for Homebrew Magic: RPG v3.",
"private": true,
"type": "module",
diff --git a/scripts/build-journal-packs.ts b/scripts/build-journal-packs.ts
index bef4bda..ebd353f 100644
--- a/scripts/build-journal-packs.ts
+++ b/scripts/build-journal-packs.ts
@@ -17,6 +17,7 @@ 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';
+import { parseCheatsheet } from './parsers/cheatsheet-parser';
const __dirname = dirname(fileURLToPath(import.meta.url));
const moduleRoot = resolve(__dirname, '..');
@@ -163,6 +164,12 @@ const TOPICAL_CONFIGS: TopicalConfig[] = [
{ 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
// ---------------------------------------------------------------------------
@@ -235,6 +242,18 @@ for (const top of TOPICAL_CONFIGS) {
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`);
if (existsSync(packsSrcDir)) rmSync(packsSrcDir, { recursive: true });
diff --git a/scripts/parsers/cheatsheet-parser.ts b/scripts/parsers/cheatsheet-parser.ts
new file mode 100644
index 0000000..9f2c209
--- /dev/null
+++ b/scripts/parsers/cheatsheet-parser.ts
@@ -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, '>');
+}
+
+function inline(raw: string): string {
+ const codes: string[] = [];
+ let s = raw.replace(/`([^`]+)`/g, (_m, c: string) => {
+ codes.push(`${esc(c)}`);
+ 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, '$1');
+ s = s.replace(/(^|[^*])\*([^*\s][^*]*?)\*(?!\*)/g, '$1$2');
+ 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) => `
${esc(buf.join('\n'))}`);
+ continue;
+ }
+ // heading (### and deeper; ## is split into pages by the caller)
+ const h = /^(#{2,6})\s+(.*)$/.exec(line);
+ if (h) { out.push(``); + } else { + out.push(`${inline(title)}
${markdownToHtml(buf.slice(1).join('\n'))}
${markdownToHtml(buf.join('\n'))}`); + } + 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(`
${inline(buf.join(' '))}
`); + } + 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, + }]; +}