@mintlify/common
Version:
Commonly shared code within Mintlify
430 lines (429 loc) • 17.1 kB
JavaScript
export class MetaOptions {
constructor(input, reservedKeys = []) {
const { options, errors, remainingText } = parseOptions(input, reservedKeys);
this.parsedOptions = options;
this.errors = errors.length ? errors : undefined;
this.remainingText = remainingText;
}
/**
* A list of error messages that occurred when parsing the meta string,
* or `undefined` if no errors occurred.
*/
get getErrors() {
return this.errors;
}
/**
* Returns the remaining text after all parsed options are removed.
* This is useful for extracting filename from meta strings.
*/
getRemainingText() {
return this.remainingText;
}
/**
* Returns a list of meta options, optionally filtered by their key and/or {@link MetaOptionKind}.
*
* @param keyOrKeys
* Allows to filter the options by key. An empty string will return options without a key.
* A non-empty string will return options with a matching key (case-insensitive).
* An array of strings will return options with any of the matching keys.
* If omitted, no key-based filtering will be applied.
*
* @param kind
* Allows to filter the options by {@link MetaOptionKind}.
* If omitted, no kind-based filtering will be applied.
*/
list(keyOrKeys, kind) {
const filtered = this.parsedOptions.filter((option) => {
if (kind !== undefined && option.kind !== kind)
return false;
if (keyOrKeys === undefined)
return true;
const keys = Array.isArray(keyOrKeys) ? keyOrKeys : [keyOrKeys];
return keys.some((key) => { var _a; return (key === '' && !option.key) || ((_a = option.key) === null || _a === void 0 ? void 0 : _a.toLowerCase()) === key.toLowerCase(); });
});
return filtered;
}
value(key, kind) {
var _a;
if (!key)
throw new Error('You must specify a non-empty key when using getString, getRange, getRegExp or getBoolean.');
return (_a = this.list(key, kind).pop()) === null || _a === void 0 ? void 0 : _a.value;
}
/**
* Returns the last string value with the given key (case-insensitive),
* or without a key by passing an empty string.
*/
getString(key) {
return this.value(key, 'string');
}
/**
* Returns an array of all string values with the given keys (case-insensitive),
* or without a key by passing an empty string.
*/
getStrings(keyOrKeys) {
return this.list(keyOrKeys, 'string').map((option) => option.value);
}
/**
* Returns the last range value (`{value}`) with the given key (case-insensitive),
* or without a key by passing an empty string.
*/
getRange(key) {
return this.value(key, 'range');
}
/**
* Returns an array of all range values (`{value}`) with the given keys (case-insensitive),
* or without a key by passing an empty string.
*/
getRanges(keyOrKeys) {
return this.list(keyOrKeys, 'range').map((option) => option.value);
}
/**
* Returns the last integer value with the given key (case-insensitive),
* or without a key by passing an empty string.
*/
getInteger(key) {
return this.getIntegers(key).pop();
}
/**
* Returns an array of all integer values with the given keys (case-insensitive),
* or without a key by passing an empty string.
*/
getIntegers(keyOrKeys) {
return this.list(keyOrKeys)
.map((option) => {
// Skip values that are neither strings nor ranges
if (option.kind !== 'string' && option.kind !== 'range')
return NaN;
// Skip values that don't look like valid numbers
if (!/^-?\d+$/.test(option.value.trim()))
return NaN;
// Try to parse the value as an integer and return it
return parseInt(option.value, 10);
})
.filter((value) => !isNaN(value));
}
getStringsWithInteger(keyOrKeys) {
var _a, _b, _c, _d;
const strings = this.getStrings(keyOrKeys);
const integers = [];
for (const string of strings) {
const parts = string.split(',');
for (const part of parts) {
const trimmed = part.trim();
if (trimmed.includes('-')) {
const rangeParts = trimmed.split('-');
if (rangeParts.length === 2) {
const start = parseInt((_b = (_a = rangeParts[0]) === null || _a === void 0 ? void 0 : _a.trim()) !== null && _b !== void 0 ? _b : '', 10);
const end = parseInt((_d = (_c = rangeParts[1]) === null || _c === void 0 ? void 0 : _c.trim()) !== null && _d !== void 0 ? _d : '', 10);
if (!isNaN(start) && !isNaN(end)) {
for (let i = start; i <= end; i++) {
integers.push(i);
}
}
}
}
else {
// Handle single number
const num = parseInt(trimmed, 10);
if (!isNaN(num)) {
integers.push(num);
}
}
}
}
return integers;
}
/**
* Returns an array of all integers from range values with the given keys (case-insensitive),
* parsing ranges like "1,3-5" to return [1,3,4,5].
* Works with range values (`{value}`) that contain comma-separated integers and ranges.
*/
getRangesWithInteger(keyOrKeys) {
var _a, _b, _c, _d;
const ranges = this.getRanges(keyOrKeys);
const integers = [];
for (const range of ranges) {
const parts = range.split(',');
for (const part of parts) {
const trimmed = part.trim();
if (trimmed.includes('-')) {
const rangeParts = trimmed.split('-');
if (rangeParts.length === 2) {
const start = parseInt((_b = (_a = rangeParts[0]) === null || _a === void 0 ? void 0 : _a.trim()) !== null && _b !== void 0 ? _b : '', 10);
const end = parseInt((_d = (_c = rangeParts[1]) === null || _c === void 0 ? void 0 : _c.trim()) !== null && _d !== void 0 ? _d : '', 10);
if (!isNaN(start) && !isNaN(end)) {
for (let i = start; i <= end; i++) {
integers.push(i);
}
}
}
}
else {
// Handle single number
const num = parseInt(trimmed, 10);
if (!isNaN(num)) {
integers.push(num);
}
}
}
}
return integers;
}
/**
* Returns the last RegExp value (`/value/`) with the given key (case-insensitive),
* or without a key by passing an empty string.
*/
getRegExp(key) {
return this.value(key, 'regexp');
}
/**
* Returns an array of all RegExp values (`/value/`) with the given keys (case-insensitive),
* or without a key by passing an empty string.
*/
getRegExps(keyOrKeys) {
return this.list(keyOrKeys, 'regexp').map((option) => option.value);
}
/**
* Returns the last boolean value with the given key (case-insensitive).
*/
getBoolean(key) {
// First try to get a boolean option
const booleanValue = this.value(key, 'boolean');
if (booleanValue !== undefined) {
return booleanValue;
}
// Fall back to checking string values for boolean-like content
const stringValue = this.value(key, 'string');
if (stringValue !== undefined) {
const lowerValue = stringValue.toLowerCase();
if (lowerValue === 'true')
return true;
if (lowerValue === 'false')
return false;
}
return undefined;
}
}
function parseOptions(input, reservedKeys = [], syntax = {
valueDelimiters: ["'", '"', '/', '{...}'],
keyValueSeparator: '=',
}) {
const options = [];
const errors = [];
// Parse delimited values first and remove them from the input string
const delimitedValues = parseDelimitedValues(input, syntax);
let inputWithoutDelimited = input;
delimitedValues.forEach(({ index, fullMatch: raw, key, value, valueStartDelimiter, valueEndDelimiter }) => {
inputWithoutDelimited =
inputWithoutDelimited.slice(0, index) +
' '.repeat(raw.length) +
inputWithoutDelimited.slice(index + raw.length);
// Handle regular expressions
if (valueStartDelimiter === '/') {
let regExp;
try {
// Try to use regular expressions with capture group indices
regExp = new RegExp(value, 'gd');
}
catch (_error) {
try {
// Use fallback if unsupported
regExp = new RegExp(value, 'g');
}
catch (error) {
/* c8 ignore next */
const msg = error instanceof Error ? error.message : error;
errors.push(`Failed to parse option \`${raw.trim()}\`: ${msg}`);
return;
}
}
options.push({
index,
raw,
kind: 'regexp',
key,
value: regExp,
valueStartDelimiter,
valueEndDelimiter,
});
return;
}
// Handle ranges
if (valueStartDelimiter === '{') {
options.push({
index,
raw,
kind: 'range',
key,
value,
valueStartDelimiter,
valueEndDelimiter,
});
return;
}
// Handle quoted boolean values
if (value.toLowerCase() === 'true' || value.toLowerCase() === 'false') {
options.push({
index,
raw,
kind: 'boolean',
key,
value: value.toLowerCase() === 'true',
valueStartDelimiter,
valueEndDelimiter,
});
return;
}
// Treat all other options as strings
options.push({
index,
raw,
kind: 'string',
key,
value,
valueStartDelimiter,
valueEndDelimiter,
});
});
// Now parse all remaining options
const escapedSeparator = escapeRegExp(syntax.keyValueSeparator).replace(/-/g, '\\-');
const regExp = new RegExp(`([^\\s${escapedSeparator}]+)(?:\\s*${escapedSeparator}\\s*(\\S+))?`, 'g');
const simpleOptions = [...inputWithoutDelimited.matchAll(regExp)];
let remainingText = inputWithoutDelimited;
simpleOptions.forEach((match) => {
const index = match.index;
const [raw, key, value] = match;
let option;
if (value === 'true' || value === 'false' || value === undefined) {
// Handle booleans
option = {
index,
raw,
kind: 'boolean',
key,
value: value !== 'false',
valueStartDelimiter: '',
valueEndDelimiter: '',
};
}
else {
// Treat all other options as strings
option = {
index,
raw,
kind: 'string',
key,
value,
valueStartDelimiter: '',
valueEndDelimiter: '',
};
}
options.push(option);
if (option.key && reservedKeys.includes(option.key)) {
remainingText =
remainingText.slice(0, index) +
' '.repeat(raw.length) +
remainingText.slice(index + raw.length);
}
});
// Sort options by their index in the input string
options.sort((a, b) => a.index - b.index);
return {
options,
errors,
remainingText: remainingText.trim(),
};
}
function parseDelimitedValues(input, syntax) {
const valueDelimiterPairs = syntax.valueDelimiters.map((valueDelimiter) => {
const parts = valueDelimiter.split('...');
const isPair = parts.length === 2;
return {
valueStartDelimiter: isPair ? parts[0] : valueDelimiter,
valueEndDelimiter: isPair ? parts[1] : valueDelimiter,
};
});
const singleCharValueDelimiters = valueDelimiterPairs
.map((pair) => pair.valueStartDelimiter)
.filter((delimiter) => delimiter != null && delimiter.length === 1)
.join('');
// Build a regular expression that contains alternatives for all value delimiters
const regExpParts = valueDelimiterPairs
.filter((pair) => pair.valueStartDelimiter && pair.valueEndDelimiter)
.map(({ valueStartDelimiter, valueEndDelimiter }) => {
const part = [
// Whitespace or start of string
`(?:\\s|^)`,
// Optional group for key name and key/value separator
[
// Start of non-capturing optional group
`(?:`,
// Key name (captured)
`([^\\s${escapeRegExp((singleCharValueDelimiters + syntax.keyValueSeparator).replace(/-/g, '\\-'))}]+)`,
// Optional whitespace
`\\s*`,
// Key/value separator (e.g. `=`)
escapeRegExp(syntax.keyValueSeparator),
// Optional whitespace
`\\s*`,
// End of non-capturing optional group
`)?`,
],
// Value start delimiter
escapeRegExp(valueStartDelimiter),
// Value string (captured, can be an empty string),
// consisting of any of the following parts:
// - any character that is not a backslash
// - a backslash followed by any character
`((?:[^\\\\]|\\\\.)*?)`,
// Value end delimiter that is not escaped by a preceding `\`
`${escapeRegExp(valueEndDelimiter)}`,
// Whitespace or end of string
`(?=\\s|$)`,
];
return part.flat().join('');
});
if (regExpParts.length === 0) {
return [];
}
const regExp = new RegExp(regExpParts.join('|'), 'g');
// Now use the regular expression to find all matches
const matches = [...input.matchAll(regExp)];
return matches.map((match) => {
const [fullMatch, ...keyValuePairs] = match;
// Determine which value delimiter pair was used for this match
// by looking for the first defined value in the capture group array
// (matches can have no key, so the found capture group can either be a key or a value,
// but as they come in pairs, a division by 2 will give us the delimiter pair index)
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
const firstCaptureGroupIndex = keyValuePairs.findIndex((value) => value !== undefined);
const delimiterPairIdx = Math.floor(firstCaptureGroupIndex / 2);
const delimiterPair = valueDelimiterPairs[delimiterPairIdx];
if (!delimiterPair) {
throw new Error(`Unable to find delimiter pair at index ${delimiterPairIdx}`);
}
const { valueStartDelimiter, valueEndDelimiter } = delimiterPair;
// Also retrieve the actual matched key and value
const [key, escapedValue] = keyValuePairs.slice(delimiterPairIdx * 2, delimiterPairIdx * 2 + 2);
// Unescape value by removing any backslash that escapes any of the following:
// - another backslash (e.g. `\\` becomes `\`)
// - the value end delimiter (e.g. `\"` becomes `"`)
// Any other backslashes are kept because users may not know
// that they need to be escaped in the first place.
const escapedBackslashOrValueEndDelimiter = new RegExp(`\\\\(\\\\|${escapeRegExp(valueEndDelimiter)})`, 'g');
const value = escapedValue
? escapedValue.replace(escapedBackslashOrValueEndDelimiter, '$1')
: '';
return {
index: match.index || 0,
fullMatch,
key: key || '',
value,
valueStartDelimiter: valueStartDelimiter,
valueEndDelimiter: valueEndDelimiter,
};
});
}
function escapeRegExp(input) {
return input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}