const fs = require('fs'); function isTableRowLine(line) { const t = line.trim(); return t.length > 0 && t.includes('|'); } function isSeparatorLine(line) { const t = line.trim(); if (!t.includes('-')) return false; const cells = stripOuterPipes(t).split('|'); return cells.length > 0 && cells.every((c) => /^:?-{1,}:?$/.test(c.trim())); } function stripOuterPipes(t) { let s = t; if (s.startsWith('|')) s = s.slice(1); if (s.endsWith('|')) s = s.slice(0, -1); return s; } function splitRow(line) { return stripOuterPipes(line.trim()).split('|').map((c) => c.trim()); } function parseRollCell(raw) { if (raw == null) return null; const t = raw.trim().replace(/\*\*/g, '').trim(); if (t === '') return null; let m = /^(\d+)\s*[-–—]\s*$/.exec(t); if (m) return { min: parseInt(m[1], 10), max: 9999 }; m = /^(\d+)\s*\+\s*$/.exec(t); if (m) return { min: parseInt(m[1], 10), max: 9999 }; m = /^(\d+)\s*or\s*(less|lower|fewer|under)$/i.exec(t); if (m) return { min: 0, max: parseInt(m[1], 10) }; m = /^(\d+)\s*or\s*(more|higher|greater|above)$/i.exec(t); if (m) return { min: parseInt(m[1], 10), max: 9999 }; m = /^(\d+)\s*[-–—]\s*(\d+)$/.exec(t); if (m) return { min: parseInt(m[1], 10), max: parseInt(m[2], 10) }; m = /^(\d+)$/.exec(t); if (m) { const v = parseInt(m[1], 10); return { min: v, max: v }; } return null; } function inferDiceFromMax(maxRoll) { const standard = [4, 6, 8, 10, 12, 20, 100]; const found = standard.find((d) => maxRoll <= d); return `d${found || maxRoll}`; } function buildTableObject(columns, rowsRaw, headingText) { if (columns.length === 1) { const rows = rowsRaw.map((cells, idx) => ({ roll_min: idx + 1, roll_max: idx + 1, cells, })); return { columns, rows, diceNotation: rows.length ? `d${rows.length}` : null, tableType: 'list', }; } const headingDiceMatch = /\((\d*d\d+)\)/i.exec(headingText || ''); const diceFromHeading = headingDiceMatch ? headingDiceMatch[1].toLowerCase() : null; const firstColHeader = columns[0]; const diceFromColumn = /^\d*d\d+$/i.test(firstColHeader.replace(/\s/g, '')) ? firstColHeader.toLowerCase() : null; const firstColRanges = rowsRaw.map((cells) => parseRollCell(cells[0])); const allFirstColParsed = firstColRanges.length > 0 && firstColRanges.every((r) => r !== null); if (!allFirstColParsed) { // First column isn't actually a roll column (or some cells use unrecognized // phrasing) - keep it as real data rather than silently discarding it. if (firstColRanges.some((r) => r !== null)) { console.warn(`"${headingText}": first column has a mix of roll-like and non-roll-like cells; keeping it as a data column.`); } const rows = rowsRaw.map((cells, idx) => ({ roll_min: idx + 1, roll_max: idx + 1, cells })); return { columns, rows, diceNotation: rows.length ? `d${rows.length}` : null, tableType: 'list', }; } let maxRoll = 0; const parsedRows = rowsRaw.map((cells, idx) => { const range = firstColRanges[idx]; if (range.max < 9999) maxRoll = Math.max(maxRoll, range.max); return { roll_min: range.min, roll_max: range.max, cells: cells.slice(1) }; }); const resultColumns = columns.slice(1); const tableType = resultColumns.length > 1 ? 'multi' : 'single'; let diceNotation = diceFromHeading || diceFromColumn || null; if (!diceNotation && maxRoll > 0) { diceNotation = inferDiceFromMax(maxRoll); } return { columns: resultColumns, rows: parsedRows, diceNotation, tableType }; } function extractFirstTable(bodyLines, headingText) { for (let i = 0; i < bodyLines.length - 1; i++) { if (isTableRowLine(bodyLines[i]) && isSeparatorLine(bodyLines[i + 1])) { const columns = splitRow(bodyLines[i]); const rowsRaw = []; let j = i + 2; while (j < bodyLines.length && isTableRowLine(bodyLines[j]) && !isSeparatorLine(bodyLines[j])) { rowsRaw.push(splitRow(bodyLines[j])); j++; } return buildTableObject(columns, rowsRaw, headingText); } } return null; } function parseMarkdownFile(filePath) { const content = fs.readFileSync(filePath, 'utf8'); const lines = content.split(/\r?\n/); const root = { level: 0, headingText: null, table: null, children: [] }; const stack = [root]; let bodyLines = []; let currentNode = null; function flushBody() { if (!currentNode) return; const table = extractFirstTable(bodyLines, currentNode.headingText); if (table) currentNode.table = table; } for (const line of lines) { const m = /^(#{1,6})\s+(.+?)\s*$/.exec(line); if (m) { flushBody(); bodyLines = []; const level = m[1].length; const headingText = m[2].trim(); while (stack.length && stack[stack.length - 1].level >= level) stack.pop(); const parent = stack[stack.length - 1]; const node = { level, headingText, table: null, children: [] }; parent.children.push(node); stack.push(node); currentNode = node; } else { bodyLines.push(line); } } flushBody(); return root.children; } module.exports = { parseMarkdownFile, parseRollCell };