UNPKG

@mintlify/common

Version:

Commonly shared code within Mintlify

430 lines (429 loc) 17.1 kB
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, '\\$&'); }