@microflash/fenceparser
Version:
A metadata parser for code fences in markdown
244 lines (222 loc) • 6.11 kB
JavaScript
/**
* @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;
}