html-validate 8.20.1 → 8.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cjs/core.js CHANGED
@@ -1279,14 +1279,12 @@ class MetaTable {
1279
1279
  * @returns A shallow copy of metadata.
1280
1280
  */
1281
1281
  getMetaFor(tagName) {
1282
- tagName = tagName.toLowerCase();
1283
- if (this.elements[tagName]) {
1284
- return { ...this.elements[tagName] };
1285
- }
1286
- if (this.elements["*"]) {
1287
- return { ...this.elements["*"] };
1282
+ const meta = this.elements[tagName.toLowerCase()] ?? this.elements["*"];
1283
+ if (meta) {
1284
+ return { ...meta };
1285
+ } else {
1286
+ return null;
1288
1287
  }
1289
- return null;
1290
1288
  }
1291
1289
  /**
1292
1290
  * Find all tags which has enabled given property.
@@ -1294,7 +1292,7 @@ class MetaTable {
1294
1292
  * @public
1295
1293
  */
1296
1294
  getTagsWithProperty(propName) {
1297
- return Object.entries(this.elements).filter(([, entry]) => entry[propName]).map(([tagName]) => tagName);
1295
+ return this.entries.filter(([, entry]) => entry[propName]).map(([tagName]) => tagName);
1298
1296
  }
1299
1297
  /**
1300
1298
  * Find tag matching tagName or inheriting from it.
@@ -1302,10 +1300,10 @@ class MetaTable {
1302
1300
  * @public
1303
1301
  */
1304
1302
  getTagsDerivedFrom(tagName) {
1305
- return Object.entries(this.elements).filter(([key, entry]) => key === tagName || entry.inherit === tagName).map(([tagName2]) => tagName2);
1303
+ return this.entries.filter(([key, entry]) => key === tagName || entry.inherit === tagName).map(([tagName2]) => tagName2);
1306
1304
  }
1307
1305
  addEntry(tagName, entry) {
1308
- let parent = this.elements[tagName] || {};
1306
+ let parent = this.elements[tagName];
1309
1307
  if (entry.inherit) {
1310
1308
  const name = entry.inherit;
1311
1309
  parent = this.elements[name];
@@ -1316,7 +1314,7 @@ class MetaTable {
1316
1314
  });
1317
1315
  }
1318
1316
  }
1319
- const expanded = this.mergeElement(parent, { ...entry, tagName });
1317
+ const expanded = this.mergeElement(parent ?? {}, { ...entry, tagName });
1320
1318
  expandRegex(expanded);
1321
1319
  this.elements[tagName] = expanded;
1322
1320
  }
@@ -1345,6 +1343,12 @@ class MetaTable {
1345
1343
  getJSONSchema() {
1346
1344
  return this.schema;
1347
1345
  }
1346
+ /**
1347
+ * @internal
1348
+ */
1349
+ get entries() {
1350
+ return Object.entries(this.elements);
1351
+ }
1348
1352
  /**
1349
1353
  * Finds the global element definition and merges each known element with the
1350
1354
  * global, e.g. to assign global attributes.
@@ -1356,7 +1360,7 @@ class MetaTable {
1356
1360
  delete this.elements["*"];
1357
1361
  delete global.tagName;
1358
1362
  delete global.void;
1359
- for (const [tagName, entry] of Object.entries(this.elements)) {
1363
+ for (const [tagName, entry] of this.entries) {
1360
1364
  this.elements[tagName] = this.mergeElement(global, entry);
1361
1365
  }
1362
1366
  }
@@ -2144,7 +2148,7 @@ class AttrMatcher extends Matcher {
2144
2148
  this.value = value;
2145
2149
  }
2146
2150
  match(node) {
2147
- const attr = node.getAttribute(this.key, true) || [];
2151
+ const attr = node.getAttribute(this.key, true);
2148
2152
  return attr.some((cur) => {
2149
2153
  switch (this.op) {
2150
2154
  case void 0:
@@ -2590,10 +2594,13 @@ class HtmlElement extends DOMNode {
2590
2594
  */
2591
2595
  setAttribute(key, value, keyLocation, valueLocation, originalAttribute) {
2592
2596
  key = key.toLowerCase();
2593
- if (!this.attr[key]) {
2594
- this.attr[key] = [];
2597
+ const attr = new Attribute(key, value, keyLocation, valueLocation, originalAttribute);
2598
+ const list = this.attr[key];
2599
+ if (list) {
2600
+ list.push(attr);
2601
+ } else {
2602
+ this.attr[key] = [attr];
2595
2603
  }
2596
- this.attr[key].push(new Attribute(key, value, keyLocation, valueLocation, originalAttribute));
2597
2604
  }
2598
2605
  /**
2599
2606
  * Get parsed tabindex for this element.
@@ -2650,7 +2657,7 @@ class HtmlElement extends DOMNode {
2650
2657
  const matches = this.attr[key];
2651
2658
  return all ? matches : matches[0];
2652
2659
  } else {
2653
- return null;
2660
+ return all ? [] : null;
2654
2661
  }
2655
2662
  }
2656
2663
  /**
@@ -2756,20 +2763,6 @@ class HtmlElement extends DOMNode {
2756
2763
  yield* pattern.match(this);
2757
2764
  }
2758
2765
  }
2759
- /**
2760
- * Visit all nodes from this node and down. Depth first.
2761
- *
2762
- * @internal
2763
- */
2764
- visitDepthFirst(callback) {
2765
- function visit(node) {
2766
- node.childElements.forEach(visit);
2767
- if (!node.isRootElement()) {
2768
- callback(node);
2769
- }
2770
- }
2771
- visit(this);
2772
- }
2773
2766
  /**
2774
2767
  * Evaluates callbackk on all descendants, returning true if any are true.
2775
2768
  *
@@ -2833,591 +2826,133 @@ function isClosed(endToken, meta) {
2833
2826
  return closed;
2834
2827
  }
2835
2828
 
2836
- class DOMTree {
2837
- constructor(location) {
2838
- this.root = HtmlElement.rootNode(location);
2839
- this.active = this.root;
2840
- this.doctype = null;
2841
- }
2842
- pushActive(node) {
2843
- this.active = node;
2829
+ function escape(value) {
2830
+ return JSON.stringify(value);
2831
+ }
2832
+ function format(value, quote = false) {
2833
+ if (value === null) {
2834
+ return "null";
2844
2835
  }
2845
- popActive() {
2846
- if (this.active.isRootElement()) {
2847
- return;
2848
- }
2849
- this.active = this.active.parent ?? this.root;
2836
+ if (typeof value === "number") {
2837
+ return value.toString();
2850
2838
  }
2851
- getActive() {
2852
- return this.active;
2839
+ if (typeof value === "string") {
2840
+ return quote ? escape(value) : value;
2853
2841
  }
2854
- /**
2855
- * Resolve dynamic meta expressions.
2856
- */
2857
- resolveMeta(table) {
2858
- this.visitDepthFirst((node) => {
2859
- table.resolve(node);
2860
- });
2842
+ if (Array.isArray(value)) {
2843
+ const content = value.map((it) => format(it, true)).join(", ");
2844
+ return `[ ${content} ]`;
2861
2845
  }
2862
- getElementsByTagName(tagName) {
2863
- return this.root.getElementsByTagName(tagName);
2846
+ if (typeof value === "object") {
2847
+ const content = Object.entries(value).map(([key, nested]) => `${key}: ${format(nested, true)}`).join(", ");
2848
+ return `{ ${content} }`;
2864
2849
  }
2865
- visitDepthFirst(callback) {
2866
- this.root.visitDepthFirst(callback);
2850
+ return String(value);
2851
+ }
2852
+ function interpolate(text, data) {
2853
+ return text.replace(/{{\s*([^\s{}]+)\s*}}/g, (match, key) => {
2854
+ return typeof data[key] !== "undefined" ? format(data[key]) : match;
2855
+ });
2856
+ }
2857
+
2858
+ function isThenable(value) {
2859
+ return value && typeof value === "object" && "then" in value && typeof value.then === "function";
2860
+ }
2861
+
2862
+ var Severity = /* @__PURE__ */ ((Severity2) => {
2863
+ Severity2[Severity2["DISABLED"] = 0] = "DISABLED";
2864
+ Severity2[Severity2["WARN"] = 1] = "WARN";
2865
+ Severity2[Severity2["ERROR"] = 2] = "ERROR";
2866
+ return Severity2;
2867
+ })(Severity || {});
2868
+ function parseSeverity(value) {
2869
+ switch (value) {
2870
+ case 0:
2871
+ case "off":
2872
+ return 0 /* DISABLED */;
2873
+ case 1:
2874
+ case "warn":
2875
+ return 1 /* WARN */;
2876
+ case 2:
2877
+ case "error":
2878
+ return 2 /* ERROR */;
2879
+ default:
2880
+ throw new Error(`Invalid severity "${String(value)}"`);
2867
2881
  }
2868
- find(callback) {
2869
- return this.root.find(callback);
2882
+ }
2883
+
2884
+ const cacheKey = Symbol("aria-naming");
2885
+ const defaultValue = "allowed";
2886
+ const prohibitedRoles = [
2887
+ "caption",
2888
+ "code",
2889
+ "deletion",
2890
+ "emphasis",
2891
+ "generic",
2892
+ "insertion",
2893
+ "paragraph",
2894
+ "presentation",
2895
+ "strong",
2896
+ "subscript",
2897
+ "superscript"
2898
+ ];
2899
+ function byRole(role) {
2900
+ return prohibitedRoles.includes(role) ? "prohibited" : "allowed";
2901
+ }
2902
+ function byMeta(element, meta) {
2903
+ return meta.aria.naming(element._adapter);
2904
+ }
2905
+ function ariaNaming(element) {
2906
+ var _a;
2907
+ const cached = element.cacheGet(cacheKey);
2908
+ if (cached) {
2909
+ return cached;
2870
2910
  }
2871
- querySelector(selector) {
2872
- return this.root.querySelector(selector);
2911
+ const role = (_a = element.getAttribute("role")) == null ? void 0 : _a.value;
2912
+ if (role) {
2913
+ if (role instanceof DynamicValue) {
2914
+ return element.cacheSet(cacheKey, defaultValue);
2915
+ } else {
2916
+ return element.cacheSet(cacheKey, byRole(role));
2917
+ }
2873
2918
  }
2874
- querySelectorAll(selector) {
2875
- return this.root.querySelectorAll(selector);
2919
+ const meta = element.meta;
2920
+ if (!meta) {
2921
+ return element.cacheSet(cacheKey, defaultValue);
2876
2922
  }
2923
+ return element.cacheSet(cacheKey, byMeta(element, meta));
2877
2924
  }
2878
2925
 
2879
- const allowedKeys = ["exclude"];
2880
- class Validator {
2881
- /**
2882
- * Test if element is used in a proper context.
2883
- *
2884
- * @param node - Element to test.
2885
- * @param rules - List of rules.
2886
- * @returns `true` if element passes all tests.
2887
- */
2888
- static validatePermitted(node, rules) {
2889
- if (!rules) {
2890
- return true;
2891
- }
2892
- return rules.some((rule) => {
2893
- return Validator.validatePermittedRule(node, rule);
2894
- });
2926
+ const patternCache = /* @__PURE__ */ new Map();
2927
+ function compileStringPattern(pattern) {
2928
+ const regexp = pattern.replace(/[*]+/g, ".+");
2929
+ return new RegExp(`^${regexp}$`);
2930
+ }
2931
+ function compileRegExpPattern(pattern) {
2932
+ return new RegExp(`^${pattern}$`);
2933
+ }
2934
+ function compilePattern(pattern) {
2935
+ const cached = patternCache.get(pattern);
2936
+ if (cached) {
2937
+ return cached;
2895
2938
  }
2896
- /**
2897
- * Test if an element is used the correct amount of times.
2898
- *
2899
- * For instance, a `<table>` element can only contain a single `<tbody>`
2900
- * child. If multiple `<tbody>` exists this test will fail both nodes.
2901
- * Note that this is called on the parent but will fail the children violating
2902
- * the rule.
2903
- *
2904
- * @param children - Array of children to validate.
2905
- * @param rules - List of rules of the parent element.
2906
- * @returns `true` if the parent element of the children passes the test.
2907
- */
2908
- static validateOccurrences(children, rules, cb) {
2909
- if (!rules) {
2939
+ const match = pattern.match(/^\/(.*)\/$/);
2940
+ const regexp = match ? compileRegExpPattern(match[1]) : compileStringPattern(pattern);
2941
+ patternCache.set(pattern, regexp);
2942
+ return regexp;
2943
+ }
2944
+ function keywordPatternMatcher(list, keyword) {
2945
+ for (const pattern of list) {
2946
+ const regexp = compilePattern(pattern);
2947
+ if (regexp.test(keyword)) {
2910
2948
  return true;
2911
2949
  }
2912
- let valid = true;
2913
- for (const rule of rules) {
2914
- if (typeof rule !== "string") {
2915
- return false;
2916
- }
2917
- const [, category, quantifier] = rule.match(/^(@?.*?)([?*]?)$/);
2918
- const limit = category && quantifier && parseQuantifier(quantifier);
2919
- if (limit) {
2920
- const siblings = children.filter(
2921
- (cur) => Validator.validatePermittedCategory(cur, rule, true)
2922
- );
2923
- if (siblings.length > limit) {
2924
- for (const child of siblings.slice(limit)) {
2925
- cb(child, category);
2926
- }
2927
- valid = false;
2928
- }
2929
- }
2930
- }
2931
- return valid;
2932
2950
  }
2933
- /**
2934
- * Validate elements order.
2935
- *
2936
- * Given a parent element with children and metadata containing permitted
2937
- * order it will validate each children and ensure each one exists in the
2938
- * specified order.
2939
- *
2940
- * For instance, for a `<table>` element the `<caption>` element must come
2941
- * before a `<thead>` which must come before `<tbody>`.
2942
- *
2943
- * @param children - Array of children to validate.
2944
- */
2945
- static validateOrder(children, rules, cb) {
2946
- if (!rules) {
2947
- return true;
2948
- }
2949
- let i = 0;
2950
- let prev = null;
2951
- for (const node of children) {
2952
- const old = i;
2953
- while (rules[i] && !Validator.validatePermittedCategory(node, rules[i], true)) {
2954
- i++;
2955
- }
2956
- if (i >= rules.length) {
2957
- const orderSpecified = rules.find(
2958
- (cur) => Validator.validatePermittedCategory(node, cur, true)
2959
- );
2960
- if (orderSpecified) {
2961
- cb(node, prev);
2962
- return false;
2963
- }
2964
- i = old;
2965
- }
2966
- prev = node;
2967
- }
2968
- return true;
2969
- }
2970
- /**
2971
- * Validate element ancestors.
2972
- *
2973
- * Check if an element has the required set of elements. At least one of the
2974
- * selectors must match.
2975
- */
2976
- static validateAncestors(node, rules) {
2977
- if (!rules || rules.length === 0) {
2978
- return true;
2979
- }
2980
- return rules.some((rule) => node.closest(rule));
2981
- }
2982
- /**
2983
- * Validate element required content.
2984
- *
2985
- * Check if an element has the required set of elements. At least one of the
2986
- * selectors must match.
2987
- *
2988
- * Returns `[]` when valid or a list of required but missing tagnames or
2989
- * categories.
2990
- */
2991
- static validateRequiredContent(node, rules) {
2992
- if (!rules || rules.length === 0) {
2993
- return [];
2994
- }
2995
- return rules.filter((tagName) => {
2996
- const haveMatchingChild = node.childElements.some(
2997
- (child) => Validator.validatePermittedCategory(child, tagName, false)
2998
- );
2999
- return !haveMatchingChild;
3000
- });
3001
- }
3002
- /**
3003
- * Test if an attribute has an allowed value and/or format.
3004
- *
3005
- * @param attr - Attribute to test.
3006
- * @param rules - Element attribute metadta.
3007
- * @returns `true` if attribute passes all tests.
3008
- */
3009
- static validateAttribute(attr, rules) {
3010
- const rule = rules[attr.key];
3011
- if (!rule) {
3012
- return true;
3013
- }
3014
- const value = attr.value;
3015
- if (value instanceof DynamicValue) {
3016
- return true;
3017
- }
3018
- const empty = value === null || value === "";
3019
- if (rule.boolean) {
3020
- return empty || value === attr.key;
3021
- }
3022
- if (rule.omit && empty) {
3023
- return true;
3024
- }
3025
- if (rule.list) {
3026
- const tokens = new DOMTokenList(value, attr.valueLocation);
3027
- return tokens.every((token) => {
3028
- return this.validateAttributeValue(token, rule);
3029
- });
3030
- }
3031
- return this.validateAttributeValue(value, rule);
3032
- }
3033
- static validateAttributeValue(value, rule) {
3034
- if (!rule.enum) {
3035
- return true;
3036
- }
3037
- if (value === null || value === void 0) {
3038
- return false;
3039
- }
3040
- const caseInsensitiveValue = value.toLowerCase();
3041
- return rule.enum.some((entry) => {
3042
- if (entry instanceof RegExp) {
3043
- return !!value.match(entry);
3044
- } else {
3045
- return caseInsensitiveValue === entry;
3046
- }
3047
- });
3048
- }
3049
- static validatePermittedRule(node, rule, isExclude = false) {
3050
- if (typeof rule === "string") {
3051
- return Validator.validatePermittedCategory(node, rule, !isExclude);
3052
- } else if (Array.isArray(rule)) {
3053
- return rule.every((inner) => {
3054
- return Validator.validatePermittedRule(node, inner, isExclude);
3055
- });
3056
- } else {
3057
- validateKeys(rule);
3058
- if (rule.exclude) {
3059
- if (Array.isArray(rule.exclude)) {
3060
- return !rule.exclude.some((inner) => {
3061
- return Validator.validatePermittedRule(node, inner, true);
3062
- });
3063
- } else {
3064
- return !Validator.validatePermittedRule(node, rule.exclude, true);
3065
- }
3066
- } else {
3067
- return true;
3068
- }
3069
- }
3070
- }
3071
- /**
3072
- * Validate node against a content category.
3073
- *
3074
- * When matching parent nodes against permitted parents use the superset
3075
- * parameter to also match for `@flow`. E.g. if a node expects a `@phrasing`
3076
- * parent it should also allow `@flow` parent since `@phrasing` is a subset of
3077
- * `@flow`.
3078
- *
3079
- * @param node - The node to test against
3080
- * @param category - Name of category with `@` prefix or tag name.
3081
- * @param defaultMatch - The default return value when node categories is not known.
3082
- */
3083
- /* eslint-disable-next-line complexity -- rule does not like switch */
3084
- static validatePermittedCategory(node, category, defaultMatch) {
3085
- const [, rawCategory] = category.match(/^(@?.*?)([?*]?)$/);
3086
- if (!rawCategory.startsWith("@")) {
3087
- return node.tagName === rawCategory;
3088
- }
3089
- if (!node.meta) {
3090
- return defaultMatch;
3091
- }
3092
- switch (rawCategory) {
3093
- case "@meta":
3094
- return node.meta.metadata;
3095
- case "@flow":
3096
- return node.meta.flow;
3097
- case "@sectioning":
3098
- return node.meta.sectioning;
3099
- case "@heading":
3100
- return node.meta.heading;
3101
- case "@phrasing":
3102
- return node.meta.phrasing;
3103
- case "@embedded":
3104
- return node.meta.embedded;
3105
- case "@interactive":
3106
- return node.meta.interactive;
3107
- case "@script":
3108
- return Boolean(node.meta.scriptSupporting);
3109
- case "@form":
3110
- return Boolean(node.meta.form);
3111
- default:
3112
- throw new Error(`Invalid content category "${category}"`);
3113
- }
3114
- }
3115
- }
3116
- function validateKeys(rule) {
3117
- for (const key of Object.keys(rule)) {
3118
- if (!allowedKeys.includes(key)) {
3119
- const str = JSON.stringify(rule);
3120
- throw new Error(`Permitted rule "${str}" contains unknown property "${key}"`);
3121
- }
3122
- }
3123
- }
3124
- function parseQuantifier(quantifier) {
3125
- switch (quantifier) {
3126
- case "?":
3127
- return 1;
3128
- case "*":
3129
- return null;
3130
- default:
3131
- throw new Error(`Invalid quantifier "${quantifier}" used`);
3132
- }
3133
- }
3134
-
3135
- const $schema = "http://json-schema.org/draft-06/schema#";
3136
- const $id = "https://html-validate.org/schemas/config.json";
3137
- const type = "object";
3138
- const additionalProperties = false;
3139
- const properties = {
3140
- $schema: {
3141
- type: "string"
3142
- },
3143
- root: {
3144
- type: "boolean",
3145
- title: "Mark as root configuration",
3146
- description: "If this is set to true no further configurations will be searched.",
3147
- "default": false
3148
- },
3149
- "extends": {
3150
- type: "array",
3151
- items: {
3152
- type: "string"
3153
- },
3154
- title: "Configurations to extend",
3155
- description: "Array of shareable or builtin configurations to extend."
3156
- },
3157
- elements: {
3158
- type: "array",
3159
- items: {
3160
- anyOf: [
3161
- {
3162
- type: "string"
3163
- },
3164
- {
3165
- type: "object"
3166
- }
3167
- ]
3168
- },
3169
- title: "Element metadata to load",
3170
- description: "Array of modules, plugins or files to load element metadata from. Use <rootDir> to refer to the folder with the package.json file.",
3171
- examples: [
3172
- [
3173
- "html-validate:recommended",
3174
- "plugin:recommended",
3175
- "module",
3176
- "./local-file.json"
3177
- ]
3178
- ]
3179
- },
3180
- plugins: {
3181
- type: "array",
3182
- items: {
3183
- anyOf: [
3184
- {
3185
- type: "string"
3186
- },
3187
- {
3188
- type: "object"
3189
- }
3190
- ]
3191
- },
3192
- title: "Plugins to load",
3193
- description: "Array of plugins load. Use <rootDir> to refer to the folder with the package.json file.",
3194
- examples: [
3195
- [
3196
- "my-plugin",
3197
- "./local-plugin"
3198
- ]
3199
- ]
3200
- },
3201
- transform: {
3202
- type: "object",
3203
- additionalProperties: {
3204
- type: "string"
3205
- },
3206
- title: "File transformations to use.",
3207
- description: "Object where key is regular expression to match filename and value is name of transformer.",
3208
- examples: [
3209
- {
3210
- "^.*\\.foo$": "my-transformer",
3211
- "^.*\\.bar$": "my-plugin",
3212
- "^.*\\.baz$": "my-plugin:named"
3213
- }
3214
- ]
3215
- },
3216
- rules: {
3217
- type: "object",
3218
- patternProperties: {
3219
- ".*": {
3220
- anyOf: [
3221
- {
3222
- "enum": [
3223
- 0,
3224
- 1,
3225
- 2,
3226
- "off",
3227
- "warn",
3228
- "error"
3229
- ]
3230
- },
3231
- {
3232
- type: "array",
3233
- minItems: 1,
3234
- maxItems: 1,
3235
- items: [
3236
- {
3237
- "enum": [
3238
- 0,
3239
- 1,
3240
- 2,
3241
- "off",
3242
- "warn",
3243
- "error"
3244
- ]
3245
- }
3246
- ]
3247
- },
3248
- {
3249
- type: "array",
3250
- minItems: 2,
3251
- maxItems: 2,
3252
- items: [
3253
- {
3254
- "enum": [
3255
- 0,
3256
- 1,
3257
- 2,
3258
- "off",
3259
- "warn",
3260
- "error"
3261
- ]
3262
- },
3263
- {
3264
- }
3265
- ]
3266
- }
3267
- ]
3268
- }
3269
- },
3270
- title: "Rule configuration.",
3271
- description: "Enable/disable rules, set severity. Some rules have additional configuration like style or patterns to use.",
3272
- examples: [
3273
- {
3274
- foo: "error",
3275
- bar: "off",
3276
- baz: [
3277
- "error",
3278
- {
3279
- style: "camelcase"
3280
- }
3281
- ]
3282
- }
3283
- ]
3284
- }
3285
- };
3286
- var configurationSchema = {
3287
- $schema: $schema,
3288
- $id: $id,
3289
- type: type,
3290
- additionalProperties: additionalProperties,
3291
- properties: properties
3292
- };
3293
-
3294
- const TRANSFORMER_API = {
3295
- VERSION: 1
3296
- };
3297
-
3298
- var Severity = /* @__PURE__ */ ((Severity2) => {
3299
- Severity2[Severity2["DISABLED"] = 0] = "DISABLED";
3300
- Severity2[Severity2["WARN"] = 1] = "WARN";
3301
- Severity2[Severity2["ERROR"] = 2] = "ERROR";
3302
- return Severity2;
3303
- })(Severity || {});
3304
- function parseSeverity(value) {
3305
- switch (value) {
3306
- case 0:
3307
- case "off":
3308
- return 0 /* DISABLED */;
3309
- case 1:
3310
- case "warn":
3311
- return 1 /* WARN */;
3312
- case 2:
3313
- case "error":
3314
- return 2 /* ERROR */;
3315
- default:
3316
- throw new Error(`Invalid severity "${String(value)}"`);
3317
- }
3318
- }
3319
-
3320
- function escape(value) {
3321
- return JSON.stringify(value);
3322
- }
3323
- function format(value, quote = false) {
3324
- if (value === null) {
3325
- return "null";
3326
- }
3327
- if (typeof value === "number") {
3328
- return value.toString();
3329
- }
3330
- if (typeof value === "string") {
3331
- return quote ? escape(value) : value;
3332
- }
3333
- if (Array.isArray(value)) {
3334
- const content = value.map((it) => format(it, true)).join(", ");
3335
- return `[ ${content} ]`;
3336
- }
3337
- if (typeof value === "object") {
3338
- const content = Object.entries(value).map(([key, nested]) => `${key}: ${format(nested, true)}`).join(", ");
3339
- return `{ ${content} }`;
3340
- }
3341
- return String(value);
3342
- }
3343
- function interpolate(text, data) {
3344
- return text.replace(/{{\s*([^\s{}]+)\s*}}/g, (match, key) => {
3345
- return typeof data[key] !== "undefined" ? format(data[key]) : match;
3346
- });
3347
- }
3348
-
3349
- const cacheKey = Symbol("aria-naming");
3350
- const defaultValue = "allowed";
3351
- const prohibitedRoles = [
3352
- "caption",
3353
- "code",
3354
- "deletion",
3355
- "emphasis",
3356
- "generic",
3357
- "insertion",
3358
- "paragraph",
3359
- "presentation",
3360
- "strong",
3361
- "subscript",
3362
- "superscript"
3363
- ];
3364
- function byRole(role) {
3365
- return prohibitedRoles.includes(role) ? "prohibited" : "allowed";
3366
- }
3367
- function byMeta(element, meta) {
3368
- return meta.aria.naming(element._adapter);
3369
- }
3370
- function ariaNaming(element) {
3371
- var _a;
3372
- const cached = element.cacheGet(cacheKey);
3373
- if (cached) {
3374
- return cached;
3375
- }
3376
- const role = (_a = element.getAttribute("role")) == null ? void 0 : _a.value;
3377
- if (role) {
3378
- if (role instanceof DynamicValue) {
3379
- return element.cacheSet(cacheKey, defaultValue);
3380
- } else {
3381
- return element.cacheSet(cacheKey, byRole(role));
3382
- }
3383
- }
3384
- const meta = element.meta;
3385
- if (!meta) {
3386
- return element.cacheSet(cacheKey, defaultValue);
3387
- }
3388
- return element.cacheSet(cacheKey, byMeta(element, meta));
3389
- }
3390
-
3391
- const patternCache = /* @__PURE__ */ new Map();
3392
- function compileStringPattern(pattern) {
3393
- const regexp = pattern.replace(/[*]+/g, ".+");
3394
- return new RegExp(`^${regexp}$`);
3395
- }
3396
- function compileRegExpPattern(pattern) {
3397
- return new RegExp(`^${pattern}$`);
3398
- }
3399
- function compilePattern(pattern) {
3400
- const cached = patternCache.get(pattern);
3401
- if (cached) {
3402
- return cached;
3403
- }
3404
- const match = pattern.match(/^\/(.*)\/$/);
3405
- const regexp = match ? compileRegExpPattern(match[1]) : compileStringPattern(pattern);
3406
- patternCache.set(pattern, regexp);
3407
- return regexp;
3408
- }
3409
- function keywordPatternMatcher(list, keyword) {
3410
- for (const pattern of list) {
3411
- const regexp = compilePattern(pattern);
3412
- if (regexp.test(keyword)) {
3413
- return true;
3414
- }
3415
- }
3416
- return false;
3417
- }
3418
- function isKeywordIgnored(options, keyword, matcher = (list, it) => list.includes(it)) {
3419
- const { include, exclude } = options;
3420
- if (include && !matcher(include, keyword)) {
2951
+ return false;
2952
+ }
2953
+ function isKeywordIgnored(options, keyword, matcher = (list, it) => list.includes(it)) {
2954
+ const { include, exclude } = options;
2955
+ if (include && !matcher(include, keyword)) {
3421
2956
  return true;
3422
2957
  }
3423
2958
  if (exclude && matcher(exclude, keyword)) {
@@ -5022,7 +4557,7 @@ class AttributeAllowedValues extends Rule {
5022
4557
  setup() {
5023
4558
  this.on("dom:ready", (event) => {
5024
4559
  const doc = event.document;
5025
- doc.visitDepthFirst((node) => {
4560
+ walk.depthFirst(doc, (node) => {
5026
4561
  const meta = node.meta;
5027
4562
  if (!(meta == null ? void 0 : meta.attributes))
5028
4563
  return;
@@ -5082,7 +4617,7 @@ class AttributeBooleanStyle extends Rule {
5082
4617
  setup() {
5083
4618
  this.on("dom:ready", (event) => {
5084
4619
  const doc = event.document;
5085
- doc.visitDepthFirst((node) => {
4620
+ walk.depthFirst(doc, (node) => {
5086
4621
  const meta = node.meta;
5087
4622
  if (!(meta == null ? void 0 : meta.attributes))
5088
4623
  return;
@@ -5154,7 +4689,7 @@ class AttributeEmptyStyle extends Rule {
5154
4689
  setup() {
5155
4690
  this.on("dom:ready", (event) => {
5156
4691
  const doc = event.document;
5157
- doc.visitDepthFirst((node) => {
4692
+ walk.depthFirst(doc, (node) => {
5158
4693
  const meta = node.meta;
5159
4694
  if (!(meta == null ? void 0 : meta.attributes))
5160
4695
  return;
@@ -5806,7 +5341,7 @@ class ElementPermittedContent extends Rule {
5806
5341
  setup() {
5807
5342
  this.on("dom:ready", (event) => {
5808
5343
  const doc = event.document;
5809
- doc.visitDepthFirst((node) => {
5344
+ walk.depthFirst(doc, (node) => {
5810
5345
  const parent = node.parent;
5811
5346
  if (!parent) {
5812
5347
  return;
@@ -5885,7 +5420,7 @@ class ElementPermittedOccurrences extends Rule {
5885
5420
  setup() {
5886
5421
  this.on("dom:ready", (event) => {
5887
5422
  const doc = event.document;
5888
- doc.visitDepthFirst((node) => {
5423
+ walk.depthFirst(doc, (node) => {
5889
5424
  if (!node.meta) {
5890
5425
  return;
5891
5426
  }
@@ -5918,7 +5453,7 @@ class ElementPermittedOrder extends Rule {
5918
5453
  setup() {
5919
5454
  this.on("dom:ready", (event) => {
5920
5455
  const doc = event.document;
5921
- doc.visitDepthFirst((node) => {
5456
+ walk.depthFirst(doc, (node) => {
5922
5457
  if (!node.meta) {
5923
5458
  return;
5924
5459
  }
@@ -5988,7 +5523,7 @@ class ElementPermittedParent extends Rule {
5988
5523
  setup() {
5989
5524
  this.on("dom:ready", (event) => {
5990
5525
  const doc = event.document;
5991
- doc.visitDepthFirst((node) => {
5526
+ walk.depthFirst(doc, (node) => {
5992
5527
  var _a;
5993
5528
  const parent = node.parent;
5994
5529
  if (!parent) {
@@ -6036,7 +5571,7 @@ class ElementRequiredAncestor extends Rule {
6036
5571
  setup() {
6037
5572
  this.on("dom:ready", (event) => {
6038
5573
  const doc = event.document;
6039
- doc.visitDepthFirst((node) => {
5574
+ walk.depthFirst(doc, (node) => {
6040
5575
  const parent = node.parent;
6041
5576
  if (!parent) {
6042
5577
  return;
@@ -6120,7 +5655,7 @@ class ElementRequiredContent extends Rule {
6120
5655
  setup() {
6121
5656
  this.on("dom:ready", (event) => {
6122
5657
  const doc = event.document;
6123
- doc.visitDepthFirst((node) => {
5658
+ walk.depthFirst(doc, (node) => {
6124
5659
  if (!node.meta) {
6125
5660
  return;
6126
5661
  }
@@ -6640,7 +6175,7 @@ function isFocusableImpl(element) {
6640
6175
  if (isDisabled(element, meta)) {
6641
6176
  return false;
6642
6177
  }
6643
- return Boolean(meta == null ? void 0 : meta.focusable);
6178
+ return Boolean(meta.focusable);
6644
6179
  }
6645
6180
  function isFocusable(element) {
6646
6181
  const cached = element.cacheGet(FOCUSABLE_CACHE);
@@ -8550,1778 +8085,2291 @@ class RequireSri extends Rule {
8550
8085
  items: {
8551
8086
  type: "string"
8552
8087
  },
8553
- type: "array"
8554
- },
8555
- {
8556
- type: "null"
8557
- }
8558
- ]
8559
- },
8560
- exclude: {
8561
- anyOf: [
8562
- {
8563
- items: {
8088
+ type: "array"
8089
+ },
8090
+ {
8091
+ type: "null"
8092
+ }
8093
+ ]
8094
+ },
8095
+ exclude: {
8096
+ anyOf: [
8097
+ {
8098
+ items: {
8099
+ type: "string"
8100
+ },
8101
+ type: "array"
8102
+ },
8103
+ {
8104
+ type: "null"
8105
+ }
8106
+ ]
8107
+ }
8108
+ };
8109
+ }
8110
+ documentation() {
8111
+ return {
8112
+ description: `Subresource Integrity (SRI) \`integrity\` attribute is required to prevent tampering or manipulation from Content Delivery Networks (CDN), rouge proxies, malicious entities, etc.`,
8113
+ url: "https://html-validate.org/rules/require-sri.html"
8114
+ };
8115
+ }
8116
+ setup() {
8117
+ this.on("tag:end", (event) => {
8118
+ const node = event.previous;
8119
+ if (!(this.supportSri(node) && this.needSri(node))) {
8120
+ return;
8121
+ }
8122
+ if (node.hasAttribute("integrity")) {
8123
+ return;
8124
+ }
8125
+ this.report(
8126
+ node,
8127
+ `SRI "integrity" attribute is required on <${node.tagName}> element`,
8128
+ node.location
8129
+ );
8130
+ });
8131
+ }
8132
+ supportSri(node) {
8133
+ return Object.keys(supportSri).includes(node.tagName);
8134
+ }
8135
+ needSri(node) {
8136
+ const attr = this.elementSourceAttr(node);
8137
+ if (!attr) {
8138
+ return false;
8139
+ }
8140
+ if (attr.value === null || attr.value === "" || attr.isDynamic) {
8141
+ return false;
8142
+ }
8143
+ const url = attr.value.toString();
8144
+ if (this.target === "all" || crossorigin.test(url)) {
8145
+ return !this.isIgnored(url);
8146
+ }
8147
+ return false;
8148
+ }
8149
+ elementSourceAttr(node) {
8150
+ const key = supportSri[node.tagName];
8151
+ return node.getAttribute(key);
8152
+ }
8153
+ isIgnored(url) {
8154
+ return this.isKeywordIgnored(url, (list, it) => {
8155
+ return list.some((pattern) => it.includes(pattern));
8156
+ });
8157
+ }
8158
+ }
8159
+
8160
+ class ScriptElement extends Rule {
8161
+ documentation() {
8162
+ return {
8163
+ description: "The end tag for `<script>` is a hard requirement and must never be omitted even when using the `src` attribute.",
8164
+ url: "https://html-validate.org/rules/script-element.html"
8165
+ };
8166
+ }
8167
+ setup() {
8168
+ this.on("tag:end", (event) => {
8169
+ const node = event.target;
8170
+ if (!node || node.tagName !== "script") {
8171
+ return;
8172
+ }
8173
+ if (node.closed !== NodeClosed.EndTag) {
8174
+ this.report(node, `End tag for <${node.tagName}> must not be omitted`);
8175
+ }
8176
+ });
8177
+ }
8178
+ }
8179
+
8180
+ const javascript = [
8181
+ "",
8182
+ "application/ecmascript",
8183
+ "application/javascript",
8184
+ "text/ecmascript",
8185
+ "text/javascript"
8186
+ ];
8187
+ class ScriptType extends Rule {
8188
+ documentation() {
8189
+ return {
8190
+ description: "While valid the HTML5 standard encourages authors to omit the type element for JavaScript resources.",
8191
+ url: "https://html-validate.org/rules/script-type.html"
8192
+ };
8193
+ }
8194
+ setup() {
8195
+ this.on("tag:end", (event) => {
8196
+ const node = event.previous;
8197
+ if (node.tagName !== "script") {
8198
+ return;
8199
+ }
8200
+ const attr = node.getAttribute("type");
8201
+ if (!attr || attr.isDynamic) {
8202
+ return;
8203
+ }
8204
+ const value = attr.value ? attr.value.toString() : "";
8205
+ if (!this.isJavascript(value)) {
8206
+ return;
8207
+ }
8208
+ this.report(
8209
+ node,
8210
+ '"type" attribute is unnecessary for javascript resources',
8211
+ attr.keyLocation
8212
+ );
8213
+ });
8214
+ }
8215
+ isJavascript(mime) {
8216
+ const type = mime.replace(/;.*/, "");
8217
+ return javascript.includes(type);
8218
+ }
8219
+ }
8220
+
8221
+ class SvgFocusable extends Rule {
8222
+ documentation() {
8223
+ return {
8224
+ description: `Inline SVG elements in IE are focusable by default which may cause issues with tab-ordering. The \`focusable\` attribute should explicitly be set to avoid unintended behaviour.`,
8225
+ url: "https://html-validate.org/rules/svg-focusable.html"
8226
+ };
8227
+ }
8228
+ setup() {
8229
+ this.on("element:ready", (event) => {
8230
+ if (event.target.is("svg")) {
8231
+ this.validate(event.target);
8232
+ }
8233
+ });
8234
+ }
8235
+ validate(svg) {
8236
+ if (svg.hasAttribute("focusable")) {
8237
+ return;
8238
+ }
8239
+ this.report(svg, `<${svg.tagName}> is missing required "focusable" attribute`);
8240
+ }
8241
+ }
8242
+
8243
+ const defaults$5 = {
8244
+ characters: [
8245
+ { pattern: " ", replacement: "&nbsp;", description: "non-breaking space" },
8246
+ { pattern: "-", replacement: "&#8209;", description: "non-breaking hyphen" }
8247
+ ],
8248
+ ignoreClasses: [],
8249
+ ignoreStyle: true
8250
+ };
8251
+ function constructRegex(characters) {
8252
+ const disallowed = characters.map((it) => {
8253
+ return it.pattern;
8254
+ }).join("|");
8255
+ const pattern = `(${disallowed})`;
8256
+ return new RegExp(pattern, "g");
8257
+ }
8258
+ function getText(node) {
8259
+ const match = node.textContent.match(/^(\s*)(.*)$/);
8260
+ const [, leading, text] = match;
8261
+ return [leading.length, text.trimEnd()];
8262
+ }
8263
+ function matchAll(text, regexp) {
8264
+ const copy = new RegExp(regexp);
8265
+ const matches = [];
8266
+ let match;
8267
+ while (match = copy.exec(text)) {
8268
+ matches.push(match);
8269
+ }
8270
+ return matches;
8271
+ }
8272
+ class TelNonBreaking extends Rule {
8273
+ constructor(options) {
8274
+ super({ ...defaults$5, ...options });
8275
+ this.regex = constructRegex(this.options.characters);
8276
+ }
8277
+ static schema() {
8278
+ return {
8279
+ characters: {
8280
+ type: "array",
8281
+ items: {
8282
+ type: "object",
8283
+ additionalProperties: false,
8284
+ properties: {
8285
+ pattern: {
8286
+ type: "string"
8287
+ },
8288
+ replacement: {
8564
8289
  type: "string"
8565
8290
  },
8566
- type: "array"
8567
- },
8568
- {
8569
- type: "null"
8291
+ description: {
8292
+ type: "string"
8293
+ }
8570
8294
  }
8571
- ]
8295
+ }
8296
+ },
8297
+ ignoreClasses: {
8298
+ type: "array",
8299
+ items: {
8300
+ type: "string"
8301
+ }
8302
+ },
8303
+ ignoreStyle: {
8304
+ type: "boolean"
8572
8305
  }
8573
8306
  };
8574
8307
  }
8575
- documentation() {
8308
+ documentation(context) {
8309
+ const { characters } = this.options;
8310
+ const replacements = characters.map((it) => {
8311
+ return ` - \`${it.pattern}\` - replace with \`${it.replacement}\` (${it.description}).`;
8312
+ });
8576
8313
  return {
8577
- description: `Subresource Integrity (SRI) \`integrity\` attribute is required to prevent tampering or manipulation from Content Delivery Networks (CDN), rouge proxies, malicious entities, etc.`,
8578
- url: "https://html-validate.org/rules/require-sri.html"
8314
+ description: [
8315
+ `The \`${context.pattern}\` character should be replaced with \`${context.replacement}\` character (${context.description}) when used in a telephone number.`,
8316
+ "",
8317
+ "Unless non-breaking characters is used there could be a line break inserted at that character.",
8318
+ "Line breaks make is harder to read and understand the telephone number.",
8319
+ "",
8320
+ "The following characters should be avoided:",
8321
+ "",
8322
+ ...replacements
8323
+ ].join("\n"),
8324
+ url: "https://html-validate.org/rules/tel-non-breaking.html"
8579
8325
  };
8580
8326
  }
8581
8327
  setup() {
8582
- this.on("tag:end", (event) => {
8583
- const node = event.previous;
8584
- if (!(this.supportSri(node) && this.needSri(node))) {
8585
- return;
8586
- }
8587
- if (node.hasAttribute("integrity")) {
8328
+ this.on("element:ready", this.isRelevant, (event) => {
8329
+ const { target } = event;
8330
+ if (this.isIgnored(target)) {
8588
8331
  return;
8589
8332
  }
8590
- this.report(
8591
- node,
8592
- `SRI "integrity" attribute is required on <${node.tagName}> element`,
8593
- node.location
8594
- );
8333
+ this.walk(target, target);
8595
8334
  });
8596
8335
  }
8597
- supportSri(node) {
8598
- return Object.keys(supportSri).includes(node.tagName);
8599
- }
8600
- needSri(node) {
8601
- const attr = this.elementSourceAttr(node);
8602
- if (!attr) {
8336
+ isRelevant(event) {
8337
+ const { target } = event;
8338
+ if (!target.is("a")) {
8603
8339
  return false;
8604
8340
  }
8605
- if (attr.value === null || attr.value === "" || attr.isDynamic) {
8341
+ const attr = target.getAttribute("href");
8342
+ if (!(attr == null ? void 0 : attr.valueMatches(/^tel:/, false))) {
8606
8343
  return false;
8607
8344
  }
8608
- const url = attr.value.toString();
8609
- if (this.target === "all" || crossorigin.test(url)) {
8610
- return !this.isIgnored(url);
8345
+ return true;
8346
+ }
8347
+ isIgnoredClass(node) {
8348
+ const { ignoreClasses } = this.options;
8349
+ const { classList } = node;
8350
+ return ignoreClasses.some((it) => classList.contains(it));
8351
+ }
8352
+ isIgnoredStyle(node) {
8353
+ const { ignoreStyle } = this.options;
8354
+ const { style } = node;
8355
+ if (!ignoreStyle) {
8356
+ return false;
8357
+ }
8358
+ if (style["white-space"] === "nowrap" || style["white-space"] === "pre") {
8359
+ return true;
8611
8360
  }
8612
8361
  return false;
8613
8362
  }
8614
- elementSourceAttr(node) {
8615
- const key = supportSri[node.tagName];
8616
- return node.getAttribute(key);
8363
+ isIgnored(node) {
8364
+ return this.isIgnoredClass(node) || this.isIgnoredStyle(node);
8365
+ }
8366
+ walk(anchor, node) {
8367
+ for (const child of node.childNodes) {
8368
+ if (isTextNode(child)) {
8369
+ this.detectDisallowed(anchor, child);
8370
+ } else if (isElementNode(child)) {
8371
+ this.walk(anchor, child);
8372
+ }
8373
+ }
8374
+ }
8375
+ detectDisallowed(anchor, node) {
8376
+ const [offset, text] = getText(node);
8377
+ const matches = matchAll(text, this.regex);
8378
+ for (const match of matches) {
8379
+ const detected = match[0];
8380
+ const entry = this.options.characters.find((it) => it.pattern === detected);
8381
+ if (!entry) {
8382
+ throw new Error(`Failed to find entry for "${detected}" when searching text "${text}"`);
8383
+ }
8384
+ const message = `"${detected}" should be replaced with "${entry.replacement}" (${entry.description}) in telephone number`;
8385
+ const begin = offset + match.index;
8386
+ const end = begin + detected.length;
8387
+ const location = sliceLocation(node.location, begin, end);
8388
+ const context = entry;
8389
+ this.report(anchor, message, location, context);
8390
+ }
8391
+ }
8392
+ }
8393
+
8394
+ function hasNonEmptyAttribute(node, key) {
8395
+ const attr = node.getAttribute(key);
8396
+ return Boolean(attr == null ? void 0 : attr.valueMatches(/.+/, true));
8397
+ }
8398
+ function hasDefaultText(node) {
8399
+ if (!node.is("input")) {
8400
+ return false;
8401
+ }
8402
+ if (node.hasAttribute("value")) {
8403
+ return false;
8404
+ }
8405
+ const type = node.getAttribute("type");
8406
+ return Boolean(type == null ? void 0 : type.valueMatches(/submit|reset/, false));
8407
+ }
8408
+ function isNonEmptyText(node) {
8409
+ if (isTextNode(node)) {
8410
+ return node.isDynamic || node.textContent.trim() !== "";
8411
+ } else {
8412
+ return false;
8413
+ }
8414
+ }
8415
+ function haveAccessibleText(node) {
8416
+ if (!inAccessibilityTree(node)) {
8417
+ return false;
8418
+ }
8419
+ const haveText = node.childNodes.some((child) => isNonEmptyText(child));
8420
+ if (haveText) {
8421
+ return true;
8422
+ }
8423
+ if (hasNonEmptyAttribute(node, "aria-label")) {
8424
+ return true;
8425
+ }
8426
+ if (hasNonEmptyAttribute(node, "aria-labelledby")) {
8427
+ return true;
8428
+ }
8429
+ if (node.is("img") && hasNonEmptyAttribute(node, "alt")) {
8430
+ return true;
8431
+ }
8432
+ if (hasDefaultText(node)) {
8433
+ return true;
8434
+ }
8435
+ return node.childElements.some((child) => {
8436
+ return haveAccessibleText(child);
8437
+ });
8438
+ }
8439
+ class TextContent extends Rule {
8440
+ documentation(context) {
8441
+ const doc = {
8442
+ description: `The textual content for this element is not valid.`,
8443
+ url: "https://html-validate.org/rules/text-content.html"
8444
+ };
8445
+ switch (context.textContent) {
8446
+ case TextContent$1.NONE:
8447
+ doc.description = `The \`<${context.tagName}>\` element must not have textual content.`;
8448
+ break;
8449
+ case TextContent$1.REQUIRED:
8450
+ doc.description = `The \`<${context.tagName}>\` element must have textual content.`;
8451
+ break;
8452
+ case TextContent$1.ACCESSIBLE:
8453
+ doc.description = `The \`<${context.tagName}>\` element must have accessible text.`;
8454
+ break;
8455
+ }
8456
+ return doc;
8457
+ }
8458
+ static filter(event) {
8459
+ const { target } = event;
8460
+ if (!target.meta) {
8461
+ return false;
8462
+ }
8463
+ const { textContent } = target.meta;
8464
+ if (!textContent || textContent === TextContent$1.DEFAULT) {
8465
+ return false;
8466
+ }
8467
+ return true;
8468
+ }
8469
+ setup() {
8470
+ this.on("element:ready", TextContent.filter, (event) => {
8471
+ const target = event.target;
8472
+ const { textContent } = target.meta;
8473
+ switch (textContent) {
8474
+ case TextContent$1.NONE:
8475
+ this.validateNone(target);
8476
+ break;
8477
+ case TextContent$1.REQUIRED:
8478
+ this.validateRequired(target);
8479
+ break;
8480
+ case TextContent$1.ACCESSIBLE:
8481
+ this.validateAccessible(target);
8482
+ break;
8483
+ }
8484
+ });
8485
+ }
8486
+ /**
8487
+ * Validate element has empty text (inter-element whitespace is not considered text)
8488
+ */
8489
+ validateNone(node) {
8490
+ if (classifyNodeText(node) === TextClassification.EMPTY_TEXT) {
8491
+ return;
8492
+ }
8493
+ this.reportError(node, node.meta, `${node.annotatedName} must not have text content`);
8494
+ }
8495
+ /**
8496
+ * Validate element has any text (inter-element whitespace is not considered text)
8497
+ */
8498
+ validateRequired(node) {
8499
+ if (classifyNodeText(node) !== TextClassification.EMPTY_TEXT) {
8500
+ return;
8501
+ }
8502
+ this.reportError(node, node.meta, `${node.annotatedName} must have text content`);
8503
+ }
8504
+ /**
8505
+ * Validate element has accessible text (either regular text or text only
8506
+ * exposed in accessibility tree via aria-label or similar)
8507
+ */
8508
+ validateAccessible(node) {
8509
+ if (!inAccessibilityTree(node)) {
8510
+ return;
8511
+ }
8512
+ if (haveAccessibleText(node)) {
8513
+ return;
8514
+ }
8515
+ this.reportError(node, node.meta, `${node.annotatedName} must have accessible text`);
8617
8516
  }
8618
- isIgnored(url) {
8619
- return this.isKeywordIgnored(url, (list, it) => {
8620
- return list.some((pattern) => it.includes(pattern));
8517
+ reportError(node, meta, message) {
8518
+ this.report(node, message, null, {
8519
+ tagName: node.tagName,
8520
+ textContent: meta.textContent
8621
8521
  });
8622
8522
  }
8623
8523
  }
8624
8524
 
8625
- class ScriptElement extends Rule {
8626
- documentation() {
8627
- return {
8628
- description: "The end tag for `<script>` is a hard requirement and must never be omitted even when using the `src` attribute.",
8629
- url: "https://html-validate.org/rules/script-element.html"
8630
- };
8525
+ const roles = ["complementary", "contentinfo", "form", "banner", "main", "navigation", "region"];
8526
+ const selectors = [
8527
+ "aside",
8528
+ "footer",
8529
+ "form",
8530
+ "header",
8531
+ "main",
8532
+ "nav",
8533
+ "section",
8534
+ ...roles.map((it) => `[role="${it}"]`)
8535
+ /* <search> does not (yet?) require a unique name */
8536
+ ];
8537
+ function getTextFromReference(document, id) {
8538
+ if (!id || id instanceof DynamicValue) {
8539
+ return id;
8631
8540
  }
8632
- setup() {
8633
- this.on("tag:end", (event) => {
8634
- const node = event.target;
8635
- if (!node || node.tagName !== "script") {
8636
- return;
8637
- }
8638
- if (node.closed !== NodeClosed.EndTag) {
8639
- this.report(node, `End tag for <${node.tagName}> must not be omitted`);
8640
- }
8641
- });
8541
+ const selector = `#${id}`;
8542
+ const ref = document.querySelector(selector);
8543
+ if (ref) {
8544
+ return ref.textContent;
8545
+ } else {
8546
+ return selector;
8642
8547
  }
8643
8548
  }
8644
-
8645
- const javascript = [
8646
- "",
8647
- "application/ecmascript",
8648
- "application/javascript",
8649
- "text/ecmascript",
8650
- "text/javascript"
8651
- ];
8652
- class ScriptType extends Rule {
8653
- documentation() {
8549
+ function groupBy(values, callback) {
8550
+ const result = {};
8551
+ for (const value of values) {
8552
+ const key = callback(value);
8553
+ if (key in result) {
8554
+ result[key].push(value);
8555
+ } else {
8556
+ result[key] = [value];
8557
+ }
8558
+ }
8559
+ return result;
8560
+ }
8561
+ function getTextEntryFromElement(document, node) {
8562
+ const ariaLabel = node.getAttribute("aria-label");
8563
+ if (ariaLabel) {
8654
8564
  return {
8655
- description: "While valid the HTML5 standard encourages authors to omit the type element for JavaScript resources.",
8656
- url: "https://html-validate.org/rules/script-type.html"
8565
+ node,
8566
+ text: ariaLabel.value,
8567
+ location: ariaLabel.keyLocation
8657
8568
  };
8658
8569
  }
8659
- setup() {
8660
- this.on("tag:end", (event) => {
8661
- const node = event.previous;
8662
- if (node.tagName !== "script") {
8663
- return;
8664
- }
8665
- const attr = node.getAttribute("type");
8666
- if (!attr || attr.isDynamic) {
8667
- return;
8668
- }
8669
- const value = attr.value ? attr.value.toString() : "";
8670
- if (!this.isJavascript(value)) {
8671
- return;
8672
- }
8673
- this.report(
8674
- node,
8675
- '"type" attribute is unnecessary for javascript resources',
8676
- attr.keyLocation
8677
- );
8678
- });
8570
+ const ariaLabelledby = node.getAttribute("aria-labelledby");
8571
+ if (ariaLabelledby) {
8572
+ const text = getTextFromReference(document, ariaLabelledby.value);
8573
+ return {
8574
+ node,
8575
+ text,
8576
+ location: ariaLabelledby.keyLocation
8577
+ };
8679
8578
  }
8680
- isJavascript(mime) {
8681
- const type = mime.replace(/;.*/, "");
8682
- return javascript.includes(type);
8579
+ return {
8580
+ node,
8581
+ text: null,
8582
+ location: node.location
8583
+ };
8584
+ }
8585
+ function isExcluded(entry) {
8586
+ const { node, text } = entry;
8587
+ if (text === null) {
8588
+ return !(node.is("form") || node.is("section"));
8683
8589
  }
8590
+ return true;
8684
8591
  }
8685
-
8686
- class SvgFocusable extends Rule {
8592
+ class UniqueLandmark extends Rule {
8687
8593
  documentation() {
8688
8594
  return {
8689
- description: `Inline SVG elements in IE are focusable by default which may cause issues with tab-ordering. The \`focusable\` attribute should explicitly be set to avoid unintended behaviour.`,
8690
- url: "https://html-validate.org/rules/svg-focusable.html"
8595
+ description: [
8596
+ "When the same type of landmark is present more than once in the same document each must be uniquely identifiable with a non-empty and unique name.",
8597
+ "For instance, if the document has two `<nav>` elements each of them need an accessible name to be distinguished from each other.",
8598
+ "",
8599
+ "The following elements / roles are considered landmarks:",
8600
+ "",
8601
+ ' - `aside` or `[role="complementary"]`',
8602
+ ' - `footer` or `[role="contentinfo"]`',
8603
+ ' - `form` or `[role="form"]`',
8604
+ ' - `header` or `[role="banner"]`',
8605
+ ' - `main` or `[role="main"]`',
8606
+ ' - `nav` or `[role="navigation"]`',
8607
+ ' - `section` or `[role="region"]`',
8608
+ "",
8609
+ "To fix this either:",
8610
+ "",
8611
+ " - Add `aria-label`.",
8612
+ " - Add `aria-labelledby`.",
8613
+ " - Remove one of the landmarks."
8614
+ ].join("\n"),
8615
+ url: "https://html-validate.org/rules/unique-landmark.html"
8691
8616
  };
8692
8617
  }
8693
8618
  setup() {
8694
- this.on("element:ready", (event) => {
8695
- if (event.target.is("svg")) {
8696
- this.validate(event.target);
8619
+ this.on("dom:ready", (event) => {
8620
+ const { document } = event;
8621
+ const elements = document.querySelectorAll(selectors.join(",")).filter((it) => typeof it.role === "string" && roles.includes(it.role));
8622
+ const grouped = groupBy(elements, (it) => it.role);
8623
+ for (const nodes of Object.values(grouped)) {
8624
+ if (nodes.length <= 1) {
8625
+ continue;
8626
+ }
8627
+ const entries = nodes.map((it) => getTextEntryFromElement(document, it));
8628
+ const filteredEntries = entries.filter(isExcluded);
8629
+ for (const entry of filteredEntries) {
8630
+ if (entry.text instanceof DynamicValue) {
8631
+ continue;
8632
+ }
8633
+ const dup = entries.filter((it) => it.text === entry.text).length > 1;
8634
+ if (!entry.text || dup) {
8635
+ const message = `Landmarks must have a non-empty and unique accessible name (aria-label or aria-labelledby)`;
8636
+ const location = entry.location;
8637
+ this.report({
8638
+ node: entry.node,
8639
+ message,
8640
+ location
8641
+ });
8642
+ }
8643
+ }
8697
8644
  }
8698
8645
  });
8699
8646
  }
8700
- validate(svg) {
8701
- if (svg.hasAttribute("focusable")) {
8702
- return;
8703
- }
8704
- this.report(svg, `<${svg.tagName}> is missing required "focusable" attribute`);
8705
- }
8706
8647
  }
8707
8648
 
8708
- const defaults$5 = {
8709
- characters: [
8710
- { pattern: " ", replacement: "&nbsp;", description: "non-breaking space" },
8711
- { pattern: "-", replacement: "&#8209;", description: "non-breaking hyphen" }
8712
- ],
8713
- ignoreClasses: [],
8714
- ignoreStyle: true
8649
+ const defaults$4 = {
8650
+ ignoreCase: false,
8651
+ requireSemicolon: true
8715
8652
  };
8716
- function constructRegex(characters) {
8717
- const disallowed = characters.map((it) => {
8718
- return it.pattern;
8719
- }).join("|");
8720
- const pattern = `(${disallowed})`;
8721
- return new RegExp(pattern, "g");
8722
- }
8723
- function getText(node) {
8724
- const match = node.textContent.match(/^(\s*)(.*)$/);
8725
- const [, leading, text] = match;
8726
- return [leading.length, text.trimEnd()];
8653
+ const regexp$1 = /&(?:[a-z0-9]+|#x?[0-9a-f]+)(;|[^a-z0-9]|$)/gi;
8654
+ const lowercaseEntities = elements.entities.map((it) => it.toLowerCase());
8655
+ function isNumerical(entity) {
8656
+ return entity.startsWith("&#");
8727
8657
  }
8728
- function matchAll(text, regexp) {
8729
- const copy = new RegExp(regexp);
8730
- const matches = [];
8731
- let match;
8732
- while (match = copy.exec(text)) {
8733
- matches.push(match);
8734
- }
8735
- return matches;
8658
+ function getLocation(location, entity, match) {
8659
+ const index = match.index ?? 0;
8660
+ return sliceLocation(location, index, index + entity.length);
8736
8661
  }
8737
- class TelNonBreaking extends Rule {
8738
- constructor(options) {
8739
- super({ ...defaults$5, ...options });
8740
- this.regex = constructRegex(this.options.characters);
8662
+ function getDescription(context, options) {
8663
+ const url = "https://html.spec.whatwg.org/multipage/named-characters.html";
8664
+ let message;
8665
+ if (context.terminated) {
8666
+ message = `Unrecognized character reference \`${context.entity}\`.`;
8667
+ } else {
8668
+ message = `Character reference \`${context.entity}\` must be terminated by a semicolon.`;
8741
8669
  }
8742
- static schema() {
8743
- return {
8744
- characters: {
8745
- type: "array",
8746
- items: {
8747
- type: "object",
8748
- additionalProperties: false,
8749
- properties: {
8750
- pattern: {
8751
- type: "string"
8752
- },
8753
- replacement: {
8754
- type: "string"
8755
- },
8756
- description: {
8757
- type: "string"
8758
- }
8759
- }
8760
- }
8761
- },
8762
- ignoreClasses: {
8763
- type: "array",
8764
- items: {
8765
- type: "string"
8766
- }
8670
+ return [
8671
+ message,
8672
+ `HTML5 defines a set of [valid character references](${url}) but this is not a valid one.`,
8673
+ "",
8674
+ "Ensure that:",
8675
+ "",
8676
+ "1. The character is one of the listed names.",
8677
+ ...options.ignoreCase ? [] : ["1. The case is correct (names are case sensitive)."],
8678
+ ...options.requireSemicolon ? ["1. The name is terminated with a `;`."] : []
8679
+ ].join("\n");
8680
+ }
8681
+ class UnknownCharReference extends Rule {
8682
+ constructor(options) {
8683
+ super({ ...defaults$4, ...options });
8684
+ }
8685
+ static schema() {
8686
+ return {
8687
+ ignoreCase: {
8688
+ type: "boolean"
8767
8689
  },
8768
- ignoreStyle: {
8690
+ requireSemicolon: {
8769
8691
  type: "boolean"
8770
8692
  }
8771
8693
  };
8772
8694
  }
8773
8695
  documentation(context) {
8774
- const { characters } = this.options;
8775
- const replacements = characters.map((it) => {
8776
- return ` - \`${it.pattern}\` - replace with \`${it.replacement}\` (${it.description}).`;
8777
- });
8778
8696
  return {
8779
- description: [
8780
- `The \`${context.pattern}\` character should be replaced with \`${context.replacement}\` character (${context.description}) when used in a telephone number.`,
8781
- "",
8782
- "Unless non-breaking characters is used there could be a line break inserted at that character.",
8783
- "Line breaks make is harder to read and understand the telephone number.",
8784
- "",
8785
- "The following characters should be avoided:",
8786
- "",
8787
- ...replacements
8788
- ].join("\n"),
8789
- url: "https://html-validate.org/rules/tel-non-breaking.html"
8697
+ description: getDescription(context, this.options),
8698
+ url: "https://html-validate.org/rules/unrecognized-char-ref.html"
8790
8699
  };
8791
8700
  }
8792
8701
  setup() {
8793
- this.on("element:ready", this.isRelevant, (event) => {
8794
- const { target } = event;
8795
- if (this.isIgnored(target)) {
8702
+ this.on("element:ready", (event) => {
8703
+ const node = event.target;
8704
+ for (const child of node.childNodes) {
8705
+ if (child.nodeType !== NodeType.TEXT_NODE) {
8706
+ continue;
8707
+ }
8708
+ this.findCharacterReferences(node, child.textContent, child.location, {
8709
+ isAttribute: false
8710
+ });
8711
+ }
8712
+ });
8713
+ this.on("attr", (event) => {
8714
+ if (!event.value) {
8796
8715
  return;
8797
8716
  }
8798
- this.walk(target, target);
8717
+ this.findCharacterReferences(event.target, event.value.toString(), event.valueLocation, {
8718
+ isAttribute: true
8719
+ });
8799
8720
  });
8800
8721
  }
8801
- isRelevant(event) {
8802
- const { target } = event;
8803
- if (!target.is("a")) {
8804
- return false;
8722
+ get entities() {
8723
+ if (this.options.ignoreCase) {
8724
+ return lowercaseEntities;
8725
+ } else {
8726
+ return elements.entities;
8805
8727
  }
8806
- const attr = target.getAttribute("href");
8807
- if (!(attr == null ? void 0 : attr.valueMatches(/^tel:/, false))) {
8808
- return false;
8728
+ }
8729
+ findCharacterReferences(node, text, location, { isAttribute }) {
8730
+ const isQuerystring = isAttribute && text.includes("?");
8731
+ for (const match of this.getMatches(text)) {
8732
+ this.validateCharacterReference(node, location, match, { isQuerystring });
8809
8733
  }
8810
- return true;
8811
8734
  }
8812
- isIgnoredClass(node) {
8813
- const { ignoreClasses } = this.options;
8814
- const { classList } = node;
8815
- return ignoreClasses.some((it) => classList.contains(it));
8735
+ validateCharacterReference(node, location, foobar, { isQuerystring }) {
8736
+ const { requireSemicolon } = this.options;
8737
+ const { match, entity, raw, terminated } = foobar;
8738
+ if (isNumerical(entity)) {
8739
+ return;
8740
+ }
8741
+ if (isQuerystring && !terminated) {
8742
+ return;
8743
+ }
8744
+ const found = this.entities.includes(entity);
8745
+ if (found && (terminated || !requireSemicolon)) {
8746
+ return;
8747
+ }
8748
+ if (found && !terminated) {
8749
+ const entityLocation2 = getLocation(location, entity, match);
8750
+ const message2 = `Character reference "{{ entity }}" must be terminated by a semicolon`;
8751
+ const context2 = {
8752
+ entity: raw,
8753
+ terminated: false
8754
+ };
8755
+ this.report(node, message2, entityLocation2, context2);
8756
+ return;
8757
+ }
8758
+ const entityLocation = getLocation(location, entity, match);
8759
+ const message = `Unrecognized character reference "{{ entity }}"`;
8760
+ const context = {
8761
+ entity: raw,
8762
+ terminated: true
8763
+ };
8764
+ this.report(node, message, entityLocation, context);
8765
+ }
8766
+ *getMatches(text) {
8767
+ let match;
8768
+ do {
8769
+ match = regexp$1.exec(text);
8770
+ if (match) {
8771
+ const terminator = match[1];
8772
+ const terminated = terminator === ";";
8773
+ const needSlice = terminator !== ";" && terminator.length > 0;
8774
+ const entity = needSlice ? match[0].slice(0, -1) : match[0];
8775
+ if (this.options.ignoreCase) {
8776
+ yield { match, entity: entity.toLowerCase(), raw: entity, terminated };
8777
+ } else {
8778
+ yield { match, entity, raw: entity, terminated };
8779
+ }
8780
+ }
8781
+ } while (match);
8782
+ }
8783
+ }
8784
+
8785
+ const expectedOrder = ["section", "hint", "contact", "field1", "field2", "webauthn"];
8786
+ const fieldNames1 = [
8787
+ "name",
8788
+ "honorific-prefix",
8789
+ "given-name",
8790
+ "additional-name",
8791
+ "family-name",
8792
+ "honorific-suffix",
8793
+ "nickname",
8794
+ "username",
8795
+ "new-password",
8796
+ "current-password",
8797
+ "one-time-code",
8798
+ "organization-title",
8799
+ "organization",
8800
+ "street-address",
8801
+ "address-line1",
8802
+ "address-line2",
8803
+ "address-line3",
8804
+ "address-level4",
8805
+ "address-level3",
8806
+ "address-level2",
8807
+ "address-level1",
8808
+ "country",
8809
+ "country-name",
8810
+ "postal-code",
8811
+ "cc-name",
8812
+ "cc-given-name",
8813
+ "cc-additional-name",
8814
+ "cc-family-name",
8815
+ "cc-number",
8816
+ "cc-exp",
8817
+ "cc-exp-month",
8818
+ "cc-exp-year",
8819
+ "cc-csc",
8820
+ "cc-type",
8821
+ "transaction-currency",
8822
+ "transaction-amount",
8823
+ "language",
8824
+ "bday",
8825
+ "bday-day",
8826
+ "bday-month",
8827
+ "bday-year",
8828
+ "sex",
8829
+ "url",
8830
+ "photo"
8831
+ ];
8832
+ const fieldNames2 = [
8833
+ "tel",
8834
+ "tel-country-code",
8835
+ "tel-national",
8836
+ "tel-area-code",
8837
+ "tel-local",
8838
+ "tel-local-prefix",
8839
+ "tel-local-suffix",
8840
+ "tel-extension",
8841
+ "email",
8842
+ "impp"
8843
+ ];
8844
+ const fieldNameGroup = {
8845
+ name: "text",
8846
+ "honorific-prefix": "text",
8847
+ "given-name": "text",
8848
+ "additional-name": "text",
8849
+ "family-name": "text",
8850
+ "honorific-suffix": "text",
8851
+ nickname: "text",
8852
+ username: "username",
8853
+ "new-password": "password",
8854
+ "current-password": "password",
8855
+ "one-time-code": "password",
8856
+ "organization-title": "text",
8857
+ organization: "text",
8858
+ "street-address": "multiline",
8859
+ "address-line1": "text",
8860
+ "address-line2": "text",
8861
+ "address-line3": "text",
8862
+ "address-level4": "text",
8863
+ "address-level3": "text",
8864
+ "address-level2": "text",
8865
+ "address-level1": "text",
8866
+ country: "text",
8867
+ "country-name": "text",
8868
+ "postal-code": "text",
8869
+ "cc-name": "text",
8870
+ "cc-given-name": "text",
8871
+ "cc-additional-name": "text",
8872
+ "cc-family-name": "text",
8873
+ "cc-number": "text",
8874
+ "cc-exp": "month",
8875
+ "cc-exp-month": "numeric",
8876
+ "cc-exp-year": "numeric",
8877
+ "cc-csc": "text",
8878
+ "cc-type": "text",
8879
+ "transaction-currency": "text",
8880
+ "transaction-amount": "numeric",
8881
+ language: "text",
8882
+ bday: "date",
8883
+ "bday-day": "numeric",
8884
+ "bday-month": "numeric",
8885
+ "bday-year": "numeric",
8886
+ sex: "text",
8887
+ url: "url",
8888
+ photo: "url",
8889
+ tel: "tel",
8890
+ "tel-country-code": "text",
8891
+ "tel-national": "text",
8892
+ "tel-area-code": "text",
8893
+ "tel-local": "text",
8894
+ "tel-local-prefix": "text",
8895
+ "tel-local-suffix": "text",
8896
+ "tel-extension": "text",
8897
+ email: "username",
8898
+ impp: "url"
8899
+ };
8900
+ const disallowedInputTypes = ["checkbox", "radio", "file", "submit", "image", "reset", "button"];
8901
+ function matchSection(token) {
8902
+ return token.startsWith("section-");
8903
+ }
8904
+ function matchHint(token) {
8905
+ return token === "shipping" || token === "billing";
8906
+ }
8907
+ function matchFieldNames1(token) {
8908
+ return fieldNames1.includes(token);
8909
+ }
8910
+ function matchContact(token) {
8911
+ const haystack = ["home", "work", "mobile", "fax", "pager"];
8912
+ return haystack.includes(token);
8913
+ }
8914
+ function matchFieldNames2(token) {
8915
+ return fieldNames2.includes(token);
8916
+ }
8917
+ function matchWebauthn(token) {
8918
+ return token === "webauthn";
8919
+ }
8920
+ function matchToken(token) {
8921
+ if (matchSection(token)) {
8922
+ return "section";
8923
+ }
8924
+ if (matchHint(token)) {
8925
+ return "hint";
8816
8926
  }
8817
- isIgnoredStyle(node) {
8818
- const { ignoreStyle } = this.options;
8819
- const { style } = node;
8820
- if (!ignoreStyle) {
8821
- return false;
8822
- }
8823
- if (style["white-space"] === "nowrap" || style["white-space"] === "pre") {
8824
- return true;
8825
- }
8826
- return false;
8927
+ if (matchFieldNames1(token)) {
8928
+ return "field1";
8827
8929
  }
8828
- isIgnored(node) {
8829
- return this.isIgnoredClass(node) || this.isIgnoredStyle(node);
8930
+ if (matchFieldNames2(token)) {
8931
+ return "field2";
8830
8932
  }
8831
- walk(anchor, node) {
8832
- for (const child of node.childNodes) {
8833
- if (isTextNode(child)) {
8834
- this.detectDisallowed(anchor, child);
8835
- } else if (isElementNode(child)) {
8836
- this.walk(anchor, child);
8837
- }
8838
- }
8933
+ if (matchContact(token)) {
8934
+ return "contact";
8839
8935
  }
8840
- detectDisallowed(anchor, node) {
8841
- const [offset, text] = getText(node);
8842
- const matches = matchAll(text, this.regex);
8843
- for (const match of matches) {
8844
- const detected = match[0];
8845
- const entry = this.options.characters.find((it) => it.pattern === detected);
8846
- if (!entry) {
8847
- throw new Error(`Failed to find entry for "${detected}" when searching text "${text}"`);
8848
- }
8849
- const message = `"${detected}" should be replaced with "${entry.replacement}" (${entry.description}) in telephone number`;
8850
- const begin = offset + match.index;
8851
- const end = begin + detected.length;
8852
- const location = sliceLocation(node.location, begin, end);
8853
- const context = entry;
8854
- this.report(anchor, message, location, context);
8855
- }
8936
+ if (matchWebauthn(token)) {
8937
+ return "webauthn";
8856
8938
  }
8939
+ return null;
8857
8940
  }
8858
-
8859
- function hasNonEmptyAttribute(node, key) {
8860
- const attr = node.getAttribute(key);
8861
- return Boolean(attr == null ? void 0 : attr.valueMatches(/.+/, true));
8941
+ function getControlGroups(type) {
8942
+ const allGroups = [
8943
+ "text",
8944
+ "multiline",
8945
+ "password",
8946
+ "url",
8947
+ "username",
8948
+ "tel",
8949
+ "numeric",
8950
+ "month",
8951
+ "date"
8952
+ ];
8953
+ const mapping = {
8954
+ hidden: allGroups,
8955
+ text: allGroups.filter((it) => it !== "multiline"),
8956
+ search: allGroups.filter((it) => it !== "multiline"),
8957
+ password: ["password"],
8958
+ url: ["url"],
8959
+ email: ["username"],
8960
+ tel: ["tel"],
8961
+ number: ["numeric"],
8962
+ month: ["month"],
8963
+ date: ["date"]
8964
+ };
8965
+ return mapping[type] ?? [];
8862
8966
  }
8863
- function hasDefaultText(node) {
8967
+ function isDisallowedType(node, type) {
8864
8968
  if (!node.is("input")) {
8865
8969
  return false;
8866
8970
  }
8867
- if (node.hasAttribute("value")) {
8868
- return false;
8869
- }
8870
- const type = node.getAttribute("type");
8871
- return Boolean(type == null ? void 0 : type.valueMatches(/submit|reset/, false));
8971
+ return disallowedInputTypes.includes(type);
8872
8972
  }
8873
- function isNonEmptyText(node) {
8874
- if (isTextNode(node)) {
8875
- return node.isDynamic || node.textContent.trim() !== "";
8876
- } else {
8877
- return false;
8973
+ function getTerminalMessage(context) {
8974
+ switch (context.msg) {
8975
+ case 0 /* InvalidAttribute */:
8976
+ return "autocomplete attribute cannot be used on {{ what }}";
8977
+ case 1 /* InvalidValue */:
8978
+ return '"{{ value }}" cannot be used on {{ what }}';
8979
+ case 2 /* InvalidOrder */:
8980
+ return '"{{ second }}" must appear before "{{ first }}"';
8981
+ case 3 /* InvalidToken */:
8982
+ return '"{{ token }}" is not a valid autocomplete token or field name';
8983
+ case 4 /* InvalidCombination */:
8984
+ return '"{{ second }}" cannot be combined with "{{ first }}"';
8985
+ case 5 /* MissingField */:
8986
+ return "autocomplete attribute is missing field name";
8878
8987
  }
8879
8988
  }
8880
- function haveAccessibleText(node) {
8881
- if (!inAccessibilityTree(node)) {
8882
- return false;
8883
- }
8884
- const haveText = node.childNodes.some((child) => isNonEmptyText(child));
8885
- if (haveText) {
8886
- return true;
8887
- }
8888
- if (hasNonEmptyAttribute(node, "aria-label")) {
8889
- return true;
8890
- }
8891
- if (hasNonEmptyAttribute(node, "aria-labelledby")) {
8892
- return true;
8893
- }
8894
- if (node.is("img") && hasNonEmptyAttribute(node, "alt")) {
8895
- return true;
8896
- }
8897
- if (hasDefaultText(node)) {
8898
- return true;
8989
+ function getMarkdownMessage(context) {
8990
+ switch (context.msg) {
8991
+ case 0 /* InvalidAttribute */:
8992
+ return [
8993
+ `\`autocomplete\` attribute cannot be used on \`${context.what}\``,
8994
+ "",
8995
+ "The following input types cannot use the `autocomplete` attribute:",
8996
+ "",
8997
+ ...disallowedInputTypes.map((it) => `- \`${it}\``)
8998
+ ].join("\n");
8999
+ case 1 /* InvalidValue */: {
9000
+ const message = `\`"${context.value}"\` cannot be used on \`${context.what}\``;
9001
+ if (context.type === "form") {
9002
+ return [
9003
+ message,
9004
+ "",
9005
+ 'The `<form>` element can only use the values `"on"` and `"off"`.'
9006
+ ].join("\n");
9007
+ }
9008
+ if (context.type === "hidden") {
9009
+ return [
9010
+ message,
9011
+ "",
9012
+ '`<input type="hidden">` cannot use the values `"on"` and `"off"`.'
9013
+ ].join("\n");
9014
+ }
9015
+ const controlGroups = getControlGroups(context.type);
9016
+ const currentGroup = fieldNameGroup[context.value];
9017
+ return [
9018
+ message,
9019
+ "",
9020
+ `\`${context.what}\` allows autocomplete fields from the following group${controlGroups.length > 1 ? "s" : ""}:`,
9021
+ "",
9022
+ ...controlGroups.map((it) => `- ${it}`),
9023
+ "",
9024
+ `The field \`"${context.value}"\` belongs to the group /${currentGroup}/ which cannot be used with this input type.`
9025
+ ].join("\n");
9026
+ }
9027
+ case 2 /* InvalidOrder */:
9028
+ return [
9029
+ `\`"${context.second}"\` must appear before \`"${context.first}"\``,
9030
+ "",
9031
+ "The autocomplete tokens must appear in the following order:",
9032
+ "",
9033
+ "- Optional section name (`section-` prefix).",
9034
+ "- Optional `shipping` or `billing` token.",
9035
+ "- Optional `home`, `work`, `mobile`, `fax` or `pager` token (for fields supporting it).",
9036
+ "- Field name",
9037
+ "- Optional `webauthn` token."
9038
+ ].join("\n");
9039
+ case 3 /* InvalidToken */:
9040
+ return `\`"${context.token}"\` is not a valid autocomplete token or field name`;
9041
+ case 4 /* InvalidCombination */:
9042
+ return `\`"${context.second}"\` cannot be combined with \`"${context.first}"\``;
9043
+ case 5 /* MissingField */:
9044
+ return "Autocomplete attribute is missing field name";
8899
9045
  }
8900
- return node.childElements.some((child) => {
8901
- return haveAccessibleText(child);
8902
- });
8903
9046
  }
8904
- class TextContent extends Rule {
9047
+ class ValidAutocomplete extends Rule {
8905
9048
  documentation(context) {
8906
- const doc = {
8907
- description: `The textual content for this element is not valid.`,
8908
- url: "https://html-validate.org/rules/text-content.html"
9049
+ return {
9050
+ description: getMarkdownMessage(context),
9051
+ url: "https://html-validate.org/rules/valid-autocomplete.html"
8909
9052
  };
8910
- switch (context.textContent) {
8911
- case TextContent$1.NONE:
8912
- doc.description = `The \`<${context.tagName}>\` element must not have textual content.`;
8913
- break;
8914
- case TextContent$1.REQUIRED:
8915
- doc.description = `The \`<${context.tagName}>\` element must have textual content.`;
8916
- break;
8917
- case TextContent$1.ACCESSIBLE:
8918
- doc.description = `The \`<${context.tagName}>\` element must have accessible text.`;
8919
- break;
8920
- }
8921
- return doc;
8922
- }
8923
- static filter(event) {
8924
- const { target } = event;
8925
- if (!target.meta) {
8926
- return false;
8927
- }
8928
- const { textContent } = target.meta;
8929
- if (!textContent || textContent === TextContent$1.DEFAULT) {
8930
- return false;
8931
- }
8932
- return true;
8933
9053
  }
8934
9054
  setup() {
8935
- this.on("element:ready", TextContent.filter, (event) => {
8936
- const target = event.target;
8937
- const { textContent } = target.meta;
8938
- switch (textContent) {
8939
- case TextContent$1.NONE:
8940
- this.validateNone(target);
8941
- break;
8942
- case TextContent$1.REQUIRED:
8943
- this.validateRequired(target);
8944
- break;
8945
- case TextContent$1.ACCESSIBLE:
8946
- this.validateAccessible(target);
8947
- break;
9055
+ this.on("dom:ready", (event) => {
9056
+ const { document } = event;
9057
+ const elements = document.querySelectorAll("[autocomplete]");
9058
+ for (const element of elements) {
9059
+ const autocomplete = element.getAttribute("autocomplete");
9060
+ if (autocomplete.value === null || autocomplete.value instanceof DynamicValue) {
9061
+ continue;
9062
+ }
9063
+ const location = autocomplete.valueLocation;
9064
+ const value = autocomplete.value.toLowerCase();
9065
+ const tokens = new DOMTokenList(value, location);
9066
+ if (tokens.length === 0) {
9067
+ continue;
9068
+ }
9069
+ this.validate(element, value, tokens, autocomplete.keyLocation, location);
8948
9070
  }
8949
9071
  });
8950
9072
  }
8951
- /**
8952
- * Validate element has empty text (inter-element whitespace is not considered text)
8953
- */
8954
- validateNone(node) {
8955
- if (classifyNodeText(node) === TextClassification.EMPTY_TEXT) {
8956
- return;
9073
+ validate(node, value, tokens, keyLocation, valueLocation) {
9074
+ switch (node.tagName) {
9075
+ case "form":
9076
+ this.validateFormAutocomplete(node, value, valueLocation);
9077
+ break;
9078
+ case "input":
9079
+ case "textarea":
9080
+ case "select":
9081
+ this.validateControlAutocomplete(node, tokens, keyLocation);
9082
+ break;
8957
9083
  }
8958
- this.reportError(node, node.meta, `${node.annotatedName} must not have text content`);
8959
9084
  }
8960
- /**
8961
- * Validate element has any text (inter-element whitespace is not considered text)
8962
- */
8963
- validateRequired(node) {
8964
- if (classifyNodeText(node) !== TextClassification.EMPTY_TEXT) {
9085
+ validateControlAutocomplete(node, tokens, keyLocation) {
9086
+ const type = node.getAttributeValue("type") ?? "text";
9087
+ const mantle = type !== "hidden" ? "expectation" : "anchor";
9088
+ if (isDisallowedType(node, type)) {
9089
+ const context = {
9090
+ msg: 0 /* InvalidAttribute */,
9091
+ what: `<input type="${type}">`
9092
+ };
9093
+ this.report({
9094
+ node,
9095
+ message: getTerminalMessage(context),
9096
+ location: keyLocation,
9097
+ context
9098
+ });
8965
9099
  return;
8966
9100
  }
8967
- this.reportError(node, node.meta, `${node.annotatedName} must have text content`);
8968
- }
8969
- /**
8970
- * Validate element has accessible text (either regular text or text only
8971
- * exposed in accessibility tree via aria-label or similar)
8972
- */
8973
- validateAccessible(node) {
8974
- if (!inAccessibilityTree(node)) {
9101
+ if (tokens.includes("on") || tokens.includes("off")) {
9102
+ this.validateOnOff(node, mantle, tokens);
8975
9103
  return;
8976
9104
  }
8977
- if (haveAccessibleText(node)) {
9105
+ this.validateTokens(node, tokens, keyLocation);
9106
+ }
9107
+ validateFormAutocomplete(node, value, location) {
9108
+ const trimmed = value.trim();
9109
+ if (["on", "off"].includes(trimmed)) {
8978
9110
  return;
8979
9111
  }
8980
- this.reportError(node, node.meta, `${node.annotatedName} must have accessible text`);
8981
- }
8982
- reportError(node, meta, message) {
8983
- this.report(node, message, null, {
8984
- tagName: node.tagName,
8985
- textContent: meta.textContent
9112
+ const context = {
9113
+ msg: 1 /* InvalidValue */,
9114
+ type: "form",
9115
+ value: trimmed,
9116
+ what: "<form>"
9117
+ };
9118
+ this.report({
9119
+ node,
9120
+ message: getTerminalMessage(context),
9121
+ location,
9122
+ context
8986
9123
  });
8987
9124
  }
8988
- }
8989
-
8990
- const roles = ["complementary", "contentinfo", "form", "banner", "main", "navigation", "region"];
8991
- const selectors = [
8992
- "aside",
8993
- "footer",
8994
- "form",
8995
- "header",
8996
- "main",
8997
- "nav",
8998
- "section",
8999
- ...roles.map((it) => `[role="${it}"]`)
9000
- /* <search> does not (yet?) require a unique name */
9001
- ];
9002
- function getTextFromReference(document, id) {
9003
- if (!id || id instanceof DynamicValue) {
9004
- return id;
9005
- }
9006
- const selector = `#${id}`;
9007
- const ref = document.querySelector(selector);
9008
- if (ref) {
9009
- return ref.textContent;
9010
- } else {
9011
- return selector;
9012
- }
9013
- }
9014
- function groupBy(values, callback) {
9015
- const result = {};
9016
- for (const value of values) {
9017
- const key = callback(value);
9018
- if (key in result) {
9019
- result[key].push(value);
9020
- } else {
9021
- result[key] = [value];
9125
+ validateOnOff(node, mantle, tokens) {
9126
+ const index = tokens.findIndex((it) => it === "on" || it === "off");
9127
+ const value = tokens.item(index);
9128
+ const location = tokens.location(index);
9129
+ if (tokens.length > 1) {
9130
+ const context = {
9131
+ msg: 4 /* InvalidCombination */,
9132
+ /* eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- it must be present of it wouldn't be found */
9133
+ first: tokens.item(index > 0 ? 0 : 1),
9134
+ second: value
9135
+ };
9136
+ this.report({
9137
+ node,
9138
+ message: getTerminalMessage(context),
9139
+ location,
9140
+ context
9141
+ });
9142
+ }
9143
+ switch (mantle) {
9144
+ case "expectation":
9145
+ return;
9146
+ case "anchor": {
9147
+ const context = {
9148
+ msg: 1 /* InvalidValue */,
9149
+ type: "hidden",
9150
+ value,
9151
+ what: `<input type="hidden">`
9152
+ };
9153
+ this.report({
9154
+ node,
9155
+ message: getTerminalMessage(context),
9156
+ location: tokens.location(0),
9157
+ context
9158
+ });
9159
+ }
9022
9160
  }
9023
9161
  }
9024
- return result;
9025
- }
9026
- function getTextEntryFromElement(document, node) {
9027
- const ariaLabel = node.getAttribute("aria-label");
9028
- if (ariaLabel) {
9029
- return {
9030
- node,
9031
- text: ariaLabel.value,
9032
- location: ariaLabel.keyLocation
9033
- };
9034
- }
9035
- const ariaLabelledby = node.getAttribute("aria-labelledby");
9036
- if (ariaLabelledby) {
9037
- const text = getTextFromReference(document, ariaLabelledby.value);
9038
- return {
9039
- node,
9040
- text,
9041
- location: ariaLabelledby.keyLocation
9042
- };
9162
+ validateTokens(node, tokens, keyLocation) {
9163
+ const order = [];
9164
+ for (const { item, location } of tokens.iterator()) {
9165
+ const tokenType = matchToken(item);
9166
+ if (tokenType) {
9167
+ order.push(tokenType);
9168
+ } else {
9169
+ const context = {
9170
+ msg: 3 /* InvalidToken */,
9171
+ token: item
9172
+ };
9173
+ this.report({
9174
+ node,
9175
+ message: getTerminalMessage(context),
9176
+ location,
9177
+ context
9178
+ });
9179
+ return;
9180
+ }
9181
+ }
9182
+ const fieldTokens = order.map((it) => it === "field1" || it === "field2");
9183
+ this.validateFieldPresence(node, tokens, fieldTokens, keyLocation);
9184
+ this.validateContact(node, tokens, order);
9185
+ this.validateOrder(node, tokens, order);
9186
+ this.validateControlGroup(node, tokens, fieldTokens);
9043
9187
  }
9044
- return {
9045
- node,
9046
- text: null,
9047
- location: node.location
9048
- };
9049
- }
9050
- function isExcluded(entry) {
9051
- const { node, text } = entry;
9052
- if (text === null) {
9053
- return !(node.is("form") || node.is("section"));
9188
+ /**
9189
+ * Ensure that exactly one field name is present from the two field lists.
9190
+ */
9191
+ validateFieldPresence(node, tokens, fieldTokens, keyLocation) {
9192
+ const numFields = fieldTokens.filter(Boolean).length;
9193
+ if (numFields === 0) {
9194
+ const context = {
9195
+ msg: 5 /* MissingField */
9196
+ };
9197
+ this.report({
9198
+ node,
9199
+ message: getTerminalMessage(context),
9200
+ location: keyLocation,
9201
+ context
9202
+ });
9203
+ } else if (numFields > 1) {
9204
+ const a = fieldTokens.indexOf(true);
9205
+ const b = fieldTokens.lastIndexOf(true);
9206
+ const context = {
9207
+ msg: 4 /* InvalidCombination */,
9208
+ /* eslint-disable @typescript-eslint/no-non-null-assertion -- it must be present of it wouldn't be found */
9209
+ first: tokens.item(a),
9210
+ second: tokens.item(b)
9211
+ /* eslint-enable @typescript-eslint/no-non-null-assertion */
9212
+ };
9213
+ this.report({
9214
+ node,
9215
+ message: getTerminalMessage(context),
9216
+ location: tokens.location(b),
9217
+ context
9218
+ });
9219
+ }
9054
9220
  }
9055
- return true;
9056
- }
9057
- class UniqueLandmark extends Rule {
9058
- documentation() {
9059
- return {
9060
- description: [
9061
- "When the same type of landmark is present more than once in the same document each must be uniquely identifiable with a non-empty and unique name.",
9062
- "For instance, if the document has two `<nav>` elements each of them need an accessible name to be distinguished from each other.",
9063
- "",
9064
- "The following elements / roles are considered landmarks:",
9065
- "",
9066
- ' - `aside` or `[role="complementary"]`',
9067
- ' - `footer` or `[role="contentinfo"]`',
9068
- ' - `form` or `[role="form"]`',
9069
- ' - `header` or `[role="banner"]`',
9070
- ' - `main` or `[role="main"]`',
9071
- ' - `nav` or `[role="navigation"]`',
9072
- ' - `section` or `[role="region"]`',
9073
- "",
9074
- "To fix this either:",
9075
- "",
9076
- " - Add `aria-label`.",
9077
- " - Add `aria-labelledby`.",
9078
- " - Remove one of the landmarks."
9079
- ].join("\n"),
9080
- url: "https://html-validate.org/rules/unique-landmark.html"
9081
- };
9221
+ /**
9222
+ * Ensure contact token is only used with field names from the second list.
9223
+ */
9224
+ validateContact(node, tokens, order) {
9225
+ if (order.includes("contact") && order.includes("field1")) {
9226
+ const a = order.indexOf("field1");
9227
+ const b = order.indexOf("contact");
9228
+ const context = {
9229
+ msg: 4 /* InvalidCombination */,
9230
+ /* eslint-disable @typescript-eslint/no-non-null-assertion -- it must be present of it wouldn't be found */
9231
+ first: tokens.item(a),
9232
+ second: tokens.item(b)
9233
+ /* eslint-enable @typescript-eslint/no-non-null-assertion */
9234
+ };
9235
+ this.report({
9236
+ node,
9237
+ message: getTerminalMessage(context),
9238
+ location: tokens.location(b),
9239
+ context
9240
+ });
9241
+ }
9082
9242
  }
9083
- setup() {
9084
- this.on("dom:ready", (event) => {
9085
- const { document } = event;
9086
- const elements = document.querySelectorAll(selectors.join(",")).filter((it) => typeof it.role === "string" && roles.includes(it.role));
9087
- const grouped = groupBy(elements, (it) => it.role);
9088
- for (const nodes of Object.values(grouped)) {
9089
- if (nodes.length <= 1) {
9090
- continue;
9091
- }
9092
- const entries = nodes.map((it) => getTextEntryFromElement(document, it));
9093
- const filteredEntries = entries.filter(isExcluded);
9094
- for (const entry of filteredEntries) {
9095
- if (entry.text instanceof DynamicValue) {
9096
- continue;
9097
- }
9098
- const dup = entries.filter((it) => it.text === entry.text).length > 1;
9099
- if (!entry.text || dup) {
9100
- const message = `Landmarks must have a non-empty and unique accessible name (aria-label or aria-labelledby)`;
9101
- const location = entry.location;
9102
- this.report({
9103
- node: entry.node,
9104
- message,
9105
- location
9106
- });
9107
- }
9108
- }
9243
+ validateOrder(node, tokens, order) {
9244
+ const indicies = order.map((it) => expectedOrder.indexOf(it));
9245
+ for (let i = 0; i < indicies.length - 1; i++) {
9246
+ if (indicies[0] > indicies[i + 1]) {
9247
+ const context = {
9248
+ msg: 2 /* InvalidOrder */,
9249
+ /* eslint-disable @typescript-eslint/no-non-null-assertion -- it must be present of it wouldn't be found */
9250
+ first: tokens.item(i),
9251
+ second: tokens.item(i + 1)
9252
+ /* eslint-enable @typescript-eslint/no-non-null-assertion */
9253
+ };
9254
+ this.report({
9255
+ node,
9256
+ message: getTerminalMessage(context),
9257
+ location: tokens.location(i + 1),
9258
+ context
9259
+ });
9109
9260
  }
9110
- });
9261
+ }
9262
+ }
9263
+ validateControlGroup(node, tokens, fieldTokens) {
9264
+ const numFields = fieldTokens.filter(Boolean).length;
9265
+ if (numFields === 0) {
9266
+ return;
9267
+ }
9268
+ if (!node.is("input")) {
9269
+ return;
9270
+ }
9271
+ const attr = node.getAttribute("type");
9272
+ const type = (attr == null ? void 0 : attr.value) ?? "text";
9273
+ if (type instanceof DynamicValue) {
9274
+ return;
9275
+ }
9276
+ const controlGroups = getControlGroups(type);
9277
+ const fieldIndex = fieldTokens.indexOf(true);
9278
+ const fieldToken = tokens.item(fieldIndex);
9279
+ const fieldGroup = fieldNameGroup[fieldToken];
9280
+ if (!controlGroups.includes(fieldGroup)) {
9281
+ const context = {
9282
+ msg: 1 /* InvalidValue */,
9283
+ type,
9284
+ value: fieldToken,
9285
+ what: `<input type="${type}">`
9286
+ };
9287
+ this.report({
9288
+ node,
9289
+ message: getTerminalMessage(context),
9290
+ location: tokens.location(fieldIndex),
9291
+ context
9292
+ });
9293
+ }
9111
9294
  }
9112
9295
  }
9113
9296
 
9114
- const defaults$4 = {
9115
- ignoreCase: false,
9116
- requireSemicolon: true
9297
+ const defaults$3 = {
9298
+ relaxed: false
9117
9299
  };
9118
- const regexp$1 = /&(?:[a-z0-9]+|#x?[0-9a-f]+)(;|[^a-z0-9]|$)/gi;
9119
- const lowercaseEntities = elements.entities.map((it) => it.toLowerCase());
9120
- function isNumerical(entity) {
9121
- return entity.startsWith("&#");
9122
- }
9123
- function getLocation(location, entity, match) {
9124
- const index = match.index ?? 0;
9125
- return sliceLocation(location, index, index + entity.length);
9126
- }
9127
- function getDescription(context, options) {
9128
- const url = "https://html.spec.whatwg.org/multipage/named-characters.html";
9129
- let message;
9130
- if (context.terminated) {
9131
- message = `Unrecognized character reference \`${context.entity}\`.`;
9132
- } else {
9133
- message = `Character reference \`${context.entity}\` must be terminated by a semicolon.`;
9134
- }
9135
- return [
9136
- message,
9137
- `HTML5 defines a set of [valid character references](${url}) but this is not a valid one.`,
9138
- "",
9139
- "Ensure that:",
9140
- "",
9141
- "1. The character is one of the listed names.",
9142
- ...options.ignoreCase ? [] : ["1. The case is correct (names are case sensitive)."],
9143
- ...options.requireSemicolon ? ["1. The name is terminated with a `;`."] : []
9144
- ].join("\n");
9145
- }
9146
- class UnknownCharReference extends Rule {
9300
+ class ValidID extends Rule {
9147
9301
  constructor(options) {
9148
- super({ ...defaults$4, ...options });
9302
+ super({ ...defaults$3, ...options });
9149
9303
  }
9150
9304
  static schema() {
9151
9305
  return {
9152
- ignoreCase: {
9153
- type: "boolean"
9154
- },
9155
- requireSemicolon: {
9306
+ relaxed: {
9156
9307
  type: "boolean"
9157
9308
  }
9158
9309
  };
9159
9310
  }
9160
9311
  documentation(context) {
9312
+ const { relaxed } = this.options;
9313
+ const message = this.messages[context].replace("id", "ID").replace(/^(.)/, (m) => m.toUpperCase());
9314
+ const relaxedDescription = relaxed ? [] : [
9315
+ " - ID must begin with a letter",
9316
+ " - ID must only contain letters, digits, `-` and `_`"
9317
+ ];
9161
9318
  return {
9162
- description: getDescription(context, this.options),
9163
- url: "https://html-validate.org/rules/unrecognized-char-ref.html"
9319
+ description: [
9320
+ `${message}.`,
9321
+ "",
9322
+ "Under the current configuration the following rules are applied:",
9323
+ "",
9324
+ " - ID must not be empty",
9325
+ " - ID must not contain any whitespace characters",
9326
+ ...relaxedDescription
9327
+ ].join("\n"),
9328
+ url: "https://html-validate.org/rules/valid-id.html"
9164
9329
  };
9165
9330
  }
9166
9331
  setup() {
9167
- this.on("element:ready", (event) => {
9168
- const node = event.target;
9169
- for (const child of node.childNodes) {
9170
- if (child.nodeType !== NodeType.TEXT_NODE) {
9171
- continue;
9172
- }
9173
- this.findCharacterReferences(node, child.textContent, child.location, {
9174
- isAttribute: false
9175
- });
9332
+ this.on("attr", this.isRelevant, (event) => {
9333
+ const { value } = event;
9334
+ if (value === null || value instanceof DynamicValue) {
9335
+ return;
9176
9336
  }
9177
- });
9178
- this.on("attr", (event) => {
9179
- if (!event.value) {
9337
+ if (value === "") {
9338
+ const context = 1 /* EMPTY */;
9339
+ this.report(event.target, this.messages[context], event.location, context);
9180
9340
  return;
9181
9341
  }
9182
- this.findCharacterReferences(event.target, event.value.toString(), event.valueLocation, {
9183
- isAttribute: true
9184
- });
9342
+ if (value.match(/\s/)) {
9343
+ const context = 2 /* WHITESPACE */;
9344
+ this.report(event.target, this.messages[context], event.valueLocation, context);
9345
+ return;
9346
+ }
9347
+ const { relaxed } = this.options;
9348
+ if (relaxed) {
9349
+ return;
9350
+ }
9351
+ if (value.match(/^[^\p{L}]/u)) {
9352
+ const context = 3 /* LEADING_CHARACTER */;
9353
+ this.report(event.target, this.messages[context], event.valueLocation, context);
9354
+ return;
9355
+ }
9356
+ if (value.match(/[^\p{L}\p{N}_-]/u)) {
9357
+ const context = 4 /* DISALLOWED_CHARACTER */;
9358
+ this.report(event.target, this.messages[context], event.valueLocation, context);
9359
+ }
9185
9360
  });
9186
9361
  }
9187
- get entities() {
9188
- if (this.options.ignoreCase) {
9189
- return lowercaseEntities;
9190
- } else {
9191
- return elements.entities;
9192
- }
9193
- }
9194
- findCharacterReferences(node, text, location, { isAttribute }) {
9195
- const isQuerystring = isAttribute && text.includes("?");
9196
- for (const match of this.getMatches(text)) {
9197
- this.validateCharacterReference(node, location, match, { isQuerystring });
9198
- }
9199
- }
9200
- validateCharacterReference(node, location, foobar, { isQuerystring }) {
9201
- const { requireSemicolon } = this.options;
9202
- const { match, entity, raw, terminated } = foobar;
9203
- if (isNumerical(entity)) {
9204
- return;
9205
- }
9206
- if (isQuerystring && !terminated) {
9207
- return;
9208
- }
9209
- const found = this.entities.includes(entity);
9210
- if (found && (terminated || !requireSemicolon)) {
9211
- return;
9212
- }
9213
- if (found && !terminated) {
9214
- const entityLocation2 = getLocation(location, entity, match);
9215
- const message2 = `Character reference "{{ entity }}" must be terminated by a semicolon`;
9216
- const context2 = {
9217
- entity: raw,
9218
- terminated: false
9219
- };
9220
- this.report(node, message2, entityLocation2, context2);
9221
- return;
9222
- }
9223
- const entityLocation = getLocation(location, entity, match);
9224
- const message = `Unrecognized character reference "{{ entity }}"`;
9225
- const context = {
9226
- entity: raw,
9227
- terminated: true
9362
+ get messages() {
9363
+ return {
9364
+ [1 /* EMPTY */]: "element id must not be empty",
9365
+ [2 /* WHITESPACE */]: "element id must not contain whitespace",
9366
+ [3 /* LEADING_CHARACTER */]: "element id must begin with a letter",
9367
+ [4 /* DISALLOWED_CHARACTER */]: "element id must only contain letters, digits, dash and underscore characters"
9228
9368
  };
9229
- this.report(node, message, entityLocation, context);
9230
9369
  }
9231
- *getMatches(text) {
9232
- let match;
9233
- do {
9234
- match = regexp$1.exec(text);
9235
- if (match) {
9236
- const terminator = match[1];
9237
- const terminated = terminator === ";";
9238
- const needSlice = terminator !== ";" && terminator.length > 0;
9239
- const entity = needSlice ? match[0].slice(0, -1) : match[0];
9240
- if (this.options.ignoreCase) {
9241
- yield { match, entity: entity.toLowerCase(), raw: entity, terminated };
9242
- } else {
9243
- yield { match, entity, raw: entity, terminated };
9244
- }
9370
+ isRelevant(event) {
9371
+ return event.key === "id";
9372
+ }
9373
+ }
9374
+
9375
+ class VoidContent extends Rule {
9376
+ documentation(tagName) {
9377
+ const doc = {
9378
+ description: "HTML void elements cannot have any content and must not have content or end tag.",
9379
+ url: "https://html-validate.org/rules/void-content.html"
9380
+ };
9381
+ if (tagName) {
9382
+ doc.description = `<${tagName}> is a void element and must not have content or end tag.`;
9383
+ }
9384
+ return doc;
9385
+ }
9386
+ setup() {
9387
+ this.on("tag:end", (event) => {
9388
+ const node = event.target;
9389
+ if (!node) {
9390
+ return;
9245
9391
  }
9246
- } while (match);
9392
+ if (!node.voidElement) {
9393
+ return;
9394
+ }
9395
+ if (node.closed === NodeClosed.EndTag) {
9396
+ this.report(
9397
+ null,
9398
+ `End tag for <${node.tagName}> must be omitted`,
9399
+ node.location,
9400
+ node.tagName
9401
+ );
9402
+ }
9403
+ });
9247
9404
  }
9248
9405
  }
9249
9406
 
9250
- const expectedOrder = ["section", "hint", "contact", "field1", "field2", "webauthn"];
9251
- const fieldNames1 = [
9252
- "name",
9253
- "honorific-prefix",
9254
- "given-name",
9255
- "additional-name",
9256
- "family-name",
9257
- "honorific-suffix",
9258
- "nickname",
9259
- "username",
9260
- "new-password",
9261
- "current-password",
9262
- "one-time-code",
9263
- "organization-title",
9264
- "organization",
9265
- "street-address",
9266
- "address-line1",
9267
- "address-line2",
9268
- "address-line3",
9269
- "address-level4",
9270
- "address-level3",
9271
- "address-level2",
9272
- "address-level1",
9273
- "country",
9274
- "country-name",
9275
- "postal-code",
9276
- "cc-name",
9277
- "cc-given-name",
9278
- "cc-additional-name",
9279
- "cc-family-name",
9280
- "cc-number",
9281
- "cc-exp",
9282
- "cc-exp-month",
9283
- "cc-exp-year",
9284
- "cc-csc",
9285
- "cc-type",
9286
- "transaction-currency",
9287
- "transaction-amount",
9288
- "language",
9289
- "bday",
9290
- "bday-day",
9291
- "bday-month",
9292
- "bday-year",
9293
- "sex",
9294
- "url",
9295
- "photo"
9296
- ];
9297
- const fieldNames2 = [
9298
- "tel",
9299
- "tel-country-code",
9300
- "tel-national",
9301
- "tel-area-code",
9302
- "tel-local",
9303
- "tel-local-prefix",
9304
- "tel-local-suffix",
9305
- "tel-extension",
9306
- "email",
9307
- "impp"
9308
- ];
9309
- const fieldNameGroup = {
9310
- name: "text",
9311
- "honorific-prefix": "text",
9312
- "given-name": "text",
9313
- "additional-name": "text",
9314
- "family-name": "text",
9315
- "honorific-suffix": "text",
9316
- nickname: "text",
9317
- username: "username",
9318
- "new-password": "password",
9319
- "current-password": "password",
9320
- "one-time-code": "password",
9321
- "organization-title": "text",
9322
- organization: "text",
9323
- "street-address": "multiline",
9324
- "address-line1": "text",
9325
- "address-line2": "text",
9326
- "address-line3": "text",
9327
- "address-level4": "text",
9328
- "address-level3": "text",
9329
- "address-level2": "text",
9330
- "address-level1": "text",
9331
- country: "text",
9332
- "country-name": "text",
9333
- "postal-code": "text",
9334
- "cc-name": "text",
9335
- "cc-given-name": "text",
9336
- "cc-additional-name": "text",
9337
- "cc-family-name": "text",
9338
- "cc-number": "text",
9339
- "cc-exp": "month",
9340
- "cc-exp-month": "numeric",
9341
- "cc-exp-year": "numeric",
9342
- "cc-csc": "text",
9343
- "cc-type": "text",
9344
- "transaction-currency": "text",
9345
- "transaction-amount": "numeric",
9346
- language: "text",
9347
- bday: "date",
9348
- "bday-day": "numeric",
9349
- "bday-month": "numeric",
9350
- "bday-year": "numeric",
9351
- sex: "text",
9352
- url: "url",
9353
- photo: "url",
9354
- tel: "tel",
9355
- "tel-country-code": "text",
9356
- "tel-national": "text",
9357
- "tel-area-code": "text",
9358
- "tel-local": "text",
9359
- "tel-local-prefix": "text",
9360
- "tel-local-suffix": "text",
9361
- "tel-extension": "text",
9362
- email: "username",
9363
- impp: "url"
9407
+ const defaults$2 = {
9408
+ style: "omit"
9364
9409
  };
9365
- const disallowedInputTypes = ["checkbox", "radio", "file", "submit", "image", "reset", "button"];
9366
- function matchSection(token) {
9367
- return token.startsWith("section-");
9368
- }
9369
- function matchHint(token) {
9370
- return token === "shipping" || token === "billing";
9371
- }
9372
- function matchFieldNames1(token) {
9373
- return fieldNames1.includes(token);
9374
- }
9375
- function matchContact(token) {
9376
- const haystack = ["home", "work", "mobile", "fax", "pager"];
9377
- return haystack.includes(token);
9378
- }
9379
- function matchFieldNames2(token) {
9380
- return fieldNames2.includes(token);
9381
- }
9382
- function matchWebauthn(token) {
9383
- return token === "webauthn";
9384
- }
9385
- function matchToken(token) {
9386
- if (matchSection(token)) {
9387
- return "section";
9410
+ class VoidStyle extends Rule {
9411
+ constructor(options) {
9412
+ super({ ...defaults$2, ...options });
9413
+ this.style = parseStyle(this.options.style);
9388
9414
  }
9389
- if (matchHint(token)) {
9390
- return "hint";
9415
+ static schema() {
9416
+ return {
9417
+ style: {
9418
+ enum: ["omit", "selfclose", "selfclosing"],
9419
+ type: "string"
9420
+ }
9421
+ };
9391
9422
  }
9392
- if (matchFieldNames1(token)) {
9393
- return "field1";
9423
+ documentation(context) {
9424
+ const [desc, end] = styleDescription(context.style);
9425
+ return {
9426
+ description: `The current configuration requires void elements to ${desc}, use <${context.tagName}${end}> instead.`,
9427
+ url: "https://html-validate.org/rules/void-style.html"
9428
+ };
9394
9429
  }
9395
- if (matchFieldNames2(token)) {
9396
- return "field2";
9430
+ setup() {
9431
+ this.on("tag:end", (event) => {
9432
+ const active = event.previous;
9433
+ if (active.meta) {
9434
+ this.validateActive(active);
9435
+ }
9436
+ });
9397
9437
  }
9398
- if (matchContact(token)) {
9399
- return "contact";
9438
+ validateActive(node) {
9439
+ if (!node.voidElement) {
9440
+ return;
9441
+ }
9442
+ if (this.shouldBeOmitted(node)) {
9443
+ this.reportError(
9444
+ node,
9445
+ `Expected omitted end tag <${node.tagName}> instead of self-closing element <${node.tagName}/>`
9446
+ );
9447
+ }
9448
+ if (this.shouldBeSelfClosed(node)) {
9449
+ this.reportError(
9450
+ node,
9451
+ `Expected self-closing element <${node.tagName}/> instead of omitted end-tag <${node.tagName}>`
9452
+ );
9453
+ }
9400
9454
  }
9401
- if (matchWebauthn(token)) {
9402
- return "webauthn";
9455
+ reportError(node, message) {
9456
+ const context = {
9457
+ style: this.style,
9458
+ tagName: node.tagName
9459
+ };
9460
+ super.report(node, message, null, context);
9461
+ }
9462
+ shouldBeOmitted(node) {
9463
+ return this.style === 1 /* AlwaysOmit */ && node.closed === NodeClosed.VoidSelfClosed;
9464
+ }
9465
+ shouldBeSelfClosed(node) {
9466
+ return this.style === 2 /* AlwaysSelfclose */ && node.closed === NodeClosed.VoidOmitted;
9403
9467
  }
9404
- return null;
9405
9468
  }
9406
- function getControlGroups(type) {
9407
- const allGroups = [
9408
- "text",
9409
- "multiline",
9410
- "password",
9411
- "url",
9412
- "username",
9413
- "tel",
9414
- "numeric",
9415
- "month",
9416
- "date"
9417
- ];
9418
- const mapping = {
9419
- hidden: allGroups,
9420
- text: allGroups.filter((it) => it !== "multiline"),
9421
- search: allGroups.filter((it) => it !== "multiline"),
9422
- password: ["password"],
9423
- url: ["url"],
9424
- email: ["username"],
9425
- tel: ["tel"],
9426
- number: ["numeric"],
9427
- month: ["month"],
9428
- date: ["date"]
9429
- };
9430
- const groups = mapping[type];
9431
- if (groups) {
9432
- return groups;
9469
+ function parseStyle(name) {
9470
+ switch (name) {
9471
+ case "omit":
9472
+ return 1 /* AlwaysOmit */;
9473
+ case "selfclose":
9474
+ case "selfclosing":
9475
+ return 2 /* AlwaysSelfclose */;
9476
+ default:
9477
+ throw new Error(`Invalid style "${name}" for "void-style" rule`);
9433
9478
  }
9434
- return [];
9435
9479
  }
9436
- function isDisallowedType(node, type) {
9437
- if (!node.is("input")) {
9438
- return false;
9480
+ function styleDescription(style) {
9481
+ switch (style) {
9482
+ case 1 /* AlwaysOmit */:
9483
+ return ["omit end tag", ""];
9484
+ case 2 /* AlwaysSelfclose */:
9485
+ return ["be self-closed", "/"];
9486
+ default:
9487
+ throw new Error(`Unknown style`);
9439
9488
  }
9440
- return disallowedInputTypes.includes(type);
9441
9489
  }
9442
- function getTerminalMessage(context) {
9443
- switch (context.msg) {
9444
- case 0 /* InvalidAttribute */:
9445
- return "autocomplete attribute cannot be used on {{ what }}";
9446
- case 1 /* InvalidValue */:
9447
- return '"{{ value }}" cannot be used on {{ what }}';
9448
- case 2 /* InvalidOrder */:
9449
- return '"{{ second }}" must appear before "{{ first }}"';
9450
- case 3 /* InvalidToken */:
9451
- return '"{{ token }}" is not a valid autocomplete token or field name';
9452
- case 4 /* InvalidCombination */:
9453
- return '"{{ second }}" cannot be combined with "{{ first }}"';
9454
- case 5 /* MissingField */:
9455
- return "autocomplete attribute is missing field name";
9490
+
9491
+ class H30 extends Rule {
9492
+ documentation() {
9493
+ return {
9494
+ description: "WCAG 2.1 requires each `<a href>` anchor link to have a text describing the purpose of the link using either plain text or an `<img>` with the `alt` attribute set.",
9495
+ url: "https://html-validate.org/rules/wcag/h30.html"
9496
+ };
9456
9497
  }
9457
- }
9458
- function getMarkdownMessage(context) {
9459
- switch (context.msg) {
9460
- case 0 /* InvalidAttribute */:
9461
- return [
9462
- `\`autocomplete\` attribute cannot be used on \`${context.what}\``,
9463
- "",
9464
- "The following input types cannot use the `autocomplete` attribute:",
9465
- "",
9466
- ...disallowedInputTypes.map((it) => `- \`${it}\``)
9467
- ].join("\n");
9468
- case 1 /* InvalidValue */: {
9469
- const message = `\`"${context.value}"\` cannot be used on \`${context.what}\``;
9470
- if (context.type === "form") {
9471
- return [
9472
- message,
9473
- "",
9474
- 'The `<form>` element can only use the values `"on"` and `"off"`.'
9475
- ].join("\n");
9476
- }
9477
- if (context.type === "hidden") {
9478
- return [
9479
- message,
9480
- "",
9481
- '`<input type="hidden">` cannot use the values `"on"` and `"off"`.'
9482
- ].join("\n");
9498
+ setup() {
9499
+ this.on("dom:ready", (event) => {
9500
+ const links = event.document.getElementsByTagName("a");
9501
+ for (const link of links) {
9502
+ if (!link.hasAttribute("href")) {
9503
+ continue;
9504
+ }
9505
+ if (!inAccessibilityTree(link)) {
9506
+ continue;
9507
+ }
9508
+ const textClassification = classifyNodeText(link, { ignoreHiddenRoot: true });
9509
+ if (textClassification !== TextClassification.EMPTY_TEXT) {
9510
+ continue;
9511
+ }
9512
+ const images = link.querySelectorAll("img");
9513
+ if (images.some((image) => hasAltText(image))) {
9514
+ continue;
9515
+ }
9516
+ const labels = link.querySelectorAll("[aria-label]");
9517
+ if (hasAriaLabel(link) || labels.some((cur) => hasAriaLabel(cur))) {
9518
+ continue;
9519
+ }
9520
+ this.report(link, "Anchor link must have a text describing its purpose");
9483
9521
  }
9484
- const controlGroups = getControlGroups(context.type);
9485
- const currentGroup = fieldNameGroup[context.value];
9486
- return [
9487
- message,
9488
- "",
9489
- `\`${context.what}\` allows autocomplete fields from the following group${controlGroups.length > 1 ? "s" : ""}:`,
9490
- "",
9491
- ...controlGroups.map((it) => `- ${it}`),
9492
- "",
9493
- `The field \`"${context.value}"\` belongs to the group /${currentGroup}/ which cannot be used with this input type.`
9494
- ].join("\n");
9495
- }
9496
- case 2 /* InvalidOrder */:
9497
- return [
9498
- `\`"${context.second}"\` must appear before \`"${context.first}"\``,
9499
- "",
9500
- "The autocomplete tokens must appear in the following order:",
9501
- "",
9502
- "- Optional section name (`section-` prefix).",
9503
- "- Optional `shipping` or `billing` token.",
9504
- "- Optional `home`, `work`, `mobile`, `fax` or `pager` token (for fields supporting it).",
9505
- "- Field name",
9506
- "- Optional `webauthn` token."
9507
- ].join("\n");
9508
- case 3 /* InvalidToken */:
9509
- return `\`"${context.token}"\` is not a valid autocomplete token or field name`;
9510
- case 4 /* InvalidCombination */:
9511
- return `\`"${context.second}"\` cannot be combined with \`"${context.first}"\``;
9512
- case 5 /* MissingField */:
9513
- return "Autocomplete attribute is missing field name";
9522
+ });
9514
9523
  }
9515
9524
  }
9516
- class ValidAutocomplete extends Rule {
9517
- documentation(context) {
9525
+
9526
+ class H32 extends Rule {
9527
+ documentation() {
9518
9528
  return {
9519
- description: getMarkdownMessage(context),
9520
- url: "https://html-validate.org/rules/valid-autocomplete.html"
9529
+ description: "WCAG 2.1 requires each `<form>` element to have at least one submit button.",
9530
+ url: "https://html-validate.org/rules/wcag/h32.html"
9521
9531
  };
9522
9532
  }
9523
9533
  setup() {
9534
+ const formTags = this.getTagsWithProperty("form");
9535
+ const formSelector = formTags.join(",");
9524
9536
  this.on("dom:ready", (event) => {
9525
9537
  const { document } = event;
9526
- const elements = document.querySelectorAll("[autocomplete]");
9527
- for (const element of elements) {
9528
- const autocomplete = element.getAttribute("autocomplete");
9529
- if (autocomplete.value === null || autocomplete.value instanceof DynamicValue) {
9538
+ const forms = document.querySelectorAll(formSelector);
9539
+ for (const form of forms) {
9540
+ if (hasNestedSubmit(form)) {
9530
9541
  continue;
9531
9542
  }
9532
- const location = autocomplete.valueLocation;
9533
- const value = autocomplete.value.toLowerCase();
9534
- const tokens = new DOMTokenList(value, location);
9535
- if (tokens.length === 0) {
9543
+ if (hasAssociatedSubmit(document, form)) {
9536
9544
  continue;
9537
9545
  }
9538
- this.validate(element, value, tokens, autocomplete.keyLocation, location);
9546
+ this.report(form, `<${form.tagName}> element must have a submit button`);
9539
9547
  }
9540
9548
  });
9541
9549
  }
9542
- validate(node, value, tokens, keyLocation, valueLocation) {
9543
- switch (node.tagName) {
9544
- case "form":
9545
- this.validateFormAutocomplete(node, value, valueLocation);
9546
- break;
9547
- case "input":
9548
- case "textarea":
9549
- case "select":
9550
- this.validateControlAutocomplete(node, tokens, keyLocation);
9551
- break;
9552
- }
9553
- }
9554
- validateControlAutocomplete(node, tokens, keyLocation) {
9555
- const type = node.getAttributeValue("type") ?? "text";
9556
- const mantle = type !== "hidden" ? "expectation" : "anchor";
9557
- if (isDisallowedType(node, type)) {
9558
- const context = {
9559
- msg: 0 /* InvalidAttribute */,
9560
- what: `<input type="${type}">`
9561
- };
9562
- this.report({
9563
- node,
9564
- message: getTerminalMessage(context),
9565
- location: keyLocation,
9566
- context
9567
- });
9568
- return;
9569
- }
9570
- if (tokens.includes("on") || tokens.includes("off")) {
9571
- this.validateOnOff(node, mantle, tokens);
9572
- return;
9573
- }
9574
- this.validateTokens(node, tokens, keyLocation);
9550
+ }
9551
+ function isSubmit(node) {
9552
+ const type = node.getAttribute("type");
9553
+ return Boolean(!type || type.valueMatches(/submit|image/));
9554
+ }
9555
+ function isAssociated(id, node) {
9556
+ const form = node.getAttribute("form");
9557
+ return Boolean(form == null ? void 0 : form.valueMatches(id, true));
9558
+ }
9559
+ function hasNestedSubmit(form) {
9560
+ const matches = form.querySelectorAll("button,input").filter(isSubmit).filter((node) => !node.hasAttribute("form"));
9561
+ return matches.length > 0;
9562
+ }
9563
+ function hasAssociatedSubmit(document, form) {
9564
+ const { id } = form;
9565
+ if (!id) {
9566
+ return false;
9575
9567
  }
9576
- validateFormAutocomplete(node, value, location) {
9577
- const trimmed = value.trim();
9578
- if (["on", "off"].includes(trimmed)) {
9579
- return;
9580
- }
9581
- const context = {
9582
- msg: 1 /* InvalidValue */,
9583
- type: "form",
9584
- value: trimmed,
9585
- what: "<form>"
9568
+ const matches = document.querySelectorAll("button[form],input[form]").filter(isSubmit).filter((node) => isAssociated(id, node));
9569
+ return matches.length > 0;
9570
+ }
9571
+
9572
+ class H36 extends Rule {
9573
+ documentation() {
9574
+ return {
9575
+ description: [
9576
+ "WCAG 2.1 requires all images used as submit buttons to have a non-empty textual description using the `alt` attribute.",
9577
+ 'The alt text cannot be empty (`alt=""`).'
9578
+ ].join("\n"),
9579
+ url: "https://html-validate.org/rules/wcag/h36.html"
9586
9580
  };
9587
- this.report({
9588
- node,
9589
- message: getTerminalMessage(context),
9590
- location,
9591
- context
9592
- });
9593
9581
  }
9594
- validateOnOff(node, mantle, tokens) {
9595
- const index = tokens.findIndex((it) => it === "on" || it === "off");
9596
- const value = tokens.item(index);
9597
- const location = tokens.location(index);
9598
- if (tokens.length > 1) {
9599
- const context = {
9600
- msg: 4 /* InvalidCombination */,
9601
- /* eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- it must be present of it wouldn't be found */
9602
- first: tokens.item(index > 0 ? 0 : 1),
9603
- second: value
9604
- };
9605
- this.report({
9606
- node,
9607
- message: getTerminalMessage(context),
9608
- location,
9609
- context
9610
- });
9611
- }
9612
- switch (mantle) {
9613
- case "expectation":
9614
- return;
9615
- case "anchor": {
9616
- const context = {
9617
- msg: 1 /* InvalidValue */,
9618
- type: "hidden",
9619
- value,
9620
- what: `<input type="hidden">`
9621
- };
9622
- this.report({
9623
- node,
9624
- message: getTerminalMessage(context),
9625
- location: tokens.location(0),
9626
- context
9627
- });
9582
+ setup() {
9583
+ this.on("tag:end", (event) => {
9584
+ const node = event.previous;
9585
+ if (node.tagName !== "input")
9586
+ return;
9587
+ if (node.getAttributeValue("type") !== "image") {
9588
+ return;
9628
9589
  }
9629
- }
9630
- }
9631
- validateTokens(node, tokens, keyLocation) {
9632
- const order = [];
9633
- for (const { item, location } of tokens.iterator()) {
9634
- const tokenType = matchToken(item);
9635
- if (tokenType) {
9636
- order.push(tokenType);
9637
- } else {
9638
- const context = {
9639
- msg: 3 /* InvalidToken */,
9640
- token: item
9641
- };
9642
- this.report({
9643
- node,
9644
- message: getTerminalMessage(context),
9645
- location,
9646
- context
9647
- });
9590
+ if (!inAccessibilityTree(node)) {
9648
9591
  return;
9649
9592
  }
9650
- }
9651
- const fieldTokens = order.map((it) => it === "field1" || it === "field2");
9652
- this.validateFieldPresence(node, tokens, fieldTokens, keyLocation);
9653
- this.validateContact(node, tokens, order);
9654
- this.validateOrder(node, tokens, order);
9655
- this.validateControlGroup(node, tokens, fieldTokens);
9656
- }
9657
- /**
9658
- * Ensure that exactly one field name is present from the two field lists.
9659
- */
9660
- validateFieldPresence(node, tokens, fieldTokens, keyLocation) {
9661
- const numFields = fieldTokens.filter(Boolean).length;
9662
- if (numFields === 0) {
9663
- const context = {
9664
- msg: 5 /* MissingField */
9665
- };
9666
- this.report({
9667
- node,
9668
- message: getTerminalMessage(context),
9669
- location: keyLocation,
9670
- context
9671
- });
9672
- } else if (numFields > 1) {
9673
- const a = fieldTokens.indexOf(true);
9674
- const b = fieldTokens.lastIndexOf(true);
9675
- const context = {
9676
- msg: 4 /* InvalidCombination */,
9677
- /* eslint-disable @typescript-eslint/no-non-null-assertion -- it must be present of it wouldn't be found */
9678
- first: tokens.item(a),
9679
- second: tokens.item(b)
9680
- /* eslint-enable @typescript-eslint/no-non-null-assertion */
9681
- };
9682
- this.report({
9683
- node,
9684
- message: getTerminalMessage(context),
9685
- location: tokens.location(b),
9686
- context
9687
- });
9688
- }
9689
- }
9690
- /**
9691
- * Ensure contact token is only used with field names from the second list.
9692
- */
9693
- validateContact(node, tokens, order) {
9694
- if (order.includes("contact") && order.includes("field1")) {
9695
- const a = order.indexOf("field1");
9696
- const b = order.indexOf("contact");
9697
- const context = {
9698
- msg: 4 /* InvalidCombination */,
9699
- /* eslint-disable @typescript-eslint/no-non-null-assertion -- it must be present of it wouldn't be found */
9700
- first: tokens.item(a),
9701
- second: tokens.item(b)
9702
- /* eslint-enable @typescript-eslint/no-non-null-assertion */
9703
- };
9704
- this.report({
9705
- node,
9706
- message: getTerminalMessage(context),
9707
- location: tokens.location(b),
9708
- context
9709
- });
9710
- }
9711
- }
9712
- validateOrder(node, tokens, order) {
9713
- const indicies = order.map((it) => expectedOrder.indexOf(it));
9714
- for (let i = 0; i < indicies.length - 1; i++) {
9715
- if (indicies[0] > indicies[i + 1]) {
9716
- const context = {
9717
- msg: 2 /* InvalidOrder */,
9718
- /* eslint-disable @typescript-eslint/no-non-null-assertion -- it must be present of it wouldn't be found */
9719
- first: tokens.item(i),
9720
- second: tokens.item(i + 1)
9721
- /* eslint-enable @typescript-eslint/no-non-null-assertion */
9722
- };
9593
+ if (!hasAltText(node)) {
9594
+ const message = "image used as submit button must have non-empty alt text";
9595
+ const alt = node.getAttribute("alt");
9723
9596
  this.report({
9724
9597
  node,
9725
- message: getTerminalMessage(context),
9726
- location: tokens.location(i + 1),
9727
- context
9598
+ message,
9599
+ location: alt ? alt.keyLocation : node.location
9728
9600
  });
9729
9601
  }
9730
- }
9731
- }
9732
- validateControlGroup(node, tokens, fieldTokens) {
9733
- const numFields = fieldTokens.filter(Boolean).length;
9734
- if (numFields === 0) {
9735
- return;
9736
- }
9737
- if (!node.is("input")) {
9738
- return;
9739
- }
9740
- const attr = node.getAttribute("type");
9741
- const type = (attr == null ? void 0 : attr.value) ?? "text";
9742
- if (type instanceof DynamicValue) {
9743
- return;
9744
- }
9745
- const controlGroups = getControlGroups(type);
9746
- const fieldIndex = fieldTokens.indexOf(true);
9747
- const fieldToken = tokens.item(fieldIndex);
9748
- const fieldGroup = fieldNameGroup[fieldToken];
9749
- if (!controlGroups.includes(fieldGroup)) {
9750
- const context = {
9751
- msg: 1 /* InvalidValue */,
9752
- type,
9753
- value: fieldToken,
9754
- what: `<input type="${type}">`
9755
- };
9756
- this.report({
9757
- node,
9758
- message: getTerminalMessage(context),
9759
- location: tokens.location(fieldIndex),
9760
- context
9761
- });
9762
- }
9602
+ });
9763
9603
  }
9764
9604
  }
9765
9605
 
9766
- const defaults$3 = {
9767
- relaxed: false
9606
+ const defaults$1 = {
9607
+ allowEmpty: true,
9608
+ alias: []
9768
9609
  };
9769
- class ValidID extends Rule {
9610
+ class H37 extends Rule {
9770
9611
  constructor(options) {
9771
- super({ ...defaults$3, ...options });
9612
+ super({ ...defaults$1, ...options });
9613
+ if (!Array.isArray(this.options.alias)) {
9614
+ this.options.alias = [this.options.alias];
9615
+ }
9772
9616
  }
9773
9617
  static schema() {
9774
9618
  return {
9775
- relaxed: {
9619
+ alias: {
9620
+ anyOf: [
9621
+ {
9622
+ items: {
9623
+ type: "string"
9624
+ },
9625
+ type: "array"
9626
+ },
9627
+ {
9628
+ type: "string"
9629
+ }
9630
+ ]
9631
+ },
9632
+ allowEmpty: {
9776
9633
  type: "boolean"
9777
9634
  }
9778
9635
  };
9779
9636
  }
9780
- documentation(context) {
9781
- const { relaxed } = this.options;
9782
- const message = this.messages[context].replace("id", "ID").replace(/^(.)/, (m) => m.toUpperCase());
9783
- const relaxedDescription = relaxed ? [] : [
9784
- " - ID must begin with a letter",
9785
- " - ID must only contain letters, digits, `-` and `_`"
9786
- ];
9637
+ documentation() {
9787
9638
  return {
9788
- description: [
9789
- `${message}.`,
9790
- "",
9791
- "Under the current configuration the following rules are applied:",
9792
- "",
9793
- " - ID must not be empty",
9794
- " - ID must not contain any whitespace characters",
9795
- ...relaxedDescription
9796
- ].join("\n"),
9797
- url: "https://html-validate.org/rules/valid-id.html"
9639
+ description: "Both HTML5 and WCAG 2.0 requires images to have a alternative text for each image.",
9640
+ url: "https://html-validate.org/rules/wcag/h37.html"
9798
9641
  };
9799
9642
  }
9800
9643
  setup() {
9801
- this.on("attr", this.isRelevant, (event) => {
9802
- const { value } = event;
9803
- if (value === null || value instanceof DynamicValue) {
9804
- return;
9805
- }
9806
- if (value === "") {
9807
- const context = 1 /* EMPTY */;
9808
- this.report(event.target, this.messages[context], event.location, context);
9809
- return;
9810
- }
9811
- if (value.match(/\s/)) {
9812
- const context = 2 /* WHITESPACE */;
9813
- this.report(event.target, this.messages[context], event.valueLocation, context);
9814
- return;
9815
- }
9816
- const { relaxed } = this.options;
9817
- if (relaxed) {
9818
- return;
9819
- }
9820
- if (value.match(/^[^\p{L}]/u)) {
9821
- const context = 3 /* LEADING_CHARACTER */;
9822
- this.report(event.target, this.messages[context], event.valueLocation, context);
9823
- return;
9824
- }
9825
- if (value.match(/[^\p{L}\p{N}_-]/u)) {
9826
- const context = 4 /* DISALLOWED_CHARACTER */;
9827
- this.report(event.target, this.messages[context], event.valueLocation, context);
9644
+ this.on("dom:ready", (event) => {
9645
+ const { document } = event;
9646
+ const nodes = document.querySelectorAll("img");
9647
+ for (const node of nodes) {
9648
+ this.validateNode(node);
9828
9649
  }
9829
9650
  });
9830
9651
  }
9831
- get messages() {
9832
- return {
9833
- [1 /* EMPTY */]: "element id must not be empty",
9834
- [2 /* WHITESPACE */]: "element id must not contain whitespace",
9835
- [3 /* LEADING_CHARACTER */]: "element id must begin with a letter",
9836
- [4 /* DISALLOWED_CHARACTER */]: "element id must only contain letters, digits, dash and underscore characters"
9837
- };
9838
- }
9839
- isRelevant(event) {
9840
- return event.key === "id";
9652
+ validateNode(node) {
9653
+ if (!inAccessibilityTree(node)) {
9654
+ return;
9655
+ }
9656
+ if (Boolean(node.getAttributeValue("alt")) || Boolean(node.hasAttribute("alt") && this.options.allowEmpty)) {
9657
+ return;
9658
+ }
9659
+ for (const attr of this.options.alias) {
9660
+ if (node.getAttribute(attr)) {
9661
+ return;
9662
+ }
9663
+ }
9664
+ const tag = node.annotatedName;
9665
+ if (node.hasAttribute("alt")) {
9666
+ const attr = node.getAttribute("alt");
9667
+ this.report(node, `${tag} cannot have empty "alt" attribute`, attr.keyLocation);
9668
+ } else {
9669
+ this.report(node, `${tag} is missing required "alt" attribute`, node.location);
9670
+ }
9841
9671
  }
9842
9672
  }
9843
9673
 
9844
- class VoidContent extends Rule {
9845
- documentation(tagName) {
9846
- const doc = {
9847
- description: "HTML void elements cannot have any content and must not have content or end tag.",
9848
- url: "https://html-validate.org/rules/void-content.html"
9674
+ var _a;
9675
+ const { enum: validScopes } = (_a = elements.html5.th.attributes) == null ? void 0 : _a.scope;
9676
+ const joinedScopes = utils_naturalJoin.naturalJoin(validScopes);
9677
+ class H63 extends Rule {
9678
+ documentation() {
9679
+ return {
9680
+ description: "H63: Using the scope attribute to associate header cells and data cells in data tables",
9681
+ url: "https://html-validate.org/rules/wcag/h63.html"
9849
9682
  };
9850
- if (tagName) {
9851
- doc.description = `<${tagName}> is a void element and must not have content or end tag.`;
9852
- }
9853
- return doc;
9854
9683
  }
9855
9684
  setup() {
9856
- this.on("tag:end", (event) => {
9685
+ this.on("tag:ready", (event) => {
9857
9686
  const node = event.target;
9858
- if (!node) {
9687
+ if (node.tagName !== "th") {
9859
9688
  return;
9860
9689
  }
9861
- if (!node.voidElement) {
9690
+ const scope = node.getAttribute("scope");
9691
+ const value = scope == null ? void 0 : scope.value;
9692
+ if (value instanceof DynamicValue) {
9862
9693
  return;
9863
9694
  }
9864
- if (node.closed === NodeClosed.EndTag) {
9865
- this.report(
9866
- null,
9867
- `End tag for <${node.tagName}> must be omitted`,
9868
- node.location,
9869
- node.tagName
9870
- );
9695
+ if (value && validScopes.includes(value)) {
9696
+ return;
9871
9697
  }
9698
+ const message = `<th> element must have a valid scope attribute: ${joinedScopes}`;
9699
+ const location = (scope == null ? void 0 : scope.valueLocation) ?? (scope == null ? void 0 : scope.keyLocation) ?? node.location;
9700
+ this.report(node, message, location);
9872
9701
  });
9873
9702
  }
9874
9703
  }
9875
9704
 
9876
- const defaults$2 = {
9877
- style: "omit"
9878
- };
9879
- class VoidStyle extends Rule {
9880
- constructor(options) {
9881
- super({ ...defaults$2, ...options });
9882
- this.style = parseStyle(this.options.style);
9883
- }
9884
- static schema() {
9885
- return {
9886
- style: {
9887
- enum: ["omit", "selfclose", "selfclosing"],
9888
- type: "string"
9889
- }
9890
- };
9891
- }
9892
- documentation(context) {
9893
- const [desc, end] = styleDescription(context.style);
9705
+ class H67 extends Rule {
9706
+ documentation() {
9894
9707
  return {
9895
- description: `The current configuration requires void elements to ${desc}, use <${context.tagName}${end}> instead.`,
9896
- url: "https://html-validate.org/rules/void-style.html"
9708
+ description: "A decorative image cannot have a title attribute. Either remove `title` or add a descriptive `alt` text.",
9709
+ url: "https://html-validate.org/rules/wcag/h67.html"
9897
9710
  };
9898
9711
  }
9899
9712
  setup() {
9900
9713
  this.on("tag:end", (event) => {
9901
- const active = event.previous;
9902
- if (active.meta) {
9903
- this.validateActive(active);
9714
+ const node = event.target;
9715
+ if (!node || node.tagName !== "img") {
9716
+ return;
9717
+ }
9718
+ const title = node.getAttribute("title");
9719
+ if (!title || title.value === "") {
9720
+ return;
9721
+ }
9722
+ const alt = node.getAttributeValue("alt");
9723
+ if (alt && alt !== "") {
9724
+ return;
9904
9725
  }
9726
+ this.report(node, "<img> with empty alt text cannot have title attribute", title.keyLocation);
9905
9727
  });
9906
9728
  }
9907
- validateActive(node) {
9908
- if (!node.voidElement) {
9909
- return;
9910
- }
9911
- if (this.shouldBeOmitted(node)) {
9912
- this.reportError(
9913
- node,
9914
- `Expected omitted end tag <${node.tagName}> instead of self-closing element <${node.tagName}/>`
9915
- );
9916
- }
9917
- if (this.shouldBeSelfClosed(node)) {
9918
- this.reportError(
9919
- node,
9920
- `Expected self-closing element <${node.tagName}/> instead of omitted end-tag <${node.tagName}>`
9921
- );
9922
- }
9923
- }
9924
- reportError(node, message) {
9925
- const context = {
9926
- style: this.style,
9927
- tagName: node.tagName
9928
- };
9929
- super.report(node, message, null, context);
9930
- }
9931
- shouldBeOmitted(node) {
9932
- return this.style === 1 /* AlwaysOmit */ && node.closed === NodeClosed.VoidSelfClosed;
9933
- }
9934
- shouldBeSelfClosed(node) {
9935
- return this.style === 2 /* AlwaysSelfclose */ && node.closed === NodeClosed.VoidOmitted;
9936
- }
9937
- }
9938
- function parseStyle(name) {
9939
- switch (name) {
9940
- case "omit":
9941
- return 1 /* AlwaysOmit */;
9942
- case "selfclose":
9943
- case "selfclosing":
9944
- return 2 /* AlwaysSelfclose */;
9945
- default:
9946
- throw new Error(`Invalid style "${name}" for "void-style" rule`);
9947
- }
9948
- }
9949
- function styleDescription(style) {
9950
- switch (style) {
9951
- case 1 /* AlwaysOmit */:
9952
- return ["omit end tag", ""];
9953
- case 2 /* AlwaysSelfclose */:
9954
- return ["be self-closed", "/"];
9955
- default:
9956
- throw new Error(`Unknown style`);
9957
- }
9958
9729
  }
9959
9730
 
9960
- class H30 extends Rule {
9731
+ class H71 extends Rule {
9961
9732
  documentation() {
9962
9733
  return {
9963
- description: "WCAG 2.1 requires each `<a href>` anchor link to have a text describing the purpose of the link using either plain text or an `<img>` with the `alt` attribute set.",
9964
- url: "https://html-validate.org/rules/wcag/h30.html"
9734
+ description: "H71: Providing a description for groups of form controls using fieldset and legend elements",
9735
+ url: "https://html-validate.org/rules/wcag/h71.html"
9965
9736
  };
9966
9737
  }
9967
9738
  setup() {
9968
9739
  this.on("dom:ready", (event) => {
9969
- const links = event.document.getElementsByTagName("a");
9970
- for (const link of links) {
9971
- if (!link.hasAttribute("href")) {
9972
- continue;
9973
- }
9974
- if (!inAccessibilityTree(link)) {
9975
- continue;
9976
- }
9977
- const textClassification = classifyNodeText(link, { ignoreHiddenRoot: true });
9978
- if (textClassification !== TextClassification.EMPTY_TEXT) {
9979
- continue;
9980
- }
9981
- const images = link.querySelectorAll("img");
9982
- if (images.some((image) => hasAltText(image))) {
9983
- continue;
9984
- }
9985
- const labels = link.querySelectorAll("[aria-label]");
9986
- if (hasAriaLabel(link) || labels.some((cur) => hasAriaLabel(cur))) {
9987
- continue;
9988
- }
9989
- this.report(link, "Anchor link must have a text describing its purpose");
9740
+ const { document } = event;
9741
+ const fieldsets = document.querySelectorAll(this.selector);
9742
+ for (const fieldset of fieldsets) {
9743
+ this.validate(fieldset);
9990
9744
  }
9991
9745
  });
9992
9746
  }
9747
+ validate(fieldset) {
9748
+ const legend = fieldset.querySelectorAll("> legend");
9749
+ if (legend.length === 0) {
9750
+ this.reportNode(fieldset);
9751
+ }
9752
+ }
9753
+ reportNode(node) {
9754
+ super.report(node, `${node.annotatedName} must have a <legend> as the first child`);
9755
+ }
9756
+ get selector() {
9757
+ return this.getTagsDerivedFrom("fieldset").join(",");
9758
+ }
9759
+ }
9760
+
9761
+ const bundledRules$1 = {
9762
+ "wcag/h30": H30,
9763
+ "wcag/h32": H32,
9764
+ "wcag/h36": H36,
9765
+ "wcag/h37": H37,
9766
+ "wcag/h63": H63,
9767
+ "wcag/h67": H67,
9768
+ "wcag/h71": H71
9769
+ };
9770
+
9771
+ const bundledRules = {
9772
+ "allowed-links": AllowedLinks,
9773
+ "area-alt": AreaAlt,
9774
+ "aria-hidden-body": AriaHiddenBody,
9775
+ "aria-label-misuse": AriaLabelMisuse,
9776
+ "attr-case": AttrCase,
9777
+ "attr-delimiter": AttrDelimiter,
9778
+ "attr-pattern": AttrPattern,
9779
+ "attr-quotes": AttrQuotes,
9780
+ "attr-spacing": AttrSpacing,
9781
+ "attribute-allowed-values": AttributeAllowedValues,
9782
+ "attribute-boolean-style": AttributeBooleanStyle,
9783
+ "attribute-empty-style": AttributeEmptyStyle,
9784
+ "attribute-misuse": AttributeMisuse,
9785
+ "class-pattern": ClassPattern,
9786
+ "close-attr": CloseAttr,
9787
+ "close-order": CloseOrder,
9788
+ deprecated: Deprecated,
9789
+ "deprecated-rule": DeprecatedRule,
9790
+ "doctype-html": NoStyleTag$1,
9791
+ "doctype-style": DoctypeStyle,
9792
+ "element-case": ElementCase,
9793
+ "element-name": ElementName,
9794
+ "element-permitted-content": ElementPermittedContent,
9795
+ "element-permitted-occurrences": ElementPermittedOccurrences,
9796
+ "element-permitted-order": ElementPermittedOrder,
9797
+ "element-permitted-parent": ElementPermittedParent,
9798
+ "element-required-ancestor": ElementRequiredAncestor,
9799
+ "element-required-attributes": ElementRequiredAttributes,
9800
+ "element-required-content": ElementRequiredContent,
9801
+ "empty-heading": EmptyHeading,
9802
+ "empty-title": EmptyTitle,
9803
+ "form-dup-name": FormDupName,
9804
+ "heading-level": HeadingLevel,
9805
+ "hidden-focusable": HiddenFocusable,
9806
+ "id-pattern": IdPattern,
9807
+ "input-attributes": InputAttributes,
9808
+ "input-missing-label": InputMissingLabel,
9809
+ "long-title": LongTitle,
9810
+ "map-dup-name": MapDupName,
9811
+ "map-id-name": MapIdName,
9812
+ "meta-refresh": MetaRefresh,
9813
+ "missing-doctype": MissingDoctype,
9814
+ "multiple-labeled-controls": MultipleLabeledControls,
9815
+ "name-pattern": NamePattern,
9816
+ "no-abstract-role": NoAbstractRole,
9817
+ "no-autoplay": NoAutoplay,
9818
+ "no-conditional-comment": NoConditionalComment,
9819
+ "no-deprecated-attr": NoDeprecatedAttr,
9820
+ "no-dup-attr": NoDupAttr,
9821
+ "no-dup-class": NoDupClass,
9822
+ "no-dup-id": NoDupID,
9823
+ "no-implicit-button-type": NoImplicitButtonType,
9824
+ "no-implicit-input-type": NoImplicitInputType,
9825
+ "no-implicit-close": NoImplicitClose,
9826
+ "no-inline-style": NoInlineStyle,
9827
+ "no-missing-references": NoMissingReferences,
9828
+ "no-multiple-main": NoMultipleMain,
9829
+ "no-raw-characters": NoRawCharacters,
9830
+ "no-redundant-aria-label": NoRedundantAriaLabel,
9831
+ "no-redundant-for": NoRedundantFor,
9832
+ "no-redundant-role": NoRedundantRole,
9833
+ "no-self-closing": NoSelfClosing,
9834
+ "no-style-tag": NoStyleTag,
9835
+ "no-trailing-whitespace": NoTrailingWhitespace,
9836
+ "no-unknown-elements": NoUnknownElements,
9837
+ "no-unused-disable": NoUnusedDisable,
9838
+ "no-utf8-bom": NoUtf8Bom,
9839
+ "prefer-button": PreferButton,
9840
+ "prefer-native-element": PreferNativeElement,
9841
+ "prefer-tbody": PreferTbody,
9842
+ "require-csp-nonce": RequireCSPNonce,
9843
+ "require-sri": RequireSri,
9844
+ "script-element": ScriptElement,
9845
+ "script-type": ScriptType,
9846
+ "svg-focusable": SvgFocusable,
9847
+ "tel-non-breaking": TelNonBreaking,
9848
+ "text-content": TextContent,
9849
+ "unique-landmark": UniqueLandmark,
9850
+ "unrecognized-char-ref": UnknownCharReference,
9851
+ "valid-autocomplete": ValidAutocomplete,
9852
+ "valid-id": ValidID,
9853
+ "void-content": VoidContent,
9854
+ "void-style": VoidStyle,
9855
+ ...bundledRules$1
9856
+ };
9857
+
9858
+ const ruleIds = new Set(Object.keys(bundledRules));
9859
+ function ruleExists(ruleId) {
9860
+ return ruleIds.has(ruleId);
9993
9861
  }
9994
9862
 
9995
- class H32 extends Rule {
9996
- documentation() {
9997
- return {
9998
- description: "WCAG 2.1 requires each `<form>` element to have at least one submit button.",
9999
- url: "https://html-validate.org/rules/wcag/h32.html"
10000
- };
10001
- }
10002
- setup() {
10003
- const formTags = this.getTagsWithProperty("form");
10004
- const formSelector = formTags.join(",");
10005
- this.on("dom:ready", (event) => {
10006
- const { document } = event;
10007
- const forms = document.querySelectorAll(formSelector);
10008
- for (const form of forms) {
10009
- if (hasNestedSubmit(form)) {
10010
- continue;
10011
- }
10012
- if (hasAssociatedSubmit(document, form)) {
10013
- continue;
10014
- }
10015
- this.report(form, `<${form.tagName}> element must have a submit button`);
10016
- }
10017
- });
9863
+ function depthFirst(root, callback) {
9864
+ if (root instanceof DOMTree) {
9865
+ if (root.readyState !== "complete") {
9866
+ throw new Error(`Cannot call walk.depthFirst(..) before document is ready`);
9867
+ }
9868
+ root = root.root;
10018
9869
  }
10019
- }
10020
- function isSubmit(node) {
10021
- const type = node.getAttribute("type");
10022
- return Boolean(!type || type.valueMatches(/submit|image/));
10023
- }
10024
- function isAssociated(id, node) {
10025
- const form = node.getAttribute("form");
10026
- return Boolean(form == null ? void 0 : form.valueMatches(id, true));
10027
- }
10028
- function hasNestedSubmit(form) {
10029
- const matches = form.querySelectorAll("button,input").filter(isSubmit).filter((node) => !node.hasAttribute("form"));
10030
- return matches.length > 0;
10031
- }
10032
- function hasAssociatedSubmit(document, form) {
10033
- const { id } = form;
10034
- if (!id) {
10035
- return false;
9870
+ function visit(node) {
9871
+ node.childElements.forEach(visit);
9872
+ if (!node.isRootElement()) {
9873
+ callback(node);
9874
+ }
10036
9875
  }
10037
- const matches = document.querySelectorAll("button[form],input[form]").filter(isSubmit).filter((node) => isAssociated(id, node));
10038
- return matches.length > 0;
9876
+ visit(root);
10039
9877
  }
9878
+ const walk = {
9879
+ depthFirst
9880
+ };
10040
9881
 
10041
- class H36 extends Rule {
10042
- documentation() {
10043
- return {
10044
- description: [
10045
- "WCAG 2.1 requires all images used as submit buttons to have a non-empty textual description using the `alt` attribute.",
10046
- 'The alt text cannot be empty (`alt=""`).'
10047
- ].join("\n"),
10048
- url: "https://html-validate.org/rules/wcag/h36.html"
10049
- };
9882
+ class DOMTree {
9883
+ /**
9884
+ * @internal
9885
+ */
9886
+ constructor(location) {
9887
+ this.root = HtmlElement.rootNode(location);
9888
+ this.active = this.root;
9889
+ this.doctype = null;
9890
+ this._readyState = "loading";
10050
9891
  }
10051
- setup() {
10052
- this.on("tag:end", (event) => {
10053
- const node = event.previous;
10054
- if (node.tagName !== "input")
10055
- return;
10056
- if (node.getAttributeValue("type") !== "image") {
10057
- return;
10058
- }
10059
- if (!inAccessibilityTree(node)) {
10060
- return;
10061
- }
10062
- if (!hasAltText(node)) {
10063
- const message = "image used as submit button must have non-empty alt text";
10064
- const alt = node.getAttribute("alt");
10065
- this.report({
10066
- node,
10067
- message,
10068
- location: alt ? alt.keyLocation : node.location
10069
- });
10070
- }
9892
+ /**
9893
+ * @internal
9894
+ */
9895
+ pushActive(node) {
9896
+ this.active = node;
9897
+ }
9898
+ /**
9899
+ * @internal
9900
+ */
9901
+ popActive() {
9902
+ if (this.active.isRootElement()) {
9903
+ return;
9904
+ }
9905
+ this.active = this.active.parent ?? this.root;
9906
+ }
9907
+ /**
9908
+ * @internal
9909
+ */
9910
+ getActive() {
9911
+ return this.active;
9912
+ }
9913
+ /**
9914
+ * Describes the loading state of the document.
9915
+ *
9916
+ * When `"loading"` it is still not safe to use functions such as
9917
+ * `querySelector` or presence of attributes, child nodes, etc.
9918
+ */
9919
+ get readyState() {
9920
+ return this._readyState;
9921
+ }
9922
+ /**
9923
+ * Resolve dynamic meta expressions.
9924
+ *
9925
+ * @internal
9926
+ */
9927
+ resolveMeta(table) {
9928
+ this._readyState = "complete";
9929
+ walk.depthFirst(this, (node) => {
9930
+ table.resolve(node);
10071
9931
  });
10072
9932
  }
9933
+ getElementsByTagName(tagName) {
9934
+ return this.root.getElementsByTagName(tagName);
9935
+ }
9936
+ /**
9937
+ * @deprecated use utility function `walk.depthFirst(..)` instead (since 8.21.0).
9938
+ */
9939
+ visitDepthFirst(callback) {
9940
+ walk.depthFirst(this, callback);
9941
+ }
9942
+ /**
9943
+ * @deprecated use `querySelector(..)` instead (since 8.21.0)
9944
+ */
9945
+ find(callback) {
9946
+ return this.root.find(callback);
9947
+ }
9948
+ querySelector(selector) {
9949
+ return this.root.querySelector(selector);
9950
+ }
9951
+ querySelectorAll(selector) {
9952
+ return this.root.querySelectorAll(selector);
9953
+ }
10073
9954
  }
10074
9955
 
10075
- const defaults$1 = {
10076
- allowEmpty: true,
10077
- alias: []
10078
- };
10079
- class H37 extends Rule {
10080
- constructor(options) {
10081
- super({ ...defaults$1, ...options });
10082
- if (!Array.isArray(this.options.alias)) {
10083
- this.options.alias = [this.options.alias];
9956
+ const allowedKeys = ["exclude"];
9957
+ class Validator {
9958
+ /**
9959
+ * Test if element is used in a proper context.
9960
+ *
9961
+ * @param node - Element to test.
9962
+ * @param rules - List of rules.
9963
+ * @returns `true` if element passes all tests.
9964
+ */
9965
+ static validatePermitted(node, rules) {
9966
+ if (!rules) {
9967
+ return true;
10084
9968
  }
9969
+ return rules.some((rule) => {
9970
+ return Validator.validatePermittedRule(node, rule);
9971
+ });
10085
9972
  }
10086
- static schema() {
10087
- return {
10088
- alias: {
10089
- anyOf: [
10090
- {
10091
- items: {
10092
- type: "string"
10093
- },
10094
- type: "array"
10095
- },
10096
- {
10097
- type: "string"
9973
+ /**
9974
+ * Test if an element is used the correct amount of times.
9975
+ *
9976
+ * For instance, a `<table>` element can only contain a single `<tbody>`
9977
+ * child. If multiple `<tbody>` exists this test will fail both nodes.
9978
+ * Note that this is called on the parent but will fail the children violating
9979
+ * the rule.
9980
+ *
9981
+ * @param children - Array of children to validate.
9982
+ * @param rules - List of rules of the parent element.
9983
+ * @returns `true` if the parent element of the children passes the test.
9984
+ */
9985
+ static validateOccurrences(children, rules, cb) {
9986
+ if (!rules) {
9987
+ return true;
9988
+ }
9989
+ let valid = true;
9990
+ for (const rule of rules) {
9991
+ if (typeof rule !== "string") {
9992
+ return false;
9993
+ }
9994
+ const [, category, quantifier] = rule.match(/^(@?.*?)([?*]?)$/);
9995
+ const limit = category && quantifier && parseQuantifier(quantifier);
9996
+ if (limit) {
9997
+ const siblings = children.filter(
9998
+ (cur) => Validator.validatePermittedCategory(cur, rule, true)
9999
+ );
10000
+ if (siblings.length > limit) {
10001
+ for (const child of siblings.slice(limit)) {
10002
+ cb(child, category);
10098
10003
  }
10099
- ]
10100
- },
10101
- allowEmpty: {
10102
- type: "boolean"
10004
+ valid = false;
10005
+ }
10103
10006
  }
10104
- };
10105
- }
10106
- documentation() {
10107
- return {
10108
- description: "Both HTML5 and WCAG 2.0 requires images to have a alternative text for each image.",
10109
- url: "https://html-validate.org/rules/wcag/h37.html"
10110
- };
10007
+ }
10008
+ return valid;
10111
10009
  }
10112
- setup() {
10113
- this.on("dom:ready", (event) => {
10114
- const { document } = event;
10115
- const nodes = document.querySelectorAll("img");
10116
- for (const node of nodes) {
10117
- this.validateNode(node);
10010
+ /**
10011
+ * Validate elements order.
10012
+ *
10013
+ * Given a parent element with children and metadata containing permitted
10014
+ * order it will validate each children and ensure each one exists in the
10015
+ * specified order.
10016
+ *
10017
+ * For instance, for a `<table>` element the `<caption>` element must come
10018
+ * before a `<thead>` which must come before `<tbody>`.
10019
+ *
10020
+ * @param children - Array of children to validate.
10021
+ */
10022
+ static validateOrder(children, rules, cb) {
10023
+ if (!rules) {
10024
+ return true;
10025
+ }
10026
+ let i = 0;
10027
+ let prev = null;
10028
+ for (const node of children) {
10029
+ const old = i;
10030
+ while (rules[i] && !Validator.validatePermittedCategory(node, rules[i], true)) {
10031
+ i++;
10032
+ }
10033
+ if (i >= rules.length) {
10034
+ const orderSpecified = rules.find(
10035
+ (cur) => Validator.validatePermittedCategory(node, cur, true)
10036
+ );
10037
+ if (orderSpecified) {
10038
+ cb(node, prev);
10039
+ return false;
10040
+ }
10041
+ i = old;
10118
10042
  }
10043
+ prev = node;
10044
+ }
10045
+ return true;
10046
+ }
10047
+ /**
10048
+ * Validate element ancestors.
10049
+ *
10050
+ * Check if an element has the required set of elements. At least one of the
10051
+ * selectors must match.
10052
+ */
10053
+ static validateAncestors(node, rules) {
10054
+ if (!rules || rules.length === 0) {
10055
+ return true;
10056
+ }
10057
+ return rules.some((rule) => node.closest(rule));
10058
+ }
10059
+ /**
10060
+ * Validate element required content.
10061
+ *
10062
+ * Check if an element has the required set of elements. At least one of the
10063
+ * selectors must match.
10064
+ *
10065
+ * Returns `[]` when valid or a list of required but missing tagnames or
10066
+ * categories.
10067
+ */
10068
+ static validateRequiredContent(node, rules) {
10069
+ if (!rules || rules.length === 0) {
10070
+ return [];
10071
+ }
10072
+ return rules.filter((tagName) => {
10073
+ const haveMatchingChild = node.childElements.some(
10074
+ (child) => Validator.validatePermittedCategory(child, tagName, false)
10075
+ );
10076
+ return !haveMatchingChild;
10119
10077
  });
10120
10078
  }
10121
- validateNode(node) {
10122
- if (!inAccessibilityTree(node)) {
10123
- return;
10079
+ /**
10080
+ * Test if an attribute has an allowed value and/or format.
10081
+ *
10082
+ * @param attr - Attribute to test.
10083
+ * @param rules - Element attribute metadta.
10084
+ * @returns `true` if attribute passes all tests.
10085
+ */
10086
+ static validateAttribute(attr, rules) {
10087
+ const rule = rules[attr.key];
10088
+ if (!rule) {
10089
+ return true;
10124
10090
  }
10125
- if (Boolean(node.getAttributeValue("alt")) || Boolean(node.hasAttribute("alt") && this.options.allowEmpty)) {
10126
- return;
10091
+ const value = attr.value;
10092
+ if (value instanceof DynamicValue) {
10093
+ return true;
10127
10094
  }
10128
- for (const attr of this.options.alias) {
10129
- if (node.getAttribute(attr)) {
10130
- return;
10131
- }
10095
+ const empty = value === null || value === "";
10096
+ if (rule.boolean) {
10097
+ return empty || value === attr.key;
10132
10098
  }
10133
- const tag = node.annotatedName;
10134
- if (node.hasAttribute("alt")) {
10135
- const attr = node.getAttribute("alt");
10136
- this.report(node, `${tag} cannot have empty "alt" attribute`, attr.keyLocation);
10137
- } else {
10138
- this.report(node, `${tag} is missing required "alt" attribute`, node.location);
10099
+ if (rule.omit && empty) {
10100
+ return true;
10139
10101
  }
10102
+ if (rule.list) {
10103
+ const tokens = new DOMTokenList(value, attr.valueLocation);
10104
+ return tokens.every((token) => {
10105
+ return this.validateAttributeValue(token, rule);
10106
+ });
10107
+ }
10108
+ return this.validateAttributeValue(value, rule);
10140
10109
  }
10141
- }
10142
-
10143
- var _a;
10144
- const { enum: validScopes } = (_a = elements.html5.th.attributes) == null ? void 0 : _a.scope;
10145
- const joinedScopes = utils_naturalJoin.naturalJoin(validScopes);
10146
- class H63 extends Rule {
10147
- documentation() {
10148
- return {
10149
- description: "H63: Using the scope attribute to associate header cells and data cells in data tables",
10150
- url: "https://html-validate.org/rules/wcag/h63.html"
10151
- };
10152
- }
10153
- setup() {
10154
- this.on("tag:ready", (event) => {
10155
- const node = event.target;
10156
- if (node.tagName !== "th") {
10157
- return;
10158
- }
10159
- const scope = node.getAttribute("scope");
10160
- const value = scope == null ? void 0 : scope.value;
10161
- if (value instanceof DynamicValue) {
10162
- return;
10163
- }
10164
- if (value && validScopes.includes(value)) {
10165
- return;
10166
- }
10167
- const message = `<th> element must have a valid scope attribute: ${joinedScopes}`;
10168
- const location = (scope == null ? void 0 : scope.valueLocation) ?? (scope == null ? void 0 : scope.keyLocation) ?? node.location;
10169
- this.report(node, message, location);
10170
- });
10171
- }
10172
- }
10173
-
10174
- class H67 extends Rule {
10175
- documentation() {
10176
- return {
10177
- description: "A decorative image cannot have a title attribute. Either remove `title` or add a descriptive `alt` text.",
10178
- url: "https://html-validate.org/rules/wcag/h67.html"
10179
- };
10180
- }
10181
- setup() {
10182
- this.on("tag:end", (event) => {
10183
- const node = event.target;
10184
- if (!node || node.tagName !== "img") {
10185
- return;
10186
- }
10187
- const title = node.getAttribute("title");
10188
- if (!title || title.value === "") {
10189
- return;
10190
- }
10191
- const alt = node.getAttributeValue("alt");
10192
- if (alt && alt !== "") {
10193
- return;
10110
+ static validateAttributeValue(value, rule) {
10111
+ if (!rule.enum) {
10112
+ return true;
10113
+ }
10114
+ if (value === null) {
10115
+ return false;
10116
+ }
10117
+ const caseInsensitiveValue = value.toLowerCase();
10118
+ return rule.enum.some((entry) => {
10119
+ if (entry instanceof RegExp) {
10120
+ return !!value.match(entry);
10121
+ } else {
10122
+ return caseInsensitiveValue === entry;
10194
10123
  }
10195
- this.report(node, "<img> with empty alt text cannot have title attribute", title.keyLocation);
10196
10124
  });
10197
10125
  }
10198
- }
10199
-
10200
- class H71 extends Rule {
10201
- documentation() {
10202
- return {
10203
- description: "H71: Providing a description for groups of form controls using fieldset and legend elements",
10204
- url: "https://html-validate.org/rules/wcag/h71.html"
10205
- };
10206
- }
10207
- setup() {
10208
- this.on("dom:ready", (event) => {
10209
- const { document } = event;
10210
- const fieldsets = document.querySelectorAll(this.selector);
10211
- for (const fieldset of fieldsets) {
10212
- this.validate(fieldset);
10126
+ static validatePermittedRule(node, rule, isExclude = false) {
10127
+ if (typeof rule === "string") {
10128
+ return Validator.validatePermittedCategory(node, rule, !isExclude);
10129
+ } else if (Array.isArray(rule)) {
10130
+ return rule.every((inner) => {
10131
+ return Validator.validatePermittedRule(node, inner, isExclude);
10132
+ });
10133
+ } else {
10134
+ validateKeys(rule);
10135
+ if (rule.exclude) {
10136
+ if (Array.isArray(rule.exclude)) {
10137
+ return !rule.exclude.some((inner) => {
10138
+ return Validator.validatePermittedRule(node, inner, true);
10139
+ });
10140
+ } else {
10141
+ return !Validator.validatePermittedRule(node, rule.exclude, true);
10142
+ }
10143
+ } else {
10144
+ return true;
10213
10145
  }
10214
- });
10215
- }
10216
- validate(fieldset) {
10217
- const legend = fieldset.querySelectorAll("> legend");
10218
- if (legend.length === 0) {
10219
- this.reportNode(fieldset);
10220
10146
  }
10221
10147
  }
10222
- reportNode(node) {
10223
- super.report(node, `${node.annotatedName} must have a <legend> as the first child`);
10148
+ /**
10149
+ * Validate node against a content category.
10150
+ *
10151
+ * When matching parent nodes against permitted parents use the superset
10152
+ * parameter to also match for `@flow`. E.g. if a node expects a `@phrasing`
10153
+ * parent it should also allow `@flow` parent since `@phrasing` is a subset of
10154
+ * `@flow`.
10155
+ *
10156
+ * @param node - The node to test against
10157
+ * @param category - Name of category with `@` prefix or tag name.
10158
+ * @param defaultMatch - The default return value when node categories is not known.
10159
+ */
10160
+ /* eslint-disable-next-line complexity -- rule does not like switch */
10161
+ static validatePermittedCategory(node, category, defaultMatch) {
10162
+ const [, rawCategory] = category.match(/^(@?.*?)([?*]?)$/);
10163
+ if (!rawCategory.startsWith("@")) {
10164
+ return node.tagName === rawCategory;
10165
+ }
10166
+ if (!node.meta) {
10167
+ return defaultMatch;
10168
+ }
10169
+ switch (rawCategory) {
10170
+ case "@meta":
10171
+ return node.meta.metadata;
10172
+ case "@flow":
10173
+ return node.meta.flow;
10174
+ case "@sectioning":
10175
+ return node.meta.sectioning;
10176
+ case "@heading":
10177
+ return node.meta.heading;
10178
+ case "@phrasing":
10179
+ return node.meta.phrasing;
10180
+ case "@embedded":
10181
+ return node.meta.embedded;
10182
+ case "@interactive":
10183
+ return node.meta.interactive;
10184
+ case "@script":
10185
+ return Boolean(node.meta.scriptSupporting);
10186
+ case "@form":
10187
+ return Boolean(node.meta.form);
10188
+ default:
10189
+ throw new Error(`Invalid content category "${category}"`);
10190
+ }
10224
10191
  }
10225
- get selector() {
10226
- return this.getTagsDerivedFrom("fieldset").join(",");
10192
+ }
10193
+ function validateKeys(rule) {
10194
+ for (const key of Object.keys(rule)) {
10195
+ if (!allowedKeys.includes(key)) {
10196
+ const str = JSON.stringify(rule);
10197
+ throw new Error(`Permitted rule "${str}" contains unknown property "${key}"`);
10198
+ }
10227
10199
  }
10228
10200
  }
10229
-
10230
- const bundledRules$1 = {
10231
- "wcag/h30": H30,
10232
- "wcag/h32": H32,
10233
- "wcag/h36": H36,
10234
- "wcag/h37": H37,
10235
- "wcag/h63": H63,
10236
- "wcag/h67": H67,
10237
- "wcag/h71": H71
10201
+ function parseQuantifier(quantifier) {
10202
+ switch (quantifier) {
10203
+ case "?":
10204
+ return 1;
10205
+ case "*":
10206
+ return null;
10207
+ default:
10208
+ throw new Error(`Invalid quantifier "${quantifier}" used`);
10209
+ }
10210
+ }
10211
+
10212
+ const $schema = "http://json-schema.org/draft-06/schema#";
10213
+ const $id = "https://html-validate.org/schemas/config.json";
10214
+ const type = "object";
10215
+ const additionalProperties = false;
10216
+ const properties = {
10217
+ $schema: {
10218
+ type: "string"
10219
+ },
10220
+ root: {
10221
+ type: "boolean",
10222
+ title: "Mark as root configuration",
10223
+ description: "If this is set to true no further configurations will be searched.",
10224
+ "default": false
10225
+ },
10226
+ "extends": {
10227
+ type: "array",
10228
+ items: {
10229
+ type: "string"
10230
+ },
10231
+ title: "Configurations to extend",
10232
+ description: "Array of shareable or builtin configurations to extend."
10233
+ },
10234
+ elements: {
10235
+ type: "array",
10236
+ items: {
10237
+ anyOf: [
10238
+ {
10239
+ type: "string"
10240
+ },
10241
+ {
10242
+ type: "object"
10243
+ }
10244
+ ]
10245
+ },
10246
+ title: "Element metadata to load",
10247
+ description: "Array of modules, plugins or files to load element metadata from. Use <rootDir> to refer to the folder with the package.json file.",
10248
+ examples: [
10249
+ [
10250
+ "html-validate:recommended",
10251
+ "plugin:recommended",
10252
+ "module",
10253
+ "./local-file.json"
10254
+ ]
10255
+ ]
10256
+ },
10257
+ plugins: {
10258
+ type: "array",
10259
+ items: {
10260
+ anyOf: [
10261
+ {
10262
+ type: "string"
10263
+ },
10264
+ {
10265
+ type: "object"
10266
+ }
10267
+ ]
10268
+ },
10269
+ title: "Plugins to load",
10270
+ description: "Array of plugins load. Use <rootDir> to refer to the folder with the package.json file.",
10271
+ examples: [
10272
+ [
10273
+ "my-plugin",
10274
+ "./local-plugin"
10275
+ ]
10276
+ ]
10277
+ },
10278
+ transform: {
10279
+ type: "object",
10280
+ additionalProperties: {
10281
+ type: "string"
10282
+ },
10283
+ title: "File transformations to use.",
10284
+ description: "Object where key is regular expression to match filename and value is name of transformer.",
10285
+ examples: [
10286
+ {
10287
+ "^.*\\.foo$": "my-transformer",
10288
+ "^.*\\.bar$": "my-plugin",
10289
+ "^.*\\.baz$": "my-plugin:named"
10290
+ }
10291
+ ]
10292
+ },
10293
+ rules: {
10294
+ type: "object",
10295
+ patternProperties: {
10296
+ ".*": {
10297
+ anyOf: [
10298
+ {
10299
+ "enum": [
10300
+ 0,
10301
+ 1,
10302
+ 2,
10303
+ "off",
10304
+ "warn",
10305
+ "error"
10306
+ ]
10307
+ },
10308
+ {
10309
+ type: "array",
10310
+ minItems: 1,
10311
+ maxItems: 1,
10312
+ items: [
10313
+ {
10314
+ "enum": [
10315
+ 0,
10316
+ 1,
10317
+ 2,
10318
+ "off",
10319
+ "warn",
10320
+ "error"
10321
+ ]
10322
+ }
10323
+ ]
10324
+ },
10325
+ {
10326
+ type: "array",
10327
+ minItems: 2,
10328
+ maxItems: 2,
10329
+ items: [
10330
+ {
10331
+ "enum": [
10332
+ 0,
10333
+ 1,
10334
+ 2,
10335
+ "off",
10336
+ "warn",
10337
+ "error"
10338
+ ]
10339
+ },
10340
+ {
10341
+ }
10342
+ ]
10343
+ }
10344
+ ]
10345
+ }
10346
+ },
10347
+ title: "Rule configuration.",
10348
+ description: "Enable/disable rules, set severity. Some rules have additional configuration like style or patterns to use.",
10349
+ examples: [
10350
+ {
10351
+ foo: "error",
10352
+ bar: "off",
10353
+ baz: [
10354
+ "error",
10355
+ {
10356
+ style: "camelcase"
10357
+ }
10358
+ ]
10359
+ }
10360
+ ]
10361
+ }
10362
+ };
10363
+ var configurationSchema = {
10364
+ $schema: $schema,
10365
+ $id: $id,
10366
+ type: type,
10367
+ additionalProperties: additionalProperties,
10368
+ properties: properties
10238
10369
  };
10239
10370
 
10240
- const bundledRules = {
10241
- "allowed-links": AllowedLinks,
10242
- "area-alt": AreaAlt,
10243
- "aria-hidden-body": AriaHiddenBody,
10244
- "aria-label-misuse": AriaLabelMisuse,
10245
- "attr-case": AttrCase,
10246
- "attr-delimiter": AttrDelimiter,
10247
- "attr-pattern": AttrPattern,
10248
- "attr-quotes": AttrQuotes,
10249
- "attr-spacing": AttrSpacing,
10250
- "attribute-allowed-values": AttributeAllowedValues,
10251
- "attribute-boolean-style": AttributeBooleanStyle,
10252
- "attribute-empty-style": AttributeEmptyStyle,
10253
- "attribute-misuse": AttributeMisuse,
10254
- "class-pattern": ClassPattern,
10255
- "close-attr": CloseAttr,
10256
- "close-order": CloseOrder,
10257
- deprecated: Deprecated,
10258
- "deprecated-rule": DeprecatedRule,
10259
- "doctype-html": NoStyleTag$1,
10260
- "doctype-style": DoctypeStyle,
10261
- "element-case": ElementCase,
10262
- "element-name": ElementName,
10263
- "element-permitted-content": ElementPermittedContent,
10264
- "element-permitted-occurrences": ElementPermittedOccurrences,
10265
- "element-permitted-order": ElementPermittedOrder,
10266
- "element-permitted-parent": ElementPermittedParent,
10267
- "element-required-ancestor": ElementRequiredAncestor,
10268
- "element-required-attributes": ElementRequiredAttributes,
10269
- "element-required-content": ElementRequiredContent,
10270
- "empty-heading": EmptyHeading,
10271
- "empty-title": EmptyTitle,
10272
- "form-dup-name": FormDupName,
10273
- "heading-level": HeadingLevel,
10274
- "hidden-focusable": HiddenFocusable,
10275
- "id-pattern": IdPattern,
10276
- "input-attributes": InputAttributes,
10277
- "input-missing-label": InputMissingLabel,
10278
- "long-title": LongTitle,
10279
- "map-dup-name": MapDupName,
10280
- "map-id-name": MapIdName,
10281
- "meta-refresh": MetaRefresh,
10282
- "missing-doctype": MissingDoctype,
10283
- "multiple-labeled-controls": MultipleLabeledControls,
10284
- "name-pattern": NamePattern,
10285
- "no-abstract-role": NoAbstractRole,
10286
- "no-autoplay": NoAutoplay,
10287
- "no-conditional-comment": NoConditionalComment,
10288
- "no-deprecated-attr": NoDeprecatedAttr,
10289
- "no-dup-attr": NoDupAttr,
10290
- "no-dup-class": NoDupClass,
10291
- "no-dup-id": NoDupID,
10292
- "no-implicit-button-type": NoImplicitButtonType,
10293
- "no-implicit-input-type": NoImplicitInputType,
10294
- "no-implicit-close": NoImplicitClose,
10295
- "no-inline-style": NoInlineStyle,
10296
- "no-missing-references": NoMissingReferences,
10297
- "no-multiple-main": NoMultipleMain,
10298
- "no-raw-characters": NoRawCharacters,
10299
- "no-redundant-aria-label": NoRedundantAriaLabel,
10300
- "no-redundant-for": NoRedundantFor,
10301
- "no-redundant-role": NoRedundantRole,
10302
- "no-self-closing": NoSelfClosing,
10303
- "no-style-tag": NoStyleTag,
10304
- "no-trailing-whitespace": NoTrailingWhitespace,
10305
- "no-unknown-elements": NoUnknownElements,
10306
- "no-unused-disable": NoUnusedDisable,
10307
- "no-utf8-bom": NoUtf8Bom,
10308
- "prefer-button": PreferButton,
10309
- "prefer-native-element": PreferNativeElement,
10310
- "prefer-tbody": PreferTbody,
10311
- "require-csp-nonce": RequireCSPNonce,
10312
- "require-sri": RequireSri,
10313
- "script-element": ScriptElement,
10314
- "script-type": ScriptType,
10315
- "svg-focusable": SvgFocusable,
10316
- "tel-non-breaking": TelNonBreaking,
10317
- "text-content": TextContent,
10318
- "unique-landmark": UniqueLandmark,
10319
- "unrecognized-char-ref": UnknownCharReference,
10320
- "valid-autocomplete": ValidAutocomplete,
10321
- "valid-id": ValidID,
10322
- "void-content": VoidContent,
10323
- "void-style": VoidStyle,
10324
- ...bundledRules$1
10371
+ const TRANSFORMER_API = {
10372
+ VERSION: 1
10325
10373
  };
10326
10374
 
10327
10375
  var defaultConfig = {};
@@ -11449,7 +11497,7 @@ class Parser {
11449
11497
  location
11450
11498
  };
11451
11499
  this.trigger("tag:end", event);
11452
- if (active && !active.isRootElement()) {
11500
+ if (!active.isRootElement()) {
11453
11501
  this.trigger("element:ready", {
11454
11502
  target: active,
11455
11503
  location: active.location
@@ -11801,15 +11849,6 @@ class Parser {
11801
11849
  }
11802
11850
  }
11803
11851
 
11804
- function isThenable(value) {
11805
- return value && typeof value === "object" && "then" in value && typeof value.then === "function";
11806
- }
11807
-
11808
- const ruleIds = new Set(Object.keys(bundledRules));
11809
- function ruleExists(ruleId) {
11810
- return ruleIds.has(ruleId);
11811
- }
11812
-
11813
11852
  function freeze(src) {
11814
11853
  return {
11815
11854
  ...src,
@@ -11980,7 +12019,7 @@ class Engine {
11980
12019
  const directiveContext = {
11981
12020
  rules,
11982
12021
  reportUnused(rules2, unused, options, location2) {
11983
- if (noUnusedDisable && !rules2.has(noUnusedDisable.name)) {
12022
+ if (!rules2.has(noUnusedDisable.name)) {
11984
12023
  noUnusedDisable.reportUnused(unused, options, location2);
11985
12024
  }
11986
12025
  }
@@ -12111,7 +12150,9 @@ class Engine {
12111
12150
  return new this.ParserClass(this.config);
12112
12151
  }
12113
12152
  processDirective(event, parser, context) {
12114
- const rules = event.data.split(",").map((name) => name.trim()).map((name) => context.rules[name]).filter((rule) => rule);
12153
+ const rules = event.data.split(",").map((name) => name.trim()).map((name) => context.rules[name]).filter((rule) => {
12154
+ return Boolean(rule);
12155
+ });
12115
12156
  const location = event.optionsLocation ?? event.location;
12116
12157
  switch (event.action) {
12117
12158
  case "enable":
@@ -12135,7 +12176,7 @@ class Engine {
12135
12176
  rule.setServerity(Severity.ERROR);
12136
12177
  }
12137
12178
  }
12138
- parser.on("tag:start", (event, data) => {
12179
+ parser.on("tag:start", (_event, data) => {
12139
12180
  data.target.enableRules(rules.map((rule) => rule.name));
12140
12181
  });
12141
12182
  }
@@ -12143,7 +12184,7 @@ class Engine {
12143
12184
  for (const rule of rules) {
12144
12185
  rule.setEnabled(false);
12145
12186
  }
12146
- parser.on("tag:start", (event, data) => {
12187
+ parser.on("tag:start", (_event, data) => {
12147
12188
  data.target.disableRules(rules.map((rule) => rule.name));
12148
12189
  });
12149
12190
  }
@@ -12155,14 +12196,14 @@ class Engine {
12155
12196
  for (const rule of rules) {
12156
12197
  rule.block(blocker);
12157
12198
  }
12158
- const unregisterOpen = parser.on("tag:start", (event, data) => {
12199
+ const unregisterOpen = parser.on("tag:start", (_event, data) => {
12159
12200
  var _a;
12160
12201
  if (directiveBlock === null) {
12161
12202
  directiveBlock = ((_a = data.target.parent) == null ? void 0 : _a.unique) ?? null;
12162
12203
  }
12163
12204
  data.target.blockRules(ruleIds, blocker);
12164
12205
  });
12165
- const unregisterClose = parser.on("tag:end", (event, data) => {
12206
+ const unregisterClose = parser.on("tag:end", (_event, data) => {
12166
12207
  const lastNode = directiveBlock === null;
12167
12208
  const parentClosed = directiveBlock === data.previous.unique;
12168
12209
  if (lastNode || parentClosed) {
@@ -12173,7 +12214,7 @@ class Engine {
12173
12214
  }
12174
12215
  }
12175
12216
  });
12176
- parser.on("rule:error", (event, data) => {
12217
+ parser.on("rule:error", (_event, data) => {
12177
12218
  if (data.blockers.includes(blocker)) {
12178
12219
  unused.delete(data.ruleId);
12179
12220
  }
@@ -12189,10 +12230,10 @@ class Engine {
12189
12230
  for (const rule of rules) {
12190
12231
  rule.block(blocker);
12191
12232
  }
12192
- const unregister = parser.on("tag:start", (event, data) => {
12233
+ const unregister = parser.on("tag:start", (_event, data) => {
12193
12234
  data.target.blockRules(ruleIds, blocker);
12194
12235
  });
12195
- parser.on("rule:error", (event, data) => {
12236
+ parser.on("rule:error", (_event, data) => {
12196
12237
  if (data.blockers.includes(blocker)) {
12197
12238
  unused.delete(data.ruleId);
12198
12239
  }
@@ -12756,7 +12797,7 @@ class HtmlValidate {
12756
12797
  }
12757
12798
 
12758
12799
  const name = "html-validate";
12759
- const version = "8.20.1";
12800
+ const version = "8.21.0";
12760
12801
  const bugs = "https://gitlab.com/html-validate/html-validate/issues/new";
12761
12802
 
12762
12803
  function definePlugin(plugin) {
@@ -13678,4 +13719,5 @@ exports.ruleExists = ruleExists;
13678
13719
  exports.sliceLocation = sliceLocation;
13679
13720
  exports.staticResolver = staticResolver;
13680
13721
  exports.version = version;
13722
+ exports.walk = walk;
13681
13723
  //# sourceMappingURL=core.js.map