diff --git a/src/helpers/attribute-utils.ts b/src/helpers/attribute-utils.ts index 782ab802..a1ccb192 100644 --- a/src/helpers/attribute-utils.ts +++ b/src/helpers/attribute-utils.ts @@ -50,3 +50,98 @@ export const setAttributeBySelector = ( logMessage(source, `Failed to set [${attribute}="${value}"] to each of selected elements.`); } }; + +export type ParsedAttributePair = { + name: string; + value: string; +}; + +/** + * Parses attribute pairs string into an array of objects with name and value properties. + * + * @param input Attribute pairs string. + * + * @returns Array of objects with name and value properties. + * @throws Error if input is invalid. + */ +export const parseAttributePairs = (input: string): ParsedAttributePair[] => { + if (!input) { + return []; + } + + const NAME_VALUE_SEPARATOR = '='; + const PAIRS_SEPARATOR = ' '; + const SINGLE_QUOTE = "'"; + const DOUBLE_QUOTE = '"'; + const BACKSLASH = '\\'; + + const pairs = []; + + for (let i = 0; i < input.length; i += 1) { + let name = ''; + let value = ''; + + // collect the name + while (i < input.length + && input[i] !== NAME_VALUE_SEPARATOR + && input[i] !== PAIRS_SEPARATOR) { + name += input[i]; + i += 1; + } + + if (i < input.length && input[i] === NAME_VALUE_SEPARATOR) { + // skip the '=' + i += 1; + + let quote = null; + if (input[i] === SINGLE_QUOTE || input[i] === DOUBLE_QUOTE) { + quote = input[i]; + // Skip the opening quote + i += 1; + for (; i < input.length; i += 1) { + if (input[i] === quote) { + if (input[i - 1] === BACKSLASH) { + // remove the backslash and save the quote to the value + value = `${value.slice(0, -1)}${quote}`; + } else { + // Skip the closing quote + i += 1; + quote = null; + break; + } + } else { + value += input[i]; + } + } + if (quote !== null) { + throw new Error(`Unbalanced quote for attribute value: '${input}'`); + } + } else { + throw new Error(`Attribute value should be quoted: "${input.slice(i)}"`); + } + } + + name = name.trim(); + value = value.trim(); + + if (!name) { + if (!value) { + // skip multiple spaces between pairs, e.g. + // 'name1="value1" name2="value2"' + continue; + } + throw new Error(`Attribute name before '=' should be specified: '${input}'`); + } + + pairs.push({ + name, + value, + }); + + if (input[i] && input[i] !== PAIRS_SEPARATOR) { + throw new Error(`No space before attribute: '${input.slice(i)}'`); + } + } + + return pairs; +}; diff --git a/tests/helpers/attribute-utils.spec.js b/tests/helpers/attribute-utils.spec.js new file mode 100644 index 00000000..a80142d4 --- /dev/null +++ b/tests/helpers/attribute-utils.spec.js @@ -0,0 +1,153 @@ +import { parseAttributePairs } from '../../src/helpers'; + +describe('parseAttributePairs', () => { + describe('valid input', () => { + const testCases = [ + { + actual: '', + expected: [], + }, + { + actual: 'test', + expected: [{ + name: 'test', + value: '', + }], + }, + { + actual: 'empty=""', + expected: [{ + name: 'empty', + value: '', + }], + }, + { + actual: 'equal-sign="="', + expected: [{ + name: 'equal-sign', + value: '=', + }], + }, + { + actual: 'name1="value1"', + expected: [{ + name: 'name1', + value: 'value1', + }], + }, + { + actual: 'test="escaped\\"quote"', + expected: [{ + name: 'test', + value: 'escaped"quote', + }], + }, + { + actual: 'test2="escaped-quote\\" and space"', + expected: [{ + name: 'test2', + value: 'escaped-quote" and space', + }], + }, + { + actual: 'n1="v1" n2="v2"', + expected: [ + { + name: 'n1', + value: 'v1', + }, + { + name: 'n2', + value: 'v2', + }, + ], + }, + { + // multiple spaces between attributes are skipped + actual: 'test1 test2', + expected: [ + { + name: 'test1', + value: '', + }, + { + name: 'test2', + value: '', + }, + ], + }, + { + actual: 'name1="has space" name2="noSpace"', + expected: [ + { + name: 'name1', + value: 'has space', + }, + { + name: 'name2', + value: 'noSpace', + }, + ], + }, + { + // eslint-disable-next-line max-len + actual: 'class="adsbygoogle adsbygoogle-noablate" data-adsbygoogle-status="done" data-ad-status="filled" style="top: 0 !important;"', + expected: [ + { + name: 'class', + value: 'adsbygoogle adsbygoogle-noablate', + }, + { + name: 'data-adsbygoogle-status', + value: 'done', + }, + { + name: 'data-ad-status', + value: 'filled', + }, + { + name: 'style', + value: 'top: 0 !important;', + }, + ], + }, + ]; + test.each(testCases)('$actual', ({ actual, expected }) => { + expect(parseAttributePairs(actual)).toStrictEqual(expected); + }); + }); + + describe('invalid input', () => { + const testCases = [ + { + actual: 'name1=value1', + expected: 'Attribute value should be quoted: "value1"', + }, + { + actual: 'name1="value1" ="value2"', + expected: "Attribute name before '=' should be specified: 'name1=\"value1\" =\"value2\"'", + }, + { + actual: 'name1="value1"name2="value2"', + expected: 'No space before attribute: \'name2="value2"\'', + }, + { + actual: 'test="non-escaped"quote"', + // non-escaped quote in the value causes value collection to finish on it + // so the following string part is treated as a new attribute + expected: 'No space before attribute: \'quote"\'', + }, + { + actual: 'name1="', + expected: 'Unbalanced quote for attribute value: \'name1="\'', + }, + { + actual: 'name1="value1', + expected: 'Unbalanced quote for attribute value: \'name1="value1\'', + }, + ]; + test.each(testCases)('$actual', ({ actual, expected }) => { + expect(() => parseAttributePairs(actual)).toThrow(expected); + }); + }); +});