/** * 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) => `${inline(c)}`).join(''); const tr = body.map((r) => `${r.map((c) => `${inline(c)}`).join('')}`).join(''); return `${th}${tr}
`; } 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) => `
  • ${inline(i.text)}${renderList(i.children)}
  • `).join(''); return `<${tag}>${lis}`; } /** 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(`
    ${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(`${inline(h[2]!)}`); i++; continue; } if (/^-{3,}\s*$/.test(line)) { out.push('
    '); 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(`

    ${inline(title)}

    ${markdownToHtml(buf.slice(1).join('\n'))}
    `); } else { out.push(`
    ${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, }]; }