5 changed files with 231 additions and 3 deletions
+9
View File
@@ -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.
+40 -2
View File
@@ -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.19",
"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.19.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",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "hbm-rpg-v3-lore",
"version": "1.3.19",
"version": "1.4.0",
"description": "Foundry VTT lore companion module for Homebrew Magic: RPG v3.",
"private": true,
"type": "module",
+19
View File
@@ -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 });
+162
View File
@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
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,
}];
}