Skip to content
Snippets Groups Projects

Compare revisions

Changes are shown as if the source revision was being merged into the target revision. Learn more about comparing revisions.

Source

Select target project
No results found

Target

Select target project
  • html-validate/html-validate
  • stofolus/html-validate
  • Hawakes/html-validate
  • lahandjch/html-validate
  • mihkeleidast/html-validate
  • danielroe/html-validate
  • annemariedupont/html-validate
  • llazzaro/html-validate
  • stklcode/html-validate
  • davideinfo4/html-validate
  • hubmisto/html-validate
  • Badlapje/html-validate
  • ongaq/html-validate
  • jeremy.greenwald/html-validate
  • Mirza-Hassan/html-validate
  • n1j0/html-validate
  • deemyboy2/html-validate
  • Zaid-Parkar/html-validate
  • shngt/html-validate
  • leahzaloshinsky/html-validate
  • daoyuly/html-validate
  • istr/html-validate
  • fulldecent/html-validate
  • notinn/html-validate
  • armbiant/html-validate
  • janschoenherr/html-validate
  • aabccd021/html-validate
  • jordanoverbye/html-validate
  • sofiajimenezcabrera14/html-validate
  • maxwell.baduduedu/html-validate
  • anubhav1450/html-validate
  • devanshbaghel/html-validate
  • saquib24k/html-validate
  • everead2001/html-validate
  • Vishalrewaskar/html-validate
  • seeerge/html-validate
  • KishanSoni66/html-validate
  • MCFK/html-validate
  • armbiant/gnome-html-5-validate
  • quitziamerc/html-validate
40 results
Show changes
Commits on Source (6)
Showing
with 1698 additions and 801 deletions
# html-validate changelog
## 8.15.0 (2024-03-11)
### Features
- **rules:** new rule `valid-autocomplete` ([bebd0d1](https://gitlab.com/html-validate/html-validate/commit/bebd0d17f6dd71401206ebf2e3b8e9271bc0c8a8))
### Bug Fixes
- **rules:** case-insensitive match for `url` in `meta-refresh` ([3177295](https://gitlab.com/html-validate/html-validate/commit/3177295c3fff37116b869bedc8588a8fb2a6c9d5))
## 8.14.0 (2024-03-09)
### Features
- **rules:** new option `allowLongDelay` to `meta-refresh` to allow 20h+ delays ([629625c](https://gitlab.com/html-validate/html-validate/commit/629625c80851b7325e9528e8c5902c903638af12))
## 8.13.0 (2024-3-6)
## 8.13.0 (2024-03-06)
### Features
......@@ -14,7 +24,7 @@
- **meta:** allow content categories to be a callback ([0eb4e77](https://gitlab.com/html-validate/html-validate/commit/0eb4e77f3ea1f04bf1a368da037df7c906f51c3e))
- **meta:** disallow invalid rel attribute keywords ([dc36cfb](https://gitlab.com/html-validate/html-validate/commit/dc36cfbdce01c9d6af49303a9ca7a5a627b5035a))
## 8.12.0 (2024-3-4)
## 8.12.0 (2024-03-04)
### Features
......
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`docs/rules/valid-autocomplete.md inline validation: correct 1`] = `[]`;
exports[`docs/rules/valid-autocomplete.md inline validation: incorrect 1`] = `
[
{
"errorCount": 3,
"filePath": "inline",
"messages": [
{
"column": 34,
"context": {
"msg": 3,
"token": "foo",
},
"line": 1,
"message": ""foo" is not a valid autocomplete token or field name",
"offset": 33,
"ruleId": "valid-autocomplete",
"ruleUrl": "https://html-validate.org/rules/valid-autocomplete.html",
"selector": "input:nth-child(1)",
"severity": 2,
"size": 3,
},
{
"column": 39,
"context": {
"first": "name",
"msg": 2,
"second": "billing",
},
"line": 2,
"message": ""billing" must appear before "name"",
"offset": 77,
"ruleId": "valid-autocomplete",
"ruleUrl": "https://html-validate.org/rules/valid-autocomplete.html",
"selector": "input:nth-child(2)",
"severity": 2,
"size": 7,
},
{
"column": 34,
"context": {
"msg": 1,
"type": "text",
"value": "street-address",
"what": "<input type="text">",
},
"line": 3,
"message": ""street-address" cannot be used on <input type="text">",
"offset": 120,
"ruleId": "valid-autocomplete",
"ruleUrl": "https://html-validate.org/rules/valid-autocomplete.html",
"selector": "input:nth-child(3)",
"severity": 2,
"size": 14,
},
],
"source": "<input type="text" autocomplete="foo">
<input type="text" autocomplete="name billing">
<input type="text" autocomplete="street-address">",
"warningCount": 0,
},
]
`;
import { HtmlValidate } from "../../../src/htmlvalidate";
const markup: Record<string, string> = {};
markup["incorrect"] = `<input type="text" autocomplete="foo">
<input type="text" autocomplete="name billing">
<input type="text" autocomplete="street-address">`;
markup["correct"] = `<input type="text" autocomplete="name">
<input type="text" autocomplete="billing name">
<textarea autocomplete="street-address"></textarea>`;
describe("docs/rules/valid-autocomplete.md", () => {
it("inline validation: incorrect", async () => {
expect.assertions(1);
const htmlvalidate = new HtmlValidate({"rules":{"valid-autocomplete":"error"}});
const report = await htmlvalidate.validateString(markup["incorrect"]);
expect(report.results).toMatchSnapshot();
});
it("inline validation: correct", async () => {
expect.assertions(1);
const htmlvalidate = new HtmlValidate({"rules":{"valid-autocomplete":"error"}});
const report = await htmlvalidate.validateString(markup["correct"]);
expect(report.results).toMatchSnapshot();
});
});
......@@ -36,4 +36,5 @@ Examples of **correct** code for this rule:
## Version history
- 8.15.0 - This rule no longer checks the `autocomplete` attribute, use {@link rule:valid-autocomplete} instead.
- v4.14.0 - Rule added.
---
docType: rule
name: valid-autocomplete
category: syntax
summary: Require autocomplete attribute to be valid
standards:
- html5
---
# Require autocomplete attribute to be valid
The HTML5 `autocomplete` attribute can be used in different ways:
- On a `<form>` element it can take the `on` or `off` values to set the default for all nested controls.
- On form controls it can either take `on` or `off` to enable and disable or it can take a number of space-separated tokens describing what type of autocompletion to use.
- With the exception that `<input type="hidden">` cannot use `on` or `off`.
Further the space-separated tokens must appear in the following order:
- An optional section name (`section-` prefix).
- An optional `shipping` or `billing` token.
- An optional `home`, `work`, `mobile`, `fax` or `pager` token (for field names supporting it).
- A required field name.
- An optional `webauthn` token.
Typical field names would be:
- `name`
- `username`
- `current-password`
- `address-line1`
- `country-name`
- etc
For a full list of valid field names refer to the HTML5 standard [Autofill][html5-autofill] section.
Some field names can only be used on specific input controls, for instance:
- A `new-password` field cannot be used on `<input type="number">`
- A `cc-exp-month` field cannot be used on `<input type="date">`
- etc
Again, refer to the Autofill section in the standard for a full table of allowed controls.
## Rule details
Examples of **incorrect** code for this rule:
<validate name="incorrect" rules="valid-autocomplete">
<input type="text" autocomplete="foo">
<input type="text" autocomplete="name billing">
<input type="text" autocomplete="street-address">
</validate>
Examples of **correct** code for this rule:
<validate name="correct" rules="valid-autocomplete">
<input type="text" autocomplete="name">
<input type="text" autocomplete="billing name">
<textarea autocomplete="street-address"></textarea>
</validate>
## References
- [HTML5 section 4.10.18.7: Autofill][html5-autofill]
## Version history
- 8.15.0 - Rule added.
[html5-autofill]: https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#autofill
This diff is collapsed.
......@@ -22,6 +22,7 @@ const config: ConfigData = {
"svg-focusable": "off",
"text-content": "error",
"unique-landmark": "error",
"valid-autocomplete": "error",
"wcag/h30": "error",
"wcag/h32": "error",
"wcag/h36": "error",
......
......@@ -68,6 +68,7 @@ const config: ConfigData = {
"text-content": "error",
"unique-landmark": "error",
"unrecognized-char-ref": "error",
"valid-autocomplete": "error",
"valid-id": ["error", { relaxed: false }],
void: "off",
"void-content": "error",
......
......@@ -32,6 +32,7 @@ const config: ConfigData = {
"no-unused-disable": "error",
"script-element": "error",
"unrecognized-char-ref": "error",
"valid-autocomplete": "error",
"valid-id": ["error", { relaxed: true }],
"void-content": "error",
},
......
......@@ -6871,14 +6871,14 @@ exports[`HTML elements <input> invalid markup 1`] = `
{
"column": 25,
"context": {
"attribute": "autocomplete",
"type": "checkbox",
"msg": 0,
"what": "<input type="checkbox">",
},
"line": 70,
"message": "Attribute "autocomplete" is not allowed on <input type="checkbox">",
"message": "autocomplete attribute cannot be used on <input type="checkbox">",
"offset": 2483,
"ruleId": "input-attributes",
"ruleUrl": "https://html-validate.org/rules/input-attributes.html",
"ruleId": "valid-autocomplete",
"ruleUrl": "https://html-validate.org/rules/valid-autocomplete.html",
"selector": "#attribute-restriction > input:nth-child(41)",
"severity": 2,
"size": 12,
......@@ -6886,14 +6886,14 @@ exports[`HTML elements <input> invalid markup 1`] = `
{
"column": 22,
"context": {
"attribute": "autocomplete",
"type": "radio",
"msg": 0,
"what": "<input type="radio">",
},
"line": 71,
"message": "Attribute "autocomplete" is not allowed on <input type="radio">",
"message": "autocomplete attribute cannot be used on <input type="radio">",
"offset": 2523,
"ruleId": "input-attributes",
"ruleUrl": "https://html-validate.org/rules/input-attributes.html",
"ruleId": "valid-autocomplete",
"ruleUrl": "https://html-validate.org/rules/valid-autocomplete.html",
"selector": "#attribute-restriction > input:nth-child(42)",
"severity": 2,
"size": 12,
......@@ -6901,14 +6901,14 @@ exports[`HTML elements <input> invalid markup 1`] = `
{
"column": 21,
"context": {
"attribute": "autocomplete",
"type": "file",
"msg": 0,
"what": "<input type="file">",
},
"line": 72,
"message": "Attribute "autocomplete" is not allowed on <input type="file">",
"message": "autocomplete attribute cannot be used on <input type="file">",
"offset": 2562,
"ruleId": "input-attributes",
"ruleUrl": "https://html-validate.org/rules/input-attributes.html",
"ruleId": "valid-autocomplete",
"ruleUrl": "https://html-validate.org/rules/valid-autocomplete.html",
"selector": "#attribute-restriction > input:nth-child(43)",
"severity": 2,
"size": 12,
......@@ -6916,14 +6916,14 @@ exports[`HTML elements <input> invalid markup 1`] = `
{
"column": 23,
"context": {
"attribute": "autocomplete",
"type": "submit",
"msg": 0,
"what": "<input type="submit">",
},
"line": 73,
"message": "Attribute "autocomplete" is not allowed on <input type="submit">",
"message": "autocomplete attribute cannot be used on <input type="submit">",
"offset": 2603,
"ruleId": "input-attributes",
"ruleUrl": "https://html-validate.org/rules/input-attributes.html",
"ruleId": "valid-autocomplete",
"ruleUrl": "https://html-validate.org/rules/valid-autocomplete.html",
"selector": "#attribute-restriction > input:nth-child(44)",
"severity": 2,
"size": 12,
......@@ -6931,14 +6931,14 @@ exports[`HTML elements <input> invalid markup 1`] = `
{
"column": 22,
"context": {
"attribute": "autocomplete",
"type": "image",
"msg": 0,
"what": "<input type="image">",
},
"line": 74,
"message": "Attribute "autocomplete" is not allowed on <input type="image">",
"message": "autocomplete attribute cannot be used on <input type="image">",
"offset": 2643,
"ruleId": "input-attributes",
"ruleUrl": "https://html-validate.org/rules/input-attributes.html",
"ruleId": "valid-autocomplete",
"ruleUrl": "https://html-validate.org/rules/valid-autocomplete.html",
"selector": "#attribute-restriction > input:nth-child(45)",
"severity": 2,
"size": 12,
......@@ -6946,14 +6946,14 @@ exports[`HTML elements <input> invalid markup 1`] = `
{
"column": 22,
"context": {
"attribute": "autocomplete",
"type": "reset",
"msg": 0,
"what": "<input type="reset">",
},
"line": 75,
"message": "Attribute "autocomplete" is not allowed on <input type="reset">",
"message": "autocomplete attribute cannot be used on <input type="reset">",
"offset": 2701,
"ruleId": "input-attributes",
"ruleUrl": "https://html-validate.org/rules/input-attributes.html",
"ruleId": "valid-autocomplete",
"ruleUrl": "https://html-validate.org/rules/valid-autocomplete.html",
"selector": "#attribute-restriction > input:nth-child(46)",
"severity": 2,
"size": 12,
......
......@@ -77,6 +77,7 @@ import TelNonBreaking from "./tel-non-breaking";
import TextContent from "./text-content";
import UniqueLandmark from "./unique-landmark";
import UnrecognizedCharRef from "./unrecognized-char-ref";
import ValidAutocomplete from "./valid-autocomplete";
import ValidID from "./valid-id";
import VoidContent from "./void-content";
import VoidStyle from "./void-style";
......@@ -161,6 +162,7 @@ const bundledRules: Record<string, RuleConstructor<any, any>> = {
"text-content": TextContent,
"unique-landmark": UniqueLandmark,
"unrecognized-char-ref": UnrecognizedCharRef,
"valid-autocomplete": ValidAutocomplete,
"valid-id": ValidID,
"void-content": VoidContent,
"void-style": VoidStyle,
......
......@@ -11,26 +11,6 @@ interface RuleContext {
const restricted = new Map<string, string[]>([
["accept", ["file"]],
["alt", ["image"]],
[
"autocomplete",
[
"hidden",
"text",
"search",
"url",
"tel",
"email",
"password",
"date",
"month",
"week",
"time",
"datetime-local",
"number",
"range",
"color",
],
],
["capture", ["file"]],
["checked", ["checkbox", "radio"]],
["dirname", ["text", "search"]],
......
......@@ -17,6 +17,13 @@ describe("rule meta-refresh", () => {
expect(report).toBeValid();
});
it("should not report error when refresh has 0 delay with url (case-insensitive)", async () => {
expect.assertions(1);
const markup = /* HTML */ ` <meta http-equiv="refresh" content="0;URL=target.html" /> `;
const report = await htmlvalidate.validateString(markup);
expect(report).toBeValid();
});
it("should not report error for other http-equiv", async () => {
expect.assertions(1);
const markup = /* HTML */ ` <meta http-equiv="foo" content="1" /> `;
......
......@@ -84,7 +84,7 @@ export default class MetaRefresh extends Rule<void, RuleOptions> {
}
function parseContent(text: string): { delay: number; url: string } | null {
const match = text.match(/^(\d+)(?:\s*;\s*url=(.*))?/);
const match = text.match(/^(\d+)(?:\s*;\s*url=(.*))?/i);
if (match) {
return {
delay: parseInt(match[1], 10),
......
This diff is collapsed.
import { type Location } from "../context";
import { type HtmlElement, DOMTokenList, DynamicValue } from "../dom";
import { type DOMReadyEvent } from "../event";
import { type RuleDocumentation, Rule, ruleDocumentationUrl } from "../rule";
export const enum MessageID {
InvalidAttribute,
InvalidValue,
InvalidOrder,
InvalidToken,
InvalidCombination,
MissingField,
}
interface RuleContextInvalidAttribute {
msg: MessageID.InvalidAttribute;
what: string;
}
interface RuleContextInvalidValue {
msg: MessageID.InvalidValue;
type: string;
value: string;
what: string;
}
interface RuleContextInvalidOrder {
msg: MessageID.InvalidOrder;
first: string;
second: string;
}
interface RuleContextInvalidToken {
msg: MessageID.InvalidToken;
token: string;
}
interface RuleContextInvalidCombine {
msg: MessageID.InvalidCombination;
first: string;
second: string;
}
interface RuleContextMissingField {
msg: MessageID.MissingField;
}
export type RuleContext =
| RuleContextInvalidAttribute
| RuleContextInvalidValue
| RuleContextInvalidOrder
| RuleContextInvalidToken
| RuleContextInvalidCombine
| RuleContextMissingField;
type ControlGroup =
| "text"
| "username"
| "password"
| "multiline"
| "month"
| "numeric"
| "date"
| "url"
| "tel";
type TokenType = "section" | "hint" | "contact" | "field1" | "field2" | "webauthn";
const expectedOrder: TokenType[] = ["section", "hint", "contact", "field1", "field2", "webauthn"];
/* Field names which does not allow the optional contact token */
const fieldNames1 = [
"name",
"honorific-prefix",
"given-name",
"additional-name",
"family-name",
"honorific-suffix",
"nickname",
"username",
"new-password",
"current-password",
"one-time-code",
"organization-title",
"organization",
"street-address",
"address-line1",
"address-line2",
"address-line3",
"address-level4",
"address-level3",
"address-level2",
"address-level1",
"country",
"country-name",
"postal-code",
"cc-name",
"cc-given-name",
"cc-additional-name",
"cc-family-name",
"cc-number",
"cc-exp",
"cc-exp-month",
"cc-exp-year",
"cc-csc",
"cc-type",
"transaction-currency",
"transaction-amount",
"language",
"bday",
"bday-day",
"bday-month",
"bday-year",
"sex",
"url",
"photo",
];
/* Field names which allows for the optional contact token */
const fieldNames2 = [
"tel",
"tel-country-code",
"tel-national",
"tel-area-code",
"tel-local",
"tel-local-prefix",
"tel-local-suffix",
"tel-extension",
"email",
"impp",
];
/* Mapping between field names and which control group it requires */
const fieldNameGroup: Record<string, ControlGroup> = {
name: "text",
"honorific-prefix": "text",
"given-name": "text",
"additional-name": "text",
"family-name": "text",
"honorific-suffix": "text",
nickname: "text",
username: "username",
"new-password": "password",
"current-password": "password",
"one-time-code": "password",
"organization-title": "text",
organization: "text",
"street-address": "multiline",
"address-line1": "text",
"address-line2": "text",
"address-line3": "text",
"address-level4": "text",
"address-level3": "text",
"address-level2": "text",
"address-level1": "text",
country: "text",
"country-name": "text",
"postal-code": "text",
"cc-name": "text",
"cc-given-name": "text",
"cc-additional-name": "text",
"cc-family-name": "text",
"cc-number": "text",
"cc-exp": "month",
"cc-exp-month": "numeric",
"cc-exp-year": "numeric",
"cc-csc": "text",
"cc-type": "text",
"transaction-currency": "text",
"transaction-amount": "numeric",
language: "text",
bday: "date",
"bday-day": "numeric",
"bday-month": "numeric",
"bday-year": "numeric",
sex: "text",
url: "url",
photo: "url",
tel: "tel",
"tel-country-code": "text",
"tel-national": "text",
"tel-area-code": "text",
"tel-local": "text",
"tel-local-prefix": "text",
"tel-local-suffix": "text",
"tel-extension": "text",
email: "username",
impp: "url",
};
const disallowedInputTypes = ["checkbox", "radio", "file", "submit", "image", "reset", "button"];
function matchSection(token: string): boolean {
return token.startsWith("section-");
}
function matchHint(token: string): boolean {
return token === "shipping" || token === "billing";
}
function matchFieldNames1(token: string): boolean {
return fieldNames1.includes(token);
}
function matchContact(token: string): boolean {
const haystack = ["home", "work", "mobile", "fax", "pager"];
return haystack.includes(token);
}
function matchFieldNames2(token: string): boolean {
return fieldNames2.includes(token);
}
function matchWebauthn(token: string): boolean {
return token === "webauthn";
}
function matchToken(token: string): TokenType | null {
if (matchSection(token)) {
return "section";
}
if (matchHint(token)) {
return "hint";
}
if (matchFieldNames1(token)) {
return "field1";
}
if (matchFieldNames2(token)) {
return "field2";
}
if (matchContact(token)) {
return "contact";
}
if (matchWebauthn(token)) {
return "webauthn";
}
return null;
}
function getControlGroups(type: string): ControlGroup[] {
const allGroups: ControlGroup[] = [
"text",
"multiline",
"password",
"url",
"username",
"tel",
"numeric",
"month",
"date",
];
const mapping: Record<string, ControlGroup[]> = {
hidden: allGroups,
text: allGroups.filter((it) => it !== "multiline"),
search: allGroups.filter((it) => it !== "multiline"),
password: ["password"],
url: ["url"],
email: ["username"],
tel: ["tel"],
number: ["numeric"],
month: ["month"],
date: ["date"],
};
const groups = mapping[type];
if (groups) {
return groups;
}
return [];
}
function isDisallowedType(node: HtmlElement, type: string): boolean {
if (!node.is("input")) {
return false;
}
return disallowedInputTypes.includes(type);
}
function getTerminalMessage(context: RuleContext): string {
switch (context.msg) {
case MessageID.InvalidAttribute:
return "autocomplete attribute cannot be used on {{ what }}";
case MessageID.InvalidValue:
return '"{{ value }}" cannot be used on {{ what }}';
case MessageID.InvalidOrder:
return '"{{ second }}" must appear before "{{ first }}"';
case MessageID.InvalidToken:
return '"{{ token }}" is not a valid autocomplete token or field name';
case MessageID.InvalidCombination:
return '"{{ second }}" cannot be combined with "{{ first }}"';
case MessageID.MissingField:
return "autocomplete attribute is missing field name";
}
}
function getMarkdownMessage(context: RuleContext): string {
switch (context.msg) {
case MessageID.InvalidAttribute:
return [
`\`autocomplete\` attribute cannot be used on \`${context.what}\``,
"",
"The following input types cannot use the `autocomplete` attribute:",
"",
...disallowedInputTypes.map((it) => `- \`${it}\``),
].join("\n");
case MessageID.InvalidValue: {
const message = `\`"${context.value}"\` cannot be used on \`${context.what}\``;
if (context.type === "form") {
return [
message,
"",
'The `<form>` element can only use the values `"on"` and `"off"`.',
].join("\n");
}
if (context.type === "hidden") {
return [
message,
"",
'`<input type="hidden">` cannot use the values `"on"` and `"off"`.',
].join("\n");
}
const controlGroups = getControlGroups(context.type);
const currentGroup = fieldNameGroup[context.value];
return [
message,
"",
`\`${context.what}\` allows autocomplete fields from the following group${controlGroups.length > 1 ? "s" : ""}:`,
"",
...controlGroups.map((it) => `- ${it}`),
"",
`The field \`"${context.value}"\` belongs to the group /${currentGroup}/ which cannot be used with this input type.`,
].join("\n");
}
case MessageID.InvalidOrder:
return [
`\`"${context.second}"\` must appear before \`"${context.first}"\``,
"",
"The autocomplete tokens must appear in the following order:",
"",
"- Optional section name (`section-` prefix).",
"- Optional `shipping` or `billing` token.",
"- Optional `home`, `work`, `mobile`, `fax` or `pager` token (for fields supporting it).",
"- Field name",
"- Optional `webauthn` token.",
].join("\n");
case MessageID.InvalidToken:
return `\`"${context.token}"\` is not a valid autocomplete token or field name`;
case MessageID.InvalidCombination:
return `\`"${context.second}"\` cannot be combined with \`"${context.first}"\``;
case MessageID.MissingField:
return "Autocomplete attribute is missing field name";
}
}
export default class ValidAutocomplete extends Rule<RuleContext> {
public documentation(context: RuleContext): RuleDocumentation {
return {
description: getMarkdownMessage(context),
url: ruleDocumentationUrl(__filename),
};
}
public setup(): void {
this.on("dom:ready", (event: DOMReadyEvent) => {
const { document } = event;
const elements = document.querySelectorAll("[autocomplete]");
for (const element of elements) {
/* eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- selector guarantees the attribute is present */
const autocomplete = element.getAttribute("autocomplete")!;
if (autocomplete.value === null || autocomplete.value instanceof DynamicValue) {
continue;
}
/* eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- location must be present as the value is */
const location = autocomplete.valueLocation!;
const value = autocomplete.value.toLowerCase();
const tokens = new DOMTokenList(value, location);
if (tokens.length === 0) {
continue;
}
this.validate(element, value, tokens, autocomplete.keyLocation, location);
}
});
}
private validate(
node: HtmlElement,
value: string,
tokens: DOMTokenList,
keyLocation: Location,
valueLocation: Location,
): void {
switch (node.tagName) {
case "form":
this.validateFormAutocomplete(node, value, valueLocation);
break;
case "input":
case "textarea":
case "select":
this.validateControlAutocomplete(node, tokens, keyLocation);
break;
}
}
private validateControlAutocomplete(
node: HtmlElement,
tokens: DOMTokenList,
keyLocation: Location,
): void {
const type = node.getAttributeValue("type") ?? "text";
const mantle = type !== "hidden" ? "expectation" : "anchor";
if (isDisallowedType(node, type)) {
const context: RuleContext = {
msg: MessageID.InvalidAttribute,
what: `<input type="${type}">`,
};
this.report({
node,
message: getTerminalMessage(context),
location: keyLocation,
context,
});
return;
}
if (tokens.includes("on") || tokens.includes("off")) {
this.validateOnOff(node, mantle, tokens);
return;
}
this.validateTokens(node, tokens, keyLocation);
}
private validateFormAutocomplete(node: HtmlElement, value: string, location: Location): void {
const trimmed = value.trim();
if (["on", "off"].includes(trimmed)) {
return;
}
const context: RuleContext = {
msg: MessageID.InvalidValue,
type: "form",
value: trimmed,
what: "<form>",
};
this.report({
node,
message: getTerminalMessage(context),
location,
context,
});
}
private validateOnOff(
node: HtmlElement,
mantle: "expectation" | "anchor",
tokens: DOMTokenList,
): void {
const index = tokens.findIndex((it) => it === "on" || it === "off");
/* eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- it must be present of it wouldn't be found */
const value = tokens.item(index)!;
const location = tokens.location(index);
if (tokens.length > 1) {
const context: RuleContext = {
msg: MessageID.InvalidCombination,
/* eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- it must be present of it wouldn't be found */
first: tokens.item(index > 0 ? 0 : 1)!,
second: value,
};
this.report({
node,
message: getTerminalMessage(context),
location,
context,
});
}
switch (mantle) {
case "expectation":
return;
case "anchor": {
const context: RuleContext = {
msg: MessageID.InvalidValue,
type: "hidden",
value,
what: `<input type="hidden">`,
};
this.report({
node,
message: getTerminalMessage(context),
location: tokens.location(0),
context,
});
}
}
}
private validateTokens(node: HtmlElement, tokens: DOMTokenList, keyLocation: Location): void {
const order: TokenType[] = [];
for (const { item, location } of tokens.iterator()) {
const tokenType = matchToken(item);
if (tokenType) {
order.push(tokenType);
} else {
const context: RuleContext = {
msg: MessageID.InvalidToken,
token: item,
};
this.report({
node,
message: getTerminalMessage(context),
location,
context,
});
return;
}
}
const fieldTokens = order.map((it) => it === "field1" || it === "field2");
this.validateFieldPresence(node, tokens, fieldTokens, keyLocation);
this.validateContact(node, tokens, order);
this.validateOrder(node, tokens, order);
this.validateControlGroup(node, tokens, fieldTokens);
}
/**
* Ensure that exactly one field name is present from the two field lists.
*/
private validateFieldPresence(
node: HtmlElement,
tokens: DOMTokenList,
fieldTokens: boolean[],
keyLocation: Location,
): void {
const numFields = fieldTokens.filter(Boolean).length;
if (numFields === 0) {
const context: RuleContext = {
msg: MessageID.MissingField,
};
this.report({
node,
message: getTerminalMessage(context),
location: keyLocation,
context,
});
} else if (numFields > 1) {
const a = fieldTokens.indexOf(true);
const b = fieldTokens.lastIndexOf(true);
const context: RuleContext = {
msg: MessageID.InvalidCombination,
/* eslint-disable @typescript-eslint/no-non-null-assertion -- it must be present of it wouldn't be found */
first: tokens.item(a)!,
second: tokens.item(b)!,
/* eslint-enable @typescript-eslint/no-non-null-assertion */
};
this.report({
node,
message: getTerminalMessage(context),
location: tokens.location(b),
context,
});
}
}
/**
* Ensure contact token is only used with field names from the second list.
*/
private validateContact(node: HtmlElement, tokens: DOMTokenList, order: TokenType[]): void {
if (order.includes("contact") && order.includes("field1")) {
const a = order.indexOf("field1");
const b = order.indexOf("contact");
const context: RuleContext = {
msg: MessageID.InvalidCombination,
/* eslint-disable @typescript-eslint/no-non-null-assertion -- it must be present of it wouldn't be found */
first: tokens.item(a)!,
second: tokens.item(b)!,
/* eslint-enable @typescript-eslint/no-non-null-assertion */
};
this.report({
node,
message: getTerminalMessage(context),
location: tokens.location(b),
context,
});
}
}
private validateOrder(node: HtmlElement, tokens: DOMTokenList, order: TokenType[]): void {
const indicies = order.map((it) => expectedOrder.indexOf(it));
for (let i = 0; i < indicies.length - 1; i++) {
if (indicies[0] > indicies[i + 1]) {
const context: RuleContext = {
msg: MessageID.InvalidOrder,
/* eslint-disable @typescript-eslint/no-non-null-assertion -- it must be present of it wouldn't be found */
first: tokens.item(i)!,
second: tokens.item(i + 1)!,
/* eslint-enable @typescript-eslint/no-non-null-assertion */
};
this.report({
node,
message: getTerminalMessage(context),
location: tokens.location(i + 1),
context,
});
}
}
}
private validateControlGroup(
node: HtmlElement,
tokens: DOMTokenList,
fieldTokens: boolean[],
): void {
const numFields = fieldTokens.filter(Boolean).length;
if (numFields === 0) {
return;
}
/* only <input> has restrictions on what field names can be used */
if (!node.is("input")) {
return;
}
/* if type attribute is dynamic we assume anything goes */
const attr = node.getAttribute("type");
const type = attr?.value ?? "text";
if (type instanceof DynamicValue) {
return;
}
const controlGroups = getControlGroups(type);
const fieldIndex = fieldTokens.indexOf(true);
/* eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- it must be present of it wouldn't be found */
const fieldToken = tokens.item(fieldIndex)!;
const fieldGroup = fieldNameGroup[fieldToken];
if (!controlGroups.includes(fieldGroup)) {
const context: RuleContext = {
msg: MessageID.InvalidValue,
type,
value: fieldToken,
what: `<input type="${type}">`,
};
this.report({
node,
message: getTerminalMessage(context),
location: tokens.location(fieldIndex),
context,
});
}
}
}
......@@ -54,7 +54,7 @@
<div id="attribute-restriction">
<input type="file" accept="image/jpeg">
<input type="image" alt="lorem ipsum">
<input type="hidden" autocomplete="on">
<input type="hidden" autocomplete="name">
<input type="text" autocomplete="on">
<input type="search" autocomplete="on">
<input type="url" autocomplete="on">
......