Result.js

import normalize from './normalize';
import parseHTML from './parseHTML';
import treeAdapter from './parseHTMLTreeAdapter';
import Comment from './Comment';
import Doctype from './Doctype';
import Element from './Element';
import Fragment from './Fragment';
import Text from './Text';
import visit, { getVisitors } from './visit';

/** Return a new syntax tree {@link Result} from a processed input. */
class Result {
	/**
	* @param {string} html - HTML string to be parsed.
	* @param {ProcessOptions} [processOptions] - Custom settings applied to the {@link Result}.
	*/
	constructor (html, processOptions) {
		/** Type identifier of the Result
		* @type {'result'} */
		this.type = 'result';

		/** Path to the HTML source file. You should always set from, because it is used in source map generation and syntax error messages
		* @type {string} */
		// the "to" and "from" locations are always string values
		this.from = 'from' in Object(processOptions) && processOptions.from !== undefined && processOptions.from !== null
			? String(processOptions.from)
		: '';

		/** Path to the HTML output file
		* @type {string} */
		this.to = 'to' in Object(processOptions) && processOptions.to !== undefined && processOptions.to !== null
			? String(processOptions.to)
		: this.from;

		/** List of the elements that only have a start tag, as they cannot have any content
		* @type {string[]} */
		this.voidElements = 'voidElements' in Object(processOptions)
			? [].concat(Object(processOptions).voidElements || [])
		: Result.voidElements;

		/** Normalized plugins and visitors
		* @type {Object<string, Function[]>} */
		// prepare visitors (which may be functions or visitors)
		this.visitors = getVisitors(Object(processOptions).visitors);

		/** List of the messages gathered during transformations
		* @type {Message[]} */
		this.messages = [];

		// prepare the result object
		this.input = { html: html, from: this.from, to: this.to };

		// parse the html and transform it into nodes
		const documentFragment = parseHTML(html, { voidElements: this.voidElements });

		/** Root Node
		* @type {Node} */
		this.root = transform(documentFragment, this);
	}

	/** Current {@link Root} as a String. */
	get html () {
		return String(this.root);
	}

	/**
	* Messages that are warnings.
	* @returns {Array<MessageWarning>}
	**/
	get warnings () {
		return /** @type Message[] */(this.messages).filter(
			message => Object(message).type === 'warning'
		);
	}

	/**
	* Return a normalized node whose instances match the current {@link Result}.
	* @param {Node} [node] - Node to be normalized.
	* @example
	* result.normalize(someNode)
	*/
	normalize (node) {
		return normalize(node);
	}

	/**
	* Current {@link Root} as an Object.
	* @returns {Object}
	*/
	toJSON () {
		return this.root.toJSON();
	}

	/**
	* Transform the current {@link Node} and any descendants using visitors.
	* @param {Node} node - {@link Node} to be visited.
	* @param {Object} [overrideVisitors] - Alternative visitors to be used in place of {@link Result} visitors.
	* @returns {ResultPromise}
	* @example
	* await result.visit(someNode)
	* @example
	* await result.visit() // visit using the root of the current result
	* @example
	* await result.visit(root, {
	*   Element () {
	*     // do something to an element
	*   }
	* })
	*/
	visit (node, overrideVisitors) {
		const nodeToUse = 0 in arguments ? node : this.root;

		return visit(nodeToUse, this, overrideVisitors);
	}

	/**
	* Add a warning to the current {@link Root}.
	* @param {string} text - Message being sent as the warning.
	* @param {Object} [opts] - Additional information about the warning.
	* @example
	* result.warn('Something went wrong')
	* @example
	* result.warn('Something went wrong', {
	*   node: someNode,
	*   plugin: somePlugin
	* })
	*/
	warn (text, rawopts) {
		const opts = Object(rawopts);

		if (!opts.plugin) {
			if (Object(this.currentPlugin).name) {
				opts.plugin = this.currentPlugin.name
			}
		}

		this.messages.push({ type: 'warning', text, opts });
	}

	static voidElements = [
		'area',
		'base',
		'br',
		'col',
		'command',
		'embed',
		'hr',
		'img',
		'input',
		'keygen',
		'link',
		'meta',
		'param',
		'source',
		'track',
		'wbr'
	];
}

function transform (node, result) {
	const hasSource = node.sourceCodeLocation === Object(node.sourceCodeLocation);
	const source = hasSource
		? {
			startOffset: node.sourceCodeLocation.startOffset,
			endOffset: node.sourceCodeLocation.endOffset,
			startInnerOffset: Object(node.sourceCodeLocation.startTag).endOffset || node.sourceCodeLocation.startOffset,
			endInnerOffset: Object(node.sourceCodeLocation.endTag).startOffset || node.sourceCodeLocation.endOffset,
			input: result.input
		}
	: {
		startOffset: 0,
		startInnerOffset: 0,
		endInnerOffset: result.input.html.length,
		endOffset: result.input.html.length,
		input: result.input
	};

	if (Object(node.sourceCodeLocation).startTag) {
		source.before = result.input.html.slice(source.startOffset, source.startInnerOffset - 1).match(/\s*\/?$/)[0]
	}

	if (Object(node.sourceCodeLocation).endTag) {
		source.after = result.input.html.slice(source.endInnerOffset + 2 + node.nodeName.length, source.endOffset - 1)
	}

	const $node = treeAdapter.isCommentNode(node)
		? new Comment({ comment: node.data, source, result })
	: treeAdapter.isDocumentTypeNode(node)
		? new Doctype(Object.assign(node, { result, source: Object.assign({}, node.source, source) }))
	: treeAdapter.isElementNode(node)
		? new Element({
			name: result.input.html.slice(source.startOffset + 1, source.startOffset + 1 + node.nodeName.length),
			attrs: node.attrs.map(attr => attr.raw),
			nodes: node.childNodes instanceof Array ? node.childNodes.map(child => transform(child, result)) : null,
			isSelfClosing: /\//.test(source.before),
			isWithoutEndTag: !Object(node.sourceCodeLocation).endTag,
			isVoid: result.voidElements.includes(node.tagName),
			result,
			source
		})
	: treeAdapter.isTextNode(node)
		? new Text({
			data: hasSource ? source.input.html.slice(
				source.startInnerOffset,
				source.endInnerOffset
			) : node.value,
			result,
			source
		})
	: new Fragment({
		nodes: node.childNodes instanceof Array ? node.childNodes.map(child => transform(child, result)) : null,
		result,
		source
	});

	return $node;
}

export default Result;

/**
* Promise to return a syntax tree.
* @typedef {Promise<Result>} ResultPromise
* @example
* resultPromise.then(result => {
*  // do something with the result
* })
*/
/**
* @typedef {Object} ProcessOptions - Custom settings applied to the {@link Result}.
* @property {string} [from] - Source input location.
* @property {string} [to] - Destination output location.
* @property {string[]} [voidElements] - Void elements.
*/
/**
* @typedef {Object} ResultType - Result of a pHTML transformation.
* @property {string} from - Path to the HTML source file. You should always set from, because it is used in source map generation and syntax error messages.
* @property {string} to - Path to the HTML output file.
* @property {Fragment} root - Object representing the parsed nodes of the HTML file.
* @property {Message[]} messages - List of the messages gathered during transformations.
* @property {string[]} voidElements - List of the elements that only have a start tag, as they cannot have any content.
*/
/**
* @typedef {Object} Message - Message gathered during transformations.
* @property {string} type - Type of message.
* @property {string} text - Message itself.
* @property {MessageInfo} [opts] - Information about the message.
*/
/**
* @typedef {Object} MessageInfo - Information about the message.
* @property {Plugin} [plugin] - Current plugin.
*/
/**
* @typedef {{ type: "warning" }} MessageWarning - Warning message gathered during transformations.
* @extends {Message}
*/