All files / src htmlvalidate.ts

53.66% Statements 44/82
24.14% Branches 7/29
63.64% Functions 14/22
54.32% Lines 44/81
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 2251x 1x   1x   1x     1x           38x 38x                   504x 504x 504x                   3x 3x 3x 3x       507x     507x                                         507x 507x 507x   507x 2171x 2171x       507x 507x                               507x       5x 5x                                                                                                                                                       512x 512x 512x       2171x 2171x   2171x 2171x   1x     1x 1x         2171x                     2171x   2177x 3098x     242x 242x               1x  
import { Config, ConfigLoader } from './config';
import { Parser } from './parser';
import { DOMNode } from 'dom';
import { Reporter, Report } from './reporter';
import { Source, Location } from './context';
import { Lexer, InvalidTokenError, TokenType } from './lexer';
import { Rule, RuleEventCallback, RuleParserProxy, RuleReport } from './rule';
 
const fs = require('fs');
 
class HtmlValidate {
	private globalConfig: Config;
 
	constructor(options?: any){
		const defaults = Config.defaultConfig();
		this.globalConfig = defaults.merge(Config.fromObject(options || {}));
	}
 
	/**
	 * Parse HTML from string.
	 *
	 * @param str {string} - Text to parse.
	 * @return {object} - Report output.
	 */
	public validateString(str: string): Report {
		const source = {data: str, filename: 'inline'};
		const config = this.getConfigFor(source.filename);
		return this.process(source, config, 'lint');
	}
 
	/**
	 * Parse HTML from file.
	 *
	 * @param filename {string} - Filename to read and parse.
	 * @return {object} - Report output.
	 */
	public validateFile(filename: string, mode?: string): Report {
		const text = fs.readFileSync(filename, {encoding: 'utf8'});
		const source = {data: text, filename};
		const config = this.getConfigFor(source.filename);
		return this.process(source, config, mode);
	}
 
	private process(source: Source, config: Config, mode?: string){
		switch (mode){
		case 'lint':
		case undefined:
			return this.parse(source, config);
		case 'dump-events':
			return this.dumpEvents(source, config);
		case 'dump-tokens':
			return this.dumpTokens(source);
		case 'dump-tree':
			return this.dumpTree(source, config);
		default:
			throw new Error(`Unknown mode "${mode}"`);
		}
	}
 
	/**
	 * Internal parse method.
	 *
	 * @param src {object} - Parse source.
	 * @param src.data {string} - Text HTML data.
	 * @param src.filename {string} - Filename of source for presentation in report.
	 * @return {object} - Report output.
	 */
	private parse(src: Source, config: Config): Report {
		const report = new Reporter();
		const rules = config.getRules();
		const parser = new Parser(config);
 
		for (const name in rules){
			const data = rules[name];
			this.loadRule(name, data, parser, report);
		}
 
		/* parse token stream */
		try {
			parser.parseHtml(src);
		} catch (e){
			if (e instanceof InvalidTokenError){
				report.addManual(e.location.filename, {
					ruleId: undefined,
					severity: Config.SEVERITY_ERROR,
					message: e.message,
					line: e.location.line,
					column: e.location.column,
				});
			} else {
				throw e;
			}
		}
 
		/* generate results from report */
		return report.save();
	}
 
	public getParserFor(source: Source){
		const config = this.getConfigFor(source.filename);
		return new Parser(config);
	}
 
	private dumpEvents(source: Source, config: Config): Report {
		const parser = new Parser(config);
		const filtered = ['parent', 'children'];
 
		parser.on('*', (event, data) => {
			const strdata = JSON.stringify(data, (key, value) => {
				return filtered.indexOf(key) >= 0 ? '[truncated]' : value;
			}, 2);
			process.stdout.write(`${event}: ${strdata}\n`);
		});
		parser.parseHtml(source);
 
		return {
			valid: true,
			results: [],
		};
	}
 
	private dumpTokens(source: Source): Report {
		const lexer = new Lexer();
		for (const token of lexer.tokenize(source)){
			const data = token.data ? token.data[0] : null;
			process.stdout.write(`TOKEN: ${TokenType[token.type]}
  Data: ${JSON.stringify(data)}
  Location: ${token.location.filename}:${token.location.line}:${token.location.column}
`);
		}
		return {
			valid: true,
			results: [],
		};
	}
 
	private dumpTree(source: Source, config: Config): Report {
		const parser = new Parser(config);
		const dom = parser.parseHtml(source);
 
		function decoration(node: DOMNode){
			let output = '';
			if (node.hasAttribute('id')){
				output += `#${node.getAttribute('id')}`;
			}
			if (node.hasAttribute('class')){
				output += `.${node.classList.join('.')}`;
			}
			return output;
		}
 
		function printNode(node: DOMNode, level: number, sibling: number){
			if (level > 0){
				const indent = '  '.repeat(level - 1);
				const l = node.children.length > 0 ? '┬' : '─';
				const b = sibling < (node.parent.children.length - 1) ? '├' : '└';
				process.stdout.write(`${indent}${b}─${l} ${node.tagName}${decoration(node)}\n`);
			} else {
				process.stdout.write(`(root)\n`);
			}
 
			node.children.forEach((child, index) => printNode(child, level + 1, index));
		}
 
		printNode(dom.root, 0, 0);
 
		return {
			valid: true,
			results: [],
		};
	}
 
	/**
	 * Get configuration for given filename.
	 */
	getConfigFor(filename: string): Config {
		const loader = new ConfigLoader();
		const config = loader.fromTarget(filename);
		return this.globalConfig.merge(config);
	}
 
	loadRule(name: string, data: any, parser: Parser, report: Reporter){
		const [severity, options] = data;
		Eif (severity >= Config.SEVERITY_WARN){
			let rule;
			try {
				rule = require(`./rules/${name}`);
			} catch (e) {
				rule = {
					name: name,
					init: (parser: RuleParserProxy) => {
						parser.on('dom:load', (event: any, report: RuleReport) => {
							report(null, `Definition for rule '${name}' was not found`);
						});
					},
				} as Rule;
			}
			rule.init(this.createProxy(parser, rule, severity, report), options);
		}
	}
 
	/**
	 * Create a proxy event binding: parser <-- rule --> report
	 *
	 * Rule can bind events on parser while maintaining "this" bound to the rule.
	 * Callbacks receives an additional argument "report" to write messages to.
	 */
	createProxy(parser: Parser, rule: Rule, severity: number, report: Reporter){
		return {
			on: function(event: string, callback: RuleEventCallback){
				parser.on(event, function(event, data){
					callback.call(rule, data, reportFunc);
 
					function reportFunc(node: DOMNode, message: string, location: Location){
						const where = location || data.location || node.location;
						report.add(node, rule, message, severity, where);
					}
				});
			},
		};
	}
}
 
export default HtmlValidate;