...
 
Commits (14)
# html-validate changelog
# [1.8.0](https://gitlab.com/html-validate/html-validate/compare/v1.7.1...v1.8.0) (2019-09-16)
### Bug Fixes
- **rules:** fix prefer-button crashing on boolean type attribute ([94ce2a8](https://gitlab.com/html-validate/html-validate/commit/94ce2a8))
### Features
- **cli:** allow specifying extensions ([2bdd75f](https://gitlab.com/html-validate/html-validate/commit/2bdd75f))
- **cli:** cli learned `--version` to print version number ([95c6737](https://gitlab.com/html-validate/html-validate/commit/95c6737))
- **cli:** exit early when encountering unknown cli arguments ([1381c51](https://gitlab.com/html-validate/html-validate/commit/1381c51))
- **cli:** expose expandFiles function ([edab9cf](https://gitlab.com/html-validate/html-validate/commit/edab9cf))
- **cli:** handle passing directories ([f152a12](https://gitlab.com/html-validate/html-validate/commit/f152a12))
- **cli:** support setting cwd when calling expandFiles ([420dc88](https://gitlab.com/html-validate/html-validate/commit/420dc88))
- **event:** new event config:ready ([c2990b5](https://gitlab.com/html-validate/html-validate/commit/c2990b5))
## [1.7.1](https://gitlab.com/html-validate/html-validate/compare/v1.7.0...v1.7.1) (2019-09-15)
### Bug Fixes
......
......@@ -5,6 +5,17 @@
# Events
## `config:ready`
```typescript
{
config: ConfigData;
rules: { [ruleId: string]: Rule };
}
```
Emitted after after configuration is ready but before DOM is initialized.
## `dom:load`
```typescript
......
......@@ -39,6 +39,7 @@
<a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">User guide <span class="caret"></span></a>
<ul class="dropdown-menu">
<li><a href="/usage">Gettings started</a></li>
<li><a href="/usage/cli.html">Using CLI</a></li>
<li><a href="/usage/elements.html">Elements</a></li>
<li><a href="/usage/transformers.html">Transfomers</a></li>
<li><a href="/usage/grunt.html">Grunt</a></li>
......
@ngdoc content
@module usage
@name Using CLI
@description
# Using the Command Line Interface
The html-validate CLI can be installed with npm:
npm i -g html-validate
To run html-validate use:
html-validate [OPTIONS] [FILE|DIR|GLOB...]
Since globs will usually be expanded by your shell remember to quote the pattern if you require node `glob` syntax:
html-validate "src/**/*.html"
## Options
### `--ext`
This option specifies the file extensions to use when searching for files in directories.
By default only `.html` files are searched.
This option is ignored when specifying files or globs.
Multiple extensions can be set with a comma-separated list: `--ext html,vue`.
Leading dots are ignored.
html-validate --ext html,vue src
### `-f`, `--formatter`
Specify which formatter(s) to use.
Possible formats are:
- checkstyle
- codeframe
- json
- stylish
- text
Multiples formatters can be set with a comma-separated list: `--formatter stylish,checkstyle`.
Output can be redirected to a file using `name=path`: `--formatter checkstyle=result.xml`.
html-validate --formatter stylish file.html
### `--max-warnings`
Exits with non-zero status if more when more than given amount of warnings occurs.
Use `0` to disallow warnings.
html-validate --max-warnings 0 file.html
### `--rule`
Inline rule configuration.
html-validate --rule void:2 file.html
Note: rules replaces existing configuration!
### `--stdin`
Process markup from stdin.
The special alias `-` can also be set as a filename to use stdin.
```bash
curl http://example.net | html-validate --stdin
```
### `--stdin-filename`
Filename to set in report when using `--stdin`.
```bash
curl http://example.net | html-validate --stdin --stdin-filename example.net
```
## Debugging options
The options are intended for debugging purposes.
### `--dump-events`
Instead of validating file print the event stream generated.
html-validate --dump-events file.html
### `--dump-tokens`
Instead of validating file print the token stream generated.
html-validate --dump-token file.html
### `--dump-tree`
Instead of validating file print the DOM tree generated.
html-validate --dump-tree file.html
## Miscellaneous options
### `-c`, `--config`
Specify a different configuration file.
html-validate --config myconfig.json file.html
### `--print-config`
Instead of validating file print the configuration generated.
### `-h`, `--help`
Show help.
### `--version`
Show version number
{
"name": "html-validate",
"version": "1.7.1",
"version": "1.8.0",
"lockfileVersion": 1,
"requires": true,
"dependencies": {
......@@ -6165,7 +6165,7 @@
"dependencies": {
"canonical-path": {
"version": "0.0.2",
"resolved": "http://registry.npmjs.org/canonical-path/-/canonical-path-0.0.2.tgz",
"resolved": "https://registry.npmjs.org/canonical-path/-/canonical-path-0.0.2.tgz",
"integrity": "sha1-4x65N6jJPuKgHfGDl5RyGQKHRXQ=",
"dev": true
}
......
{
"name": "html-validate",
"version": "1.7.1",
"version": "1.8.0",
"description": "html linter",
"keywords": [
"html",
......@@ -154,6 +154,7 @@
"jquery": "3.4.1",
"lint-staged": "9.2.5",
"load-grunt-tasks": "5.1.0",
"minimatch": "3.0.4",
"prettier": "1.18.2",
"sass": "1.22.12",
"semantic-release": "15.13.24",
......
import path from "path";
let mockDirectory: string[] = [];
function setMockDirectories(directories: string[]): void {
mockDirectory = directories;
}
function statSync(dir: string): any {
/* slice the cwd away since it is prepended automatically and makes it harder
* to test with */
const suffix = dir.replace(`${process.cwd()}${path.sep}`, "");
const found = mockDirectory.indexOf(suffix) >= 0;
return {
isDirectory() {
return found;
},
};
}
module.exports = {
setMockDirectories,
statSync,
};
import minimatch from "minimatch";
const glob: any = jest.requireActual("glob");
interface Options {
cwd?: string;
}
let mockFiles: string[] = null;
function setMockFiles(files: string[]): void {
mockFiles = files;
}
function resetMock(): void {
mockFiles = null;
}
const originalSync = glob.sync;
function syncMock(pattern: string, options: Options = {}): string[] {
if (!mockFiles) {
return originalSync(pattern, options);
}
/* slice the cwd away since it is prepended automatically and makes it harder
* to test with */
const cwd = options.cwd || "";
const dir = cwd.replace(process.cwd(), "").replace(/^\/(.*)/, "$1/");
let src = mockFiles;
if (dir) {
src = src
.filter(cur => cur.startsWith(dir))
.map(cur => cur.slice(dir.length));
}
return src.filter(cur => minimatch(cur, pattern));
}
glob.setMockFiles = setMockFiles;
glob.resetMock = resetMock;
glob.sync = syncMock;
module.exports = glob;
jest.mock("fs");
jest.mock("glob");
import fs from "fs";
import glob from "glob";
import { expandFiles } from "./expand-files";
declare module "fs" {
function setMockDirectories(directories: string[]): void;
}
declare module "glob" {
function setMockFiles(files: string[]): void;
function resetMock(): void;
}
beforeEach(() => {
glob.setMockFiles([
"/dev/stdin",
"foo.html",
"bar",
"bar/fred.html",
"bar/fred.json",
"bar/barney.html",
"bar/barney.js",
"baz",
"baz/spam.html",
]);
fs.setMockDirectories(["bar"]);
});
afterAll(() => {
glob.resetMock();
});
describe("expandFiles()", () => {
it("should expand globs", () => {
const spy = jest.spyOn(glob, "sync");
expect(expandFiles(["foo.html", "bar/**/*.html"])).toEqual([
"foo.html",
"bar/fred.html",
"bar/barney.html",
]);
expect(spy).toHaveBeenCalledWith("foo.html", expect.anything());
expect(spy).toHaveBeenCalledWith("bar/**/*.html", expect.anything());
});
it("should expand directories (default extensions)", () => {
expect(expandFiles(["bar"])).toEqual(["bar/fred.html", "bar/barney.html"]);
});
it("should expand directories (explicit extensions)", () => {
expect(expandFiles(["bar"], { extensions: ["js", "json"] })).toEqual([
"bar/fred.json",
"bar/barney.js",
]);
});
it("should expand directories (no extensions => all files)", () => {
expect(expandFiles(["bar"], { extensions: [] })).toEqual([
"bar/fred.html",
"bar/fred.json",
"bar/barney.html",
"bar/barney.js",
]);
});
it("should remove duplicates", () => {
expect(expandFiles(["foo.html", "foo.html"])).toEqual(["foo.html"]);
});
it("should replace - placeholder", () => {
expect(expandFiles(["-"])).toEqual(["/dev/stdin"]);
});
});
import fs from "fs";
import glob from "glob";
import path from "path";
const DEFAULT_EXTENSIONS = ["html"];
interface ExpandOptions {
/**
* Working directory. Defaults to `process.cwd()`.
*/
cwd?: string;
/**
* List of extensions to search for when expanding directories. Extensions
* should be passed without leading dot, e.g. "html" instead of ".html".
*/
extensions?: string[];
}
function isDirectory(filename: string): boolean {
const st = fs.statSync(filename);
return st.isDirectory();
}
function directoryPattern(extensions: string[]): string {
switch (extensions.length) {
case 0:
return "**";
case 1:
return `**/*.${extensions[0]}`;
default:
return `**/*.{${extensions.join(",")}}`;
}
}
/**
* Takes a number of file patterns (globs) and returns array of expanded
* filenames.
*/
export function expandFiles(
patterns: string[],
options: ExpandOptions = {}
): string[] {
const cwd = options.cwd || process.cwd();
const extensions = options.extensions || DEFAULT_EXTENSIONS;
const files = patterns.reduce((result: string[], pattern: string) => {
/* process - as standard input */
if (pattern === "-") {
result.push("/dev/stdin");
return result;
}
for (const filename of glob.sync(pattern, { cwd })) {
/* if file is a directory recursively expand files from it */
const fullpath = path.join(cwd, filename);
if (isDirectory(fullpath)) {
const dir = expandFiles(
[directoryPattern(extensions)],
Object.assign({}, options, { cwd: fullpath })
);
result = result.concat(dir.map(cur => path.join(filename, cur)));
continue;
}
result.push(filename);
}
return result;
}, []);
/* only return unique matches */
return Array.from(new Set(files));
}
......@@ -5,13 +5,13 @@ import { TokenDump } from "../engine";
import { UserError } from "../error/user-error";
import HtmlValidate from "../htmlvalidate";
import { Report, Reporter, Result } from "../reporter";
import { expandFiles } from "./expand-files";
import { getFormatter } from "./formatter";
import { eventFormatter } from "./json";
const pkg = require("../../package.json");
import chalk from "chalk";
import glob from "glob";
import minimist from "minimist";
enum Mode {
......@@ -122,28 +122,46 @@ const argv: minimist.ParsedArgs = minimist(process.argv.slice(2), {
string: [
"c",
"config",
"ext",
"f",
"formatter",
"h",
"help",
"max-warnings",
"rule",
"stdin-filename",
],
boolean: ["dump-events", "dump-tokens", "dump-tree", "print-config", "stdin"],
boolean: [
"dump-events",
"dump-tokens",
"dump-tree",
"print-config",
"stdin",
"version",
],
alias: {
c: "config",
f: "formatter",
h: "help",
},
default: {
formatter: "stylish",
},
unknown: (opt: string) => {
if (opt[0] === "-") {
process.stderr.write(`unknown option ${opt}\n`);
process.exit(1);
}
return true;
},
});
function showUsage(): void {
const pkg = require("../../package.json");
process.stdout.write(`${pkg.name}-${pkg.version}
Usage: html-validate [OPTIONS] [FILENAME..] [DIR..]
Common options:
--ext=STRING specify file extensions (commaseparated).
-f, --formatter=FORMATTER specify the formatter to use.
--max-warnings=INT number of warnings to trigger nonzero exit code
--rule=RULE:SEVERITY set additional rule, use comma separator for
......@@ -159,6 +177,8 @@ Debugging options:
Miscellaneous:
-c, --config=STRING use custom configuration file.
--print-config output configuration for given file.
-h, --help show help.
--version show version.
Formatters:
......@@ -170,11 +190,20 @@ e.g. "checkstyle=build/html-validate.xml"
`);
}
function showVersion(): void {
process.stdout.write(`${pkg.name}-${pkg.version}\n`);
}
if (argv.stdin) {
argv._.push("-");
}
if (argv.h || argv.help || argv._.length === 0) {
if (argv.version) {
showVersion();
process.exit();
}
if (argv.help || argv._.length === 0) {
showUsage();
process.exit();
}
......@@ -193,23 +222,20 @@ if (isNaN(maxWarnings)) {
process.exit(1);
}
const files = argv._.reduce((files: string[], pattern: string) => {
/* process - as standard input */
if (pattern === "-") {
pattern = "/dev/stdin";
}
return files.concat(glob.sync(pattern));
}, []);
const unique = Array.from(new Set(files));
/* parse extensions (used when expanding directories) */
const extensions = (argv.ext || "html").split(",").map((cur: string) => {
return cur[0] === "." ? cur.slice(1) : cur;
});
if (unique.length === 0) {
const files = expandFiles(argv._, { extensions });
if (files.length === 0) {
console.error("No files matching patterns", argv._);
process.exit(1);
}
try {
if (mode === Mode.LINT) {
const result = lint(unique);
const result = lint(files);
/* rename stdin if an explicit filename was passed */
if (argv["stdin-filename"]) {
......@@ -231,7 +257,7 @@ try {
const json = JSON.stringify(config.get(), null, 2);
console.log(json);
} else {
const output = dump(unique, mode);
const output = dump(files, mode);
console.log(output);
process.exit(0);
}
......
......@@ -240,9 +240,13 @@ export class Config {
public get(): ConfigData {
const config = Object.assign({}, this.config);
if (config.elements) {
config.elements = config.elements.map((cur: string) =>
cur.replace(this.rootDir, "<rootDir>")
);
config.elements = config.elements.map((cur: string | object) => {
if (typeof cur === "string") {
return cur.replace(this.rootDir, "<rootDir>");
} else {
return cur;
}
});
}
return config;
}
......
......@@ -37,11 +37,6 @@ class MockParser extends Parser {
return super.parseHtml(source);
}
}
/* exposed for testing */
public trigger(event: any, data: any): void {
return super.trigger(event, data);
}
}
class ExposedEngine<T extends Parser> extends Engine<T> {
......@@ -113,6 +108,21 @@ describe("Engine", () => {
expect(report).toBeInvalid();
expect(report).toHaveError("close-order", expect.any(String));
});
it("should generate config:ready event", () => {
const source: Source[] = [inline("<div></div>")];
const parser = new Parser(config);
const spy = jest.fn();
parser.on("config:ready", spy);
jest.spyOn(engine, "instantiateParser").mockReturnValue(parser);
engine.lint(source);
expect(spy).toHaveBeenCalledTimes(1);
expect(spy).toHaveBeenCalledWith("config:ready", {
location: null,
config: config.get(),
rules: expect.anything(),
});
});
});
describe("directive", () => {
......@@ -368,7 +378,7 @@ describe("Engine", () => {
engine.requireRule = jest.fn(() => null);
engine.loadRule("void", Severity.ERROR, {}, parser, reporter);
const add = jest.spyOn(reporter, "add");
parser.trigger("dom:load", { location: {} });
parser.trigger("dom:load", { location: null });
expect(add).toHaveBeenCalledWith(
expect.any(Rule),
"Definition for rule 'void' was not found",
......
import { Config, Severity } from "../config";
import { Location, Source } from "../context";
import { HtmlElement } from "../dom";
import { DirectiveEvent, TagCloseEvent, TagOpenEvent } from "../event";
import {
ConfigReadyEvent,
DirectiveEvent,
TagCloseEvent,
TagOpenEvent,
} from "../event";
import { InvalidTokenError, Lexer, TokenType } from "../lexer";
import { Parser } from "../parser";
import { Report, Reporter } from "../reporter";
......@@ -45,11 +50,19 @@ export class Engine<T extends Parser = Parser> {
public lint(sources: Source[]): Report {
for (const source of sources) {
/* create parser for source */
const parser = new this.ParserClass(this.config);
const parser = this.instantiateParser();
/* setup plugins and rules */
const { rules } = this.setupPlugins(source, this.config, parser);
/* trigger configuration ready event */
const event: ConfigReadyEvent = {
location: null,
config: this.config.get(),
rules,
};
parser.trigger("config:ready", event);
/* setup directive handling */
parser.on("directive", (_: string, event: DirectiveEvent) => {
this.processDirective(event, parser, rules);
......@@ -154,6 +167,15 @@ export class Engine<T extends Parser = Parser> {
}
}
/**
* Create a new parser instance with the current configuration.
*
* @hidden
*/
public instantiateParser(): Parser {
return new this.ParserClass(this.config);
}
private processDirective(
event: DirectiveEvent,
parser: Parser,
......
import { ConfigData } from "../config";
import { Location } from "../context";
import { DOMTree, DynamicValue, HtmlElement } from "../dom";
import { Rule } from "../rule";
/**
* @hidden
......@@ -9,6 +11,14 @@ export interface Event {
location: Location;
}
/**
* Configuration ready event.
*/
export interface ConfigReadyEvent extends Event {
config: ConfigData;
rules: { [ruleId: string]: Rule };
}
/**
* Event emitted when opening tags are encountered.
*/
......
......@@ -5,6 +5,7 @@ import { DOMTree, HtmlElement, NodeClosed } from "../dom";
import {
AttributeEvent,
ConditionalEvent,
ConfigReadyEvent,
DirectiveEvent,
DoctypeEvent,
DOMReadyEvent,
......@@ -522,17 +523,18 @@ export class Parser {
* @param {string} event - Event name
* @param {Event} data - Event data
*/
protected trigger(event: "tag:open", data: TagOpenEvent): void;
protected trigger(event: "tag:close", data: TagCloseEvent): void;
protected trigger(event: "element:ready", data: ElementReadyEvent): void;
protected trigger(event: "dom:load", data: Event): void;
protected trigger(event: "dom:ready", data: DOMReadyEvent): void;
protected trigger(event: "doctype", data: DoctypeEvent): void;
protected trigger(event: "attr", data: AttributeEvent): void;
protected trigger(event: "whitespace", data: WhitespaceEvent): void;
protected trigger(event: "conditional", data: ConditionalEvent): void;
protected trigger(event: "directive", data: DirectiveEvent): void;
protected trigger(event: any, data: any): void {
public trigger(event: "config:ready", data: ConfigReadyEvent): void;
public trigger(event: "tag:open", data: TagOpenEvent): void;
public trigger(event: "tag:close", data: TagCloseEvent): void;
public trigger(event: "element:ready", data: ElementReadyEvent): void;
public trigger(event: "dom:load", data: Event): void;
public trigger(event: "dom:ready", data: DOMReadyEvent): void;
public trigger(event: "doctype", data: DoctypeEvent): void;
public trigger(event: "attr", data: AttributeEvent): void;
public trigger(event: "whitespace", data: WhitespaceEvent): void;
public trigger(event: "conditional", data: ConditionalEvent): void;
public trigger(event: "directive", data: DirectiveEvent): void;
public trigger(event: any, data: any): void {
if (typeof data.location === "undefined") {
throw Error("Triggered event must contain location");
}
......
......@@ -12,6 +12,7 @@ import {
TagCloseEvent,
TagOpenEvent,
WhitespaceEvent,
ConfigReadyEvent,
} from "./event";
import { Parser } from "./parser";
import { Reporter } from "./reporter";
......@@ -117,6 +118,10 @@ export abstract class Rule<T = any> {
*
* @param event - Event name
*/
public on(
event: "config:ready",
callback: (event: ConfigReadyEvent) => void
): void;
public on(event: "tag:open", callback: (event: TagOpenEvent) => void): void;
public on(event: "tag:close", callback: (event: TagCloseEvent) => void): void;
public on(
......
......@@ -10,6 +10,16 @@ describe("rule prefer-button", () => {
});
});
it("should not report error when type attribute is missing type attribute", () => {
const report = htmlvalidate.validateString("<input>");
expect(report).toBeValid();
});
it("should not report error when type attribute is missing value", () => {
const report = htmlvalidate.validateString("<input type>");
expect(report).toBeValid();
});
it("should not report error when using regular input fields", () => {
const report = htmlvalidate.validateString('<input type="text">');
expect(report).toBeValid();
......
......@@ -19,7 +19,13 @@ class PreferButton extends Rule {
}
const type = node.getAttribute("type");
if (type && type.valueMatches(/^(button|submit|reset|image)$/, false)) {
/* sanity check: handle missing and boolean attributes */
if (!type || type.value === null) {
return;
}
if (type.valueMatches(/^(button|submit|reset|image)$/, false)) {
this.report(
node,
`Prefer to use <button> instead of <input type="${type.value}"> when adding buttons`,
......