164 lines
5.1 KiB
JavaScript
164 lines
5.1 KiB
JavaScript
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 };
|