Skip to content

Commit

Permalink
improve trustedCreateElement()
Browse files Browse the repository at this point in the history
  • Loading branch information
slavaleleka committed Feb 9, 2024
1 parent 21a6dde commit 0ad1a76
Show file tree
Hide file tree
Showing 2 changed files with 102 additions and 51 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,12 @@ import {
logMessage,
observeDocumentWithTimeout,
nativeIsNaN,
parseAttributePairs,
getErrorMessage,
} from '../helpers/index';

import type { ParsedAttributePair } from '../helpers/attribute-utils';

/* eslint-disable max-len */
/**
* @trustedScriptlet trusted-create-element
Expand Down Expand Up @@ -34,19 +38,19 @@ import {
* example.com#%#//scriptlet('trusted-create-element', 'body', 'div', 'data-cur|1')
* ```
*
* 2. Create a div element with text content
* 1. Create a div element with text content
*
* ```adblock
* example.com#%#//scriptlet('trusted-create-element', 'body', 'div', '', 'Hello world!')
* ```
*
* 3. Create a button element with multiple attributes, including attribute without value, and text content
* 1. Create a button element with multiple attributes, including attribute without value, and text content
*
* ```adblock
* example.com#%#//scriptlet('trusted-create-element', 'body', 'button', 'disabled aria-hidden|true style|width:0px', 'Press here') <!-- markdownlint-disable-line line-length -->
* ```
*
* 4. Create a paragraph element with text content and remove it after 5 seconds
* 1. Create a paragraph element with text content and remove it after 5 seconds
*
* ```adblock
* example.com#%#//scriptlet('trusted-create-element', '.container > article', 'p', '', 'Hello world!', 5000)
Expand All @@ -56,9 +60,9 @@ import {
*/
/* eslint-enable max-len */
export function trustedCreateElement(
source,
parentSelector,
tagName,
source: Source,
parentSelector: string,
tagName: string,
attributePairs = '',
textContent = '',
cleanupDelayMs = NaN,
Expand All @@ -75,77 +79,89 @@ export function trustedCreateElement(
return;
}

// '|' is used as it is not a valid attribute name character
const ATTR_SEPARATOR = '|';
const ATTR_STR_SEPARATOR = ' ';
const logError = (prefix: string, error: unknown) => {
logMessage(source, `${prefix} due to ${getErrorMessage(error)}`);
};

let attributes: ParsedAttributePair[] | null = null;

let attributes = [];
if (attributePairs) {
const chunks = attributePairs.split(ATTR_STR_SEPARATOR);
attributes = chunks.map((chunk) => chunk.split(ATTR_SEPARATOR));
try {
attributes = parseAttributePairs(attributePairs);
} catch (e) {
logError(`Cannot parse attributePairs param: '${attributePairs}'`, e);
return;
}

let element;
let element: HTMLElement;
try {
element = document.createElement(tagName);
element.textContent = textContent;
} catch {
logMessage(source, `Could not create element with tag name: ${tagName}.`);
} catch (e) {
logError(`Cannot create element with tag name '${tagName}'`, e);
return;
}

if (attributes.length > 0) {
attributes.forEach((entry) => {
const name = entry[0];
const value = entry[1] || '';
if (attributes && attributes.length > 0) {
attributes.forEach((attr) => {
try {
element.setAttribute(name, value);
} catch {
logMessage(source, `Could not set attribute "${name}" with value "${value}".`);
element.setAttribute(attr.name, attr.value);
} catch (e) {
logError(`Cannot set attribute '${attr.name}' with value '${attr.value}'`, e);
}
});
}

let timerId = null;
let timerId: ReturnType<typeof setInterval>;

const findParentAndAppendEl = (parentSelector, element) => {
/**
* Finds parent element by `parentElSelector` and appends the `el` element to it.
*
* If `removeElDelayMs` is not `NaN`,
* schedules the `el` element to be removed after `removeElDelayMs` milliseconds.
*
* @param parentElSelector CSS selector of the parent element.
* @param el HTML element to append to the parent element.
* @param removeElDelayMs Delay in milliseconds before the `el` element is removed from the DOM.
*
* @returns True if the `el` element was successfully appended to the parent element, otherwise false.
*/
const findParentAndAppendEl = (parentElSelector: string, el: HTMLElement, removeElDelayMs: number) => {
let parentEl;
try {
parentEl = document.querySelector(parentSelector);
} catch {
logMessage(source, `Could not find parent element by selector "${parentSelector}"`);
parentEl = document.querySelector(parentElSelector);
} catch (e) {
logError(`Cannot find parent element by selector '${parentElSelector}'`, e);
}

if (!parentEl) {
logMessage(source, `No parent element found by selector: '${parentElSelector}'`);
return false;
}

try {
parentEl.append(element);

if (element instanceof HTMLIFrameElement) {
element.contentWindow.name = IFRAME_WINDOW_NAME;
parentEl.append(el);
if (el instanceof HTMLIFrameElement && el.contentWindow) {
el.contentWindow.name = IFRAME_WINDOW_NAME;
}

hit(source);
} catch {
logMessage(source, `Could not append child to parent by selector "${parentSelector}"`);
} catch (e) {
logError(`Cannot append child to parent by selector '${parentElSelector}'`, e);
return false;
}

if (!nativeIsNaN(cleanupDelayMs)) {
if (!nativeIsNaN(removeElDelayMs)) {
timerId = setInterval(() => {
element.remove();
el.remove();
clearInterval(timerId);
}, cleanupDelayMs);
}, removeElDelayMs);
}

return true;
};

if (!findParentAndAppendEl(parentSelector, element)) {
if (!findParentAndAppendEl(parentSelector, element, cleanupDelayMs)) {
observeDocumentWithTimeout((mutations, observer) => {
if (findParentAndAppendEl(parentSelector, element)) {
if (findParentAndAppendEl(parentSelector, element, cleanupDelayMs)) {
observer.disconnect();
}
});
Expand All @@ -162,4 +178,6 @@ trustedCreateElement.injections = [
logMessage,
observeDocumentWithTimeout,
nativeIsNaN,
parseAttributePairs,
getErrorMessage,
];
55 changes: 44 additions & 11 deletions tests/scriptlets/trusted-create-element.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ module(name, { beforeEach, afterEach });
test('creating empty div', (assert) => {
const { children } = createRoot(ROOT_ID);

const childTagName = 'DIV';
const childTagName = 'div';

assert.strictEqual(children.length, 0, 'Parent element has no children before scriptlet is run');

Expand All @@ -47,7 +47,7 @@ test('creating empty div', (assert) => {

const child = children[0];

assert.strictEqual(child.tagName, childTagName, 'Tag name is set correctly');
assert.strictEqual(child.tagName.toLowerCase(), childTagName, 'Tag name is set correctly');
assert.strictEqual(child.textContent, '', 'Text content is set correctly');
assert.strictEqual(child.attributes.length, 0, 'No attributes were set');

Expand All @@ -68,7 +68,7 @@ test('setting text content', (assert) => {

const child = children[0];

assert.strictEqual(child.tagName, childTagName, 'Tag name is set correctly');
assert.strictEqual(child.tagName.toLowerCase(), childTagName.toLowerCase(), 'Tag name is set correctly');
assert.strictEqual(child.textContent, textContent, 'Text content is set correctly');
assert.strictEqual(child.attributes.length, 0, 'No attributes were set');

Expand All @@ -78,10 +78,10 @@ test('setting text content', (assert) => {
test('setting single attribute', (assert) => {
const { children } = createRoot(ROOT_ID);

const childTagName = 'IMG';
const childTagName = 'img';
const attributeName = 'src';
const attributeValue = './test-files/test-image.jpeg';
const attributesParam = `${attributeName}|${attributeValue}`;
const attributesParam = `${attributeName}="${attributeValue}"`;

assert.strictEqual(children.length, 0, 'Parent element has no children before scriptlet is run');

Expand All @@ -91,7 +91,7 @@ test('setting single attribute', (assert) => {

const child = children[0];

assert.strictEqual(child.tagName, childTagName, 'Tag name is set correctly');
assert.strictEqual(child.tagName.toLowerCase(), childTagName, 'Tag name is set correctly');
assert.strictEqual(child.textContent, '', 'Text content is set correctly');

assert.strictEqual(child.getAttribute(attributeName), attributeValue, 'Attribute is set correctly');
Expand All @@ -100,17 +100,50 @@ test('setting single attribute', (assert) => {
assert.strictEqual(window.hit, 'FIRED', 'hit fired');
});

test('multiple attributes with space in value', (assert) => {
const { children } = createRoot(ROOT_ID);

const childTagName = 'div';
const attributeName1 = 'class';
const attributeValue1 = 'adsbygoogle adsbygoogle-noablate';
const attributeName2 = 'data-adsbygoogle-status';
const attributeValue2 = 'done';
const attributeName3 = 'data-ad-status';
const attributeValue3 = 'filled';
// eslint-disable-next-line max-len
const attributesParam = `${attributeName1}="${attributeValue1}" ${attributeName2}="${attributeValue2}" ${attributeName3}="${attributeValue3}"`;
const textContent = 'this is text content of an element';

assert.strictEqual(children.length, 0, 'Parent element has no children before scriptlet is run');

runScriptlet(name, [ROOT_SELECTOR, childTagName, attributesParam, textContent]);

assert.strictEqual(children.length, 1, 'Only specified child was appended');

const child = children[0];

assert.strictEqual(child.tagName.toLowerCase(), childTagName, 'Tag name is set correctly');
assert.strictEqual(child.textContent, textContent, 'Text content is set correctly');

assert.strictEqual(child.getAttribute(attributeName1), attributeValue1, 'Attribute 1 is set correctly');
assert.strictEqual(child.getAttribute(attributeName2), attributeValue2, 'Attribute 2 is set correctly');
assert.strictEqual(child.getAttribute(attributeName3), attributeValue3, 'Attribute 3 is set correctly');
assert.strictEqual(child.getAttributeNames().length, 3, 'Specified amount of attributes were set');

assert.strictEqual(window.hit, 'FIRED', 'hit fired');
});

test('multiple attributes + attribute with no value', (assert) => {
const { children } = createRoot(ROOT_ID);

const childTagName = 'P';
const childTagName = 'p';
const attributeName1 = 'aria-hidden';
const attributeValue1 = 'true';
const attributeName2 = 'style';
const attributeValue2 = 'width:0px';
const attributeWithoutValue = 'attr3';
// eslint-disable-next-line max-len
const attributesParam = `${attributeName1}|${attributeValue1} ${attributeName2}|${attributeValue2} ${attributeWithoutValue}`;
const attributesParam = `${attributeName1}="${attributeValue1}" ${attributeName2}="${attributeValue2}" ${attributeWithoutValue}`;
const textContent = 'this is text content of an element';

assert.strictEqual(children.length, 0, 'Parent element has no children before scriptlet is run');
Expand All @@ -121,7 +154,7 @@ test('multiple attributes + attribute with no value', (assert) => {

const child = children[0];

assert.strictEqual(child.tagName, childTagName, 'Tag name is set correctly');
assert.strictEqual(child.tagName.toLowerCase(), childTagName, 'Tag name is set correctly');
assert.strictEqual(child.textContent, textContent, 'Text content is set correctly');

assert.strictEqual(child.getAttribute(attributeName1), attributeValue1, 'Attribute 1 is set correctly');
Expand Down Expand Up @@ -159,7 +192,7 @@ test('element cleanup by timeout', (assert) => {
});

test('running scriptlet before root element is created', (assert) => {
const childTagName = 'DIV';
const childTagName = 'div';

runScriptlet(name, [ROOT_SELECTOR, childTagName]);

Expand All @@ -171,7 +204,7 @@ test('running scriptlet before root element is created', (assert) => {

const child = children[0];

assert.strictEqual(child.tagName, childTagName, 'Tag name is set correctly');
assert.strictEqual(child.tagName.toLowerCase(), childTagName, 'Tag name is set correctly');
assert.strictEqual(child.textContent, '', 'Text content is set correctly');

assert.strictEqual(window.hit, 'FIRED', 'hit fired');
Expand Down

0 comments on commit 0ad1a76

Please sign in to comment.