...
 
Commits (29)
......@@ -118,6 +118,10 @@ Node 13.x (current):
<<: *compat
image: node:13
Node 14.x (current):
<<: *compat
image: node:14
Release:
stage: release
image: node:12
......
# html-validate changelog
# [2.21.0](https://gitlab.com/html-validate/html-validate/compare/v2.20.1...v2.21.0) (2020-04-26)
### Bug Fixes
- **meta:** throw schema validation error when element metadata does not validate ([6ecf050](https://gitlab.com/html-validate/html-validate/commit/6ecf0501f3f8284c9248ac5fd0643d1c32049333)), closes [#81](https://gitlab.com/html-validate/html-validate/issues/81)
- **schema:** allow `permittedContent` and `permittedDescendants` to use AND-syntax ([2fa742c](https://gitlab.com/html-validate/html-validate/commit/2fa742c03b84145d0fa334809ff1f98f80cfc263)), closes [#82](https://gitlab.com/html-validate/html-validate/issues/82)
- **transform:** expose `computeOffset` ([d033538](https://gitlab.com/html-validate/html-validate/commit/d033538c58ff921026fc3a025e679c8b8f2e144e))
### Features
- **dom:** `DOMTokenList` can extract location data for each token ([4f4dfe0](https://gitlab.com/html-validate/html-validate/commit/4f4dfe05ccdb93c8ba27754e8ae9785fc91508eb)), closes [#74](https://gitlab.com/html-validate/html-validate/issues/74)
- **rules:** add `include` and `exclude` options to `no-inline-style` ([6604e88](https://gitlab.com/html-validate/html-validate/commit/6604e88e96d59c67d596b92be760b1ba5a971589)), closes [html-validate/html-validate-angular#3](https://gitlab.com/html-validate/html-validate-angular/issues/3)
- **rules:** use more precise location from `DOMTokenList` ([e874784](https://gitlab.com/html-validate/html-validate/commit/e874784858badb3a448cc739189cdac5ef577efe))
## [2.20.1](https://gitlab.com/html-validate/html-validate/compare/v2.20.0...v2.20.1) (2020-04-19)
### Bug Fixes
......
......@@ -9,15 +9,15 @@ Array [
"filePath": "inline",
"messages": Array [
Object {
"column": 13,
"column": 21,
"context": undefined,
"line": 1,
"message": "Class \\"foo\\" duplicated",
"offset": 12,
"offset": 20,
"ruleId": "no-dup-class",
"selector": "div",
"severity": 2,
"size": 11,
"size": 3,
},
],
"source": "<div class=\\"foo bar foo\\"></div>",
......
......@@ -5,7 +5,7 @@ category: style
summary: Require classes to match a specific pattern
---
# require a specific class format (`class-pattern`)
# Require a specific class format (`class-pattern`)
Requires all classes to match a given pattern.
......@@ -14,13 +14,13 @@ Requires all classes to match a given pattern.
Examples of **incorrect** code for this rule:
<validate name="incorrect" rules="class-pattern">
<div class="fooBar"></foobar>
<div class="fooBar"></foobar>
</validate>
Examples of **correct** code for this rule:
<validate name="correct" rules="class-pattern">
<div class="foo-bar"></div>
<div class="foo-bar"></div>
</validate>
## Options
......
......@@ -4,7 +4,7 @@ name: no-dup-class
summary: Disallow duplicated classes
---
# disallows duplicated classes on same element (`no-dup-class`)
# Disallows duplicated classes on same element (`no-dup-class`)
Prevents unnecessary duplication of class names.
......
......@@ -5,7 +5,7 @@ category: style
summary: Disallow inline style
---
# disallow inline style (`no-inline-style`)
# Disallow inline style (`no-inline-style`)
Inline style is a sign of unstructured CSS. Use class or ID with a separate
stylesheet.
......@@ -23,3 +23,31 @@ Examples of **correct** code for this rule:
<validate name="correct" rules="no-inline-style">
<p class="error"></p>
</validate>
## Options
This rule takes an optional object:
```javascript
{
"include": []
"exclude": []
}
```
Both `include` and `exclude` are only useful when using a framework with dynamic attributes such as `ng-style` or `:style` to allow/disallow one or more specific variant of the attribute.
For instance:
```html
<p :style="style></p>
```
would normally trigger the rule when using {@link frameworks/vue html-validate-vue} but by adding `:style` to `exclude` it can be allowed.
### `include`
If set only attributes listed in this array generates errors.
### `exclude`
If set attributes listed in this array is ignored.
......@@ -280,6 +280,34 @@ using `exclude`:
}
```
#### Combining (AND)
Permitted content matches if the element matches any of the entries.
Entires can also be combined so multiple entries must all match by wrapping entires in an array:
```js
"custom-elements": {
"permittedContent": [
["@flow", {"exclude": "div"}]
]
}
```
This will allow any flow content except `<div>`.
Be careful when using multiple combined entries as each group will still match if any matches:
```js
"custom-elements": {
"permittedContent": [
"@flow",
["@phrasing", {"exclude": "em"}]
]
}
```
Since `<em>` will match `@flow` it will be allowed even if excluded by the next entry.
#### Limit occurrences
If a child is only allowed once it can be suffixed with `?` to limit to 0 or 1
......
This source diff could not be displayed because it is too large. You can view the blob instead.
{
"name": "html-validate",
"version": "2.20.1",
"version": "2.21.0",
"description": "html linter",
"keywords": [
"html",
......@@ -96,10 +96,10 @@
"@babel/preset-env": "7.9.5",
"@commitlint/cli": "8.3.5",
"@html-validate/commitlint-config": "1.0.3",
"@html-validate/eslint-config": "1.2.3",
"@html-validate/jest-config": "1.0.1",
"@html-validate/eslint-config": "1.3.1",
"@html-validate/jest-config": "1.0.4",
"@html-validate/prettier-config": "1.0.1",
"@html-validate/semantic-release-config": "1.0.16",
"@html-validate/semantic-release-config": "1.0.17",
"@types/babel__code-frame": "7.0.1",
"@types/estree": "0.0.44",
"@types/glob": "7.1.1",
......@@ -107,7 +107,7 @@
"@types/jest": "25.2.1",
"@types/json-merge-patch": "0.0.4",
"@types/minimist": "1.2.0",
"@types/node": "11.15.11",
"@types/node": "11.15.12",
"autoprefixer": "9.7.6",
"babelify": "10.0.0",
"bootstrap-sass": "3.4.1",
......@@ -129,18 +129,18 @@
"grunt-contrib-copy": "1.0.0",
"grunt-postcss": "0.9.0",
"grunt-sass": "3.1.0",
"highlight.js": "9.18.1",
"highlight.js": "10.0.0",
"husky": "4.2.5",
"jest": "25.3.0",
"jest-diff": "25.3.0",
"jest": "25.4.0",
"jest-diff": "25.4.0",
"jquery": "3.5.0",
"lint-staged": "10.1.3",
"lint-staged": "10.1.7",
"load-grunt-tasks": "5.1.0",
"marked": "0.8.2",
"minimatch": "3.0.4",
"prettier": "2.0.4",
"sass": "1.26.3",
"semantic-release": "17.0.6",
"prettier": "2.0.5",
"sass": "1.26.5",
"semantic-release": "17.0.7",
"serve-static": "1.14.1",
"stringmap": "0.2.2",
"strip-ansi": "6.0.0",
......
......@@ -13,7 +13,7 @@ export class Attribute {
public readonly value: string | DynamicValue;
public readonly keyLocation: Location;
public readonly valueLocation: Location;
public readonly originalAttribute: string;
public readonly originalAttribute?: string;
/**
* @param key - Attribute name.
......
import { Location } from "../context";
import { DOMTokenList } from "./domtokenlist";
import { DynamicValue } from "./dynamic-value";
......@@ -67,6 +68,55 @@ describe("DOMTokenList", () => {
});
});
describe("location()", () => {
const location: Location = {
filename: "mock",
line: 1,
column: 1,
offset: 0,
size: 11,
};
it("should return location by index", () => {
expect.assertions(3);
const list = new DOMTokenList("foo bar baz", location);
expect(list.location(0)).toEqual({
filename: "mock",
line: 1,
column: 1,
offset: 0,
size: 3,
});
expect(list.location(1)).toEqual({
filename: "mock",
line: 1,
column: 5,
offset: 4,
size: 3,
});
expect(list.location(2)).toEqual({
filename: "mock",
line: 1,
column: 9,
offset: 8,
size: 3,
});
});
it("should return undefined if out of range", () => {
expect.assertions(2);
const list = new DOMTokenList("foo bar baz", location);
expect(list.location(-1)).toBeUndefined();
expect(list.location(3)).toBeUndefined();
});
it("should throw error when accessing location without base location", () => {
expect.assertions(1);
const list = new DOMTokenList("foo");
expect(() => list.location(0)).toThrow();
});
});
describe("contains()", () => {
it("should return true if token exists", () => {
expect.assertions(1);
......
import { Location, sliceLocation } from "../context";
import { DynamicValue } from "./dynamic-value";
interface Result {
tokens: string[];
locations: Location[];
}
function parse(text: string, baseLocation: Location): Result {
const tokens: string[] = [];
const locations: Location[] | undefined = baseLocation ? [] : undefined;
for (let begin = 0; begin < text.length; ) {
let end = text.indexOf(" ", begin);
/* if the last space was found move the position to the last character
* in the string */
if (end === -1) {
end = text.length;
}
/* handle multiple spaces */
const size = end - begin;
if (size === 0) {
begin++;
continue;
}
/* extract token */
const token = text.substring(begin, end);
tokens.push(token);
/* extract location */
if (baseLocation) {
const location = sliceLocation(baseLocation, begin, end);
locations.push(location);
}
/* advance position to the character after the current end position */
begin += size + 1;
}
return { tokens, locations };
}
export class DOMTokenList extends Array<string> {
public readonly value: string;
private readonly locations: Location[] | undefined;
public constructor(value: string | DynamicValue) {
public constructor(value: string | DynamicValue, location?: Location) {
if (value && typeof value === "string") {
super(...value.trim().split(/ +/));
const { tokens, locations } = parse(value, location);
super(...tokens);
this.locations = locations;
} else {
super(0);
}
......@@ -17,10 +61,20 @@ export class DOMTokenList extends Array<string> {
}
}
public item(n: number): string {
public item(n: number): string | undefined {
return this[n];
}
public location(n: number): Location | undefined {
if (this.locations) {
return this.locations[n];
} else {
throw new Error(
"Trying to access DOMTokenList location when base location isn't set"
);
}
}
public contains(token: string): boolean {
return this.indexOf(token) >= 0;
}
......
......@@ -273,11 +273,21 @@ export class HtmlElement extends DOMNode {
this.annotation = text;
}
/**
* Set attribute. Stores all attributes set even with the same name.
*
* @param key - Attribute name
* @param value - Attribute value. Use `null` if no value is present.
* @param keyLocation - Location of the attribute name.
* @param valueLocation - Location of the attribute value (excluding quotation)
* @param originalAttribute - If attribute is an alias for another attribute
* (dynamic attributes) set this to the original attribute name.
*/
public setAttribute(
key: string,
value: string | DynamicValue,
keyLocation: Location,
valueLocation: Location,
value: string | DynamicValue | null,
keyLocation: Location | null,
valueLocation: Location | null,
originalAttribute?: string
): void {
key = key.toLowerCase();
......
......@@ -227,8 +227,8 @@ class HtmlValidate {
*/
public getRuleDocumentation(
ruleId: string,
config?: Config,
context?: any
config: Config | null = null,
context: any | null = null
): RuleDocumentation {
const engine = new Engine(config || this.getConfigFor("inline"), Parser);
return engine.getRuleDocumentation(ruleId, context);
......
......@@ -35,7 +35,7 @@ jest.mock(
);
import { Config } from "../config";
import { UserError } from "../error/user-error";
import { UserError, SchemaValidationError } from "../error";
import { Parser } from "../parser";
import { MetaDataTable } from "./element";
import { MetaData, MetaTable } from ".";
......@@ -52,7 +52,7 @@ describe("MetaTable", () => {
validate.errors = [];
});
it("should throw error if data does not validate", () => {
it("should throw SchemaValidationError if object does not validate", () => {
expect.assertions(2);
validate.errors = [
{
......@@ -68,13 +68,35 @@ describe("MetaTable", () => {
table.loadFromObject({
foo: mockEntry({ invalid: true }),
});
expect(fn).toThrow(UserError);
expect(fn).toThrow(SchemaValidationError);
expect(fn).toThrow(
"Element metadata is not valid: /foo Property invalid is not expected to be here"
);
});
it("should throw user-error if file is not properly formatted json", () => {
it("should throw SchemaValidationError if file does not validate", () => {
expect.assertions(2);
const filename = path.resolve(
__dirname,
"../../test-files/meta/invalid-schema.json"
);
const table = new MetaTable();
validate.errors = [
{
keyword: "additionalProperties",
dataPath: "/foo",
schemaPath: "#/patternProperties/%5E.*%24/additionalProperties",
params: { additionalProperty: "invalid" },
message: "should NOT have additional properties",
},
];
expect(() => table.loadFromFile(filename)).toThrow(SchemaValidationError);
expect(() => table.loadFromFile(filename)).toThrow(
"Element metadata is not valid: /foo Property invalid is not expected to be here"
);
});
it("should throw UserError if file is not properly formatted json", () => {
expect.assertions(2);
const table = new MetaTable();
expect(() => table.loadFromFile("invalid-file.json")).toThrow(UserError);
......
......@@ -106,6 +106,9 @@ export class MetaTable {
/**
* Load metadata table from object.
*
* @param obj - Object with metadata to load
* @param filename - Optional filename used when presenting validation error
*/
public loadFromObject(
obj: MetaDataTable,
......@@ -134,6 +137,8 @@ export class MetaTable {
/**
* Load metadata table from filename
*
* @param filename - Filename to load
*/
public loadFromFile(filename: string): void {
try {
......@@ -146,6 +151,9 @@ export class MetaTable {
const data = require(filename); // eslint-disable-line import/no-dynamic-require
this.loadFromObject(data, filename);
} catch (err) {
if (err instanceof SchemaValidationError) {
throw err;
}
throw new UserError(
`Failed to load element metadata from "${filename}"`,
err
......
......@@ -14,26 +14,26 @@ Array [
"filePath": "test-files/rules/class-pattern.html",
"messages": Array [
Object {
"column": 13,
"column": 17,
"context": undefined,
"line": 3,
"message": "Class \\"foo_bar\\" does not match required pattern \\"/^[a-z0-9-]+$/\\"",
"offset": 49,
"offset": 53,
"ruleId": "class-pattern",
"selector": "div:nth-child(2)",
"severity": 2,
"size": 15,
"size": 7,
},
Object {
"column": 13,
"column": 17,
"context": undefined,
"line": 5,
"message": "Class \\"fooBar\\" does not match required pattern \\"/^[a-z0-9-]+$/\\"",
"offset": 86,
"offset": 90,
"ruleId": "class-pattern",
"selector": "div:nth-child(3)",
"severity": 2,
"size": 14,
"size": 6,
},
],
"source": "<div class=\\"foo foo-bar bar\\"></div>
......
......@@ -14,15 +14,15 @@ Array [
"filePath": "test-files/rules/no-dup-class.html",
"messages": Array [
Object {
"column": 13,
"column": 21,
"context": undefined,
"line": 5,
"message": "Class \\"foo\\" duplicated",
"offset": 66,
"offset": 74,
"ruleId": "no-dup-class",
"selector": "div:nth-child(3)",
"severity": 2,
"size": 11,
"size": 3,
},
],
"source": "<div class=\\"foo\\"></div>
......
......@@ -33,13 +33,14 @@ export default class ClassPattern extends Rule<void, RuleOptions> {
return;
}
const classes = new DOMTokenList(event.value);
classes.forEach((cur) => {
const classes = new DOMTokenList(event.value, event.valueLocation);
classes.forEach((cur: string, index: number) => {
if (!cur.match(this.pattern)) {
const location = classes.location(index);
this.report(
event.target,
`Class "${cur}" does not match required pattern "${this.pattern}"`,
event.valueLocation
location
);
}
});
......
......@@ -16,15 +16,12 @@ export default class NoDupClass extends Rule {
return;
}
const classes = new DOMTokenList(event.value);
const classes = new DOMTokenList(event.value, event.valueLocation);
const unique: Set<string> = new Set();
classes.forEach((cur) => {
classes.forEach((cur: string, index: number) => {
if (unique.has(cur)) {
this.report(
event.target,
`Class "${cur}" duplicated`,
event.valueLocation
);
const location = classes.location(index);
this.report(event.target, `Class "${cur}" duplicated`, location);
}
unique.add(cur);
});
......
import HtmlValidate from "../htmlvalidate";
import "../matchers";
import { processAttribute } from "../transform/mocks/attribute";
describe("rule no-inline-style", () => {
let htmlvalidate: HtmlValidate;
beforeAll(() => {
htmlvalidate = new HtmlValidate({
rules: { "no-inline-style": "error" },
describe("default configuration", () => {
beforeAll(() => {
htmlvalidate = new HtmlValidate({
rules: { "no-inline-style": "error" },
});
});
it("should report when style attribute is used", () => {
expect.assertions(2);
const report = htmlvalidate.validateString('<p style=""></p>');
expect(report).toBeInvalid();
expect(report).toHaveError(
"no-inline-style",
"Inline style is not allowed"
);
});
it("should report when dynamic style attribute is used", () => {
expect.assertions(2);
const report = htmlvalidate.validateString(
'<p dynamic-style=""></p>',
null,
{ processAttribute }
);
expect(report).toBeInvalid();
expect(report).toHaveError(
"no-inline-style",
"Inline style is not allowed"
);
});
});
it("should report when style attribute is used", () => {
expect.assertions(2);
const report = htmlvalidate.validateString('<p style=""></p>');
expect(report).toBeInvalid();
expect(report).toHaveError(
"no-inline-style",
"Inline style is not allowed"
);
describe("configured with include", () => {
beforeAll(() => {
htmlvalidate = new HtmlValidate({
rules: { "no-inline-style": ["error", { include: ["style"] }] },
});
});
it("should report when style attribute is used", () => {
expect.assertions(2);
const report = htmlvalidate.validateString('<p style=""></p>');
expect(report).toBeInvalid();
expect(report).toHaveError(
"no-inline-style",
"Inline style is not allowed"
);
});
it("should not report when dynamic style attribute is used", () => {
expect.assertions(1);
const report = htmlvalidate.validateString(
'<p dynamic-style=""></p>',
null,
{ processAttribute }
);
expect(report).toBeValid();
});
});
describe("configured with exclude", () => {
beforeAll(() => {
htmlvalidate = new HtmlValidate({
rules: { "no-inline-style": ["error", { exclude: ["style"] }] },
});
});
it("should not report when style attribute is used", () => {
expect.assertions(1);
const report = htmlvalidate.validateString('<p style=""></p>');
expect(report).toBeValid();
});
it("should report when dynamic style attribute is used", () => {
expect.assertions(2);
const report = htmlvalidate.validateString(
'<p dynamic-style=""></p>',
null,
{ processAttribute }
);
expect(report).toBeInvalid();
expect(report).toHaveError(
"no-inline-style",
"Inline style is not allowed"
);
});
});
it("smoketest", () => {
expect.assertions(1);
htmlvalidate = new HtmlValidate({
rules: { "no-inline-style": "error" },
});
const report = htmlvalidate.validateFile(
"test-files/rules/no-inline-style.html"
);
......@@ -30,6 +106,9 @@ describe("rule no-inline-style", () => {
it("should contain documentation", () => {
expect.assertions(1);
htmlvalidate = new HtmlValidate({
rules: { "no-inline-style": "error" },
});
expect(
htmlvalidate.getRuleDocumentation("no-inline-style")
).toMatchSnapshot();
......
import { AttributeEvent } from "../event";
import { Rule, RuleDocumentation, ruleDocumentationUrl } from "../rule";
export default class NoInlineStyle extends Rule {
export interface RuleOptions {
include: string[] | null;
exclude: string[] | null;
}
const defaults: RuleOptions = {
include: null,
exclude: null,
};
export default class NoInlineStyle extends Rule<void, RuleOptions> {
public constructor(options: RuleOptions) {
super(Object.assign({}, defaults, options));
}
public documentation(): RuleDocumentation {
return {
description:
......@@ -12,9 +26,30 @@ export default class NoInlineStyle extends Rule {
public setup(): void {
this.on("attr", (event: AttributeEvent) => {
if (event.key === "style") {
if (this.isRelevant(event)) {
this.report(event.target, "Inline style is not allowed");
}
});
}
private isRelevant(event: AttributeEvent): boolean {
if (event.key !== "style") {
return false;
}
const { include, exclude } = this.options;
const key = event.originalAttribute || event.key;
/* ignore attributes not present in "include" */
if (include && !include.includes(key)) {
return false;
}
/* ignore attributes present in "exclude" */
if (exclude && exclude.includes(key)) {
return false;
}
return true;
}
}
......@@ -209,6 +209,19 @@
{
"type": "string"
},
{
"type": "array",
"items": {
"anyOf": [
{
"type": "string"
},
{
"$ref": "#/definitions/PermittedGroup"
}
]
}
},
{
"$ref": "#/definitions/PermittedGroup"
}
......
import { MetaTable } from "../meta";
describe("permittedContent", () => {
it("should allow string", () => {
expect.assertions(1);
const table = new MetaTable();
expect(() => {
table.loadFromObject({
element: {
permittedContent: ["foo"],
},
});
}).not.toThrow();
});
it("should allow exclude", () => {
expect.assertions(1);
const table = new MetaTable();
expect(() => {
table.loadFromObject({
element: {
permittedContent: [{ exclude: "foo" }],
},
});
}).not.toThrow();
});
it("should allow AND-joined array", () => {
expect.assertions(1);
const table = new MetaTable();
expect(() => {
table.loadFromObject({
element: {
permittedContent: [["foo", { exclude: "bar" }]],
},
});
}).not.toThrow();
});
});
......@@ -9,6 +9,11 @@ import { Source } from "../context";
const espree = require("espree");
const walk = require("acorn-walk");
export interface Position {
line: number;
column: number;
}
/* espree puts location information a bit different than estree */
declare module "estree" {
interface TemplateElement {
......@@ -29,9 +34,13 @@ function joinTemplateLiteral(nodes: ESTree.TemplateElement[]): string {
}
/**
* espree locations does not include offset, need to calculate it manually.
* Compute source offset from line and column and the given markup.
*
* @param position - Line and column.
* @param data - Source markup.
* @returns The byte offset into the markup which line and column corresponds to.
*/
function computeOffset(position: ESTree.Position, data: string): number {
export function computeOffset(position: Position, data: string): number {
let line = position.line;
let column = position.column + 1;
for (let i = 0; i < data.length; i++) {
......
{
"foo": {
"invalid": true
}
}