Skip to content

Commit

Permalink
add parseAttributePairs() helper and tests for it
Browse files Browse the repository at this point in the history
  • Loading branch information
slavaleleka committed Feb 9, 2024
1 parent a3c0d55 commit 21a6dde
Show file tree
Hide file tree
Showing 2 changed files with 248 additions and 0 deletions.
95 changes: 95 additions & 0 deletions src/helpers/attribute-utils.ts
Expand Up @@ -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;
};
153 changes: 153 additions & 0 deletions 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);
});
});
});

0 comments on commit 21a6dde

Please sign in to comment.