Filter.js

import difference from 'lodash.difference';
import { createDocumentFragmentFromString } from './utils';
import { replaceElementWithChildren } from './utils';
import FilteredDocumentFragment from './FilteredDocumentFragment';

function checkParser( parser ) {
	if ( typeof parser !== 'object' || typeof parser.parse !== 'function' ) {
		throw new TypeError( 'Filter constructor requires proper rules\' parser as a 2. parameter.' );
	}
}

function parseRulesParam( rules ) {
	if ( typeof rules === 'string' ) {
		return [ rules ];
	} else if ( Array.isArray( rules ) ) {
		return rules;
	}

	throw new TypeError( 'Rules must be supplied to filter\'s constructor as a string or an array of strings.' );
}

function parseFilterInput( input ) {
	if ( input instanceof DocumentFragment ) {
		return input;
	}

	if ( input instanceof HTMLElement ) {
		const documentFragment = document.createDocumentFragment();

		documentFragment.appendChild( input );

		return documentFragment;
	}

	if ( typeof input === 'string' ) {
		return createDocumentFragmentFromString( input );
	}

	throw new TypeError( 'Filter method requires string, DocumentFragment or HTMLElement as an input.' );
}

/** Class performing the filtering. */
class Filter {
	/**
	 * Creates new filter's instance.
	 *
	 * @param {String|String[]} rules Rules passed as a string or an array of strings.
	 * @param {RuleParser|Object} parser Rules' syntax to CSS parser.
	 * @class
	 */
	constructor( rules, parser ) {
		checkParser( parser );

		this.rules = parseRulesParam( rules );
		this.parsedRule = null;
		this.parser = parser;
	}

	/**
	 * Add given rule to the current's filter instance.
	 *
	 * @param {String} rule Rule to be added.
	 * @param {Boolean} [disableReparse=false] Indicates if reparsing the rules should be forbidden.
	 * @return {undefined}
	 */
	addRule( rule, disableReparse = false ) {
		if ( typeof rule !== 'string' ) {
			throw new TypeError( 'New rule must be a string.' );
		}

		this.rules.push( rule );

		if ( this.parsedRule && !disableReparse ) {
			this._parseRules();
		}
	}

	/**
	 * Add given rules to the current's filter instance.
	 *
	 * @param {String[]} rules Rules to be added.
	 * @return {undefined}
	 */
	addRules( rules ) {
		if ( !Array.isArray( rules ) || rules.length < 1 ) {
			throw new TypeError( 'New rules must be passed inside an array.' );
		}

		for ( let rule of rules ) { // eslint-disable-line prefer-const
			this.addRule( rule, true );
		}

		if ( this.parsedRule ) {
			this._parseRules();
		}
	}

	/**
	 * Filters given HTML string/DocumentFragment using CSS selectors.
	 *
	 * @param {DocumentFragment|HTMLElement|String} input DocumentFragment will be constructed from
	 * this parameter.
	 * @return {FilteredDocumentFragment} Filtered DocumentFragment.
	*/
	filter( input ) {
		const documentFragment = parseFilterInput( input );
		const allElements = documentFragment.querySelectorAll( '*' );
		const filteredDocumentFragment = new FilteredDocumentFragment();

		if ( typeof this.parsedRule !== 'string' ) {
			this._parseRules();
		}

		const allowedElements = documentFragment.querySelectorAll( this.parsedRule );
		const elementsToRemove = difference( allElements, allowedElements );

		for ( let element of elementsToRemove ) { // eslint-disable-line prefer-const
			replaceElementWithChildren( element );
		}

		filteredDocumentFragment.appendChild( documentFragment );

		return filteredDocumentFragment;
	}

	/**
	 * Parsed filter's rules into valid CSS selectors using passed in parser.
	 *
	 * @return {undefined}
	 * @private
	 */
	_parseRules() {
		this.parsedRule = this.parser.join( this.rules.map( this.parser.parse ) );
	}
}

export default Filter;