From cedf9d523ab100fb6312559da3defd8114cdff3d Mon Sep 17 00:00:00 2001 From: David Sveningsson <ext@sidvind.com> Date: Sat, 14 May 2022 18:07:39 +0200 Subject: [PATCH] feat(rules): add `any` option to `attr-quotes` fixes #152 --- .../__snapshots__/attr-quotes.md.spec.ts.snap | 7 +- docs/rules/attr-quotes.md | 9 +- .../__snapshots__/attr-quotes.spec.ts.snap | 115 +++++++++++++++-- src/rules/attr-quotes.spec.ts | 102 +++++++++++++-- src/rules/attr-quotes.ts | 118 ++++++++++++++---- 5 files changed, 301 insertions(+), 50 deletions(-) diff --git a/docs/rules/__tests__/__snapshots__/attr-quotes.md.spec.ts.snap b/docs/rules/__tests__/__snapshots__/attr-quotes.md.spec.ts.snap index 645abb826..2552df136 100644 --- a/docs/rules/__tests__/__snapshots__/attr-quotes.md.spec.ts.snap +++ b/docs/rules/__tests__/__snapshots__/attr-quotes.md.spec.ts.snap @@ -10,7 +10,12 @@ exports[`docs/rules/attr-quotes.md inline validation: incorrect 1`] = ` "messages": [ { "column": 4, - "context": undefined, + "context": { + "actual": "'", + "attr": "class", + "error": "style", + "expected": """, + }, "line": 1, "message": "Attribute "class" used ' instead of expected "", "offset": 3, diff --git a/docs/rules/attr-quotes.md b/docs/rules/attr-quotes.md index 69d5df1fa..4b3b7a9ae 100644 --- a/docs/rules/attr-quotes.md +++ b/docs/rules/attr-quotes.md @@ -13,7 +13,7 @@ HTML allows different styles for quoting attributes: `<div id='foo'>` - Double-quote `"`: `<div id="foo">` -- Unquoted `'`: +- Unquoted: `<div id=foo>` (with limitations on allowed content) This rule unifies which styles are allowed. @@ -45,9 +45,10 @@ This rule takes an optional object: ### Style -- `auto` requires usage of `"` unless the attribute value contains `"` (default). -- `single` requires usage of `'` for all attributes. -- `double` requires usage of `"` for all attributes. +- `auto` requires usage of double quotes `"` unless the attribute value contains `"` (default). +- `single` requires usage of single quotes `'` for all attributes. +- `double` requires usage of double quotes `"` for all attributes. +- `any` requires usage of either single quotes `'` or double quotes `"` for all attributes. ### Unquoted diff --git a/src/rules/__snapshots__/attr-quotes.spec.ts.snap b/src/rules/__snapshots__/attr-quotes.spec.ts.snap index a1503f3c6..de318d4e2 100644 --- a/src/rules/__snapshots__/attr-quotes.spec.ts.snap +++ b/src/rules/__snapshots__/attr-quotes.spec.ts.snap @@ -1,15 +1,106 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`rule attr-quotes should contain documentation (auto) 1`] = ` -{ - "description": "Attribute values are required to be quoted with doublequotes unless the attribute value itself contains doublequotes in which case singlequotes should be used.", - "url": "https://html-validate.org/rules/attr-quotes.html", -} -`; - -exports[`rule attr-quotes should contain documentation (fixed) 1`] = ` -{ - "description": "Attribute values are required to be quoted with doublequotes.", - "url": "https://html-validate.org/rules/attr-quotes.html", -} +exports[`rule attr-quotes should contain documentation style "any" and unquoted "false" 1`] = ` +"This attribute is not quoted properly. + +Under the current configuration attributes must be: + +- quoted with single quotes \`'\` or +- quoted with double quotes \`"\` +" +`; + +exports[`rule attr-quotes should contain documentation style "any" and unquoted "true" 1`] = ` +"This attribute is not quoted properly. + +Under the current configuration attributes must be: + +- quoted with single quotes \`'\` or +- quoted with double quotes \`"\` or +- unquoted (if applicable) +" +`; + +exports[`rule attr-quotes should contain documentation style "auto" and unquoted "false" 1`] = ` +"This attribute is not quoted properly. + +Under the current configuration attributes must be: + +- quoted with double quotes \`"\` unless the value contains double quotes in which case single quotes \`'\` should be used instead +" +`; + +exports[`rule attr-quotes should contain documentation style "auto" and unquoted "true" 1`] = ` +"This attribute is not quoted properly. + +Under the current configuration attributes must be: + +- quoted with double quotes \`"\` unless the value contains double quotes in which case single quotes \`'\` should be used instead or +- unquoted (if applicable) +" +`; + +exports[`rule attr-quotes should contain documentation style "double" and unquoted "false" 1`] = ` +"This attribute is not quoted properly. + +Under the current configuration attributes must be: + +- quoted with double quotes \`"\` +" +`; + +exports[`rule attr-quotes should contain documentation style "double" and unquoted "true" 1`] = ` +"This attribute is not quoted properly. + +Under the current configuration attributes must be: + +- quoted with double quotes \`"\` or +- unquoted (if applicable) +" +`; + +exports[`rule attr-quotes should contain documentation style "single" and unquoted "false" 1`] = ` +"This attribute is not quoted properly. + +Under the current configuration attributes must be: + +- quoted with single quotes \`'\` +" +`; + +exports[`rule attr-quotes should contain documentation style "single" and unquoted "true" 1`] = ` +"This attribute is not quoted properly. + +Under the current configuration attributes must be: + +- quoted with single quotes \`'\` or +- unquoted (if applicable) +" +`; + +exports[`rule attr-quotes should contain documentation with style context 1`] = ` +"Attribute \`foo\` must use \`"\` instead of \`'\`. + +Under the current configuration attributes must be: + +- quoted with double quotes \`"\` unless the value contains double quotes in which case single quotes \`'\` should be used instead +" +`; + +exports[`rule attr-quotes should contain documentation with unquoted context 1`] = ` +"Attribute \`foo\` must not be unquoted. + +Under the current configuration attributes must be: + +- quoted with double quotes \`"\` unless the value contains double quotes in which case single quotes \`'\` should be used instead +" +`; + +exports[`rule attr-quotes should contain documentation without context 1`] = ` +"This attribute is not quoted properly. + +Under the current configuration attributes must be: + +- quoted with double quotes \`"\` unless the value contains double quotes in which case single quotes \`'\` should be used instead +" `; diff --git a/src/rules/attr-quotes.spec.ts b/src/rules/attr-quotes.spec.ts index b0ef993c1..19738bb1a 100644 --- a/src/rules/attr-quotes.spec.ts +++ b/src/rules/attr-quotes.spec.ts @@ -1,5 +1,6 @@ import HtmlValidate from "../htmlvalidate"; import "../jest"; +import { RuleStyleContext, RuleUnquotedContext } from "./attr-quotes"; describe("rule attr-quotes", () => { let htmlvalidate: HtmlValidate; @@ -79,6 +80,32 @@ describe("rule attr-quotes", () => { }); }); + describe("with any option", () => { + beforeAll(() => { + htmlvalidate = new HtmlValidate({ + rules: { "attr-quotes": ["error", { style: "any" }] }, + }); + }); + + it("should not report when attributes use double quotes", () => { + expect.assertions(1); + const report = htmlvalidate.validateString('<div foo="bar"></div>'); + expect(report).toBeValid(); + }); + + it("should not report error when attributes use single quotes", () => { + expect.assertions(1); + const report = htmlvalidate.validateString("<div foo='bar'></div>"); + expect(report).toBeValid(); + }); + + it("should not report for boolean attribute", () => { + expect.assertions(1); + const report = htmlvalidate.validateString("<input checked>"); + expect(report).toBeValid(); + }); + }); + describe("with unquoted allowed", () => { beforeAll(() => { htmlvalidate = new HtmlValidate({ @@ -131,23 +158,74 @@ describe("rule attr-quotes", () => { rules: { "attr-quotes": ["error", { style: "foobar" }] }, }); }).toThrowErrorMatchingInlineSnapshot( - `"Rule configuration error: /rules/attr-quotes/1/style must be equal to one of the allowed values: auto, double, single"` + `"Rule configuration error: /rules/attr-quotes/1/style must be equal to one of the allowed values: auto, double, single, any"` ); }); - it("should contain documentation (auto)", () => { - expect.assertions(1); - htmlvalidate = new HtmlValidate({ - rules: { "attr-quotes": ["error", { style: "auto" }] }, + describe("should contain documentation", () => { + it("url", () => { + expect.assertions(1); + htmlvalidate = new HtmlValidate({ + rules: { "attr-quotes": "error" }, + }); + const docs = htmlvalidate.getRuleDocumentation("attr-quotes"); + expect(docs?.url).toMatchInlineSnapshot(`"https://html-validate.org/rules/attr-quotes.html"`); }); - expect(htmlvalidate.getRuleDocumentation("attr-quotes")).toMatchSnapshot(); - }); - it("should contain documentation (fixed)", () => { - expect.assertions(1); - htmlvalidate = new HtmlValidate({ - rules: { "attr-quotes": ["error", { style: "double" }] }, + it("without context", () => { + expect.assertions(1); + htmlvalidate = new HtmlValidate({ + rules: { "attr-quotes": "error" }, + }); + const docs = htmlvalidate.getRuleDocumentation("attr-quotes"); + expect(docs?.description).toMatchSnapshot(); + }); + + it("with unquoted context", () => { + expect.assertions(1); + htmlvalidate = new HtmlValidate({ + rules: { "attr-quotes": "error" }, + }); + const context: RuleUnquotedContext = { + error: "unquoted", + attr: "foo", + }; + const docs = htmlvalidate.getRuleDocumentation("attr-quotes", null, context); + expect(docs?.description).toMatchSnapshot(); + }); + + it("with style context", () => { + expect.assertions(1); + htmlvalidate = new HtmlValidate({ + rules: { "attr-quotes": "error" }, + }); + const context: RuleStyleContext = { + error: "style", + attr: "foo", + actual: "'", + expected: '"', + }; + const docs = htmlvalidate.getRuleDocumentation("attr-quotes", null, context); + expect(docs?.description).toMatchSnapshot(); + }); + + it.each` + style | unquoted + ${"auto"} | ${false} + ${"auto"} | ${true} + ${"any"} | ${false} + ${"any"} | ${true} + ${"single"} | ${false} + ${"single"} | ${true} + ${"double"} | ${false} + ${"double"} | ${true} + `('style "$style" and unquoted "$unquoted"', ({ style, unquoted }) => { + expect.assertions(1); + htmlvalidate = new HtmlValidate({ + rules: { "attr-quotes": ["error", { style, unquoted }] }, + }); + const docs = htmlvalidate.getRuleDocumentation("attr-quotes"); + expect(docs?.description).toMatchSnapshot(); }); - expect(htmlvalidate.getRuleDocumentation("attr-quotes")).toMatchSnapshot(); }); }); diff --git a/src/rules/attr-quotes.ts b/src/rules/attr-quotes.ts index 5aa611b79..a63224911 100644 --- a/src/rules/attr-quotes.ts +++ b/src/rules/attr-quotes.ts @@ -7,10 +7,25 @@ enum QuoteStyle { SINGLE_QUOTE = "'", DOUBLE_QUOTE = '"', AUTO_QUOTE = "auto", + ANY_QUOTE = "any", } +export interface RuleStyleContext { + error: "style"; + attr: string; + expected: string; + actual: string; +} + +export interface RuleUnquotedContext { + error: "unquoted"; + attr: string; +} + +type RuleContext = RuleStyleContext | RuleUnquotedContext; + interface RuleOptions { - style: "auto" | "single" | "double"; + style: "auto" | "single" | "double" | "any"; unquoted: boolean; } @@ -19,13 +34,51 @@ const defaults: RuleOptions = { unquoted: false, }; -export default class AttrQuotes extends Rule<void, RuleOptions> { +function describeError(context?: RuleContext): string { + if (context) { + switch (context.error) { + case "style": + return `Attribute \`${context.attr}\` must use \`${context.expected}\` instead of \`${context.actual}\`.`; + case "unquoted": + return `Attribute \`${context.attr}\` must not be unquoted.`; + } + } else { + return "This attribute is not quoted properly."; + } +} + +function describeStyle(style: QuoteStyle, unquoted: boolean): string { + const description: string[] = []; + switch (style) { + case QuoteStyle.AUTO_QUOTE: + description.push( + "- quoted with double quotes `\"` unless the value contains double quotes in which case single quotes `'` should be used instead" + ); + break; + case QuoteStyle.ANY_QUOTE: + description.push("- quoted with single quotes `'`"); + description.push('- quoted with double quotes `"`'); + break; + case QuoteStyle.SINGLE_QUOTE: + case QuoteStyle.DOUBLE_QUOTE: { + const name = style === QuoteStyle.SINGLE_QUOTE ? "single" : "double"; + description.push(`- quoted with ${name} quotes \`${style}\``); + break; + } + } + if (unquoted) { + description.push("- unquoted (if applicable)"); + } + return `${description.join(" or\n")}\n`; +} + +export default class AttrQuotes extends Rule<RuleContext, RuleOptions> { private style: QuoteStyle; public static schema(): SchemaObject { return { style: { - enum: ["auto", "double", "single"], + enum: ["auto", "double", "single", "any"], type: "string", }, unquoted: { @@ -34,18 +87,20 @@ export default class AttrQuotes extends Rule<void, RuleOptions> { }; } - public documentation(): RuleDocumentation { - if (this.options.style === "auto") { - return { - description: `Attribute values are required to be quoted with doublequotes unless the attribute value itself contains doublequotes in which case singlequotes should be used.`, - url: ruleDocumentationUrl(__filename), - }; - } else { - return { - description: `Attribute values are required to be quoted with ${this.options.style}quotes.`, - url: ruleDocumentationUrl(__filename), - }; - } + public documentation(context?: RuleContext): RuleDocumentation { + const { style } = this; + const { unquoted } = this.options; + const description = [ + describeError(context), + "", + "Under the current configuration attributes must be:", + "", + describeStyle(style, unquoted), + ]; + return { + description: description.join("\n"), + url: ruleDocumentationUrl(__filename), + }; } public constructor(options: Partial<RuleOptions>) { @@ -62,23 +117,42 @@ export default class AttrQuotes extends Rule<void, RuleOptions> { if (!event.quote) { if (this.options.unquoted === false) { - this.report(event.target, `Attribute "${event.key}" using unquoted value`); + const message = `Attribute "${event.key}" using unquoted value`; + const context: RuleUnquotedContext = { + error: "unquoted", + attr: event.key, + }; + this.report(event.target, message, null, context); } return; } + /* if the style is set to any we skip the rest of the rule as the only + * thing that matters is if the "unquoted" options triggers an error or + * not */ + if (this.style === QuoteStyle.ANY_QUOTE) { + return; + } + const expected = this.resolveQuotemark(event.value.toString(), this.style); if (event.quote !== expected) { - this.report( - event.target, - `Attribute "${event.key}" used ${event.quote} instead of expected ${expected}` - ); + const message = `Attribute "${event.key}" used ${event.quote} instead of expected ${expected}`; + const context: RuleStyleContext = { + error: "style", + attr: event.key, + actual: event.quote, + expected, + }; + this.report(event.target, message, null, context); } }); } - private resolveQuotemark(value: string, style: QuoteStyle): QuoteMark { + private resolveQuotemark( + value: string, + style: Exclude<QuoteStyle, QuoteStyle.ANY_QUOTE> + ): QuoteMark { if (style === QuoteStyle.AUTO_QUOTE) { return value.includes('"') ? "'" : '"'; } else { @@ -95,6 +169,8 @@ function parseStyle(style: string): QuoteStyle { return QuoteStyle.DOUBLE_QUOTE; case "single": return QuoteStyle.SINGLE_QUOTE; + case "any": + return QuoteStyle.ANY_QUOTE; /* istanbul ignore next: covered by schema validation */ default: throw new ConfigError(`Invalid style "${style}" for "attr-quotes" rule`); -- GitLab