From 5bca2c2035f933b7073fd6d5bd88b3aea55487ae Mon Sep 17 00:00:00 2001 From: David Sveningsson Date: Sat, 17 Apr 2021 22:36:11 +0200 Subject: [PATCH] feat(rules): new rule `capitalize-text` --- docs/dgeni/templates/base.template.html | 1 + docs/htmlvalidate-public.json | 1 + .../capitalize-text.md.spec.ts.snap | 32 ++++ .../__tests__/capitalize-text.md.spec.ts | 27 +++ docs/rules/capitalize-text.md | 64 ++++++++ src/dom/htmlelement.ts | 2 +- src/dom/index.ts | 2 +- .../capitalize-text.spec.ts.snap | 15 ++ src/rules/capitalize-text.spec.ts | 154 ++++++++++++++++++ src/rules/capitalize-text.ts | 154 ++++++++++++++++++ src/rules/index.ts | 2 + 11 files changed, 452 insertions(+), 2 deletions(-) create mode 100644 docs/rules/__tests__/__snapshots__/capitalize-text.md.spec.ts.snap create mode 100644 docs/rules/__tests__/capitalize-text.md.spec.ts create mode 100644 docs/rules/capitalize-text.md create mode 100644 src/rules/__snapshots__/capitalize-text.spec.ts.snap create mode 100644 src/rules/capitalize-text.spec.ts create mode 100644 src/rules/capitalize-text.ts diff --git a/docs/dgeni/templates/base.template.html b/docs/dgeni/templates/base.template.html index d87609cf..08a59499 100644 --- a/docs/dgeni/templates/base.template.html +++ b/docs/dgeni/templates/base.template.html @@ -83,6 +83,7 @@
  • {@link changelog Changelog}
  • {@link about About}
  • + diff --git a/docs/htmlvalidate-public.json b/docs/htmlvalidate-public.json index b5e216b8..a148d795 100644 --- a/docs/htmlvalidate-public.json +++ b/docs/htmlvalidate-public.json @@ -1,6 +1,7 @@ { "extends": ["html-validate:recommended", "html-validate:document"], "rules": { + "capitalize-text": "error", "long-title": ["error", { "maxlength": 120 }], "no-trailing-whitespace": "off", "require-sri": "off" diff --git a/docs/rules/__tests__/__snapshots__/capitalize-text.md.spec.ts.snap b/docs/rules/__tests__/__snapshots__/capitalize-text.md.spec.ts.snap new file mode 100644 index 00000000..7256cc62 --- /dev/null +++ b/docs/rules/__tests__/__snapshots__/capitalize-text.md.spec.ts.snap @@ -0,0 +1,32 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`docs/rules/capitalize-text.md inline validation: correct 1`] = `Array []`; + +exports[`docs/rules/capitalize-text.md inline validation: ignored 1`] = `Array []`; + +exports[`docs/rules/capitalize-text.md inline validation: incorrect 1`] = ` +Array [ + Object { + "errorCount": 1, + "filePath": "inline", + "messages": Array [ + Object { + "column": 2, + "context": Object { + "tagName": "h1", + }, + "line": 1, + "message": "Text must be capitalized in

    ", + "offset": 1, + "ruleId": "capitalize-text", + "ruleUrl": "https://html-validate.org/rules/capitalize-text.html", + "selector": "h1", + "severity": 2, + "size": 2, + }, + ], + "source": "

    lorem ipsum

    ", + "warningCount": 0, + }, +] +`; diff --git a/docs/rules/__tests__/capitalize-text.md.spec.ts b/docs/rules/__tests__/capitalize-text.md.spec.ts new file mode 100644 index 00000000..1fb67e6a --- /dev/null +++ b/docs/rules/__tests__/capitalize-text.md.spec.ts @@ -0,0 +1,27 @@ +import HtmlValidate from "../../../src/htmlvalidate"; + +const markup: { [key: string]: string } = {}; +markup["incorrect"] = `

    lorem ipsum

    `; +markup["correct"] = `

    Lorem ipsum

    `; +markup["ignored"] = `

    loremIpsum dolor sit amet

    `; + +describe("docs/rules/capitalize-text.md", () => { + it("inline validation: incorrect", () => { + expect.assertions(1); + const htmlvalidate = new HtmlValidate({"rules":{"capitalize-text":"error"}}); + const report = htmlvalidate.validateString(markup["incorrect"]); + expect(report.results).toMatchSnapshot(); + }); + it("inline validation: correct", () => { + expect.assertions(1); + const htmlvalidate = new HtmlValidate({"rules":{"capitalize-text":"error"}}); + const report = htmlvalidate.validateString(markup["correct"]); + expect(report.results).toMatchSnapshot(); + }); + it("inline validation: ignored", () => { + expect.assertions(1); + const htmlvalidate = new HtmlValidate({"rules":{"capitalize-text":"error"}}); + const report = htmlvalidate.validateString(markup["ignored"]); + expect(report.results).toMatchSnapshot(); + }); +}); diff --git a/docs/rules/capitalize-text.md b/docs/rules/capitalize-text.md new file mode 100644 index 00000000..7901038f --- /dev/null +++ b/docs/rules/capitalize-text.md @@ -0,0 +1,64 @@ +--- +docType: rule +name: capitalize-text +summary: Require text to be capitalized +--- + +# Require text to be capitalized (`capitalize-text`) + +## Rule details + +Examples of **incorrect** code for this rule: + + +

    lorem ipsum

    +
    + +Examples of **correct** code for this rule: + + +

    Lorem ipsum

    +
    + +## Options + +This rule takes an optional object: + +```javascript +{ + include: [ + "button", + "caption", + "figcaption", + "h1", + "h2", + "h3", + "h4", + "h5", + "h6", + "label", + "legend", + "option", + "p", + ], + exclude: null, + skip: ["code"] +} +``` + +### `include` + +List of elements to test. + +### `exclude` + +List of elements to ignore. + +### `skip` + +When the first node (excluding interelement whitespace) is one of the listed elements the parent is ignored. +For instance, if `code` is listed `

    ..

    ` it valid even if the text is not capitalized. + + +

    loremIpsum dolor sit amet

    +
    diff --git a/src/dom/htmlelement.ts b/src/dom/htmlelement.ts index 06bfe9d4..00466cd0 100644 --- a/src/dom/htmlelement.ts +++ b/src/dom/htmlelement.ts @@ -17,7 +17,7 @@ export enum NodeClosed { ImplicitClosed = 4, // element with optional end tag
  • foo
  • bar } -function isElement(node: DOMNode): node is HtmlElement { +export function isElement(node: DOMNode): node is HtmlElement { return node.nodeType === NodeType.ELEMENT_NODE; } diff --git a/src/dom/index.ts b/src/dom/index.ts index 5156c7f8..e1333941 100644 --- a/src/dom/index.ts +++ b/src/dom/index.ts @@ -1,5 +1,5 @@ export { Attribute } from "./attribute"; -export { HtmlElement, NodeClosed } from "./htmlelement"; +export { HtmlElement, NodeClosed, isElement } from "./htmlelement"; export { DOMNode } from "./domnode"; export { DOMTokenList } from "./domtokenlist"; export { DOMTree } from "./domtree"; diff --git a/src/rules/__snapshots__/capitalize-text.spec.ts.snap b/src/rules/__snapshots__/capitalize-text.spec.ts.snap new file mode 100644 index 00000000..e1931e83 --- /dev/null +++ b/src/rules/__snapshots__/capitalize-text.spec.ts.snap @@ -0,0 +1,15 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`rule capitalize-text should contain contextual documentation 1`] = ` +Object { + "description": "Text must be capitalized in

    . Under the current configuration the

    text is required to begin with a capital letter.", + "url": "https://html-validate.org/rules/capitalize-text.html", +} +`; + +exports[`rule capitalize-text should contain documentation 1`] = ` +Object { + "description": "Text must be capitalized in this element. Under the current configuration the current elements text is required to begin with a capital letter.", + "url": "https://html-validate.org/rules/capitalize-text.html", +} +`; diff --git a/src/rules/capitalize-text.spec.ts b/src/rules/capitalize-text.spec.ts new file mode 100644 index 00000000..1dcf9da4 --- /dev/null +++ b/src/rules/capitalize-text.spec.ts @@ -0,0 +1,154 @@ +import { DynamicValue, HtmlElement } from "../dom"; +import HtmlValidate from "../htmlvalidate"; +import "../matchers"; + +function processElement(node: HtmlElement): void { + if (node.hasAttribute("bind-text")) { + node.appendText(new DynamicValue(""), { + filename: "mock", + line: 1, + column: 1, + offset: 0, + size: 1, + }); + } +} + +describe("rule capitalize-text", () => { + let htmlvalidate: HtmlValidate; + + beforeAll(() => { + htmlvalidate = new HtmlValidate({ + root: true, + rules: { "capitalize-text": "error" }, + }); + }); + + it("should not report error when text is capitalized", () => { + expect.assertions(1); + const markup = "

    Lorem ipsum

    "; + const report = htmlvalidate.validateString(markup); + expect(report).toBeValid(); + }); + + it("should not report error when international text is capitalized", () => { + expect.assertions(1); + const markup = "

    Öland

    Δ

    "; + const report = htmlvalidate.validateString(markup); + expect(report).toBeValid(); + }); + + it("should not report error when text begins with non-letter character", () => { + expect.assertions(1); + const markup = "

    #hashtag

    "; + const report = htmlvalidate.validateString(markup); + expect(report).toBeValid(); + }); + + it("should not report error when text is dyanmic", () => { + expect.assertions(1); + const markup = '

    '; + const report = htmlvalidate.validateString(markup, { processElement }); + expect(report).toBeValid(); + }); + + it("should not report error when text is missing", () => { + expect.assertions(1); + const markup = "

    "; + const report = htmlvalidate.validateString(markup); + expect(report).toBeValid(); + }); + + it("should report error when text is lowercase", () => { + expect.assertions(2); + const markup = "

    lorem ipsum

    "; + const report = htmlvalidate.validateString(markup); + expect(report).toBeInvalid(); + expect(report).toHaveError("capitalize-text", "Text must be capitalized in

    "); + }); + + it("should ignore whitespace", () => { + expect.assertions(2); + const markup = "

    \n\tlorem ipsum\n

    "; + const report = htmlvalidate.validateString(markup); + expect(report).toBeInvalid(); + expect(report).toHaveError("capitalize-text", "Text must be capitalized in

    "); + }); + + it("should not report error when element is excluded", () => { + expect.assertions(2); + const htmlvalidate = new HtmlValidate({ + root: true, + rules: { "capitalize-text": ["error", { exclude: ["h1"] }] }, + }); + const valid = htmlvalidate.validateString("

    lorem ipsum

    "); + const invalid = htmlvalidate.validateString("

    lorem ipsum

    "); + expect(valid).toBeValid(); + expect(invalid).toBeInvalid(); + }); + + it("should report error only for included elements", () => { + expect.assertions(2); + const htmlvalidate = new HtmlValidate({ + root: true, + rules: { "capitalize-text": ["error", { include: ["h2"] }] }, + }); + const valid = htmlvalidate.validateString("

    lorem ipsum

    "); + const invalid = htmlvalidate.validateString("

    lorem ipsum

    "); + expect(valid).toBeValid(); + expect(invalid).toBeInvalid(); + }); + + describe("ignore", () => { + it("should ignore element if the first child element is ignored", () => { + expect.assertions(1); + const markup = "

    lorem ipsum

    "; + const report = htmlvalidate.validateString(markup); + expect(report).toBeValid(); + }); + + it("should ignore element if the first child element is ignored (interelement whitespace)", () => { + expect.assertions(1); + const markup = "

    \n\tlorem ipsum\n

    "; + const report = htmlvalidate.validateString(markup); + expect(report).toBeValid(); + }); + + it("should report error for other elements", () => { + expect.assertions(2); + const markup = "

    lorem ipsum

    "; + const report = htmlvalidate.validateString(markup); + expect(report).toBeInvalid(); + expect(report).toHaveError("capitalize-text", "Text must be capitalized in

    "); + }); + + it("should report error text node precedes element", () => { + expect.assertions(2); + const markup = "

    lorem ipsum

    "; + const report = htmlvalidate.validateString(markup); + expect(report).toBeInvalid(); + expect(report).toHaveError("capitalize-text", "Text must be capitalized in

    "); + }); + }); + + it("should contain documentation", () => { + expect.assertions(1); + const htmlvalidate = new HtmlValidate({ + root: true, + rules: { "capitalize-text": "error" }, + }); + expect(htmlvalidate.getRuleDocumentation("capitalize-text")).toMatchSnapshot(); + }); + + it("should contain contextual documentation", () => { + expect.assertions(1); + const htmlvalidate = new HtmlValidate({ + root: true, + rules: { "capitalize-text": "error" }, + }); + const context = { + tagName: "h1", + }; + expect(htmlvalidate.getRuleDocumentation("capitalize-text", null, context)).toMatchSnapshot(); + }); +}); diff --git a/src/rules/capitalize-text.ts b/src/rules/capitalize-text.ts new file mode 100644 index 00000000..299a48be --- /dev/null +++ b/src/rules/capitalize-text.ts @@ -0,0 +1,154 @@ +import { HtmlElement, isElement } from "../dom"; +import { ElementReadyEvent } from "../event"; +import { Rule, RuleDocumentation, ruleDocumentationUrl, SchemaObject } from "../rule"; +import { classifyNodeText } from "./helper"; +import { TextClassification } from "./helper/text"; + +interface RuleContext { + tagName: string; +} + +interface RuleOptions { + include: string[] | null; + exclude: string[] | null; + skip: string[] | null; +} + +const defaults: RuleOptions = { + include: [ + "button", + "caption", + "figcaption", + "h1", + "h2", + "h3", + "h4", + "h5", + "h6", + "label", + "legend", + "option", + "p", + ], + exclude: null, + skip: ["code"], +}; + +/* Lu: uppercase letter, see https://unicode.org/reports/tr18/#General_Category_Property */ +const isCapitalized = /^\p{Lu}/u; +const isNonLetter = /^[^\p{L}]/u; + +export default class CapitalizeText extends Rule { + public constructor(options: Partial) { + super({ ...defaults, ...options }); + } + + public static schema(): SchemaObject { + return { + exclude: { + anyOf: [ + { + items: { + type: "string", + }, + type: "array", + }, + { + type: "null", + }, + ], + }, + include: { + anyOf: [ + { + items: { + type: "string", + }, + type: "array", + }, + { + type: "null", + }, + ], + }, + skip: { + anyOf: [ + { + items: { + type: "string", + }, + type: "array", + }, + { + type: "null", + }, + ], + }, + }; + } + + public documentation(context?: RuleContext): RuleDocumentation { + const description: string = context + ? `Text must be capitalized in <${context.tagName}>. Under the current configuration the <${context.tagName}> text is required to begin with a capital letter.` + : `Text must be capitalized in this element. Under the current configuration the current elements text is required to begin with a capital letter.`; + return { + description, + url: ruleDocumentationUrl(__filename), + }; + } + + public setup(): void { + this.on( + "element:ready", + (event: ElementReadyEvent) => this.isRelevant(event), + (event: ElementReadyEvent) => { + const { target } = event; + + /* ignore elements with empty or dynamic text */ + if (classifyNodeText(target) !== TextClassification.STATIC_TEXT) { + return; + } + + /* ignore elements with capitalized text */ + const text = target.textContent.trim(); + if (isCapitalized.test(text) || isNonLetter.test(text)) { + return; + } + + /* ignore element if the first child element is ignored */ + if (this.isIgnored(target)) { + return; + } + + const tagName = target.tagName; + const context: RuleContext = { tagName }; + this.report(event.target, `Text must be capitalized in <${tagName}>`, null, context); + } + ); + } + + protected isRelevant(event: ElementReadyEvent): boolean { + const tagName = event.target.tagName; + return !this.isKeywordIgnored(tagName); + } + + protected isIgnored(node: HtmlElement): boolean { + const ignored = this.options.skip; + if (!ignored) { + return false; + } + + for (const child of node.childNodes) { + if (child.textContent.trim() === "") { + continue; + } + + if (isElement(child)) { + return ignored.includes(child.tagName); + } + + break; + } + return false; + } +} diff --git a/src/rules/index.ts b/src/rules/index.ts index 344a6b3b..f656e8b8 100644 --- a/src/rules/index.ts +++ b/src/rules/index.ts @@ -7,6 +7,7 @@ import AttrSpacing from "./attr-spacing"; import AttributeAllowedValues from "./attribute-allowed-values"; import AttributeBooleanStyle from "./attribute-boolean-style"; import AttributeEmptyStyle from "./attribute-empty-style"; +import CapitalizeText from "./capitalize-text"; import ClassPattern from "./class-pattern"; import CloseAttr from "./close-attr"; import CloseOrder from "./close-order"; @@ -71,6 +72,7 @@ const bundledRules: Record> = { "attribute-allowed-values": AttributeAllowedValues, "attribute-boolean-style": AttributeBooleanStyle, "attribute-empty-style": AttributeEmptyStyle, + "capitalize-text": CapitalizeText, "class-pattern": ClassPattern, "close-attr": CloseAttr, "close-order": CloseOrder, -- GitLab