diff --git a/docs/dgeni/templates/base.template.html b/docs/dgeni/templates/base.template.html
index d87609cf71ff30924394b205e7a1b9287e3af89c..08a59499d72a44cfa84301935cdbaf7095f410bd 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}
+
{{ pkg.name }}-{{ pkg.version }}
diff --git a/docs/htmlvalidate-public.json b/docs/htmlvalidate-public.json
index b5e216b8e072dca51c722be9af14843f0c65f3ad..a148d7954f0e12849dde7537ab1179442eb8c818 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 0000000000000000000000000000000000000000..7256cc62197e604ab9ae5a4cf5a4bd2312f55761
--- /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 0000000000000000000000000000000000000000..1fb67e6a30e57f4c3aa0222a443063c4a6b6d9cf
--- /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 0000000000000000000000000000000000000000..7901038f3e6595df136aa284c80cd338af70dd1f
--- /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 06bfe9d4dc9ad4c89e099ae622c2159c63ba181b..00466cd044ce11dc96e9f2f76728868a08c1200d 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
foobar
}
-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 5156c7f8aa5211f3c97ab19e1f341c0abe8e1809..e13339412f5621dbed3584f450cda436031c1e1f 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 0000000000000000000000000000000000000000..e1931e8390a809b31f8153f58969c14d6c38e7f9
--- /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 0000000000000000000000000000000000000000..1dcf9da43ecbebc0580754db4208aab578003a57
--- /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 0000000000000000000000000000000000000000..299a48be057f09cd353e84ebc011e3d1a13730d9
--- /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 344a6b3bf325cc4cc8ef41d2311e1f7566e56a56..f656e8b8e7376f3726fa1113434018deb4a7317f 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,