UNPKG

@microflash/fenceparser

Version:

A metadata parser for code fences in markdown

244 lines (222 loc) 6.11 kB
/** * @typedef {Object} FenceLexerToken * @property {string} type - type of the token, can be `pair` or `range`. * @property {string} key - key associated with the token. * @property {string} value - value of the token. */ const newlineRegex = /\n/g; // Whitespace check function isWhitespace(char) { if (!char) return false; const code = char.charCodeAt(0); // Standard ASCII whitespace if (code === 32 || code === 9 || code === 10 || code === 13) return true; // Visible ASCII (avoids function overhead for most characters) if (code > 32 && code < 160) return false; // Full Unicode whitespace support return char.trim() === ''; } // Digits in ranges are strictly 0-9 function isDigit(char) { if (!char) return false; const code = char.charCodeAt(0); return code >= 48 && code <= 57; } /** * Lexer that converts code fence metadata string into tokens for parsing. * @function lex * @param {string} input - code fence metadata string. * @param {string} rangeKey - key used to group anonymous ranges. * @returns {Array<FenceLexerToken>} array of lexed tokens. */ export default function lex(input, rangeKey) { let i = 0; const N = input.length; function skipWhitespace() { while (i < N && isWhitespace(input[i])) { i++; }; } function peek() { return i < N ? input[i] : null; } function consume() { return i < N ? input[i++] : null; } function syntaxError(msg) { const ctx = input.slice(Math.max(0, i - 20), Math.min(N, i + 20)).replace(newlineRegex, '\\n'); return new SyntaxError(`[fenceparser]: ${msg} at ${i}. Context: "...${ctx}..."`); } // parse a key — either a quoted string with ', " or `, or a bare identifier function parseKey() { skipWhitespace(); if (i >= N) { throw syntaxError('Unexpected end while parsing key'); } const ch = peek(); if (ch === '"' || ch === "'" || ch === '`') { return parseQuotedValue(); } const start = i; // Advance until whitespace, '=' (61), or '{' (123) while (i < N) { const char = input[i]; const code = char.charCodeAt(0); if (isWhitespace(char) || code === 61 || code === 123) { break; } i++; } if (start === i) { throw syntaxError(`Invalid identifier start: ${input[i]}`); } return input.slice(start, i); } // parse quoted values, excluding the quotes ', " and ` // supports escapes like \`, \", \', \n, \t, \\, and so on function parseQuotedValue() { const quote = consume(); // opening quote: ', " or ` if (!quote) { throw syntaxError('Unexpected end while parsing quoted string'); } let out = ''; let escaped = false; while (i < N) { const c = consume(); if (escaped) { // common escapes switch (c) { case 'n': out += '\n'; break; case 'r': out += '\r'; break; case 't': out += '\t'; break; case 'b': out += '\b'; break; case 'f': out += '\f'; break; case 'v': out += '\v'; break; case '0': out += '\0'; break; case '\\': out += '\\'; break; case '"': out += '"'; break; case "'": out += "'"; break; case '`': out += '`'; break; default: out += c; // unknown escape -> keep char } escaped = false; } else if (c === '\\') { escaped = true; continue; } else if (c === quote) { return out; } else { out += c; } } throw syntaxError('Unterminated quoted string'); } function parseUnquotedValue() { skipWhitespace(); const start = i; while (i < N && !isWhitespace(input[i])) { i++; } return input.slice(start, i); } // numeric values are always assumed to be positive integers function parsePositiveInteger() { skipWhitespace(); const start = i; if (i >= N || !isDigit(input[i])) { throw syntaxError('Expected a positive integer'); } while (i < N && isDigit(input[i])) { i++; } const n = Number(input.slice(start, i)); if (!Number.isInteger(n) || n <= 0) { throw syntaxError('Expected a positive integer (>0)'); } return n; } // expand a range, for example, 1..4 becomes [1, 2, 3, 4] function expandRange(first, last) { const len = last - first + 1; return Array.from({ length: len }, (_, i) => i + first); } // parse range items, which can be positive integer or range, separated by comma function parseRangeItems() { const value = []; skipWhitespace(); if (peek() === '}') { throw syntaxError('Empty range not allowed'); } while (true) { const a = parsePositiveInteger(); skipWhitespace(); // Fast check for '..' without slicing a string (46 is '.') if (i + 1 < N && input.charCodeAt(i) === 46 && input.charCodeAt(i + 1) === 46) { i += 2; const b = parsePositiveInteger(); const e = a < b ? expandRange(a, b) : expandRange(b, a); for (const v of e) { value.push(v); } } else { value.push(a); } skipWhitespace(); const next = peek(); if (next === ',') { i++; skipWhitespace(); const term = peek(); if (term === '}' || term === null) { throw syntaxError('Trailing comma in range'); } continue; } else if (next === '}') { i++; break; } else { throw syntaxError(`Expected ',' or '}' in range, found '${next}'`); } } return value; } // start processing const entries = []; skipWhitespace(); while (i < N) { let k = peek(); if (k === '{') { i++; const value = parseRangeItems(); entries.push({ type: 'range', key: rangeKey, value }); skipWhitespace(); continue; } const key = parseKey(); skipWhitespace(); const next = peek(); if (next === '=') { i++; skipWhitespace(); const vp = peek(); let value; if (vp === '"' || vp === "'" || vp === '`') { value = parseQuotedValue(); } else { value = parseUnquotedValue(); if (value === '') { throw syntaxError('Missing value after ='); } } entries.push({ type: 'pair', key, value }); } else if (next === '{') { i++; const value = parseRangeItems(); entries.push({ type: 'range', key, value }); } else { throw syntaxError("Expected '=' or '{' after key"); } skipWhitespace(); } return entries; }