Container.js

import Node from './Node';

/** Return a new {@link Container} {@link Node}.
* @extends Node
*/
class Container extends Node {
	/**
	* Return the first child {@link Node} of the current {@link Container}, or `null` if there is none.
	* @returns {Node|null}
	* @example
	* container.first // returns a Node or null
	*/
	get first () {
		return this.nodes[0] || null;
	}

	/**
	* Return the first child {@link Element} of the current {@link Container}, or `null` if there is none.
	* @returns {Node|null}
	* @example
	* container.firstElement // returns an Element or null
	*/
	get firstElement () {
		return this.nodes.find(hasNodes) || null;
	}

	/**
	* Return the last child {@link Node} of the current {@link Container}, or `null` if there is none.
	* @returns {Node|null}
	* @example
	* container.last // returns a Node or null
	*/
	get last () {
		return this.nodes[this.nodes.length - 1] || null;
	}

	/**
	* Return the last child {@link Element} of the current {@link Container}, or `null` if there is none.
	* @returns {Node|null}
	* @example
	* container.lastElement // returns an Element or null
	*/
	get lastElement () {
		return this.nodes.slice().reverse().find(hasNodes) || null;
	}

	/**
	* Return a child {@link Element} {@link NodeList} of the current {@link Container}.
	* @returns {Node[]}
	* @example
	* container.elements // returns an array of Elements
	*/
	get elements () {
		return this.nodes.filter(hasNodes) || [];
	}

	/**
	* Return the innerHTML of the current {@link Container} as a String.
	* @example
	* container.innerHTML // returns a string of innerHTML
	*/
	get innerHTML () {
		return this.nodes.innerHTML;
	}

	/**
	* Define the nodes of the current {@link Container} from a String.
	* @param {string} innerHTML - Source being processed.
	* @returns {void}
	* @example
	* container.innerHTML = 'Hello <strong>world</strong>';
	* container.nodes.length; // 2
	*/
	set innerHTML (innerHTML) {
		this.nodes.innerHTML = innerHTML;
	}

	/**
	* Return the outerHTML of the current {@link Container} as a String.
	* @returns {string}
	* @example
	* container.outerHTML // returns a string of outerHTML
	*/
	get outerHTML () {
		return this.nodes.innerHTML;
	}

	/**
	* Replace the current {@link Container} from a String.
	* @param {string} input - Source being processed.
	* @returns {void}
	* @example
	* container.outerHTML = 'Hello <strong>world</strong>';
	*/
	set outerHTML (outerHTML) {
		const Result = Object(this.result).constructor;

		if (Result) {
			const childNodes = new Result(outerHTML).root.nodes;

			this.replaceWith(...childNodes);
		}
	}

	/**
	* Return the stringified innerHTML from the source input.
	* @returns {string}
	*/
	get sourceInnerHTML () {
		return this.isSelfClosing || this.isVoid || typeof Object(this.source.input).html !== 'string'
			? ''
		: 'startInnerOffset' in this.source && 'endInnerOffset' in this.source
			? this.source.input.html.slice(
				this.source.startInnerOffset,
				this.source.endInnerOffset
			)
		: this.sourceOuterHTML;
	}

	/**
	* Return the stringified outerHTML from the source input.
	* @returns {string}
	*/
	get sourceOuterHTML () {
		return typeof Object(this.source.input).html !== 'string'
			? ''
		: this.source.input.html.slice(
			this.source.startOffset,
			this.source.endOffset
		);
	}

	/**
	* Return the text content of the current {@link Container} as a String.
	* @returns {string}
	*/
	get textContent () {
		return this.nodes.textContent;
	}

	/**
	* Define the content of the current {@link Container} as a new {@link Text} {@link Node}.
	* @returns {void}
	*/
	set textContent (textContent) {
		this.nodes.textContent = textContent;
	}

	/**
	* Return a child {@link Node} of the current {@link Container} by last index, or `null` if there is none.
	* @returns {Node|null}
	* @example
	* container.lastNth(0) // returns a Node or null
	*/
	lastNth (index) {
		return this.nodes.slice().reverse()[index] || null;
	}

	/**
	* Return a child {@link Element} of the current {@link Container} by last index, or `null` if there is none.
	* @returns {Element|null}
	* @example
	* container.lastNthElement(0) // returns an Element or null
	*/
	lastNthElement (index) {
		return this.elements.reverse()[index] || null;
	}

	/**
	* Return a child {@link Node} of the current {@link Container} by index, or `null` if there is none.
	* @returns {Node|null}
	* @example
	* container.nth(0) // returns a Node or null
	*/
	nth (index) {
		return this.nodes[index] || null;
	}

	/**
	* Return an {@link Element} child of the current Container by index, or `null` if there is none.
	* @returns {Element|null}
	* @example
	* container.nthElement(0) // returns an Element or null
	*/
	nthElement (index) {
		return this.elements[index] || null;
	}

	/**
	* Replace all of the children of the current {@link Container}, returning the current {@link Container}.
	* @param {Node[]} nodes - Any nodes replacing the current children of the {@link Container}.
	* @example
	* container.replaceAll(new Text({ data: 'Hello World' }))
	*/
	replaceAll (...nodes) {
		if (this.nodes) {
			this.nodes.splice(0, this.nodes.length, ...nodes);
		}

		return this;
	}

	/**
	* Traverse the descendant {@link Node}s of the current {@link Container} with a callback function, returning the current {@link Container}.
	* @param {Function|string|RegExp} callback_or_filter - Callback function, or a filter to reduce {@link Node}s the callback is applied to.
	* @param {Function|string|RegExp} callback - Callback function when a filter is also specified.
	* @example
	* container.walk(node => {
	*   console.log(node);
	* })
	* @example
	* container.walk('*', node => {
	*   console.log(node);
	* })
	* @example <caption>Walk only "section" {@link Element}s.</caption>
	* container.walk('section', node => {
	*   console.log(node); // logs only Section Elements
	* })
	* @example
	* container.walk(/^section$/, node => {
	*   console.log(node); // logs only Section Elements
	* })
	* @example
	* container.walk(
	*   node => node.name.toLowerCase() === 'section',
	*   node => {
	*   console.log(node); // logs only Section Elements
	* })
	* @example <caption>Walk only {@link Text}.</caption>
	* container.walk('#text', node => {
	*   console.log(node); // logs only Text Nodes
	* })
	*/
	walk () {
		const [ cb, filter ] = getCbAndFilterFromArgs(arguments);

		walk(this, cb, filter);

		return this;
	}
}

function walk (node, cb, filter) {
	if (typeof cb === 'function' && node.nodes) {
		node.nodes.slice(0).forEach(child => {
			if (Object(child).parent === node) {
				if (testWithFilter(child, filter)) {
					cb(child); // eslint-disable-line callback-return
				}

				if (child.nodes) {
					walk(child, cb, filter);
				}
			}
		});
	}
}

function getCbAndFilterFromArgs (args) {
	const [ cbOrFilter, onlyCb ] = args;
	const cb = onlyCb || cbOrFilter;
	const filter = onlyCb ? cbOrFilter : undefined;

	return [cb, filter];
}

function testWithFilter (node, filter) {
	if (!filter) {
		return true;
	} else if (filter === '*') {
		return Object(node).constructor.name === 'Element';
	} else if (typeof filter === 'string') {
		return node.name === filter;
	} else if (filter instanceof RegExp) {
		return filter.test(node.name);
	} else if (filter instanceof Function) {
		return filter(node);
	} else {
		return false;
	}
}

function hasNodes (node) {
	return node.nodes;
}

export default Container;