├── .gitignore ├── .jshintrc ├── .travis.yml ├── CHANGELOG.md ├── CONTRIBUTING.md ├── FAQs.md ├── Gruntfile.js ├── README.md ├── bin ├── html-inspector ├── html-inspector-jsdom ├── phantom-runner.js └── release ├── bower.json ├── html-inspector.js ├── img └── html-inspector-console.png ├── package.json ├── src ├── callbacks.js ├── html-inspector.js ├── listener.js ├── modules.js ├── modules │ ├── css.js │ └── validation.js ├── reporter.js ├── rules.js ├── rules │ ├── best-practices │ │ ├── inline-event-handlers.js │ │ ├── script-placement.js │ │ ├── unnecessary-elements.js │ │ └── unused-classes.js │ ├── convention │ │ └── bem-conventions.js │ └── validation │ │ ├── duplicate-ids.js │ │ ├── unique-elements.js │ │ ├── validate-attributes.js │ │ ├── validate-element-location.js │ │ └── validate-elements.js └── utils │ ├── cross-origin.js │ └── string-matcher.js ├── test ├── classes │ ├── callbacks-test.js │ ├── listener-test.js │ ├── modules-test.js │ ├── reporter-test.js │ ├── rules-test.js │ └── string-matcher-test.js ├── html-inspector-test.css ├── html-inspector-test.html ├── html-inspector-test.js ├── html-inspector │ ├── html-inspector-test.js │ ├── modules-intro.txt │ ├── modules-outro.txt │ ├── modules │ │ ├── css-test.js │ │ └── validation-test.js │ ├── rules-intro.txt │ ├── rules-outro.txt │ └── rules │ │ ├── bem-conventions-test.js │ │ ├── duplicate-ids-test.js │ │ ├── inline-event-handlers-test.js │ │ ├── script-placement-test.js │ │ ├── unique-elements-test.js │ │ ├── unnecessary-elements-test.js │ │ ├── unused-classes-test.js │ │ ├── validate-attributes-test.js │ │ ├── validate-element-location-test.js │ │ └── validate-elements-test.js └── importee-test.css └── try.html /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | 3 | npm-debug.log 4 | node_modules 5 | -------------------------------------------------------------------------------- /.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "asi": true, 3 | "boss": true, 4 | "browser": true, 5 | "eqnull": true, 6 | "expr": true, 7 | "globals": { 8 | "console": false, 9 | "HTMLInspector": true 10 | }, 11 | "laxbreak": true, 12 | "laxcomma": true, 13 | "loopfunc": true, 14 | "newcap": false, 15 | "shadow": true 16 | } -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "0.10" 4 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ### 0.8.2 (January 30, 2015) 2 | 3 | * Fix an issue where the CLI doesn't work on windows. 4 | 5 | ### 0.8.1 (May 30, 2014) 6 | 7 | * Update SUIT CSS URL. 8 | 9 | ### 0.8.0 (February 7, 2014) 10 | 11 | * Remove the UMD wrapper to avoid CLI conflicts on pages using AMD. 12 | 13 | ### 0.7.3 (January 27, 2014) 14 | 15 | * Move shelljs from devDependencies to dependencies. 16 | 17 | ### 0.7.2 (January 22, 2014) 18 | 19 | * Warn if the user tries to use the CLI when phantomjs isn't installed. 20 | 21 | ### 0.7.1 (January 16, 2014) 22 | 23 | * Fix an incorrect reference to `dist/html-inspector.js` in phantom runner. 24 | 25 | ### 0.7.0 (November 9, 2013) 26 | 27 | * Move the main script from the `dist` folder to the project root. 28 | * Ignore all files execpt the main script in bower.json. 29 | 30 | ### 0.6.0 (November 9, 2013) 31 | 32 | * Add the ability to whitelist to every rule. 33 | * Fix a security warning in Firefox when trying to access properties of cross origin stylesheets. 34 | 35 | ### 0.5.1 (August 31, 2013) 36 | 37 | * Fix incorrectly reporting version number from CLI. 38 | 39 | ### 0.5.0 (August 31, 2013) 40 | 41 | * Add `excludeRules` option. 42 | * Rename the `exclude` option to `excludeElements`. 43 | * Rename the `excludeSubtree` option to `excludeSubtrees`. 44 | * Remove the setConfig method as it was overwriting previous setConfigs if there were multiple calls. 45 | * Improve the CLI and allow for async error reporting via the config file. 46 | * Convert HTML Inspector to be built with Browserify and released as a UMD module. 47 | * Convert tests from Jasmine to Mocha, Sinon, and Chai. 48 | * Remove the custom builds in favor better rule inclusion/exclusion options. 49 | 50 | ### 0.4.1 (July 10, 2013) 51 | 52 | * Update bower.json to the correct version. 53 | 54 | ### 0.4.0 (July 10, 2013) 55 | 56 | * Add a basic command line interface. 57 | * Fix a bug where @import statements weren't being accounted for when traversing the stylesheets. 58 | 59 | ### 0.3.0 (June 23, 2013) 60 | 61 | * Remove the dependency on jQuery 62 | * Add a validate-element-location rule that warns when elements appear as descendants of elements they're not allowed to descend from. 63 | * Remove the scoped-styles rule as it's now covered by the validate-element-location rule. 64 | * Add the ability to exclude DOM elements and DOM subtrees from inspection via the `exclude` and `excludeSubTree` config options. 65 | 66 | ### 0.2.3 (June 15, 2013) 67 | 68 | * Prevent scripts from warning in the script-placement rule if they have the `async` or `defer` attribute. 69 | 70 | ### 0.2.2 (June 15, 2013) 71 | 72 | * Allow a single RegExp to be a rule's whitelist option: previously an array of Regular Expressions (or strings) was required. 73 | 74 | ### 0.2.1 (June 13, 2013) 75 | 76 | * Fix an error in bower.json. 77 | 78 | ### 0.2.0 (June 13, 2013) 79 | 80 | * Update the inspector to not traverse elements and their children until rules for them can be added. 81 | * Fix a bug where invalid attributes on invalid elements were throwing an error. 82 | 83 | ### 0.1.1 (June 10, 2013) 84 | 85 | * First public release. 86 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to HTML Inspector 2 | 3 | Pull requests are greatly appreciated and encouraged. If you want to get your pull request accepted, please make sure it meets a few minimum requirements: 4 | 5 | - **Test files are required** 6 | If you're adding a new rule or module you must add a corresponding test. Check out how the built-in rules are tested for examples. 7 | 8 | - **Your changes must pass JSHint** 9 | You can run JSHint from the command line via `grunt jshint`. 10 | 11 | - **Follow the existing coding style** 12 | Consistency is key in any project, so any changes should look like the existing code. At a minimum please do the following: (1) Use two spaces for indentation, and (2) don't use semicolons at the end of lines (I know, I'm crazy, but that's how I like it). 13 | 14 | If you'd like to contribute but aren't sure how, take a look at the open issues in the issue tracker. There you'll find bug reports as well as feature requests; feel free to tackle either. -------------------------------------------------------------------------------- /FAQs.md: -------------------------------------------------------------------------------- 1 | # FAQs 2 | 3 | - [How is HTML Inspector different than the W3C Validator?](#how-is-html-inspector-different-than-the-w3c-validator) 4 | - [Can HTML Inspector detect unclosed tags?](#can-html-inspector-detect-unclosed-tags) 5 | - [Why does HTML Inspector warn me about unused classes when those classes *are* found in my stylesheets?](#why-does-html-inspector-warn-me-about-unused-classes-when-those-classes-are-found-in-my-stylesheets) 6 | - [How can I get HTML Inspector to stop warning me about unused Modernizr classes?](#how-can-i-get-html-inspector-to-stop-warning-me-about-unused-modernizr-classes) 7 | - [I use Disqus and I'm getting warnings about obsolete attributes on the `
  • `), and it will inform you of deprecated tags (like `
    `, ``, and more recently `
    `). Any element you don't want to be warned about can be whitelisted. 163 | 164 | - **Validate Element Location**: Make sure that elements don't appear as children of parents they're not allowed to descend from. An example of this is a block element like `
    ` appearing as the child of an inline element like ``. 165 | 166 | - **Validate Attributes**: Like validating elements, this rule will let you know if you're using attributes that don't belong on a particular element or perhaps don't belong on any element. If your project uses custom attributes (like `ng-*` in AngularJS), these can be whitelisted. 167 | 168 | - **Duplicate IDs**: Warn if non-unique IDs are found on the same page. 169 | 170 | - **Unique Elements**: Warn if elements that should be unique (like `` and `<main>`) appear more than once in the document. 171 | 172 | ### Best Practices 173 | 174 | Some markup may be perfectly valid but uses practices that are commonly considered to be poor or outdated. The following rules check for these types of things. (Note that everything in this list is subjective and optional.) 175 | 176 | - **Inline Event Handlers**: Warn if inline event handlers, like `onclick="return false"` are found in the document. Inline event handlers are hard to manage, hard to debug, and completely non-reusable. 177 | 178 | - **Script Placement**: Warn if `<script>` elements appear anywhere other than right before the closing `</body>` tag. Because JavaScript is a blocking resource, adding `<script>` elements anywhere other than the end of the document may delay the loading of the page. If a script must appear somewhere other than the end of the document, it can be whitelisted. 179 | 180 | - **Unused Classes**: Sometimes you'll remove a CSS rule from your stylesheet but forget to remove the class from the HTML. The "unused-classes" rule compares all the class selectors in the CSS to the classes in the HTML and reports any that aren't being used. 181 | 182 | Classes that are in the HTML as JavaScript hooks can be ignored via a whitelist. By default, any class prefixed with `js-`, `language-`, or `supports-` is whitelisted. More information on the rationale behind this rule can be found [here](http://philipwalton.com/articles/css-architecture/). 183 | 184 | - **Unnecessary Elements**: Anytime you have a plain `<div>` or `<span>` element in the HTML with no class, ID, or any other attribute, it's probably unnecessary or a mark of poor design. 185 | 186 | Elements with no semantic meaning should only be used for presentation. If the element has no attributes but is used for styling, it must be done through a rule like `.some-class > div { }`, which is just asking for trouble. Again, more information can be found [here](http://philipwalton.com/articles/css-architecture/). 187 | 188 | ### Convention 189 | 190 | The real power of HTML Inspector lies in its ability to enforce your team's chosen conventions. If you've decided that all groups of links should be contained in a `<nav>` element, or all `<section>` elements must contain a heading, you can write those rules and an error will be thrown when someone breaks them. 191 | 192 | Because conventions are usually specific to individual teams, there's only one built-in rule in this category, but hopefully it'll get you thinking about rules your team could use. 193 | 194 | - **BEM**: The increasingly popular [BEM](http://bem.info/) (block, element, modifier) methodology is a CSS naming convention that is very helpful for large projects. The problem is that using it correctly in the CSS is only half the battle. If it's not used correctly in the HTML it doesn't work either. 195 | 196 | This rule throws an error when an element class name is used but that element isn't a descendant of a block by the same name. It also errors when a modifier is used on a block or element without the unmodified class there too. 197 | 198 | *(Note: there are a few different BEM naming conventions out there. HTML Inspector support the [three most common](https://github.com/philipwalton/html-inspector/blob/master/src/rules/convention/bem-conventions.js#L3-L29))* 199 | 200 | ## Writing Your Own Rules 201 | 202 | Rules are the bread and butter of HTML Inspector. They are where you check for problems and report errors. 203 | 204 | Here's how you add new rules: 205 | 206 | ```js 207 | HTMLInspector.rules.add(name, [config], func) 208 | ``` 209 | 210 | - **name**: (String) The `name` parameter is a string used to identify the rule. It must be unique. 211 | - **config** *optional* (Object) The `config` parameter stores configuration data that is used by the rule. Anything that users of your rule might want to customize themselves should be set in the `config` object. 212 | - **func**: (Function) The `func` parameter is an initialization function that is invoked as soon as you call `HTMLInspector.inspect()`. The function is passed three arguments `listener`, `reporter`, and `config`. The `listener` object is used to subscribe to events that are triggered as HTML Inspector is traversing the DOM. When problems are found, they can be reported to the `reporter` object. The `config` object is the same `config` that was passed to `HTMLInspector.rules.add`, though its properties may have been customized by other users between then and now. 213 | 214 | ### Events 215 | 216 | The `listener` object can subscribe to events via the `on` method. Like with many other event binding libraries, `on` takes two parameters: the event name, and a callback function: 217 | 218 | ```js 219 | listener.on(event, callback) 220 | ``` 221 | 222 | - **event**: (String) The name of the event. See below for a complete list of events. 223 | - **callback**: (Function) A function to be invoked when the event occurs. The function will be passed certain arguments depending on the event type. See the event list below for argument details. 224 | 225 | Here is an example of binding a function to the "class" event: 226 | 227 | ```js 228 | listener.on("class", function(className, domElement) { 229 | if (className == "foo" and element.nodeName.toLowerCase() == "bar") { 230 | // report the error 231 | } 232 | }) 233 | ``` 234 | 235 | Below is a complete list of events along with the arguments that are passed to their respective handlers. For events that occur on a DOM element, that element is passed as the final argument. It is also bound to the `this` context. 236 | 237 | - **beforeInspect** : domRoot 238 | - **element** : elementName, domElement 239 | - **id**: idName, domElement 240 | - **class**: className, domElement 241 | - **attribute**: attrName, attrValue, domElement 242 | - **afterInspect** : domRoot 243 | 244 | ### Reporting Errors 245 | 246 | When you find something in the HTML that you to want warn about, you simply call the `warn` method on the `reporter` object. 247 | 248 | ```js 249 | reporter.warn(rule, message, context) 250 | ``` 251 | 252 | - **rule**: (String) The rule name identifier. 253 | - **message**: (String) The warning to report. 254 | - **context**: (mixed) The context in which the rule was broken. This is usually a DOM element or collection of DOM elements, but doesn't have to be. It can be anything that helps the user track down where the error occurred. 255 | 256 | Here's an example from the [validate-elements](https://github.com/philipwalton/html-inspector/blob/master/src/rules/validation/validate-elements.js) rule: 257 | 258 | ```js 259 | reporter.warn( 260 | "validate-elements", 261 | "The <" + name + "> element is not a valid HTML element.", 262 | element 263 | ) 264 | ``` 265 | 266 | ### An Example Rule 267 | 268 | Imagine your team previously used the custom data attributes `data-foo-*` and `data-bar-*`, but now the convention is to use something else. Here's a rule that would warn users when they're using the old convention: 269 | 270 | ```js 271 | HTMLInspector.rules.add( 272 | "deprecated-data-prefixes", 273 | { 274 | deprecated: ["foo", "bar"] 275 | }, 276 | function(listener, reporter, config) { 277 | 278 | // register a handler for the `attribute` event 279 | listener.on('attribute', function(name, value, element) { 280 | 281 | var prefix = /data-([a-z]+)/.test(name) && RegExp.$1 282 | 283 | // return if there's no data prefix 284 | if (!prefix) return 285 | 286 | // loop through each of the deprecated names from the 287 | // config array and compare them to the prefix. 288 | // Warn if they're the same 289 | config.deprecated.forEach(function(item) { 290 | if (item === prefix) { 291 | reporter.warn( 292 | "deprecated-data-prefixes", 293 | "The 'data-" + item + "' prefix is deprecated.", 294 | element 295 | ) 296 | } 297 | }) 298 | } 299 | ) 300 | }) 301 | ``` 302 | 303 | ## Overriding Rule Configurations 304 | 305 | Individual rules may or may not do exactly what you need, which is why most rules come with a configurations object that users can customize. A rule's configuration can be changed to meet your needs via the `extend` method of the `HTMLInspector.rules` object. 306 | 307 | ```js 308 | HTMLInspector.rules.extend(rule, overrides) 309 | ``` 310 | 311 | - **rule**: (String) The rule name identifier. 312 | - **overrides**: (Object | Function) An object (or function that returns an object) to be merged with the rule's config object. If `overrides` is a function, it will be passed the rule's config object as its first argument. 313 | 314 | Here are two examples overriding the "deprecated-data-prefixes" rule defined above. The first example passes an object and the second passes a function: 315 | 316 | ```js 317 | // using an object 318 | HTMLInspector.rules.extend("deprecated-data-prefixes", { 319 | deprecated: ["fizz", "buzz"] 320 | }) 321 | 322 | // using a function 323 | HTMLInspector.rules.extend("deprecated-data-prefixes", function(config) { 324 | return { 325 | deprecated: config.deprecated.concat(["bazz"]) 326 | } 327 | }) 328 | ``` 329 | 330 | Here are a few more examples. The following override the defaults of a few of the built-in rules. 331 | 332 | 333 | ```js 334 | // use the `inuit.css` BEM naming convention 335 | HTMLInspector.rules.extend("bem-conventions", { 336 | methodology: "inuit" 337 | }) 338 | 339 | // add Twitter generated classes to the whitelist 340 | HTMLInspector.rules.extend("unused-classes", { 341 | whitelist: /^js\-|^tweet\-/ 342 | }) 343 | ``` 344 | 345 | ## Browser Support 346 | 347 | HTML Inspector has been tested and known to work in the latest versions of all modern browsers including Chrome, Firefox, Safari, Opera, and Internet Explorer. It will not work in older browsers that do not support ES5 methods, the CSS Object Model, or `console.warn()`. Since HTML Inspector is primarily a development tool, it is not intended to work in browsers that aren't typically used for development and don't support modern Web standards. 348 | 349 | If you need to test your site in older versions of IE and don't want to see JavaScript errors, simply wrap all your HTML Inspector code inside a conditional comment, so it is ignored by IE9 and below. Here is an example: 350 | 351 | ```html 352 | <!--[if gt IE 9]><!--> 353 | <script src="path/to/html-inspector.js"></script> 354 | <script>HTMLInspector.inspect()</script> 355 | <!--<![endif]--> 356 | ``` 357 | 358 | ## Running the Tests 359 | 360 | If Grunt and all the dependencies are installed, you can run the tests with the following command. 361 | 362 | ```sh 363 | grunt test 364 | ``` 365 | 366 | HTML Inspector has two test suites, one that runs in pure Node and one that uses [Mocha](http://mochajs.org/) and [PhantomJS](http://phantomjs.org/) because it needs a browser. 367 | 368 | If you want to run the browser tests in a real browser (instead of via PhantomJS) simply fire up a local server and load the `tests/html-inspector-test.html` file. Make sure to run `grunt test` beforehand as it builds the tests. 369 | 370 | ## Contributing 371 | 372 | I'm always open to feedback and suggestions for how to make HTML Inspector better. All feedback from bug reports to API design is quite welcome. 373 | 374 | If you're submitting a bug report, please search the issues to make sure there isn't one already filed. 375 | 376 | If you're submitting a pull request please read [CONTRIBUTING.md](https://github.com/philipwalton/html-inspector/blob/master/CONTRIBUTING.md) before submitting. 377 | 378 | ## FAQs 379 | 380 | The FAQs section has grown rather large, so it has been moved to its own page. You can find the [full FAQs here](https://github.com/philipwalton/html-inspector/blob/master/FAQs.md). 381 | 382 | ## Third Party Rules 383 | 384 | - [Large Viewstate](https://github.com/palewar/html-inspector/blob/master/src/rules/best-practices/large-viewstate.js) - warn if View State takes up more than 50KB (configurable) in ASP.NET generated HTML 385 | - [Voice Input](https://googledrive.com/host/0B8yu2s4Q9YD8VEZNUHJaV3BkSzA/File.htm) - warn when input fields are inaccessible to voice input 386 | 387 | -------------------------------------------------------------------------------- /bin/html-inspector: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | require('colors') 4 | require('shelljs/global') 5 | 6 | // set the shelljs config 7 | config.silent = true 8 | 9 | var fs = require('fs') 10 | , path = require('path') 11 | , program = require('commander') 12 | 13 | , inspectLocation 14 | , configFile 15 | , basePath = path.normalize(__dirname + path.sep + '..') 16 | , version = JSON.parse(fs.readFileSync(path.join(basePath, "package.json"))).version 17 | , phantomRunner = basePath + path.sep + 'bin' + path.sep + 'phantom-runner.js' 18 | 19 | 20 | program 21 | .version(version) 22 | .usage('[options] <file or url>') 23 | .option('-c, --config [file]', 'Configuration file (./html-inspector-config.js)', './html-inspector-config.js') 24 | .parse(process.argv) 25 | 26 | if(program.args.length !== 1) { 27 | program.help() 28 | exit() 29 | } 30 | 31 | // warn is PhantomJS isn't install 32 | if (!which("phantomjs")) { 33 | echo("PhantomJS must be installed to use the HTML Inspector CLI.".red) 34 | exit(1) 35 | } 36 | 37 | // Try to resolve local file, otherwise assume and pass url 38 | var inputLocation = program.args[0] 39 | , inspectLocation = fs.existsSync(path.resolve(inputLocation)) ? path.resolve(inputLocation) : inputLocation 40 | 41 | if (program.config) { 42 | configFile = fs.existsSync(path.resolve(program.config)) 43 | ? path.resolve(program.config) 44 | : "" 45 | } 46 | 47 | // Spawn process to run PhantomJS from CLI 48 | function run(cmd, args, callback) { 49 | var spawn = require('child_process').spawn 50 | var command = spawn(cmd, args) 51 | var result = '' 52 | command.stdout.on('data', function(data) { 53 | result += data.toString() 54 | }) 55 | command.on('error', function() { 56 | console.log(arguments) 57 | }) 58 | command.on('close', function(code) { 59 | return callback(result) 60 | }) 61 | } 62 | 63 | run("phantomjs", [phantomRunner, basePath, inspectLocation, configFile], function(result) { 64 | 65 | var errors 66 | 67 | try { 68 | errors = JSON.parse(result) 69 | } catch(error) { 70 | console.log(result + '\n') 71 | } 72 | 73 | if(errors) { 74 | reporter.write(errors) 75 | } 76 | 77 | }) 78 | -------------------------------------------------------------------------------- /bin/html-inspector-jsdom: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | var fs = require('fs'); 4 | var jsdom = require('jsdom'); 5 | var program = require('commander'); 6 | 7 | program 8 | .version(require('../package.json').version) 9 | .usage('[options] <file or url>') 10 | .option('-c, --config [file]', 11 | 'Configuration file (./html-inspector-config.js)', 12 | './html-inspector-config.js') 13 | .parse(process.argv); 14 | 15 | if (program.args.length === 0) { 16 | program.help(); 17 | process.exit(); 18 | } 19 | 20 | // Try to resolve local file, otherwise assume and pass url 21 | // var inputLocation = program.args[0]; 22 | // inspectLocation = fs.existsSync(path.resolve(inputLocation)) ? path.resolve(inputLocation) : inputLocation; 23 | 24 | 25 | // if (program.config) { 26 | // configFile = fs.existsSync(path.resolve(program.config)) 27 | // ? path.resolve(program.config) : ""; 28 | // } 29 | 30 | jsdom.env( 31 | program.args[0], 32 | ['html-inspector.js'], 33 | function(errors, window) { 34 | 35 | window.HTMLInspector.inspect({ 36 | onComplete: function(errors) { 37 | errors.forEach(function(error) { 38 | console.log(error.message); 39 | }); 40 | } 41 | }); 42 | } 43 | ) 44 | -------------------------------------------------------------------------------- /bin/phantom-runner.js: -------------------------------------------------------------------------------- 1 | // Safe to assume arguments here 2 | var basePath = phantom.args[0] 3 | , inspectLocation = phantom.args[1] 4 | , configFile = phantom.args[2] 5 | 6 | var system = require('system') 7 | , page = require('webpage').create() 8 | 9 | page.onCallback = function(data) { 10 | if (data && data.sender && data.sender == "HTMLInspector") { 11 | console.log(data.message) 12 | } 13 | } 14 | 15 | page.onClosing = function() { 16 | phantom.exit() 17 | } 18 | 19 | page.onError = function(msg) { 20 | console.error(msg) 21 | phantom.exit() 22 | } 23 | 24 | page.onLoadFinished = function(status) { 25 | 26 | if(status !== 'success') { 27 | system.stdout.write('Unable to open location "' + inspectLocation + '"') 28 | phantom.exit() 29 | } 30 | 31 | var hasInspectorScript = page.evaluate(function() { 32 | return window.HTMLInspector 33 | }) 34 | 35 | if (!hasInspectorScript) { 36 | page.injectJs(basePath + '/html-inspector.js') 37 | } 38 | 39 | page.evaluate(function() { 40 | HTMLInspector.defaults.onComplete = function(errors) { 41 | window.callPhantom({ 42 | sender: "HTMLInspector", 43 | message: errors.map(function(error) { 44 | return "[" + error.rule + "] " + error.message 45 | }).join("\n") 46 | }) 47 | window.close() 48 | } 49 | }) 50 | 51 | if (configFile) { 52 | page.injectJs(configFile) 53 | } else { 54 | page.evaluate(function() { 55 | HTMLInspector.inspect() 56 | }) 57 | } 58 | } 59 | 60 | page.open(inspectLocation) 61 | -------------------------------------------------------------------------------- /bin/release: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | var fs = require("fs") 4 | , path = require("path") 5 | , shell = require("shelljs") 6 | , semver = require("semver") 7 | , colors = require("colors") 8 | , moment = require("moment") 9 | , version = determineVersion() 10 | 11 | updateChangelog() 12 | updateFiles() 13 | 14 | run([ 15 | "grunt", 16 | "git checkout master", 17 | "git add -A", 18 | "git commit -m 'Tag version " + version + ".'", 19 | "git tag " + version, 20 | "git push origin master", 21 | "git push --tags", 22 | "npm publish" 23 | ]) 24 | 25 | function run(commands) { 26 | var command 27 | while (command = commands.shift()) { 28 | console.log("\n >> Running: ".grey + command + "\n") 29 | if (shell.exec(command).code) { 30 | error("Aborted due to errors trying to run: " + command) 31 | } 32 | } 33 | } 34 | 35 | function error(msg) { 36 | console.error("\n" + " >> ".grey + msg.red + "\n") 37 | shell.exit(1) 38 | } 39 | 40 | function updateChangelog() { 41 | var filename = path.resolve(__dirname, "../CHANGELOG.md") 42 | , changelog = fs.readFileSync(filename, {encoding: "utf8"}) 43 | , entry = new RegExp("### (" + version + ")(?: \\((.+?)\\))?\\n") 44 | // get the changelog entry for the release version, warn if it's not found 45 | if (!entry.test(changelog)) { 46 | error("Cannot find entry for version " + version + " in CHANGELOG.md.") 47 | } 48 | fs.writeFileSync( 49 | filename, 50 | changelog.replace(entry, "### " + version + " (" + moment().format("MMMM D, YYYY") + ")\n") 51 | ) 52 | } 53 | 54 | function updateFiles() { 55 | var files = ["../package.json", "../bower.json"] 56 | files.forEach(function(file) { 57 | var filepath = path.resolve(__dirname, file) 58 | var data = JSON.parse(fs.readFileSync(filepath)) 59 | data.version = version 60 | fs.writeFileSync(filepath, JSON.stringify(data, null, 2) + "\n") 61 | }) 62 | } 63 | 64 | function determineVersion() { 65 | var oldVersion = JSON.parse(fs.readFileSync("./package.json")).version 66 | , newVersion = process.argv[2] 67 | if (!newVersion) { 68 | error("You must provide a version number.") 69 | } else if (["--major", "--minor", "--patch"].indexOf(newVersion) >= 0) { 70 | newVersion = semver.inc(oldVersion, newVersion.substr(2)) 71 | } else { 72 | newVersion = semver.parse(newVersion) 73 | if (!newVersion) error("Version number is invalid.") 74 | } 75 | return newVersion.toString() 76 | } 77 | -------------------------------------------------------------------------------- /bower.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "html-inspector", 3 | "version": "0.8.2", 4 | "main": "html-inspector.js", 5 | "ignore": [ 6 | "bin", 7 | "img", 8 | "src", 9 | "test", 10 | ".*", 11 | "bower.json", 12 | "CHANGELOG.md", 13 | "CONTRIBUTING.md", 14 | "FAQs.md", 15 | "Gruntfile.js", 16 | "package.json", 17 | "README.md", 18 | "try.html" 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /img/html-inspector-console.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/philipwalton/html-inspector/654899673b8618b4ca29a38a1de28eeee30493db/img/html-inspector-console.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "html-inspector", 3 | "title": "HTML Inspector", 4 | "version": "0.8.2", 5 | "description": "HTML Inspector is a code quality tool to help you and your team write better markup. It's written in JavaScript and runs in the browser, so testing your HTML has never been easier.", 6 | "license": "MIT", 7 | "author": { 8 | "name": "Philip Walton", 9 | "url": "http://philipwalton.com" 10 | }, 11 | "scripts": { 12 | "test": "./node_modules/.bin/grunt test" 13 | }, 14 | "repository": { 15 | "type": "git", 16 | "url": "git@github.com:philipwalton/html-inspector.git" 17 | }, 18 | "bugs": "https://github.com/philipwalton/html-inspector/issues", 19 | "bin": { 20 | "htmlinspector": "bin/html-inspector" 21 | }, 22 | "dependencies": { 23 | "colors": "*", 24 | "commander": "^2.6.0", 25 | "shelljs": "^0.3.0" 26 | }, 27 | "devDependencies": { 28 | "chai": "*", 29 | "dom-utils": "*", 30 | "grunt": "*", 31 | "grunt-cli": "~0.1.13", 32 | "grunt-contrib-concat": "*", 33 | "grunt-contrib-uglify": "*", 34 | "grunt-contrib-watch": "*", 35 | "grunt-contrib-jshint": "*", 36 | "grunt-browserify": "*", 37 | "grunt-mocha-phantomjs": "*", 38 | "grunt-mocha-cli": "*", 39 | "mocha": "*", 40 | "moment": "*", 41 | "mout": "*", 42 | "semver": "*", 43 | "sinon": "1.7.3" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/callbacks.js: -------------------------------------------------------------------------------- 1 | function Callbacks() { 2 | this.handlers = [] 3 | } 4 | 5 | Callbacks.prototype.add = function(fn) { 6 | this.handlers.push(fn) 7 | } 8 | 9 | Callbacks.prototype.remove = function(fn) { 10 | this.handlers = this.handlers.filter(function(handler) { 11 | return handler != fn 12 | }) 13 | } 14 | 15 | Callbacks.prototype.fire = function(context, args) { 16 | this.handlers.forEach(function(handler) { 17 | handler.apply(context, args) 18 | }) 19 | } 20 | 21 | module.exports = Callbacks 22 | -------------------------------------------------------------------------------- /src/html-inspector.js: -------------------------------------------------------------------------------- 1 | var Listener = require("./listener") 2 | , Modules = require("./modules") 3 | , Reporter = require("./reporter") 4 | , Rules = require("./rules") 5 | 6 | , toArray = require("mout/lang/toArray") 7 | , isRegExp = require("mout/lang/isRegExp") 8 | , unique = require("mout/array/unique") 9 | , mixIn = require("mout/object/mixIn") 10 | 11 | , matches = require("dom-utils/src/matches") 12 | , getAttributes = require("dom-utils/src/get-attributes") 13 | 14 | , isCrossOrigin = require("./utils/cross-origin") 15 | 16 | /** 17 | * Set (or reset) all data back to its original value 18 | * and initialize the specified rules 19 | */ 20 | function setup(listener, reporter, useRules, excludeRules) { 21 | var rules = useRules == null 22 | ? Object.keys(HTMLInspector.rules) 23 | : useRules 24 | if (excludeRules) { 25 | rules = rules.filter(function(rule) { 26 | return excludeRules.indexOf(rule) < 0 27 | }) 28 | } 29 | rules.forEach(function(rule) { 30 | if (HTMLInspector.rules[rule]) { 31 | HTMLInspector.rules[rule].func.call( 32 | HTMLInspector, 33 | listener, 34 | reporter, 35 | HTMLInspector.rules[rule].config 36 | ) 37 | } 38 | }) 39 | } 40 | 41 | function traverseDOM(listener, node, excludeElements, excludeSubTrees) { 42 | 43 | // only deal with element nodes 44 | if (node.nodeType != 1) return 45 | 46 | var attrs = getAttributes(node) 47 | 48 | // trigger events for this element unless it's been excluded 49 | if (!matches(node, excludeElements)) { 50 | listener.trigger("element", node, [node.nodeName.toLowerCase(), node]) 51 | if (node.id) { 52 | listener.trigger("id", node, [node.id, node]) 53 | } 54 | toArray(node.classList).forEach(function(name) { 55 | listener.trigger("class", node, [name, node]) 56 | }) 57 | Object.keys(attrs).sort().forEach(function(name) { 58 | listener.trigger("attribute", node, [name, attrs[name], node]) 59 | }) 60 | } 61 | 62 | // recurse through the subtree unless it's been excluded 63 | if (!matches(node, excludeSubTrees)) { 64 | toArray(node.childNodes).forEach(function(node) { 65 | traverseDOM(listener, node, excludeElements, excludeSubTrees) 66 | }) 67 | } 68 | } 69 | 70 | function mergeOptions(options) { 71 | // allow config to be individual properties of the defaults object 72 | if (options) { 73 | if (typeof options == "string" || options.nodeType == 1) { 74 | options = { domRoot: options } 75 | } else if (Array.isArray(options)) { 76 | options = { useRules: options } 77 | } else if (typeof options == "function") { 78 | options = { onComplete: options } 79 | } 80 | } 81 | 82 | // merge options with the defaults 83 | options = mixIn({}, HTMLInspector.defaults, options) 84 | 85 | // set the domRoot to an HTMLElement if it's not 86 | options.domRoot = typeof options.domRoot == "string" 87 | ? document.querySelector(options.domRoot) 88 | : options.domRoot 89 | 90 | return options 91 | } 92 | 93 | /** 94 | * cross-origin iframe elements throw errors when being 95 | * logged to the console. 96 | * This function removes them from the context before 97 | * logging them to the console. 98 | */ 99 | function filterCrossOrigin(elements) { 100 | // convert elements to an array if it's not already 101 | if (!Array.isArray(elements)) elements = [elements] 102 | elements = elements.map(function(el) { 103 | if (el 104 | && el.nodeName 105 | && el.nodeName.toLowerCase() == "iframe" 106 | && isCrossOrigin(el.src) 107 | ) 108 | return "(can't display iframe with cross-origin source: " + el.src + ")" 109 | else 110 | return el 111 | }) 112 | return elements.length === 1 ? elements[0] : elements 113 | } 114 | 115 | 116 | var HTMLInspector = { 117 | 118 | defaults: { 119 | domRoot: "html", 120 | useRules: null, 121 | excludeRules: null, 122 | excludeElements: "svg", 123 | excludeSubTrees: ["svg", "iframe"], 124 | onComplete: function(errors) { 125 | errors.forEach(function(error) { 126 | console.warn(error.message, filterCrossOrigin(error.context)) 127 | }) 128 | } 129 | }, 130 | 131 | rules: new Rules(), 132 | 133 | modules: new Modules(), 134 | 135 | inspect: function(options) { 136 | var config = mergeOptions(options) 137 | , listener = new Listener() 138 | , reporter = new Reporter() 139 | 140 | setup(listener, reporter, config.useRules, config.excludeRules) 141 | 142 | listener.trigger("beforeInspect", config.domRoot) 143 | traverseDOM(listener, config.domRoot, config.excludeElements, config.excludeSubTrees) 144 | listener.trigger("afterInspect", config.domRoot) 145 | 146 | config.onComplete(reporter.getWarnings()) 147 | } 148 | } 149 | 150 | HTMLInspector.modules.add( require("./modules/css.js") ) 151 | HTMLInspector.modules.add( require("./modules/validation.js") ) 152 | 153 | HTMLInspector.rules.add( require("./rules/best-practices/inline-event-handlers.js") ) 154 | HTMLInspector.rules.add( require("./rules/best-practices/script-placement.js") ) 155 | HTMLInspector.rules.add( require("./rules/best-practices/unnecessary-elements.js") ) 156 | HTMLInspector.rules.add( require("./rules/best-practices/unused-classes.js") ) 157 | HTMLInspector.rules.add( require("./rules/convention/bem-conventions.js") ) 158 | HTMLInspector.rules.add( require("./rules/validation/duplicate-ids.js") ) 159 | HTMLInspector.rules.add( require("./rules/validation/unique-elements.js") ) 160 | HTMLInspector.rules.add( require("./rules/validation/validate-attributes.js") ) 161 | HTMLInspector.rules.add( require("./rules/validation/validate-element-location.js") ) 162 | HTMLInspector.rules.add( require("./rules/validation/validate-elements.js") ) 163 | 164 | window.HTMLInspector = HTMLInspector 165 | -------------------------------------------------------------------------------- /src/listener.js: -------------------------------------------------------------------------------- 1 | var Callbacks = require("./callbacks") 2 | 3 | function Listener() { 4 | this._events = {} 5 | } 6 | 7 | Listener.prototype.on = function(event, fn) { 8 | this._events[event] || (this._events[event] = new Callbacks()) 9 | this._events[event].add(fn) 10 | } 11 | 12 | Listener.prototype.off = function(event, fn) { 13 | this._events[event] && this._events[event].remove(fn) 14 | } 15 | 16 | Listener.prototype.trigger = function(event, context, args) { 17 | this._events[event] && this._events[event].fire(context, args) 18 | } 19 | 20 | module.exports = Listener -------------------------------------------------------------------------------- /src/modules.js: -------------------------------------------------------------------------------- 1 | var mixIn = require("mout/object/mixIn") 2 | 3 | function Modules() {} 4 | 5 | Modules.prototype.add = function(obj) { 6 | this[obj.name] = obj.module 7 | } 8 | 9 | Modules.prototype.extend = function(name, options) { 10 | if (typeof options == "function") 11 | options = options.call(this[name], this[name]) 12 | mixIn(this[name], options) 13 | } 14 | 15 | module.exports = Modules -------------------------------------------------------------------------------- /src/modules/css.js: -------------------------------------------------------------------------------- 1 | var reClassSelector = /\.[a-z0-9_\-]+/ig 2 | , toArray = require("mout/lang/toArray") 3 | , unique = require("mout/array/unique") 4 | , matches = require("dom-utils/src/matches") 5 | , isCrossOrigin = require("../utils/cross-origin") 6 | 7 | /** 8 | * Get an array of class selectors from a CSSRuleList object 9 | */ 10 | function getClassesFromRuleList(rulelist) { 11 | return rulelist.reduce(function(classes, rule) { 12 | var matches 13 | if (rule.styleSheet) { // from @import rules 14 | return classes.concat(getClassesFromStyleSheets([rule.styleSheet])) 15 | } 16 | else if (rule.cssRules) { // from @media rules (or other conditionals) 17 | return classes.concat(getClassesFromRuleList(toArray(rule.cssRules))) 18 | } 19 | else if (rule.selectorText) { 20 | matches = rule.selectorText.match(reClassSelector) || [] 21 | return classes.concat(matches.map(function(cls) { return cls.slice(1) } )) 22 | } 23 | return classes 24 | }, []) 25 | } 26 | 27 | /** 28 | * Get an array of class selectors from a CSSSytleSheetList object 29 | */ 30 | function getClassesFromStyleSheets(styleSheets) { 31 | return styleSheets.reduce(function(classes, sheet) { 32 | // cross origin stylesheets don't expose their cssRules property 33 | return sheet.href && isCrossOrigin(sheet.href) 34 | ? classes 35 | : classes.concat(getClassesFromRuleList(toArray(sheet.cssRules))) 36 | }, []) 37 | } 38 | 39 | function getStyleSheets() { 40 | return toArray(document.styleSheets).filter(function(sheet) { 41 | return matches(sheet.ownerNode, css.styleSheets) 42 | }) 43 | } 44 | 45 | var css = { 46 | getClassSelectors: function() { 47 | return unique(getClassesFromStyleSheets(getStyleSheets())) 48 | }, 49 | // getSelectors: function() { 50 | // return [] 51 | // }, 52 | styleSheets: 'link[rel="stylesheet"], style' 53 | } 54 | 55 | module.exports = { 56 | name: "css", 57 | module: css 58 | } 59 | -------------------------------------------------------------------------------- /src/modules/validation.js: -------------------------------------------------------------------------------- 1 | var foundIn = require("../utils/string-matcher") 2 | 3 | // ============================================================ 4 | // A data map of all valid HTML elements, their attributes 5 | // and what type of children they may contain 6 | // 7 | // http://drafts.htmlwg.org/html/master/iana.html#index 8 | // ============================================================ 9 | 10 | var elementData = { 11 | "a": { 12 | children: "transparent*", 13 | attributes: "globals; href; target; download; rel; hreflang; type" 14 | }, 15 | "abbr": { 16 | children: "phrasing", 17 | attributes: "globals" 18 | }, 19 | "address": { 20 | children: "flow*", 21 | attributes: "globals" 22 | }, 23 | "area": { 24 | children: "empty", 25 | attributes: "globals; alt; coords; shape; href; target; download; rel; hreflang; type" 26 | }, 27 | "article": { 28 | children: "flow", 29 | attributes: "globals" 30 | }, 31 | "aside": { 32 | children: "flow", 33 | attributes: "globals" 34 | }, 35 | "audio": { 36 | children: "source*; transparent*", 37 | attributes: "globals; src; crossorigin; preload; autoplay; mediagroup; loop; muted; controls" 38 | }, 39 | "b": { 40 | children: "phrasing", 41 | attributes: "globals" 42 | }, 43 | "base": { 44 | children: "empty", 45 | attributes: "globals; href; target" 46 | }, 47 | "bdi": { 48 | children: "phrasing", 49 | attributes: "globals" 50 | }, 51 | "bdo": { 52 | children: "phrasing", 53 | attributes: "globals" 54 | }, 55 | "blockquote": { 56 | children: "flow", 57 | attributes: "globals; cite" 58 | }, 59 | "body": { 60 | children: "flow", 61 | attributes: "globals; onafterprint; onbeforeprint; onbeforeunload; onfullscreenchange; onfullscreenerror; onhashchange; onmessage; onoffline; ononline; onpagehide; onpageshow; onpopstate; onresize; onstorage; onunload" 62 | }, 63 | "br": { 64 | children: "empty", 65 | attributes: "globals" 66 | }, 67 | "button": { 68 | children: "phrasing*", 69 | attributes: "globals; autofocus; disabled; form; formaction; formenctype; formmethod; formnovalidate; formtarget; name; type; value" 70 | }, 71 | "canvas": { 72 | children: "transparent", 73 | attributes: "globals; width; height" 74 | }, 75 | "caption": { 76 | children: "flow*", 77 | attributes: "globals" 78 | }, 79 | "cite": { 80 | children: "phrasing", 81 | attributes: "globals" 82 | }, 83 | "code": { 84 | children: "phrasing", 85 | attributes: "globals" 86 | }, 87 | "col": { 88 | children: "empty", 89 | attributes: "globals; span" 90 | }, 91 | "colgroup": { 92 | children: "col", 93 | attributes: "globals; span" 94 | }, 95 | "menuitem": { 96 | children: "empty", 97 | attributes: "globals; type; label; icon; disabled; checked; radiogroup; command" 98 | }, 99 | "data": { 100 | children: "phrasing", 101 | attributes: "globals; value" 102 | }, 103 | "datalist": { 104 | children: "phrasing; option", 105 | attributes: "globals" 106 | }, 107 | "dd": { 108 | children: "flow", 109 | attributes: "globals" 110 | }, 111 | "del": { 112 | children: "transparent", 113 | attributes: "globals; cite; datetime" 114 | }, 115 | "details": { 116 | children: "summary*; flow", 117 | attributes: "globals; open" 118 | }, 119 | "dfn": { 120 | children: "phrasing*", 121 | attributes: "globals" 122 | }, 123 | "dialog": { 124 | children: "flow", 125 | attributes: "globals; open" 126 | }, 127 | "div": { 128 | children: "flow", 129 | attributes: "globals" 130 | }, 131 | "dl": { 132 | children: "dt*; dd*", 133 | attributes: "globals" 134 | }, 135 | "dt": { 136 | children: "flow*", 137 | attributes: "globals" 138 | }, 139 | "em": { 140 | children: "phrasing", 141 | attributes: "globals" 142 | }, 143 | "embed": { 144 | children: "empty", 145 | attributes: "globals; src; type; width; height; any*" 146 | }, 147 | "fieldset": { 148 | children: "legend*; flow", 149 | attributes: "globals; disabled; form; name" 150 | }, 151 | "figcaption": { 152 | children: "flow", 153 | attributes: "globals" 154 | }, 155 | "figure": { 156 | children: "figcaption*; flow", 157 | attributes: "globals" 158 | }, 159 | "footer": { 160 | children: "flow*", 161 | attributes: "globals" 162 | }, 163 | "form": { 164 | children: "flow*", 165 | attributes: "globals; accept-charset; action; autocomplete; enctype; method; name; novalidate; target" 166 | }, 167 | "h1": { 168 | children: "phrasing", 169 | attributes: "globals" 170 | }, 171 | "h2": { 172 | children: "phrasing", 173 | attributes: "globals" 174 | }, 175 | "h3": { 176 | children: "phrasing", 177 | attributes: "globals" 178 | }, 179 | "h4": { 180 | children: "phrasing", 181 | attributes: "globals" 182 | }, 183 | "h5": { 184 | children: "phrasing", 185 | attributes: "globals" 186 | }, 187 | "h6": { 188 | children: "phrasing", 189 | attributes: "globals" 190 | }, 191 | "head": { 192 | children: "metadata content*", 193 | attributes: "globals" 194 | }, 195 | "header": { 196 | children: "flow*", 197 | attributes: "globals" 198 | }, 199 | "hr": { 200 | children: "empty", 201 | attributes: "globals" 202 | }, 203 | "html": { 204 | children: "head*; body*", 205 | attributes: "globals; manifest" 206 | }, 207 | "i": { 208 | children: "phrasing", 209 | attributes: "globals" 210 | }, 211 | "iframe": { 212 | children: "text*", 213 | attributes: "globals; src; srcdoc; name; sandbox; seamless; allowfullscreen; width; height" 214 | }, 215 | "img": { 216 | children: "empty", 217 | attributes: "globals; alt; src; crossorigin; usemap; ismap; width; height" 218 | }, 219 | "input": { 220 | children: "empty", 221 | attributes: "globals; accept; alt; autocomplete; autofocus; checked; dirname; disabled; form; formaction; formenctype; formmethod; formnovalidate; formtarget; height; list; max; maxlength; min; multiple; name; pattern; placeholder; readonly; required; size; src; step; type; value; width" 222 | }, 223 | "ins": { 224 | children: "transparent", 225 | attributes: "globals; cite; datetime" 226 | }, 227 | "kbd": { 228 | children: "phrasing", 229 | attributes: "globals" 230 | }, 231 | "keygen": { 232 | children: "empty", 233 | attributes: "globals; autofocus; challenge; disabled; form; keytype; name" 234 | }, 235 | "label": { 236 | children: "phrasing*", 237 | attributes: "globals; form; for" 238 | }, 239 | "legend": { 240 | children: "phrasing", 241 | attributes: "globals" 242 | }, 243 | "li": { 244 | children: "flow", 245 | attributes: "globals; value*" 246 | }, 247 | "link": { 248 | children: "empty", 249 | attributes: "globals; href; crossorigin; rel; media; hreflang; type; sizes" 250 | }, 251 | "main": { 252 | children: "flow*", 253 | attributes: "globals" 254 | }, 255 | "map": { 256 | children: "transparent; area*", 257 | attributes: "globals; name" 258 | }, 259 | "mark": { 260 | children: "phrasing", 261 | attributes: "globals" 262 | }, 263 | "menu": { 264 | children: "li*; flow*; menuitem*; hr*; menu*", 265 | attributes: "globals; type; label" 266 | }, 267 | "meta": { 268 | children: "empty", 269 | attributes: "globals; name; http-equiv; content; charset" 270 | }, 271 | "meter": { 272 | children: "phrasing*", 273 | attributes: "globals; value; min; max; low; high; optimum" 274 | }, 275 | "nav": { 276 | children: "flow", 277 | attributes: "globals" 278 | }, 279 | "noscript": { 280 | children: "varies*", 281 | attributes: "globals" 282 | }, 283 | "object": { 284 | children: "param*; transparent", 285 | attributes: "globals; data; type; typemustmatch; name; usemap; form; width; height" 286 | }, 287 | "ol": { 288 | children: "li", 289 | attributes: "globals; reversed; start; type" 290 | }, 291 | "optgroup": { 292 | children: "option", 293 | attributes: "globals; disabled; label" 294 | }, 295 | "option": { 296 | children: "text*", 297 | attributes: "globals; disabled; label; selected; value" 298 | }, 299 | "output": { 300 | children: "phrasing", 301 | attributes: "globals; for; form; name" 302 | }, 303 | "p": { 304 | children: "phrasing", 305 | attributes: "globals" 306 | }, 307 | "param": { 308 | children: "empty", 309 | attributes: "globals; name; value" 310 | }, 311 | "pre": { 312 | children: "phrasing", 313 | attributes: "globals" 314 | }, 315 | "progress": { 316 | children: "phrasing*", 317 | attributes: "globals; value; max" 318 | }, 319 | "q": { 320 | children: "phrasing", 321 | attributes: "globals; cite" 322 | }, 323 | "rp": { 324 | children: "phrasing", 325 | attributes: "globals" 326 | }, 327 | "rt": { 328 | children: "phrasing", 329 | attributes: "globals" 330 | }, 331 | "ruby": { 332 | children: "phrasing; rt; rp*", 333 | attributes: "globals" 334 | }, 335 | "s": { 336 | children: "phrasing", 337 | attributes: "globals" 338 | }, 339 | "samp": { 340 | children: "phrasing", 341 | attributes: "globals" 342 | }, 343 | "script": { 344 | children: "script, data, or script documentation*", 345 | attributes: "globals; src; type; charset; async; defer; crossorigin" 346 | }, 347 | "section": { 348 | children: "flow", 349 | attributes: "globals" 350 | }, 351 | "select": { 352 | children: "option; optgroup", 353 | attributes: "globals; autofocus; disabled; form; multiple; name; required; size" 354 | }, 355 | "small": { 356 | children: "phrasing", 357 | attributes: "globals" 358 | }, 359 | "source": { 360 | children: "empty", 361 | attributes: "globals; src; type; media" 362 | }, 363 | "span": { 364 | children: "phrasing", 365 | attributes: "globals" 366 | }, 367 | "strong": { 368 | children: "phrasing", 369 | attributes: "globals" 370 | }, 371 | "style": { 372 | children: "varies*", 373 | attributes: "globals; media; type; scoped" 374 | }, 375 | "sub": { 376 | children: "phrasing", 377 | attributes: "globals" 378 | }, 379 | "summary": { 380 | children: "phrasing", 381 | attributes: "globals" 382 | }, 383 | "sup": { 384 | children: "phrasing", 385 | attributes: "globals" 386 | }, 387 | "table": { 388 | children: "caption*; colgroup*; thead*; tbody*; tfoot*; tr*", 389 | attributes: "globals; border" 390 | }, 391 | "tbody": { 392 | children: "tr", 393 | attributes: "globals" 394 | }, 395 | "td": { 396 | children: "flow", 397 | attributes: "globals; colspan; rowspan; headers" 398 | }, 399 | "template": { 400 | children: "flow; metadata", 401 | attributes: "globals" 402 | }, 403 | "textarea": { 404 | children: "text", 405 | attributes: "globals; autofocus; cols; dirname; disabled; form; maxlength; name; placeholder; readonly; required; rows; wrap" 406 | }, 407 | "tfoot": { 408 | children: "tr", 409 | attributes: "globals" 410 | }, 411 | "th": { 412 | children: "flow*", 413 | attributes: "globals; colspan; rowspan; headers; scope; abbr" 414 | }, 415 | "thead": { 416 | children: "tr", 417 | attributes: "globals" 418 | }, 419 | "time": { 420 | children: "phrasing", 421 | attributes: "globals; datetime" 422 | }, 423 | "title": { 424 | children: "text*", 425 | attributes: "globals" 426 | }, 427 | "tr": { 428 | children: "th*; td", 429 | attributes: "globals" 430 | }, 431 | "track": { 432 | children: "empty", 433 | attributes: "globals; default; kind; label; src; srclang" 434 | }, 435 | "u": { 436 | children: "phrasing", 437 | attributes: "globals" 438 | }, 439 | "ul": { 440 | children: "li", 441 | attributes: "globals" 442 | }, 443 | "var": { 444 | children: "phrasing", 445 | attributes: "globals" 446 | }, 447 | "video": { 448 | children: "source*; transparent*", 449 | attributes: "globals; src; crossorigin; poster; preload; autoplay; mediagroup; loop; muted; controls; width; height" 450 | }, 451 | "wbr": { 452 | children: "empty", 453 | attributes: "globals" 454 | } 455 | } 456 | 457 | // ============================================================ 458 | // Element categories and the elements that are in those 459 | // categories. Elements may be in more than one category 460 | // 461 | // http://drafts.htmlwg.org/html/master/iana.html#element-content-categories 462 | // ============================================================ 463 | 464 | var elementCategories = { 465 | "metadata": { 466 | elements: ["base", "link", "meta", "noscript", "script", "style", "title"] 467 | }, 468 | "flow": { 469 | elements: ["a", "abbr", "address", "article", "aside", "audio", "b", "bdi", "bdo", "blockquote", "br", "button", "canvas", "cite", "code", "data", "datalist", "del", "details", "dfn", "dialog", "div", "dl", "em", "embed", "fieldset", "figure", "footer", "form", "h1", "h2", "h3", "h4", "h5", "h6", "header", "hr", "i", "iframe", "img", "input", "ins", "kbd", "keygen", "label", "main", "map", "mark", "math", "menu", "meter", "nav", "noscript", "object", "ol", "output", "p", "pre", "progress", "q", "ruby", "s", "samp", "script", "section", "select", "small", "span", "strong", "sub", "sup", "svg", "table", "textarea", "time", "u", "ul", "var", "video", "wbr"], 470 | exceptions: ["area", "link", "meta", "style"], 471 | exceptionsSelectors: ["map area", "link[itemprop]", "meta[itemprop]", "style[scoped]"] 472 | }, 473 | "sectioning": { 474 | elements: ["article", "aside", "nav", "section"] 475 | }, 476 | "heading": { 477 | elements: ["h1", "h2", "h3", "h4", "h5", "h6"] 478 | }, 479 | "phrasing": { 480 | elements: ["a", "abbr", "audio", "b", "bdi", "bdo", "br", "button", "canvas", "cite", "code", "data", "datalist", "del", "dfn", "em", "embed", "i", "iframe", "img", "input", "ins", "kbd", "keygen", "label", "map", "mark", "math", "meter", "noscript", "object", "output", "progress", "q", "ruby", "s", "samp", "script", "select", "small", "span", "strong", "sub", "sup", "svg", "textarea", "time", "u", "var", "video", "wbr"], 481 | exceptions: ["area", "link", "meta"], 482 | exceptionsSelectors: ["map area", "link[itemprop]", "meta[itemprop]"] 483 | }, 484 | "embedded": { 485 | elements: ["audio", "canvas", "embed", "iframe", "img", "math", "object", "svg", "video"] 486 | }, 487 | "interactive": { 488 | elements: ["a", "button", "details", "embed", "iframe", "keygen", "label", "select", "textarea"], 489 | exceptions: ["audio", "img", "input", "object", "video"], 490 | exceptionsSelectors: ["audio[controls]", "img[usemap]", "input:not([type=hidden])", "object[usemap]", "video[controls]"] 491 | }, 492 | "sectioning roots": { 493 | elements: ["blockquote", "body", "details", "dialog", "fieldset", "figure", "td"] 494 | }, 495 | "form-associated": { 496 | elements: ["button", "fieldset", "input", "keygen", "label", "object", "output", "select", "textarea"] 497 | }, 498 | "listed": { 499 | elements: ["button", "fieldset", "input", "keygen", "object", "output", "select", "textarea"] 500 | }, 501 | "submittable": { 502 | elements: ["button", "input", "keygen", "object", "select", "textarea"] 503 | }, 504 | "resettable": { 505 | elements: ["input", "keygen", "output", "select", "textarea"] 506 | }, 507 | "labelable": { 508 | elements: ["button", "input", "keygen", "meter", "output", "progress", "select", "textarea"] 509 | }, 510 | "palpable": { 511 | elements: ["a", "abbr", "address", "article", "aside", "b", "bdi", "bdo", "blockquote", "button", "canvas", "cite", "code", "data", "details", "dfn", "div", "em", "embed", "fieldset", "figure", "footer", "form", "h1", "h2", "h3", "h4", "h5", "h6", "header", "i", "iframe", "img", "ins", "kbd", "keygen", "label", "map", "mark", "math", "meter", "nav", "object", "output", "p", "pre", "progress", "q", "ruby", "s", "samp", "section", "select", "small", "span", "strong", "sub", "sup", "svg", "table", "textarea", "time", "u", "var", "video"], 512 | exceptions: ["audio", "dl", "input", "menu", "ol", "ul"], 513 | exceptionsSelectors: ["audio[controls]", "dl", "input:not([type=hidden])", "menu[type=toolbar]", "ol", "ul"] 514 | } 515 | } 516 | 517 | // ============================================================ 518 | // Attributes that may be used on any valid HTML element 519 | // 520 | // http://drafts.htmlwg.org/html/master/dom.html#global-attributes 521 | // ============================================================ 522 | 523 | var globalAttributes = [ 524 | // primary 525 | "accesskey", 526 | "class", 527 | "contenteditable", 528 | "contextmenu", 529 | "dir", 530 | "draggable", 531 | "dropzone", 532 | "hidden", 533 | "id", 534 | "inert", 535 | "itemid", 536 | "itemprop", 537 | "itemref", 538 | "itemscope", 539 | "itemtype", 540 | "lang", 541 | "spellcheck", 542 | "style", 543 | "tabindex", 544 | "title", 545 | "translate", 546 | // additional 547 | "role", 548 | /aria-[a-z\-]+/, 549 | /data-[a-z\-]+/, 550 | /on[a-z\-]+/ 551 | ] 552 | 553 | // ============================================================ 554 | // HTML elements that are obsolete and no longer allowed 555 | // 556 | // http://drafts.htmlwg.org/html/master/obsolete.html#obsolete 557 | // ============================================================ 558 | 559 | var obsoluteElements = [ 560 | "applet", 561 | "acronym", 562 | "bgsound", 563 | "dir", 564 | "frame", 565 | "frameset", 566 | "noframes", 567 | "hgroup", 568 | "isindex", 569 | "listing", 570 | "nextid", 571 | "noembed", 572 | "plaintext", 573 | "rb", 574 | "strike", 575 | "xmp", 576 | "basefont", 577 | "big", 578 | "blink", 579 | "center", 580 | "font", 581 | "marquee", 582 | "multicol", 583 | "nobr", 584 | "spacer", 585 | "tt" 586 | ] 587 | 588 | // ============================================================ 589 | // Attributes that are obsolete on certain elements 590 | // 591 | // http://drafts.htmlwg.org/html/master/obsolete.html#obsolete 592 | // ============================================================ 593 | 594 | var obsoleteAttributes = [ 595 | { attribute: "charset", elements: "a" }, 596 | { attribute: "charset", elements: "link" }, 597 | { attribute: "coords", elements: "a" }, 598 | { attribute: "shape", elements: "a" }, 599 | { attribute: "methods", elements: "a" }, 600 | { attribute: "methods", elements: "link" }, 601 | { attribute: "name", elements: "a" }, 602 | { attribute: "name", elements: "embed" }, 603 | { attribute: "name", elements: "img" }, 604 | { attribute: "name", elements: "option" }, 605 | { attribute: "rev", elements: "a" }, 606 | { attribute: "rev", elements: "link" }, 607 | { attribute: "urn", elements: "a" }, 608 | { attribute: "urn", elements: "link" }, 609 | { attribute: "accept", elements: "form" }, 610 | { attribute: "nohref", elements: "area" }, 611 | { attribute: "profile", elements: "head" }, 612 | { attribute: "version", elements: "html" }, 613 | { attribute: "ismap", elements: "input" }, 614 | { attribute: "usemap", elements: "input" }, 615 | { attribute: "longdesc", elements: "iframe" }, 616 | { attribute: "longdesc", elements: "img" }, 617 | { attribute: "lowsrc", elements: "img" }, 618 | { attribute: "target", elements: "link" }, 619 | { attribute: "scheme", elements: "meta" }, 620 | { attribute: "archive", elements: "object" }, 621 | { attribute: "classid", elements: "object" }, 622 | { attribute: "code", elements: "object" }, 623 | { attribute: "codebase", elements: "object" }, 624 | { attribute: "codetype", elements: "object" }, 625 | { attribute: "declare", elements: "object" }, 626 | { attribute: "standby", elements: "object" }, 627 | { attribute: "type", elements: "param" }, 628 | { attribute: "valuetype", elements: "param" }, 629 | { attribute: "language", elements: "script" }, 630 | { attribute: "event", elements: "script" }, 631 | { attribute: "for", elements: "script" }, 632 | { attribute: "datapagesize", elements: "table" }, 633 | { attribute: "summary", elements: "table" }, 634 | { attribute: "axis", elements: "td; th" }, 635 | { attribute: "scope", elements: "td" }, 636 | { attribute: "datasrc", elements: "a; applet; button; div; frame; iframe; img; input; label; legend; marquee; object; option; select; span; table; textarea" }, 637 | { attribute: "datafld", elements: "a; applet; button; div; fieldset; frame; iframe; img; input; label; legend; marquee; object; param; select; span; textarea" }, 638 | { attribute: "dataformatas", elements: "button; div; input; label; legend; marquee; object; option; select; span; table" }, 639 | { attribute: "alink", elements: "body" }, 640 | { attribute: "bgcolor", elements: "body" }, 641 | { attribute: "link", elements: "body" }, 642 | { attribute: "marginbottom", elements: "body" }, 643 | { attribute: "marginheight", elements: "body" }, 644 | { attribute: "marginleft", elements: "body" }, 645 | { attribute: "marginright", elements: "body" }, 646 | { attribute: "margintop", elements: "body" }, 647 | { attribute: "marginwidth", elements: "body" }, 648 | { attribute: "text", elements: "body" }, 649 | { attribute: "vlink", elements: "body" }, 650 | { attribute: "clear", elements: "br" }, 651 | { attribute: "align", elements: "caption" }, 652 | { attribute: "align", elements: "col" }, 653 | { attribute: "char", elements: "col" }, 654 | { attribute: "charoff", elements: "col" }, 655 | { attribute: "valign", elements: "col" }, 656 | { attribute: "width", elements: "col" }, 657 | { attribute: "align", elements: "div" }, 658 | { attribute: "compact", elements: "dl" }, 659 | { attribute: "align", elements: "embed" }, 660 | { attribute: "hspace", elements: "embed" }, 661 | { attribute: "vspace", elements: "embed" }, 662 | { attribute: "align", elements: "hr" }, 663 | { attribute: "color", elements: "hr" }, 664 | { attribute: "noshade", elements: "hr" }, 665 | { attribute: "size", elements: "hr" }, 666 | { attribute: "width", elements: "hr" }, 667 | { attribute: "align", elements: "h1; h2; h3; h4; h5; h6" }, 668 | { attribute: "align", elements: "iframe" }, 669 | { attribute: "allowtransparency", elements: "iframe" }, 670 | { attribute: "frameborder", elements: "iframe" }, 671 | { attribute: "hspace", elements: "iframe" }, 672 | { attribute: "marginheight", elements: "iframe" }, 673 | { attribute: "marginwidth", elements: "iframe" }, 674 | { attribute: "scrolling", elements: "iframe" }, 675 | { attribute: "vspace", elements: "iframe" }, 676 | { attribute: "align", elements: "input" }, 677 | { attribute: "hspace", elements: "input" }, 678 | { attribute: "vspace", elements: "input" }, 679 | { attribute: "align", elements: "img" }, 680 | { attribute: "border", elements: "img" }, 681 | { attribute: "hspace", elements: "img" }, 682 | { attribute: "vspace", elements: "img" }, 683 | { attribute: "align", elements: "legend" }, 684 | { attribute: "type", elements: "li" }, 685 | { attribute: "compact", elements: "menu" }, 686 | { attribute: "align", elements: "object" }, 687 | { attribute: "border", elements: "object" }, 688 | { attribute: "hspace", elements: "object" }, 689 | { attribute: "vspace", elements: "object" }, 690 | { attribute: "compact", elements: "ol" }, 691 | { attribute: "align", elements: "p" }, 692 | { attribute: "width", elements: "pre" }, 693 | { attribute: "align", elements: "table" }, 694 | { attribute: "bgcolor", elements: "table" }, 695 | { attribute: "cellpadding", elements: "table" }, 696 | { attribute: "cellspacing", elements: "table" }, 697 | { attribute: "frame", elements: "table" }, 698 | { attribute: "rules", elements: "table" }, 699 | { attribute: "width", elements: "table" }, 700 | { attribute: "align", elements: "tbody; thead; tfoot" }, 701 | { attribute: "char", elements: "tbody; thead; tfoot" }, 702 | { attribute: "charoff", elements: "tbody; thead; tfoot" }, 703 | { attribute: "valign", elements: "tbody; thead; tfoot" }, 704 | { attribute: "align", elements: "td; th" }, 705 | { attribute: "bgcolor", elements: "td; th" }, 706 | { attribute: "char", elements: "td; th" }, 707 | { attribute: "charoff", elements: "td; th" }, 708 | { attribute: "height", elements: "td; th" }, 709 | { attribute: "nowrap", elements: "td; th" }, 710 | { attribute: "valign", elements: "td; th" }, 711 | { attribute: "width", elements: "td; th" }, 712 | { attribute: "align", elements: "tr" }, 713 | { attribute: "bgcolor", elements: "tr" }, 714 | { attribute: "char", elements: "tr" }, 715 | { attribute: "charoff", elements: "tr" }, 716 | { attribute: "valign", elements: "tr" }, 717 | { attribute: "compact", elements: "ul" }, 718 | { attribute: "type", elements: "ul" }, 719 | { attribute: "background", elements: "body; table; thead; tbody; tfoot; tr; td; th" } 720 | ] 721 | 722 | // ============================================================ 723 | // Attributes that are required to be on particular elements 724 | // 725 | // http://www.w3.org/TR/html4/index/attributes.html 726 | // http://www.w3.org/TR/html5-diff/#changed-attributes 727 | // 728 | // TODO: find a better, more comprehensive source for this 729 | // ============================================================ 730 | 731 | var requiredAttributes = [ 732 | { attributes: ["alt"], element: "area" }, 733 | { attributes: ["height", "width"], element: "applet" }, 734 | { attributes: ["dir"], element: "bdo" }, 735 | { attributes: ["action"], element: "form" }, 736 | { attributes: ["alt", "src"], element: "img" }, 737 | { attributes: ["name"], element: "map" }, 738 | { attributes: ["label"], element: "optgroup" }, 739 | { attributes: ["name"], element: "param" }, 740 | { attributes: ["cols", "rows"], element: "textarea" } 741 | ] 742 | 743 | // ============================================================ 744 | // A complete list of valid elements in HTML. This is 745 | // programatically generated form the elementData variable 746 | // ============================================================ 747 | 748 | var elements = Object.keys(elementData).sort() 749 | 750 | // TODO: memoize these functions 751 | 752 | function elementName(element) { 753 | if (typeof element == "string") 754 | return element 755 | if (element.nodeType) 756 | return element.nodeName.toLowerCase() 757 | } 758 | 759 | function allowedAttributesForElement(element) { 760 | // return an empty array if the element is invalid 761 | if (elementData[element]) 762 | return elementData[element].attributes.replace(/\*/g, "").split(/\s*;\s*/) 763 | else 764 | return [] 765 | } 766 | 767 | function elementsForCategory(category) { 768 | return elementCategories[category].split(/\s*;\s*/) 769 | } 770 | 771 | function isGlobalAttribute(attribute) { 772 | return foundIn(attribute, globalAttributes) 773 | } 774 | 775 | function getAllowedChildElements(parent) { 776 | var contents 777 | , contentModel = [] 778 | 779 | // ignore children properties that contain an asterisk for now 780 | contents = elementData[parent].children 781 | contents = contents.indexOf("*") >= 0 ? [] : contents.split(/\s*\;\s*/) 782 | 783 | // replace content categories with their elements 784 | contents.forEach(function(item) { 785 | if (elementCategories[item]) { 786 | contentModel = contentModel.concat(elementCategories[item].elements) 787 | contentModel = contentModel.concat(elementCategories[item].exceptions || []) 788 | } else { 789 | contentModel.push(item) 790 | } 791 | }) 792 | // return a guaranteed match (to be safe) when there's no children 793 | return contentModel.length ? contentModel : [/[\s\S]+/] 794 | } 795 | 796 | var spec = { 797 | 798 | isElementValid: function(element) { 799 | return elements.indexOf(element) >= 0 800 | }, 801 | 802 | isElementObsolete: function(element) { 803 | return obsoluteElements.indexOf(element) >= 0 804 | }, 805 | 806 | isAttributeValidForElement: function(attribute, element) { 807 | if (isGlobalAttribute(attribute)) return true 808 | 809 | // some elements (like embed) accept any attribute 810 | // http://drafts.htmlwg.org/html/master/embedded-content-0.html#the-embed-element 811 | if (allowedAttributesForElement(element).indexOf("any") >= 0) return true 812 | return allowedAttributesForElement(element).indexOf(attribute) >= 0 813 | }, 814 | 815 | isAttributeObsoleteForElement: function(attribute, element) { 816 | return obsoleteAttributes.some(function(item) { 817 | if (item.attribute !== attribute) return false 818 | return item.elements.split(/\s*;\s*/).some(function(name) { 819 | return name === element 820 | }) 821 | }) 822 | }, 823 | 824 | isAttributeRequiredForElement: function(attribute, element) { 825 | return requiredAttributes.some(function(item) { 826 | return element == item.element && item.attributes.indexOf(attribute) >= 0 827 | }) 828 | }, 829 | 830 | getRequiredAttributesForElement: function(element) { 831 | var filtered = requiredAttributes.filter(function(item) { 832 | return item.element == element 833 | }) 834 | return (filtered[0] && filtered[0].attributes) || [] 835 | }, 836 | 837 | isChildAllowedInParent: function(child, parent) { 838 | // only check if both elements are valid elements 839 | if (!elementData[child] || !elementData[parent]) 840 | return true 841 | else 842 | return foundIn(child, getAllowedChildElements(parent)) 843 | } 844 | 845 | } 846 | 847 | module.exports = { 848 | name: "validation", 849 | module: spec 850 | } 851 | -------------------------------------------------------------------------------- /src/reporter.js: -------------------------------------------------------------------------------- 1 | function Reporter() { 2 | this._errors = [] 3 | } 4 | 5 | Reporter.prototype.warn = function(rule, message, context) { 6 | this._errors.push({ 7 | rule: rule, 8 | message: message, 9 | context: context 10 | }) 11 | } 12 | 13 | Reporter.prototype.getWarnings = function() { 14 | return this._errors 15 | } 16 | 17 | module.exports = Reporter -------------------------------------------------------------------------------- /src/rules.js: -------------------------------------------------------------------------------- 1 | var mixIn = require("mout/object/mixIn") 2 | 3 | function Rules() {} 4 | 5 | Rules.prototype.add = function(rule, config, func) { 6 | if (typeof rule == "string") { 7 | if (typeof config == "function") { 8 | func = config 9 | config = {} 10 | } 11 | this[rule] = { 12 | name: rule, 13 | config: config, 14 | func: func 15 | } 16 | } 17 | else { 18 | this[rule.name] = { 19 | name: rule.name, 20 | config: rule.config, 21 | func: rule.func 22 | } 23 | } 24 | } 25 | 26 | Rules.prototype.extend = function(name, options) { 27 | if (typeof options == "function") 28 | options = options.call(this[name].config, this[name].config) 29 | mixIn(this[name].config, options) 30 | } 31 | 32 | module.exports = Rules 33 | -------------------------------------------------------------------------------- /src/rules/best-practices/inline-event-handlers.js: -------------------------------------------------------------------------------- 1 | var foundIn = require("../../utils/string-matcher") 2 | 3 | module.exports = { 4 | 5 | name: "inline-event-handlers", 6 | 7 | config: { 8 | whitelist: [] 9 | }, 10 | 11 | func: function(listener, reporter, config) { 12 | listener.on('attribute', function(name, value) { 13 | if (name.indexOf("on") === 0 && !foundIn(name, config.whitelist)) { 14 | reporter.warn( 15 | "inline-event-handlers", 16 | "An '" + name + "' attribute was found in the HTML. Use external scripts for event binding instead.", 17 | this 18 | ) 19 | } 20 | }) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/rules/best-practices/script-placement.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 3 | name: "script-placement", 4 | 5 | config: { 6 | whitelist: [] 7 | }, 8 | 9 | func: function(listener, reporter, config) { 10 | 11 | var elements = [] 12 | , whitelist = config.whitelist 13 | , matches = require("dom-utils/src/matches") 14 | 15 | function isWhitelisted(el) { 16 | if (!whitelist) return false 17 | if (typeof whitelist == "string") return matches(el, whitelist) 18 | if (Array.isArray(whitelist)) { 19 | return whitelist.length && whitelist.some(function(item) { 20 | return matches(el, item) 21 | }) 22 | } 23 | return false 24 | } 25 | 26 | listener.on("element", function(name) { 27 | elements.push(this) 28 | }) 29 | 30 | listener.on("afterInspect", function() { 31 | var el 32 | // scripts at the end of the elements are safe 33 | while (el = elements.pop()) { 34 | if (el.nodeName.toLowerCase() != "script") break 35 | } 36 | elements.forEach(function(el) { 37 | if (el.nodeName.toLowerCase() == "script") { 38 | // scripts with the async or defer attributes are safe 39 | if (el.async === true || el.defer === true) return 40 | // at this point, if the script isn't whitelisted, throw an error 41 | if (!isWhitelisted(el)) { 42 | reporter.warn( 43 | "script-placement", 44 | "<script> elements should appear right before " 45 | + "the closing </body> tag for optimal performance.", 46 | el 47 | ) 48 | } 49 | } 50 | }) 51 | }) 52 | } 53 | } -------------------------------------------------------------------------------- /src/rules/best-practices/unnecessary-elements.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 3 | name: "unnecessary-elements", 4 | 5 | config: { 6 | isUnnecessary: function(element) { 7 | var name = element.nodeName.toLowerCase() 8 | , isUnsemantic = name == "div" || name == "span" 9 | , isAttributed = element.attributes.length === 0 10 | return isUnsemantic && isAttributed 11 | } 12 | }, 13 | 14 | func: function(listener, reporter, config) { 15 | listener.on('element', function(name) { 16 | if (config.isUnnecessary(this)) { 17 | reporter.warn( 18 | "unnecessary-elements", 19 | "Do not use <div> or <span> elements without any attributes.", 20 | this 21 | ) 22 | } 23 | }) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/rules/best-practices/unused-classes.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 3 | name: "unused-classes", 4 | 5 | config: { 6 | whitelist: [ 7 | /^js\-/, 8 | /^supports\-/, 9 | /^language\-/, 10 | /^lang\-/ 11 | ] 12 | }, 13 | 14 | func: function(listener, reporter, config) { 15 | 16 | var classes = this.modules.css.getClassSelectors() 17 | , foundIn = require("../../utils/string-matcher") 18 | 19 | listener.on("class", function(name) { 20 | if (!foundIn(name, config.whitelist) && classes.indexOf(name) < 0) { 21 | reporter.warn( 22 | "unused-classes", 23 | "The class '" 24 | + name 25 | + "' is used in the HTML but not found in any stylesheet.", 26 | this 27 | ) 28 | } 29 | }) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/rules/convention/bem-conventions.js: -------------------------------------------------------------------------------- 1 | // ============================================================ 2 | // There are several different BEM naming conventions that 3 | // I'm aware of. To make things easier, I refer to the 4 | // methodologies by the name of projects that utilize them. 5 | // 6 | // suit: http://suitcss.github.io/ 7 | // ------------------------------------- 8 | // BlockName 9 | // BlockName--modifierName 10 | // BlockName-elementName 11 | // BlockName-elementName--modifierName 12 | // 13 | // inuit: http://inuitcss.com/ 14 | // --------------------------- 15 | // block-name 16 | // block-name--modifier-name 17 | // block-name__element-name 18 | // block-name__element-name--modifier-name 19 | // 20 | // yandex: http://bem.info/ 21 | // ------------------------ 22 | // block-name 23 | // block-name_modifier_name 24 | // block-name__element-name 25 | // block-name__element-name_modifier_name 26 | // 27 | // ============================================================ 28 | 29 | var methodologies = { 30 | "suit": { 31 | modifier: /^([A-Z][a-zA-Z]*(?:\-[a-zA-Z]+)?)\-\-[a-zA-Z]+$/, 32 | element: /^([A-Z][a-zA-Z]*)\-[a-zA-Z]+$/ 33 | }, 34 | "inuit": { 35 | modifier: /^((?:[a-z]+\-)*[a-z]+(?:__(?:[a-z]+\-)*[a-z]+)?)\-\-(?:[a-z]+\-)*[a-z]+$/, 36 | element: /^((?:[a-z]+\-)*[a-z]+)__(?:[a-z]+\-)*[a-z]+$/ 37 | }, 38 | "yandex": { 39 | modifier: /^((?:[a-z]+\-)*[a-z]+(?:__(?:[a-z]+\-)*[a-z]+)?)_(?:[a-z]+_)*[a-z]+$/, 40 | element: /^((?:[a-z]+\-)*[a-z]+)__(?:[a-z]+\-)*[a-z]+$/ 41 | } 42 | } 43 | 44 | function getMethodology() { 45 | if (typeof config.methodology == "string") { 46 | return methodologies[config.methodology] 47 | } 48 | return config.methodology 49 | } 50 | 51 | var config = { 52 | 53 | methodology: "suit", 54 | 55 | getBlockName: function(elementOrModifier) { 56 | var block 57 | , methodology = getMethodology() 58 | if (methodology.modifier.test(elementOrModifier)) 59 | return block = RegExp.$1 60 | if (methodology.element.test(elementOrModifier)) 61 | return block = RegExp.$1 62 | return block || false 63 | }, 64 | 65 | isElement: function(cls) { 66 | return getMethodology().element.test(cls) 67 | }, 68 | 69 | isModifier: function(cls) { 70 | return getMethodology().modifier.test(cls) 71 | } 72 | } 73 | 74 | module.exports = { 75 | 76 | name: "bem-conventions", 77 | 78 | config: config, 79 | 80 | func: function(listener, reporter, config) { 81 | 82 | var parents = require("dom-utils/src/parents") 83 | , matches = require("dom-utils/src/matches") 84 | 85 | listener.on('class', function(name) { 86 | if (config.isElement(name)) { 87 | // check the ancestors for the block class 88 | var ancestorIsBlock = parents(this).some(function(el) { 89 | return matches(el, "." + config.getBlockName(name)) 90 | }) 91 | if (!ancestorIsBlock) { 92 | reporter.warn( 93 | "bem-conventions", 94 | "The BEM element '" + name 95 | + "' must be a descendent of '" + config.getBlockName(name) 96 | + "'.", 97 | this 98 | ) 99 | } 100 | } 101 | if (config.isModifier(name)) { 102 | if (!matches(this, "." + config.getBlockName(name))) { 103 | reporter.warn( 104 | "bem-conventions", 105 | "The BEM modifier class '" + name 106 | + "' was found without the unmodified class '" + config.getBlockName(name) 107 | + "'.", 108 | this 109 | ) 110 | } 111 | } 112 | }) 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /src/rules/validation/duplicate-ids.js: -------------------------------------------------------------------------------- 1 | var foundIn = require("../../utils/string-matcher") 2 | 3 | module.exports = { 4 | 5 | name: "duplicate-ids", 6 | 7 | config: { 8 | whitelist: [] 9 | }, 10 | 11 | func: function(listener, reporter, config) { 12 | 13 | var elements = [] 14 | 15 | listener.on("id", function(name) { 16 | // ignore whitelisted attributes 17 | if (!foundIn(name, config.whitelist)) { 18 | elements.push({id: name, context: this}) 19 | } 20 | }) 21 | 22 | listener.on("afterInspect", function() { 23 | 24 | var duplicates = [] 25 | , element 26 | , offenders 27 | 28 | while (element = elements.shift()) { 29 | // find other elements with the same ID 30 | duplicates = elements.filter(function(el) { 31 | return element.id === el.id 32 | }) 33 | // remove elements with the same ID from the elements array 34 | elements = elements.filter(function(el) { 35 | return element.id !== el.id 36 | }) 37 | // report duplicates 38 | if (duplicates.length) { 39 | offenders = [element.context].concat(duplicates.map(function(dup) { 40 | return dup.context 41 | })) 42 | reporter.warn( 43 | "duplicate-ids", 44 | "The id '" + element.id + "' appears more than once in the document.", 45 | offenders 46 | ) 47 | } 48 | } 49 | }) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/rules/validation/unique-elements.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 3 | name: "unique-elements", 4 | 5 | config: { 6 | elements: ["title", "main"] 7 | }, 8 | 9 | func: function(listener, reporter, config) { 10 | 11 | var map = {} 12 | , elements = config.elements 13 | 14 | // create the map where the keys are elements that must be unique 15 | elements.forEach(function(item) { 16 | map[item] = [] 17 | }) 18 | 19 | listener.on("element", function(name) { 20 | if (elements.indexOf(name) >= 0) { 21 | map[name].push(this) 22 | } 23 | }) 24 | 25 | listener.on("afterInspect", function() { 26 | var offenders 27 | elements.forEach(function(item) { 28 | if (map[item].length > 1) { 29 | reporter.warn( 30 | "unique-elements", 31 | "The <" + item + "> element may only appear once in the document.", 32 | map[item] 33 | ) 34 | } 35 | }) 36 | }) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/rules/validation/validate-attributes.js: -------------------------------------------------------------------------------- 1 | var foundIn = require("../../utils/string-matcher") 2 | 3 | module.exports = { 4 | 5 | name: "validate-attributes", 6 | 7 | config: { 8 | whitelist: [ 9 | /ng\-[a-z\-]+/ // AngularJS 10 | ] 11 | }, 12 | 13 | func: function(listener, reporter, config) { 14 | 15 | var validation = this.modules.validation 16 | 17 | listener.on("element", function(name) { 18 | var required = validation.getRequiredAttributesForElement(name) 19 | 20 | required.forEach(function(attr) { 21 | // ignore whitelisted attributes 22 | if (foundIn(attr, config.whitelist)) return 23 | 24 | if (!this.hasAttribute(attr)) { 25 | reporter.warn( 26 | "validate-attributes", 27 | "The '" + attr + "' attribute is required for <" 28 | + name + "> elements.", 29 | this 30 | ) 31 | } 32 | }, this) 33 | }) 34 | 35 | listener.on("attribute", function(name) { 36 | var element = this.nodeName.toLowerCase() 37 | 38 | // don't validate the attributes of invalid elements 39 | if (!validation.isElementValid(element)) return 40 | 41 | // ignore whitelisted attributes 42 | if (foundIn(name, config.whitelist)) return 43 | 44 | if (validation.isAttributeObsoleteForElement(name, element)) { 45 | reporter.warn( 46 | "validate-attributes", 47 | "The '" + name + "' attribute is no longer valid on the <" 48 | + element + "> element and should not be used.", 49 | this 50 | ) 51 | } 52 | else if (!validation.isAttributeValidForElement(name, element)) { 53 | reporter.warn( 54 | "validate-attributes", 55 | "'" + name + "' is not a valid attribute of the <" 56 | + element + "> element.", 57 | this 58 | ) 59 | } 60 | }) 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/rules/validation/validate-element-location.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 3 | name: "validate-element-location", 4 | 5 | config: { 6 | whitelist: [] 7 | }, 8 | 9 | func: function(listener, reporter, config) { 10 | 11 | var validation = this.modules.validation 12 | , matches = require("dom-utils/src/matches") 13 | , parents = require("dom-utils/src/parents") 14 | , warned = [] // store already-warned elements to prevent double warning 15 | 16 | 17 | // =========================================================================== 18 | // Elements with clear-cut location rules are tested here. 19 | // More complicated cases are tested below 20 | // =========================================================================== 21 | 22 | function testGeneralElementLocation(name) { 23 | var child = name 24 | , parent = this.parentNode.nodeName.toLowerCase() 25 | 26 | if (!validation.isChildAllowedInParent(child, parent)) { 27 | warned.push(this) 28 | reporter.warn( 29 | "validate-element-location", 30 | "The <" + child + "> element cannot be a child of the <" + parent + "> element.", 31 | this 32 | ) 33 | } 34 | } 35 | 36 | // =========================================================================== 37 | // Make sure <style> elements inside <body> have the 'scoped' attribute. 38 | // They must also be the first element child of their parent. 39 | // =========================================================================== 40 | 41 | function testUnscopedStyles(name) { 42 | if (matches(this, "body style:not([scoped])")) { 43 | reporter.warn( 44 | "validate-element-location", 45 | "<style> elements inside <body> must contain the 'scoped' attribute.", 46 | this 47 | ) 48 | } 49 | else if (matches(this, "body style[scoped]:not(:first-child)")) { 50 | reporter.warn( 51 | "validate-element-location", 52 | "Scoped <style> elements must be the first child of their parent element.", 53 | this 54 | ) 55 | } 56 | } 57 | 58 | // =========================================================================== 59 | // Make sure <meta> and <link> elements inside <body> have the 'itemprop' 60 | // attribute 61 | // =========================================================================== 62 | 63 | function testItemProp(name) { 64 | if (matches(this, "body meta:not([itemprop]), body link:not([itemprop])")) { 65 | reporter.warn( 66 | "validate-element-location", 67 | "<" + name + "> elements inside <body> must contain the" 68 | + " 'itemprop' attribute.", 69 | this 70 | ) 71 | } 72 | } 73 | 74 | 75 | listener.on("element", function(name) { 76 | 77 | // ignore whitelisted elements 78 | if (matches(this, config.whitelist)) return 79 | 80 | // skip elements without a DOM element for a parent 81 | if (!(this.parentNode && this.parentNode.nodeType == 1)) return 82 | 83 | // don't double warn if the elements already has a location warning 84 | if (warned.indexOf(this) > -1) return 85 | 86 | testGeneralElementLocation.call(this, name) 87 | testUnscopedStyles.call(this, name) 88 | testItemProp.call(this, name) 89 | }) 90 | 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/rules/validation/validate-elements.js: -------------------------------------------------------------------------------- 1 | var foundIn = require("../../utils/string-matcher") 2 | 3 | module.exports = { 4 | 5 | name: "validate-elements", 6 | 7 | config: { 8 | whitelist: [] 9 | }, 10 | 11 | func: function(listener, reporter, config) { 12 | 13 | var validation = this.modules.validation 14 | 15 | listener.on("element", function(name) { 16 | 17 | // ignore whitelisted elements 18 | if (foundIn(name, config.whitelist)) return 19 | 20 | if (validation.isElementObsolete(name)) { 21 | reporter.warn( 22 | "validate-elements", 23 | "The <" + name + "> element is obsolete and should not be used.", 24 | this 25 | ) 26 | } 27 | else if (!validation.isElementValid(name)) { 28 | reporter.warn( 29 | "validate-elements", 30 | "The <" + name + "> element is not a valid HTML element.", 31 | this 32 | ) 33 | } 34 | }) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/utils/cross-origin.js: -------------------------------------------------------------------------------- 1 | // used to parse URLs 2 | var link = document.createElement("a") 3 | 4 | /** 5 | * Tests whether a URL is cross-origin 6 | * Same origin URLs must have the same protocol and host 7 | * (note: host include hostname and port) 8 | */ 9 | module.exports = function(url) { 10 | link.href = url 11 | return !(link.protocol == location.protocol && link.host == location.host) 12 | } 13 | -------------------------------------------------------------------------------- /src/utils/string-matcher.js: -------------------------------------------------------------------------------- 1 | var isRegExp = require("mout/lang/isRegExp") 2 | 3 | /** 4 | * Given a string and a RegExp or a list of strings or RegExps, 5 | * does the string match any of the items in the list? 6 | */ 7 | function foundIn(needle, haystack) { 8 | // if haystack is a RegExp and not an array, just compare againt it 9 | if (isRegExp(haystack)) return haystack.test(needle) 10 | 11 | // if haystack is a String, just compare against it 12 | if (typeof haystack == "string") return needle == haystack 13 | 14 | // otherwise check each item in the list 15 | return haystack.some(function(item) { 16 | return isRegExp(item) ? item.test(needle) : needle === item 17 | }) 18 | } 19 | 20 | module.exports = foundIn 21 | -------------------------------------------------------------------------------- /test/classes/callbacks-test.js: -------------------------------------------------------------------------------- 1 | var expect = require("chai").expect 2 | , assert = require("chai").assert 3 | , Callbacks = require("../../src/callbacks") 4 | 5 | describe("Callbacks", function() { 6 | var cb 7 | , log 8 | , f1 = function(a, b, c) { log.push({id:"f1", args:[a, b, c], context:this }) } 9 | , f2 = function(a, b, c) { log.push({id:"f2", args:[a, b, c], context:this }) } 10 | , f3 = function(a, b, c) { log.push({id:"f3", args:[a, b, c], context:this }) } 11 | 12 | beforeEach(function() { 13 | cb = new Callbacks() 14 | log = [] 15 | }) 16 | 17 | describe("#add", function() { 18 | it("can add functions", function() { 19 | cb.add(f1) 20 | cb.add(f2) 21 | cb.add(f3) 22 | expect(cb.handlers.length).to.equal(3) 23 | expect(cb.handlers[0]).to.equal(f1) 24 | expect(cb.handlers[1]).to.equal(f2) 25 | expect(cb.handlers[2]).to.equal(f3) 26 | }) 27 | }) 28 | 29 | describe("#remove", function() { 30 | it("can remove functions", function() { 31 | cb.add(f1) 32 | cb.add(f2) 33 | cb.add(f3) 34 | cb.remove(f2) 35 | expect(cb.handlers.length).to.equal(2) 36 | expect(cb.handlers[0]).to.equal(f1) 37 | expect(cb.handlers[1]).to.equal(f3) 38 | cb.remove(f3) 39 | expect(cb.handlers.length).to.equal(1) 40 | expect(cb.handlers[0]).to.equal(f1) 41 | cb.remove(f1) 42 | expect(cb.handlers.length).to.equal(0) 43 | }) 44 | }) 45 | 46 | describe("#fire", function() { 47 | it("can invoke the list of callbacks", function() { 48 | var ctx1 = {} 49 | , ctx2 = {} 50 | , ctx3 = {} 51 | cb.fire() 52 | expect(log.length).to.equal(0) 53 | cb.add(f1) 54 | cb.fire(ctx1, ["arg1", "arg2"]) 55 | expect(log.length).to.equal(1) 56 | expect(log[0]).to.deep.equal({id:"f1", args:["arg1", "arg2", undefined], context:ctx1}) 57 | log = [] 58 | cb.add(f2) 59 | cb.fire(ctx1, ["arg1", "arg2", "arg3"]) 60 | expect(log.length).to.equal(2) 61 | expect(log[0]).to.deep.equal({id:"f1", args:["arg1", "arg2", "arg3"], context:ctx1}) 62 | expect(log[1]).to.deep.equal({id:"f2", args:["arg1", "arg2", "arg3"], context:ctx1}) 63 | log = [] 64 | cb.add(f3) 65 | cb.fire(ctx2) 66 | expect(log.length).to.equal(3) 67 | expect(log[0]).to.deep.equal({id:"f1", args:[undefined, undefined, undefined], context:ctx2}) 68 | expect(log[1]).to.deep.equal({id:"f2", args:[undefined, undefined, undefined], context:ctx2}) 69 | expect(log[2]).to.deep.equal({id:"f3", args:[undefined, undefined, undefined], context:ctx2}) 70 | log = [] 71 | cb.remove(f2) 72 | cb.fire(ctx3, ["arg1"]) 73 | expect(log.length).to.equal(2) 74 | expect(log[0]).to.deep.equal({id:"f1", args:["arg1", undefined, undefined], context:ctx3}) 75 | expect(log[1]).to.deep.equal({id:"f3", args:["arg1", undefined, undefined], context:ctx3}) 76 | }) 77 | }) 78 | }) 79 | -------------------------------------------------------------------------------- /test/classes/listener-test.js: -------------------------------------------------------------------------------- 1 | var expect = require("chai").expect 2 | , assert = require("chai").assert 3 | , sinon = require("sinon") 4 | , Listener = require("../../src/listener") 5 | , noop = function() { } 6 | 7 | describe("Listener", function() { 8 | 9 | describe("#on", function() { 10 | it("can add handlers to a specific event", function() { 11 | var listener = new Listener() 12 | listener.on("foo", noop) 13 | listener.on("bar", noop) 14 | expect(listener._events.foo).to.exist 15 | expect(listener._events.bar).to.exist 16 | }) 17 | }) 18 | 19 | describe("#trigger", function() { 20 | it("can trigger handlers on a specific event", function() { 21 | var listener = new Listener() 22 | , spy = sinon.spy() 23 | listener.on("foo", spy) 24 | listener.on("bar", spy) 25 | listener.trigger("foo") 26 | listener.trigger("bar") 27 | assert(spy.calledTwice) 28 | }) 29 | }) 30 | 31 | describe("#off", function() { 32 | it("can remove handlers from a specific event", function() { 33 | var listener = new Listener() 34 | , spy = sinon.spy() 35 | listener.on("foo", spy) 36 | listener.on("bar", spy) 37 | listener.off("foo", spy) 38 | listener.off("bar", spy) 39 | listener.trigger("foo") 40 | listener.trigger("bar") 41 | expect(spy.called).to.be.false 42 | }) 43 | }) 44 | 45 | }) -------------------------------------------------------------------------------- /test/classes/modules-test.js: -------------------------------------------------------------------------------- 1 | var expect = require("chai").expect 2 | , Modules = require("../../src/modules") 3 | 4 | describe("Modules", function() { 5 | 6 | var modules 7 | 8 | beforeEach(function() { 9 | modules = new Modules() 10 | }) 11 | 12 | describe("#add", function() { 13 | it("can add a new module", function() { 14 | modules.add({ 15 | name: "new-module", 16 | module: {} 17 | }) 18 | expect(modules["new-module"]).to.exist 19 | }) 20 | }) 21 | 22 | describe("#extend", function() { 23 | it("can extend an existing module with an options object", function() { 24 | modules.add({ 25 | name: "new-module", 26 | module: {foo: "bar"} 27 | }) 28 | modules.extend("new-module", {fizz: "buzz"}) 29 | expect(modules["new-module"]).to.deep.equal({foo:"bar", fizz:"buzz"}) 30 | }) 31 | it("can extend an existing module with a function that returns an options object", function() { 32 | modules.add({ 33 | name: "new-module", 34 | module: {list: [1]} 35 | }) 36 | modules.extend("new-module", function() { 37 | this.list.push(2) 38 | this.foo = "bar" 39 | return this 40 | }) 41 | expect(modules["new-module"]).to.deep.equal({list:[1, 2], foo:"bar"}) 42 | }) 43 | }) 44 | }) 45 | -------------------------------------------------------------------------------- /test/classes/reporter-test.js: -------------------------------------------------------------------------------- 1 | var expect = require("chai").expect 2 | , Reporter = require("../../src/reporter") 3 | , ctx = {} 4 | 5 | describe("Reporter", function() { 6 | 7 | describe("#warn", function() { 8 | it("can add an error to the report log", function() { 9 | var reporter = new Reporter() 10 | reporter.warn("rule-name", "This is the message", ctx) 11 | expect(reporter._errors.length).to.equal(1) 12 | expect(reporter._errors[0].rule).to.equal("rule-name") 13 | expect(reporter._errors[0].message).to.equal("This is the message") 14 | expect(reporter._errors[0].context).to.equal(ctx) 15 | }) 16 | }) 17 | 18 | describe("#getWarnings", function() { 19 | it("can get all the errors that have been logged", function() { 20 | var reporter = new Reporter() 21 | reporter.warn("rule-name", "This is the first message", ctx) 22 | reporter.warn("rule-name", "This is the second message", ctx) 23 | reporter.warn("rule-name", "This is the third message", ctx) 24 | expect(reporter.getWarnings().length).to.equal(3) 25 | expect(reporter.getWarnings()[0].message).to.equal("This is the first message") 26 | expect(reporter.getWarnings()[1].message).to.equal("This is the second message") 27 | expect(reporter.getWarnings()[2].message).to.equal("This is the third message") 28 | }) 29 | }) 30 | 31 | }) 32 | -------------------------------------------------------------------------------- /test/classes/rules-test.js: -------------------------------------------------------------------------------- 1 | var expect = require("chai").expect 2 | , Rules = require("../../src/rules") 3 | 4 | describe("Rules", function() { 5 | 6 | var rules 7 | , fn = function() { } 8 | 9 | beforeEach(function() { 10 | rules = new Rules() 11 | }) 12 | 13 | describe("#add", function() { 14 | it("can add a new rule", function() { 15 | var config = { foo:"bar" } 16 | rules.add({ 17 | name: "new-rule", 18 | config: config, 19 | func: fn 20 | }) 21 | expect(rules["new-rule"]).to.exist 22 | expect(rules["new-rule"].config).to.equal(config) 23 | expect(rules["new-rule"].func).to.equal(fn) 24 | }) 25 | it("can accept arguments in multiple formats", function() { 26 | var config = { foo:"bar" } 27 | // as individual arguments rather than a single object 28 | rules.add("new-rule", config, fn) 29 | expect(rules["new-rule"]).to.exist 30 | expect(rules["new-rule"].config).to.deep.equal(config) 31 | expect(rules["new-rule"].func).to.equal(fn) 32 | // as individual arguments without a config object 33 | rules.add("newer-rule", fn) 34 | expect(rules["newer-rule"]).to.exist 35 | expect(rules["newer-rule"].config).to.deep.equal({}) 36 | expect(rules["newer-rule"].func).to.equal(fn) 37 | }) 38 | }) 39 | 40 | describe("#extend", function() { 41 | it("can extend an existing rule with an options object", function() { 42 | var config = {foo: "bar"} 43 | rules.add({ 44 | name: "new-rule", 45 | config: config, 46 | func: fn 47 | }) 48 | rules.extend("new-rule", {fizz: "buzz"}) 49 | expect(rules["new-rule"].config).to.deep.equal({foo:"bar", fizz:"buzz"}) 50 | }) 51 | it("can extend an existing rule with a function that returns an options object", function() { 52 | var config = {list: [1]} 53 | rules.add({ 54 | name: "new-rule", 55 | config: config, 56 | func: fn 57 | }) 58 | rules.extend("new-rule", function(config) { 59 | config.list.push(2) 60 | return config 61 | }) 62 | expect(rules["new-rule"].config).to.deep.equal({list:[1, 2]}) 63 | rules.extend("new-rule", function(config) { 64 | this.foo = "bar" 65 | return this 66 | }) 67 | expect(rules["new-rule"].config).to.deep.equal({list:[1, 2], foo:"bar"}) 68 | }) 69 | }) 70 | }) 71 | -------------------------------------------------------------------------------- /test/classes/string-matcher-test.js: -------------------------------------------------------------------------------- 1 | var expect = require("chai").expect 2 | , foundIn = require("../../src/utils/string-matcher") 3 | 4 | describe("foundIn", function() { 5 | it("matches a string against a string, RegExp, or list of strings/RegeExps", function() { 6 | expect(foundIn("foo", "foo")).to.equal(true) 7 | expect(foundIn("foo", /^fo\w/)).to.equal(true) 8 | expect(foundIn("foo", [/\d+/, /^fo\w/])).to.equal(true) 9 | expect(foundIn("foo", ["fo", "f", /foo/])).to.equal(true) 10 | expect(foundIn("bar", "foo")).to.equal(false) 11 | expect(foundIn("bar", /^fo\w/)).to.equal(false) 12 | expect(foundIn("bar", [/\d+/, /^fo\w/])).to.equal(false) 13 | expect(foundIn("bar", ["fo", "f", /foo/])).to.equal(false) 14 | }) 15 | }) 16 | -------------------------------------------------------------------------------- /test/html-inspector-test.css: -------------------------------------------------------------------------------- 1 | @import url("importee-test.css"); 2 | 3 | .foo { 4 | visibility: visible; 5 | } 6 | 7 | .bar { 8 | visibility: visible; 9 | } 10 | 11 | .alpha, .bravo, .charlie { 12 | visibility: visible; 13 | } 14 | 15 | @media screen and (max-width: 40em) { 16 | #delta .delta p, #echo .echo pre { 17 | visibility: visible; 18 | } 19 | } -------------------------------------------------------------------------------- /test/html-inspector-test.html: -------------------------------------------------------------------------------- 1 | <!DOCTYPE html> 2 | <html> 3 | <head> 4 | <meta charset="utf-8"> 5 | <title>Mocha Tests 6 | 7 | 8 | 15 | 16 | 17 |
    18 | 19 | 20 | 21 | 22 | 23 | 27 | 28 | 29 | 30 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /test/html-inspector/html-inspector-test.js: -------------------------------------------------------------------------------- 1 | describe("HTMLInspector", function() { 2 | 3 | var originalRules = HTMLInspector.rules 4 | , originalModules = HTMLInspector.modules 5 | , html = parseHTML('' 6 | + '
    ' 7 | + '

    Heading

    ' 8 | + '

    One

    ' 9 | + '

    More

    ' 10 | + '
    ' 11 | + '

    Nested

    ' 12 | + '

    Stuff' 13 | + ' lolz' 14 | + '

    ' 15 | + '
    ' 16 | + '
    ' 17 | ) 18 | 19 | beforeEach(function() { 20 | // remove all rule and modules 21 | HTMLInspector.rules = new originalRules.constructor() 22 | HTMLInspector.modules = new originalModules.constructor() 23 | }) 24 | 25 | afterEach(function() { 26 | // restore all rules and modules 27 | HTMLInspector.rules = originalRules 28 | HTMLInspector.modules = originalModules 29 | }) 30 | 31 | describe(".inspect", function() { 32 | 33 | it("inspects the HTML starting from the specified domRoot", function() { 34 | var events = [] 35 | HTMLInspector.rules.add("traverse-test", function(listener, reporter) { 36 | listener.on("element", function(name) { 37 | events.push(name) 38 | }) 39 | }) 40 | HTMLInspector.inspect() 41 | expect(events[0]).to.equal("html") 42 | events = [] 43 | HTMLInspector.inspect({ domRoot: html }) 44 | expect(events[0]).to.equal("section") 45 | }) 46 | 47 | it("only runs the specified rules (or all rules if none are specified)", function() { 48 | var rules = [] 49 | HTMLInspector.rules.add("one", function(listener, reporter) { 50 | listener.on("beforeInspect", function(name) { rules.push("one") }) 51 | }) 52 | HTMLInspector.rules.add("two", function(listener, reporter) { 53 | listener.on("beforeInspect", function(name) { rules.push("two") }) 54 | }) 55 | HTMLInspector.rules.add("three", function(listener, reporter) { 56 | listener.on("beforeInspect", function(name) { rules.push("three") }) 57 | }) 58 | HTMLInspector.inspect() 59 | expect(rules.length).to.equal(3) 60 | expect(rules[0]).to.equal("one") 61 | expect(rules[1]).to.equal("two") 62 | expect(rules[2]).to.equal("three") 63 | rules = [] 64 | HTMLInspector.inspect(["one"]) 65 | expect(rules.length).to.equal(1) 66 | expect(rules[0]).to.equal("one") 67 | rules = [] 68 | HTMLInspector.inspect(["one", "two"]) 69 | expect(rules.length).to.equal(2) 70 | expect(rules[0]).to.equal("one") 71 | expect(rules[1]).to.equal("two") 72 | }) 73 | 74 | it("excludes rules specifically mentioned in the `excludeRules` options", function() { 75 | var rules = [] 76 | HTMLInspector.rules.add("one", function(listener, reporter) { 77 | listener.on("beforeInspect", function(name) { rules.push("one") }) 78 | }) 79 | HTMLInspector.rules.add("two", function(listener, reporter) { 80 | listener.on("beforeInspect", function(name) { rules.push("two") }) 81 | }) 82 | HTMLInspector.rules.add("three", function(listener, reporter) { 83 | listener.on("beforeInspect", function(name) { rules.push("three") }) 84 | }) 85 | HTMLInspector.inspect({ 86 | excludeRules: ["one", "two"] 87 | }) 88 | expect(rules.length).to.equal(1) 89 | expect(rules[0]).to.equal("three") 90 | rules = [] 91 | HTMLInspector.inspect({ 92 | useRules: ["one", "two"], 93 | excludeRules: ["two"] 94 | }) 95 | expect(rules.length).to.equal(1) 96 | expect(rules[0]).to.equal("one") 97 | }) 98 | 99 | it("ignores elements matching the `excludeElements` config option", function() { 100 | var events = [] 101 | HTMLInspector.rules.add("traverse-test", function(listener, reporter) { 102 | listener.on("element", function(name) { 103 | events.push(name) 104 | }) 105 | }) 106 | HTMLInspector.inspect({ 107 | domRoot: html, 108 | excludeElements: ["h1", "p"] 109 | }) 110 | expect(events).to.deep.equal(["section", "a", "blockquote", "em"]) 111 | events = [] 112 | HTMLInspector.inspect({ 113 | domRoot: html, 114 | excludeElements: html.querySelector("blockquote") 115 | }) 116 | expect(events).to.deep.equal(["section", "h1", "p", "p", "a", "p", "p", "em"]) 117 | }) 118 | 119 | it("ignores elements that descend from the `excludeSubTrees` config option", function() { 120 | var events = [] 121 | HTMLInspector.rules.add("traverse-test", function(listener, reporter) { 122 | listener.on("element", function(name) { 123 | events.push(name) 124 | }) 125 | }) 126 | HTMLInspector.inspect({ 127 | domRoot: html, 128 | excludeSubTrees: "p" 129 | }) 130 | expect(events).to.deep.equal(["section", "h1", "p", "p", "blockquote", "p", "p"]) 131 | events = [] 132 | HTMLInspector.inspect({ 133 | domRoot: html, 134 | excludeSubTrees: [html.querySelector("p:not(.first)"), html.querySelector("blockquote")] 135 | }) 136 | expect(events).to.deep.equal(["section", "h1", "p", "p", "blockquote"]) 137 | }) 138 | 139 | it("invokes the onComplete callback passing in an array of errors", function() { 140 | var log 141 | HTMLInspector.rules.add("one-two", function(listener, reporter) { 142 | reporter.warn("one-two", "This is the `one` error message", document) 143 | reporter.warn("one-two", "This is the `two` error message", document) 144 | 145 | }) 146 | HTMLInspector.rules.add("three", function(listener, reporter) { 147 | reporter.warn("three", "This is the `three` error message", document) 148 | }) 149 | HTMLInspector.inspect(function(errors) { 150 | log = errors 151 | }) 152 | expect(log.length).to.equal(3) 153 | expect(log[0].message).to.equal("This is the `one` error message") 154 | expect(log[1].message).to.equal("This is the `two` error message") 155 | expect(log[2].message).to.equal("This is the `three` error message") 156 | }) 157 | 158 | it("accepts a variety of types for the options paramter", function() { 159 | var log = [] 160 | , div = document.createElement("div") 161 | HTMLInspector.rules.add("dom", function(listener, reporter) { 162 | listener.on("element", function(name) { 163 | log.push(this) 164 | }) 165 | }) 166 | HTMLInspector.rules.add("rules", function() { 167 | log.push("rules") 168 | }) 169 | // if it's an object, assume it's the full config object 170 | HTMLInspector.inspect({ 171 | useRules: ["dom"], 172 | domRoot: parseHTML("

    foobar

    "), 173 | onComplete: function(errors) { 174 | log.push("done") 175 | } 176 | }) 177 | expect(log.length).to.equal(2) 178 | expect(log[0].innerHTML).to.equal("foobar") 179 | expect(log[1]).to.equal("done") 180 | log = [] 181 | // if it's an array, assume it's a list of rules 182 | HTMLInspector.inspect(["dom"]) 183 | expect(log.indexOf("rules")).to.equal(-1) 184 | log = [] 185 | // if it's a string, assume it's a selector 186 | HTMLInspector.inspect("body") 187 | expect(log[1]).to.equal(document.body) 188 | log = [] 189 | // if it's a DOM element, assume it's the domRoot 190 | HTMLInspector.inspect(div) 191 | expect(log[1]).to.equal(div) 192 | log = [] 193 | // if it's a function, assume it's complete 194 | HTMLInspector.inspect(function(errors) { 195 | log = "func" 196 | }) 197 | expect(log).to.equal("func") 198 | }) 199 | 200 | it("triggers `beforeInspect` before the DOM traversal", function() { 201 | var events = [] 202 | HTMLInspector.rules.add("traverse-test", function(listener, reporter) { 203 | listener.on("beforeInspect", function() { 204 | events.push("beforeInspect") 205 | }) 206 | listener.on("element", function() { 207 | events.push("element") 208 | }) 209 | }) 210 | HTMLInspector.inspect(html) 211 | expect(events.length).to.be.above(2) 212 | expect(events[0]).to.equal("beforeInspect") 213 | expect(events[1]).to.equal("element") 214 | }) 215 | 216 | it("traverses the DOM emitting events for each element", function() { 217 | var events = [] 218 | HTMLInspector.rules.add("traverse-test", function(listener, reporter) { 219 | listener.on("element", function(name) { 220 | events.push(name) 221 | }) 222 | }) 223 | HTMLInspector.inspect(html) 224 | expect(events.length).to.equal(9) 225 | expect(events[0]).to.equal("section") 226 | expect(events[1]).to.equal("h1") 227 | expect(events[2]).to.equal("p") 228 | expect(events[3]).to.equal("p") 229 | expect(events[4]).to.equal("a") 230 | expect(events[5]).to.equal("blockquote") 231 | expect(events[6]).to.equal("p") 232 | expect(events[7]).to.equal("p") 233 | expect(events[8]).to.equal("em") 234 | }) 235 | 236 | it("traverses the DOM emitting events for each id attribute", function() { 237 | var events = [] 238 | HTMLInspector.rules.add("traverse-test", function(listener, reporter) { 239 | listener.on("id", function(name) { 240 | events.push(name) 241 | }) 242 | }) 243 | HTMLInspector.inspect(html) 244 | expect(events.length).to.equal(2) 245 | expect(events[0]).to.equal("heading") 246 | expect(events[1]).to.equal("emphasis") 247 | }) 248 | 249 | it("traverses the DOM emitting events for each class attribute", function() { 250 | var events = [] 251 | HTMLInspector.rules.add("traverse-test", function(listener, reporter) { 252 | listener.on("class", function(name) { 253 | events.push(name) 254 | }) 255 | }) 256 | HTMLInspector.inspect(html) 257 | expect(events.length).to.equal(5) 258 | expect(events[0]).to.equal("section") 259 | expect(events[1]).to.equal("multiple") 260 | expect(events[2]).to.equal("classes") 261 | expect(events[3]).to.equal("first") 262 | expect(events[4]).to.equal("stuff") 263 | }) 264 | 265 | it("traverses the DOM emitting events for each attribute", function() { 266 | var events = [] 267 | HTMLInspector.rules.add("traverse-test", function(listener, reporter) { 268 | listener.on("attribute", function(name, value) { 269 | events.push({name:name, value:value}) 270 | }) 271 | }) 272 | HTMLInspector.inspect(html) 273 | expect(events.length).to.equal(11) 274 | expect(events[0]).to.deep.equal({name:"class", value:"section"}) 275 | expect(events[1]).to.deep.equal({name:"class", value:"multiple classes"}) 276 | expect(events[2]).to.deep.equal({name:"id", value:"heading"}) 277 | expect(events[3]).to.deep.equal({name:"class", value:"first"}) 278 | expect(events[4]).to.deep.equal({name:"href", value:"#"}) 279 | expect(events[5]).to.deep.equal({name:"data-foo", value:"bar"}) 280 | expect(events[6]).to.deep.equal({name:"onclick", value:"somefunc()"}) 281 | expect(events[7]).to.deep.equal({name:"style", value:"display: inline;"}) 282 | expect(events[8]).to.deep.equal({name:"class", value:"stuff"}) 283 | expect(events[9]).to.deep.equal({name:"data-bar", value:"foo"}) 284 | expect(events[10]).to.deep.equal({name:"id", value:"emphasis"}) 285 | }) 286 | 287 | it("triggers `afterInspect` after the DOM traversal", function() { 288 | var events = [] 289 | HTMLInspector.rules.add("traverse-test", function(listener, reporter) { 290 | listener.on("afterInspect", function() { 291 | events.push("afterInspect") 292 | }) 293 | listener.on("element", function() { 294 | events.push("element") 295 | }) 296 | }) 297 | HTMLInspector.inspect(html) 298 | expect(events.length).to.be.above(2) 299 | expect(events[events.length - 1]).to.equal("afterInspect") 300 | }) 301 | 302 | it("ignores SVG elements and their children", function() { 303 | var events = [] 304 | , div = document.createElement("div") 305 | HTMLInspector.rules.add("traverse-test", function(listener, reporter) { 306 | listener.on("element", function(name) { 307 | events.push(name) 308 | }) 309 | }) 310 | div.innerHTML = "" 311 | + '' 312 | + ' ' 313 | + '' 314 | HTMLInspector.inspect(div) 315 | expect(events.length).to.equal(1) 316 | expect(events[0]).to.equal("div") 317 | }) 318 | 319 | it("logs warnings to the console by default when onComplete isn't overriden", function() { 320 | // stub out console.warn 321 | sinon.stub(console, "warn") 322 | 323 | HTMLInspector.rules.add("console-warn-test", function(listener, reporter) { 324 | listener.on("element", function(name) { 325 | reporter.warn("console-warn-test", "Element found", this) 326 | }) 327 | }) 328 | 329 | HTMLInspector.inspect(html) 330 | expect(console.warn.callCount).to.equal(9) 331 | expect(console.warn.getCall(0).args[0]).to.equal("Element found") 332 | expect(console.warn.getCall(0).args[1]).to.equal(html) 333 | 334 | console.warn.restore() 335 | }) 336 | 337 | it("does not console.warn cross-domain iframe elements", function() { 338 | var div = document.createElement("div") 339 | , iframe1 = document.createElement("iframe") 340 | , iframe2 = document.createElement("iframe") 341 | 342 | iframe1.src = "http://example.com" 343 | iframe2.src = "foobar.html" 344 | div.appendChild(iframe1) 345 | div.appendChild(iframe2) 346 | 347 | // stub out console.warn 348 | sinon.stub(console, "warn") 349 | 350 | HTMLInspector.rules.add("cross-domain-test", function(listener, reporter) { 351 | listener.on("element", function(name) { 352 | reporter.warn("cross-domain-test", "This is a cross-origin iframe", this) 353 | }) 354 | }) 355 | HTMLInspector.inspect(div) 356 | expect(console.warn.callCount).to.equal(3) 357 | expect(console.warn.getCall(1).args[1]).to.equal("(can't display iframe with cross-origin source: http://example.com/)") 358 | expect(console.warn.getCall(2).args[1]).to.equal(iframe2) 359 | console.warn.restore() 360 | }) 361 | 362 | }) 363 | 364 | }) 365 | -------------------------------------------------------------------------------- /test/html-inspector/modules-intro.txt: -------------------------------------------------------------------------------- 1 | describe("Modules", function() { 2 | -------------------------------------------------------------------------------- /test/html-inspector/modules-outro.txt: -------------------------------------------------------------------------------- 1 | }) 2 | -------------------------------------------------------------------------------- /test/html-inspector/modules/css-test.js: -------------------------------------------------------------------------------- 1 | describe("css", function() { 2 | 3 | var css = HTMLInspector.modules.css 4 | , originalStyleSheets = css.styleSheets 5 | , classes = ["alpha", "bar", "bravo", "charlie", "delta", "echo", "foo", "importee"] 6 | 7 | afterEach(function() { 8 | css.styleSheets = originalStyleSheets 9 | }) 10 | 11 | it("can filter the searched style sheets via the styleSheets selector", function() { 12 | css.styleSheets = "#mocha-css" 13 | var classes = css.getClassSelectors() 14 | // limiting the style sheets to only mocha.css means 15 | // .alpha, .bravo, and .charlie won't be there 16 | expect(classes.indexOf("alpha")).to.equal(-1) 17 | expect(classes.indexOf("bravo")).to.equal(-1) 18 | expect(classes.indexOf("charlie")).to.equal(-1) 19 | }) 20 | 21 | it("can get all the class selectors in the style sheets", function() { 22 | css.styleSheets = 'link[rel="stylesheet"]:not(#mocha-css)' 23 | expect(css.getClassSelectors().sort()).to.deep.equal(classes) 24 | }) 25 | 26 | it("can include both and " 34 | ) 35 | 36 | // first remove any style tags that browser plugins might be putting in 37 | Array.prototype.slice.call(document.querySelectorAll("style")).forEach(function(el) { 38 | el.parentNode.removeChild(el) 39 | }) 40 | 41 | head.appendChild(styles) 42 | 43 | css.styleSheets = 'link[rel="stylesheet"]:not(#mocha-css), style' 44 | expect(css.getClassSelectors().sort()).to.deep.equal(extraClasses) 45 | head.removeChild(styles) 46 | }) 47 | 48 | }) 49 | -------------------------------------------------------------------------------- /test/html-inspector/modules/validation-test.js: -------------------------------------------------------------------------------- 1 | describe("validation", function() { 2 | 3 | var validation = HTMLInspector.modules.validation 4 | , originalElementWhitelist = validation.elementWhitelist 5 | , originalAttributeWhitelist = validation.attributeWhitelist 6 | 7 | afterEach(function() { 8 | validation.elementWhitelist = originalElementWhitelist 9 | validation.attributeWhitelist = originalAttributeWhitelist 10 | }) 11 | 12 | it("can determine if an element is a valid HTML element", function() { 13 | expect(validation.isElementValid("p")).to.equal(true) 14 | expect(validation.isElementValid("time")).to.equal(true) 15 | expect(validation.isElementValid("bogus")).to.equal(false) 16 | expect(validation.isElementValid("hgroup")).to.equal(false) 17 | }) 18 | 19 | it("can determine if an element is obsolete", function() { 20 | expect(validation.isElementObsolete("p")).to.equal(false) 21 | expect(validation.isElementObsolete("bogus")).to.equal(false) 22 | expect(validation.isElementObsolete("hgroup")).to.equal(true) 23 | expect(validation.isElementObsolete("blink")).to.equal(true) 24 | expect(validation.isElementObsolete("center")).to.equal(true) 25 | }) 26 | 27 | it("can determine if an attribute is allowed on an element", function() { 28 | expect(validation.isAttributeValidForElement("href", "a")).to.equal(true) 29 | expect(validation.isAttributeValidForElement("aria-foobar", "nav")).to.equal(true) 30 | expect(validation.isAttributeValidForElement("data-stuff", "section")).to.equal(true) 31 | expect(validation.isAttributeValidForElement("href", "button")).to.equal(false) 32 | expect(validation.isAttributeValidForElement("placeholder", "select")).to.equal(false) 33 | }) 34 | 35 | it("can determine if an attribute is obsolute for an element", function() { 36 | expect(validation.isAttributeObsoleteForElement("align", "div")).to.equal(true) 37 | expect(validation.isAttributeObsoleteForElement("bgcolor", "body")).to.equal(true) 38 | expect(validation.isAttributeObsoleteForElement("border", "img")).to.equal(true) 39 | expect(validation.isAttributeObsoleteForElement("href", "div")).to.equal(false) 40 | expect(validation.isAttributeObsoleteForElement("charset", "meta")).to.equal(false) 41 | }) 42 | 43 | it("can determine if an attribute is required for an element", function() { 44 | expect(validation.isAttributeRequiredForElement("src", "img")).to.equal(true) 45 | expect(validation.isAttributeRequiredForElement("alt", "img")).to.equal(true) 46 | expect(validation.isAttributeRequiredForElement("action", "form")).to.equal(true) 47 | expect(validation.isAttributeRequiredForElement("rows", "textarea")).to.equal(true) 48 | expect(validation.isAttributeRequiredForElement("cols", "textarea")).to.equal(true) 49 | expect(validation.isAttributeRequiredForElement("id", "div")).to.equal(false) 50 | expect(validation.isAttributeRequiredForElement("target", "a")).to.equal(false) 51 | }) 52 | 53 | it("can get a list of required attribute given an element", function() { 54 | expect(validation.getRequiredAttributesForElement("img")).to.deep.equal(["alt", "src"]) 55 | expect(validation.getRequiredAttributesForElement("optgroup")).to.deep.equal(["label"]) 56 | expect(validation.getRequiredAttributesForElement("form")).to.deep.equal(["action"]) 57 | expect(validation.getRequiredAttributesForElement("div")).to.deep.equal([]) 58 | }) 59 | 60 | it("can determine if a child elememnt is allowed inside it's parent", function() { 61 | expect(validation.isChildAllowedInParent("div", "ul")).to.equal(false) 62 | expect(validation.isChildAllowedInParent("div", "span")).to.equal(false) 63 | expect(validation.isChildAllowedInParent("section", "em")).to.equal(false) 64 | expect(validation.isChildAllowedInParent("title", "body")).to.equal(false) 65 | expect(validation.isChildAllowedInParent("strong", "p")).to.equal(true) 66 | expect(validation.isChildAllowedInParent("li", "ol")).to.equal(true) 67 | expect(validation.isChildAllowedInParent("fieldset", "form")).to.equal(true) 68 | expect(validation.isChildAllowedInParent("td", "tr")).to.equal(true) 69 | }) 70 | 71 | }) 72 | -------------------------------------------------------------------------------- /test/html-inspector/rules-intro.txt: -------------------------------------------------------------------------------- 1 | describe("Rules", function() { 2 | -------------------------------------------------------------------------------- /test/html-inspector/rules-outro.txt: -------------------------------------------------------------------------------- 1 | }) 2 | -------------------------------------------------------------------------------- /test/html-inspector/rules/bem-conventions-test.js: -------------------------------------------------------------------------------- 1 | describe("bem-conventions", function() { 2 | 3 | var log 4 | , originalConfig = HTMLInspector.rules["bem-conventions"].config 5 | 6 | function onComplete(reports) { 7 | log = [] 8 | reports.forEach(function(report) { 9 | log.push(report) 10 | }) 11 | } 12 | 13 | afterEach(function() { 14 | HTMLInspector.rules["bem-conventions"].config.methodology = "suit" 15 | }) 16 | 17 | describe("config", function() { 18 | 19 | var config = HTMLInspector.rules["bem-conventions"].config 20 | 21 | it("can take a BEM modifier or element class and returns its block's class name", function() { 22 | expect(config.getBlockName("Block--modifier")).to.equal("Block") 23 | expect(config.getBlockName("BlockName--someModifier")).to.equal("BlockName") 24 | expect(config.getBlockName("Block-element")).to.equal("Block") 25 | expect(config.getBlockName("BlockName-subElement")).to.equal("BlockName") 26 | expect(config.getBlockName("BlockName-subElement--modifierName")).to.equal("BlockName-subElement") 27 | expect(config.getBlockName("BlockName")).to.equal(false) 28 | expect(config.getBlockName("Foo---bar")).to.equal(false) 29 | expect(config.getBlockName("Foo--bar--baz")).to.equal(false) 30 | // the second convention 31 | config.methodology = "inuit" 32 | expect(config.getBlockName("block--modifier")).to.equal("block") 33 | expect(config.getBlockName("block-name--some-modifier")).to.equal("block-name") 34 | expect(config.getBlockName("block__element")).to.equal("block") 35 | expect(config.getBlockName("block-name__sub-element")).to.equal("block-name") 36 | expect(config.getBlockName("block-name__sub-element--modifier-name")).to.equal("block-name__sub-element") 37 | expect(config.getBlockName("block-name")).to.equal(false) 38 | expect(config.getBlockName("foo---bar")).to.equal(false) 39 | expect(config.getBlockName("foo--bar__baz")).to.equal(false) 40 | // the third convention 41 | config.methodology = "yandex" 42 | expect(config.getBlockName("block_modifier")).to.equal("block") 43 | expect(config.getBlockName("block-name_some_modifier")).to.equal("block-name") 44 | expect(config.getBlockName("block__element")).to.equal("block") 45 | expect(config.getBlockName("block-name__sub-element")).to.equal("block-name") 46 | expect(config.getBlockName("block-name__sub-element_modifier_name")).to.equal("block-name__sub-element") 47 | expect(config.getBlockName("block-name")).to.equal(false) 48 | expect(config.getBlockName("foo___bar")).to.equal(false) 49 | expect(config.getBlockName("foo_bar__baz")).to.equal(false) 50 | }) 51 | 52 | it("can determine if a class is a block element class", function() { 53 | expect(config.isElement("Block-element")).to.equal(true) 54 | expect(config.isElement("BlockName-elementName")).to.equal(true) 55 | expect(config.isElement("Block--modifier")).to.equal(false) 56 | expect(config.isElement("BlockName--modifierName")).to.equal(false) 57 | expect(config.isElement("Block--modifier-stuffz")).to.equal(false) 58 | expect(config.isElement("Block--modifier--stuffz")).to.equal(false) 59 | // the second convention 60 | config.methodology = "inuit" 61 | expect(config.isElement("block__element")).to.equal(true) 62 | expect(config.isElement("block-name__element-name")).to.equal(true) 63 | expect(config.isElement("block--modifier")).to.equal(false) 64 | expect(config.isElement("block-name--modifier-name")).to.equal(false) 65 | expect(config.isElement("block__element__sub-element")).to.equal(false) 66 | expect(config.isElement("block--modifier--stuffz")).to.equal(false) 67 | // the third convention 68 | config.methodology = "yandex" 69 | expect(config.isElement("block__element")).to.equal(true) 70 | expect(config.isElement("block-name__element-name")).to.equal(true) 71 | expect(config.isElement("block_modifier")).to.equal(false) 72 | expect(config.isElement("block-name_modifier_name")).to.equal(false) 73 | expect(config.isElement("block__element__sub-element")).to.equal(false) 74 | expect(config.isElement("block_modifier_stuffz")).to.equal(false) 75 | }) 76 | 77 | it("can determine if a class is a block modifier class", function() { 78 | expect(config.isModifier("Block--modifier")).to.equal(true) 79 | expect(config.isModifier("BlockName--modifierName")).to.equal(true) 80 | expect(config.isModifier("BlockName-elementName--modifierName")).to.equal(true) 81 | expect(config.isModifier("Block-element")).to.equal(false) 82 | expect(config.isModifier("BlockName-elementName")).to.equal(false) 83 | expect(config.isModifier("Block--modifier-stuffz")).to.equal(false) 84 | expect(config.isModifier("Block--modifier--stuffz")).to.equal(false) 85 | // the second convention 86 | config.methodology = "inuit" 87 | expect(config.isModifier("block--modifier")).to.equal(true) 88 | expect(config.isModifier("block-name--modifier-name")).to.equal(true) 89 | expect(config.isModifier("block-name__element-name--modifier-name")).to.equal(true) 90 | expect(config.isModifier("block__element")).to.equal(false) 91 | expect(config.isModifier("block-name__element-name")).to.equal(false) 92 | expect(config.isModifier("block--modifierStuffz")).to.equal(false) 93 | // the third convention 94 | config.methodology = "yandex" 95 | expect(config.isModifier("block_modifier")).to.equal(true) 96 | expect(config.isModifier("block-name_modifier_name")).to.equal(true) 97 | expect(config.isModifier("block-name__element-name_modifier_name")).to.equal(true) 98 | expect(config.isModifier("block__element")).to.equal(false) 99 | expect(config.isModifier("block-name__element-name")).to.equal(false) 100 | expect(config.isModifier("block_modifierStuffz")).to.equal(false) 101 | }) 102 | 103 | }) 104 | 105 | it("warns when a BEM element class is used when not the descendent of a block", function() { 106 | var html = parseHTML('' 107 | + '
    ' 108 | + '

    Foo

    ' 109 | + '

    Bar three

    ' 110 | + '
    ' 111 | ) 112 | HTMLInspector.inspect({ 113 | useRules: ["bem-conventions"], 114 | domRoot: html, 115 | onComplete: onComplete 116 | }) 117 | expect(log.length).to.equal(2) 118 | expect(log[0].message).to.equal("The BEM element 'BlockTwo-element' must be a descendent of 'BlockTwo'.") 119 | expect(log[1].message).to.equal("The BEM element 'BlockThree-elementName' must be a descendent of 'BlockThree'.") 120 | expect(log[0].context).to.equal(html.querySelector(".BlockTwo-element")) 121 | expect(log[1].context).to.equal(html.querySelector(".BlockThree-elementName")) 122 | }) 123 | 124 | it("doesn't warn when a BEM element class is used as the descendent of a block", function() { 125 | var html = parseHTML('' 126 | + '
    ' 127 | + '

    Foo

    ' 128 | + '

    Bar three

    ' 129 | + '
    ' 130 | ) 131 | HTMLInspector.inspect({ 132 | useRules: ["bem-conventions"], 133 | domRoot: html, 134 | onComplete: onComplete 135 | }) 136 | expect(log.length).to.equal(0) 137 | }) 138 | 139 | it("warns when a BEM modifier class is used without the unmodified block or element class", function() { 140 | var html = parseHTML('' 141 | + '
    ' 142 | + '

    Foo

    ' 143 | + '

    Bar

    ' 144 | + '
    ' 145 | ) 146 | HTMLInspector.inspect({ 147 | useRules: ["bem-conventions"], 148 | domRoot: html, 149 | onComplete: onComplete 150 | }) 151 | expect(log.length).to.equal(3) 152 | expect(log[0].message).to.equal("The BEM modifier class 'BlockOne--active' was found without the unmodified class 'BlockOne'.") 153 | expect(log[0].context).to.equal(html) 154 | expect(log[1].message).to.equal("The BEM modifier class 'BlockTwo--validName' was found without the unmodified class 'BlockTwo'.") 155 | expect(log[1].context).to.equal(html.querySelector(".BlockTwo--validName")) 156 | expect(log[2].message).to.equal("The BEM modifier class 'Block-element--modified' was found without the unmodified class 'Block-element'.") 157 | expect(log[2].context).to.equal(html.querySelector(".Block-element--modified")) 158 | }) 159 | 160 | it("doesn't warn when a BEM modifier is used along with the unmodified block or element class", function() { 161 | var html = parseHTML('' 162 | + '
    ' 163 | + '

    Foo

    ' 164 | + '

    Bar

    ' 165 | + '
    ' 166 | ) 167 | HTMLInspector.inspect({ 168 | useRules: ["bem-conventions"], 169 | domRoot: html, 170 | onComplete: onComplete 171 | }) 172 | expect(log.length).to.equal(0) 173 | }) 174 | 175 | it("allows for customization by altering the config object", function() { 176 | var html = parseHTML('' 177 | + '
    ' 178 | + '

    Foo

    ' 179 | + '

    Bar

    ' 180 | + '
    ' 181 | ) 182 | HTMLInspector.rules.extend("bem-conventions", { 183 | methodology: { 184 | modifier: /^((?:[a-z]+\-)*[a-z]+(?:___(?:[a-z]+\-)*[a-z]+)?)\-\-\-(?:[a-z]+\-)*[a-z]+$/, 185 | element: /^((?:[a-z]+\-)*[a-z]+)___(?:[a-z]+\-)*[a-z]+$/ 186 | } 187 | }) 188 | HTMLInspector.inspect({ 189 | useRules: ["bem-conventions"], 190 | domRoot: html, 191 | onComplete: onComplete 192 | }) 193 | expect(log.length).to.equal(2) 194 | expect(log[0].message).to.equal("The BEM modifier class 'block-two---valid-name' was found without the unmodified class 'block-two'.") 195 | expect(log[1].message).to.equal("The BEM element 'block-three___element-name' must be a descendent of 'block-three'.") 196 | }) 197 | 198 | }) -------------------------------------------------------------------------------- /test/html-inspector/rules/duplicate-ids-test.js: -------------------------------------------------------------------------------- 1 | describe("duplicate-ids", function() { 2 | 3 | var log 4 | 5 | function onComplete(reports) { 6 | log = [] 7 | reports.forEach(function(report) { 8 | log.push(report) 9 | }) 10 | } 11 | 12 | it("warns when the same ID attribute is used more than once", function() { 13 | var html = parseHTML('' 14 | + '
    ' 15 | + '

    Foo

    ' 16 | + '

    bar Em

    ' 17 | + '
    ' 18 | ) 19 | 20 | HTMLInspector.inspect({ 21 | useRules: ["duplicate-ids"], 22 | domRoot: html, 23 | onComplete: onComplete 24 | }) 25 | 26 | expect(log.length).to.equal(2) 27 | expect(log[0].message).to.equal("The id 'foobar' appears more than once in the document.") 28 | expect(log[1].message).to.equal("The id 'barfoo' appears more than once in the document.") 29 | expect(log[0].context).to.deep.equal([html, html.querySelector("p#foobar")]) 30 | expect(log[1].context).to.deep.equal([html.querySelector("p#barfoo"), html.querySelector("em#barfoo")]) 31 | 32 | }) 33 | 34 | it("doesn't warn when all ids are unique", function() { 35 | var html = parseHTML('' 36 | + '
    ' 37 | + '

    Foo

    ' 38 | + '

    Bar Em

    ' 39 | + '
    ' 40 | ) 41 | 42 | HTMLInspector.inspect({ 43 | useRules: ["duplicate-ids"], 44 | domRoot: html, 45 | onComplete: onComplete 46 | }) 47 | 48 | expect(log.length).to.equal(0) 49 | }) 50 | 51 | it("allows for customization by altering the config object", function() { 52 | 53 | var html = parseHTML('' 54 | + '
    ' 55 | + '

    Foo

    ' 56 | + '

    bar Em

    ' 57 | + '
    ' 58 | ) 59 | 60 | // whitelist foobar 61 | HTMLInspector.rules.extend("duplicate-ids", { 62 | whitelist: ["foobar"] 63 | }) 64 | 65 | HTMLInspector.inspect({ 66 | useRules: ["duplicate-ids"], 67 | domRoot: html, 68 | onComplete: onComplete 69 | }) 70 | 71 | expect(log.length).to.equal(1) 72 | expect(log[0].message).to.equal("The id 'barfoo' appears more than once in the document.") 73 | expect(log[0].context).to.deep.equal([html.querySelector("p#barfoo"), html.querySelector("em#barfoo")]) 74 | }) 75 | 76 | }) 77 | -------------------------------------------------------------------------------- /test/html-inspector/rules/inline-event-handlers-test.js: -------------------------------------------------------------------------------- 1 | describe("inline-event-handlers", function() { 2 | 3 | var log 4 | 5 | function onComplete(reports) { 6 | log = [] 7 | reports.forEach(function(report) { 8 | log.push(report) 9 | }) 10 | } 11 | 12 | it("warns when inline event handlers are found on elements", function() { 13 | var html = parseHTML('' 14 | + '
    ' 15 | + '

    Foo

    ' 16 | + '

    Bar click me

    ' 17 | + '
    ' 18 | ) 19 | 20 | HTMLInspector.inspect({ 21 | useRules: ["inline-event-handlers"], 22 | domRoot: html, 23 | onComplete: onComplete 24 | }) 25 | 26 | expect(log.length).to.equal(2) 27 | expect(log[0].message).to.equal("An 'onresize' attribute was found in the HTML. Use external scripts for event binding instead.") 28 | expect(log[1].message).to.equal("An 'onclick' attribute was found in the HTML. Use external scripts for event binding instead.") 29 | expect(log[0].context).to.deep.equal(html) 30 | expect(log[1].context).to.deep.equal(html.querySelector("a")) 31 | 32 | }) 33 | 34 | it("doesn't warn there are no inline event handlers", function() { 35 | var html = parseHTML('' 36 | + '' 40 | ) 41 | 42 | HTMLInspector.inspect({ 43 | useRules: ["inline-event-handlers"], 44 | domRoot: html, 45 | onComplete: onComplete 46 | }) 47 | 48 | expect(log.length).to.equal(0) 49 | }) 50 | 51 | it("allows for customization by altering the config object", function() { 52 | var html = parseHTML('' 53 | + '' 57 | ) 58 | 59 | // whitelist onclick 60 | HTMLInspector.rules.extend("inline-event-handlers", { 61 | whitelist: ["onclick"] 62 | }) 63 | 64 | HTMLInspector.inspect({ 65 | useRules: ["inline-event-handlers"], 66 | domRoot: html, 67 | onComplete: onComplete 68 | }) 69 | 70 | expect(log.length).to.equal(1) 71 | expect(log[0].message).to.equal("An 'onresize' attribute was found in the HTML. Use external scripts for event binding instead.") 72 | expect(log[0].context).to.deep.equal(html) 73 | }) 74 | 75 | }) 76 | -------------------------------------------------------------------------------- /test/html-inspector/rules/script-placement-test.js: -------------------------------------------------------------------------------- 1 | describe("script-placement", function() { 2 | 3 | var log 4 | 5 | function onComplete(reports) { 6 | log = [] 7 | reports.forEach(function(report) { 8 | log.push(report) 9 | }) 10 | } 11 | 12 | it("warns when script tags aren't found as the last elemenet in ", function() { 13 | HTMLInspector.inspect({ 14 | useRules: ["script-placement"], 15 | onComplete: onComplete 16 | }) 17 | expect(log.length).to.be.above(0) 18 | log.forEach(function(error, i) { 19 | expect(log[i].message).to.equal("')) 25 | body.appendChild(parseHTML('
    Header content
    ')) 26 | body.appendChild(parseHTML('
    Main content
    ')) 27 | body.appendChild(parseHTML('
    Footer content
    ')) 28 | body.appendChild(parseHTML('')) 29 | body.appendChild(parseHTML('')) 30 | 31 | // Make sure the scripts aren't async or defer 32 | Array.prototype.slice.call(body.querySelectorAll("script")).forEach(function(script) { 33 | script.async = false 34 | script.defer = false 35 | }) 36 | 37 | HTMLInspector.inspect({ 38 | useRules: ["script-placement"], 39 | domRoot: body, 40 | onComplete: onComplete 41 | }) 42 | 43 | expect(log.length).to.equal(1) 44 | expect(log[0].message).to.equal("')) 54 | body.appendChild(parseHTML('')) 55 | 56 | HTMLInspector.inspect({ 57 | useRules: ["script-placement"], 58 | domRoot: body, 59 | onComplete: onComplete 60 | }) 61 | expect(log.length).to.equal(0) 62 | }) 63 | 64 | it("doesn't warn when the script uses either the async or defer attribute", function() { 65 | var body = document.createElement("body") 66 | body.appendChild(parseHTML('')) 67 | body.appendChild(parseHTML('')) 68 | body.appendChild(parseHTML('
    Header content
    ')) 69 | body.appendChild(parseHTML('
    Main content
    ')) 70 | body.appendChild(parseHTML('