html-validate 8.20.0 → 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;
@@ -5053,11 +4588,7 @@ class AttributeAllowedValues extends Rule {
5053
4588
  }
5054
4589
  }
5055
4590
  getLocation(attr) {
5056
- if (attr.value !== null) {
5057
- return attr.valueLocation;
5058
- } else {
5059
- return attr.keyLocation;
5060
- }
4591
+ return attr.valueLocation ?? attr.keyLocation;
5061
4592
  }
5062
4593
  }
5063
4594
 
@@ -5086,7 +4617,7 @@ class AttributeBooleanStyle extends Rule {
5086
4617
  setup() {
5087
4618
  this.on("dom:ready", (event) => {
5088
4619
  const doc = event.document;
5089
- doc.visitDepthFirst((node) => {
4620
+ walk.depthFirst(doc, (node) => {
5090
4621
  const meta = node.meta;
5091
4622
  if (!(meta == null ? void 0 : meta.attributes))
5092
4623
  return;
@@ -5158,7 +4689,7 @@ class AttributeEmptyStyle extends Rule {
5158
4689
  setup() {
5159
4690
  this.on("dom:ready", (event) => {
5160
4691
  const doc = event.document;
5161
- doc.visitDepthFirst((node) => {
4692
+ walk.depthFirst(doc, (node) => {
5162
4693
  const meta = node.meta;
5163
4694
  if (!(meta == null ? void 0 : meta.attributes))
5164
4695
  return;
@@ -5810,7 +5341,7 @@ class ElementPermittedContent extends Rule {
5810
5341
  setup() {
5811
5342
  this.on("dom:ready", (event) => {
5812
5343
  const doc = event.document;
5813
- doc.visitDepthFirst((node) => {
5344
+ walk.depthFirst(doc, (node) => {
5814
5345
  const parent = node.parent;
5815
5346
  if (!parent) {
5816
5347
  return;
@@ -5889,7 +5420,7 @@ class ElementPermittedOccurrences extends Rule {
5889
5420
  setup() {
5890
5421
  this.on("dom:ready", (event) => {
5891
5422
  const doc = event.document;
5892
- doc.visitDepthFirst((node) => {
5423
+ walk.depthFirst(doc, (node) => {
5893
5424
  if (!node.meta) {
5894
5425
  return;
5895
5426
  }
@@ -5922,7 +5453,7 @@ class ElementPermittedOrder extends Rule {
5922
5453
  setup() {
5923
5454
  this.on("dom:ready", (event) => {
5924
5455
  const doc = event.document;
5925
- doc.visitDepthFirst((node) => {
5456
+ walk.depthFirst(doc, (node) => {
5926
5457
  if (!node.meta) {
5927
5458
  return;
5928
5459
  }
@@ -5992,7 +5523,7 @@ class ElementPermittedParent extends Rule {
5992
5523
  setup() {
5993
5524
  this.on("dom:ready", (event) => {
5994
5525
  const doc = event.document;
5995
- doc.visitDepthFirst((node) => {
5526
+ walk.depthFirst(doc, (node) => {
5996
5527
  var _a;
5997
5528
  const parent = node.parent;
5998
5529
  if (!parent) {
@@ -6040,7 +5571,7 @@ class ElementRequiredAncestor extends Rule {
6040
5571
  setup() {
6041
5572
  this.on("dom:ready", (event) => {
6042
5573
  const doc = event.document;
6043
- doc.visitDepthFirst((node) => {
5574
+ walk.depthFirst(doc, (node) => {
6044
5575
  const parent = node.parent;
6045
5576
  if (!parent) {
6046
5577
  return;
@@ -6124,7 +5655,7 @@ class ElementRequiredContent extends Rule {
6124
5655
  setup() {
6125
5656
  this.on("dom:ready", (event) => {
6126
5657
  const doc = event.document;
6127
- doc.visitDepthFirst((node) => {
5658
+ walk.depthFirst(doc, (node) => {
6128
5659
  if (!node.meta) {
6129
5660
  return;
6130
5661
  }
@@ -6644,7 +6175,7 @@ function isFocusableImpl(element) {
6644
6175
  if (isDisabled(element, meta)) {
6645
6176
  return false;
6646
6177
  }
6647
- return Boolean(meta == null ? void 0 : meta.focusable);
6178
+ return Boolean(meta.focusable);
6648
6179
  }
6649
6180
  function isFocusable(element) {
6650
6181
  const cached = element.cacheGet(FOCUSABLE_CACHE);
@@ -8554,1778 +8085,2291 @@ class RequireSri extends Rule {
8554
8085
  items: {
8555
8086
  type: "string"
8556
8087
  },
8557
- type: "array"
8558
- },
8559
- {
8560
- type: "null"
8561
- }
8562
- ]
8563
- },
8564
- exclude: {
8565
- anyOf: [
8566
- {
8567
- 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: {
8568
8289
  type: "string"
8569
8290
  },
8570
- type: "array"
8571
- },
8572
- {
8573
- type: "null"
8291
+ description: {
8292
+ type: "string"
8293
+ }
8574
8294
  }
8575
- ]
8295
+ }
8296
+ },
8297
+ ignoreClasses: {
8298
+ type: "array",
8299
+ items: {
8300
+ type: "string"
8301
+ }
8302
+ },
8303
+ ignoreStyle: {
8304
+ type: "boolean"
8576
8305
  }
8577
8306
  };
8578
8307
  }
8579
- 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
+ });
8580
8313
  return {
8581
- description: `Subresource Integrity (SRI) \`integrity\` attribute is required to prevent tampering or manipulation from Content Delivery Networks (CDN), rouge proxies, malicious entities, etc.`,
8582
- 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"
8583
8325
  };
8584
8326
  }
8585
8327
  setup() {
8586
- this.on("tag:end", (event) => {
8587
- const node = event.previous;
8588
- if (!(this.supportSri(node) && this.needSri(node))) {
8589
- return;
8590
- }
8591
- if (node.hasAttribute("integrity")) {
8328
+ this.on("element:ready", this.isRelevant, (event) => {
8329
+ const { target } = event;
8330
+ if (this.isIgnored(target)) {
8592
8331
  return;
8593
8332
  }
8594
- this.report(
8595
- node,
8596
- `SRI "integrity" attribute is required on <${node.tagName}> element`,
8597
- node.location
8598
- );
8333
+ this.walk(target, target);
8599
8334
  });
8600
8335
  }
8601
- supportSri(node) {
8602
- return Object.keys(supportSri).includes(node.tagName);
8603
- }
8604
- needSri(node) {
8605
- const attr = this.elementSourceAttr(node);
8606
- if (!attr) {
8336
+ isRelevant(event) {
8337
+ const { target } = event;
8338
+ if (!target.is("a")) {
8607
8339
  return false;
8608
8340
  }
8609
- if (attr.value === null || attr.value === "" || attr.isDynamic) {
8341
+ const attr = target.getAttribute("href");
8342
+ if (!(attr == null ? void 0 : attr.valueMatches(/^tel:/, false))) {
8610
8343
  return false;
8611
8344
  }
8612
- const url = attr.value.toString();
8613
- if (this.target === "all" || crossorigin.test(url)) {
8614
- 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;
8615
8360
  }
8616
8361
  return false;
8617
8362
  }
8618
- elementSourceAttr(node) {
8619
- const key = supportSri[node.tagName];
8620
- 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`);
8621
8516
  }
8622
- isIgnored(url) {
8623
- return this.isKeywordIgnored(url, (list, it) => {
8624
- 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
8625
8521
  });
8626
8522
  }
8627
8523
  }
8628
8524
 
8629
- class ScriptElement extends Rule {
8630
- documentation() {
8631
- return {
8632
- description: "The end tag for `<script>` is a hard requirement and must never be omitted even when using the `src` attribute.",
8633
- url: "https://html-validate.org/rules/script-element.html"
8634
- };
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;
8635
8540
  }
8636
- setup() {
8637
- this.on("tag:end", (event) => {
8638
- const node = event.target;
8639
- if (!node || node.tagName !== "script") {
8640
- return;
8641
- }
8642
- if (node.closed !== NodeClosed.EndTag) {
8643
- this.report(node, `End tag for <${node.tagName}> must not be omitted`);
8644
- }
8645
- });
8541
+ const selector = `#${id}`;
8542
+ const ref = document.querySelector(selector);
8543
+ if (ref) {
8544
+ return ref.textContent;
8545
+ } else {
8546
+ return selector;
8646
8547
  }
8647
8548
  }
8648
-
8649
- const javascript = [
8650
- "",
8651
- "application/ecmascript",
8652
- "application/javascript",
8653
- "text/ecmascript",
8654
- "text/javascript"
8655
- ];
8656
- class ScriptType extends Rule {
8657
- 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) {
8658
8564
  return {
8659
- description: "While valid the HTML5 standard encourages authors to omit the type element for JavaScript resources.",
8660
- url: "https://html-validate.org/rules/script-type.html"
8565
+ node,
8566
+ text: ariaLabel.value,
8567
+ location: ariaLabel.keyLocation
8661
8568
  };
8662
8569
  }
8663
- setup() {
8664
- this.on("tag:end", (event) => {
8665
- const node = event.previous;
8666
- if (node.tagName !== "script") {
8667
- return;
8668
- }
8669
- const attr = node.getAttribute("type");
8670
- if (!attr || attr.isDynamic) {
8671
- return;
8672
- }
8673
- const value = attr.value ? attr.value.toString() : "";
8674
- if (!this.isJavascript(value)) {
8675
- return;
8676
- }
8677
- this.report(
8678
- node,
8679
- '"type" attribute is unnecessary for javascript resources',
8680
- attr.keyLocation
8681
- );
8682
- });
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
+ };
8683
8578
  }
8684
- isJavascript(mime) {
8685
- const type = mime.replace(/;.*/, "");
8686
- 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"));
8687
8589
  }
8590
+ return true;
8688
8591
  }
8689
-
8690
- class SvgFocusable extends Rule {
8592
+ class UniqueLandmark extends Rule {
8691
8593
  documentation() {
8692
8594
  return {
8693
- 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.`,
8694
- 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"
8695
8616
  };
8696
8617
  }
8697
8618
  setup() {
8698
- this.on("element:ready", (event) => {
8699
- if (event.target.is("svg")) {
8700
- 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
+ }
8701
8644
  }
8702
8645
  });
8703
8646
  }
8704
- validate(svg) {
8705
- if (svg.hasAttribute("focusable")) {
8706
- return;
8707
- }
8708
- this.report(svg, `<${svg.tagName}> is missing required "focusable" attribute`);
8709
- }
8710
8647
  }
8711
8648
 
8712
- const defaults$5 = {
8713
- characters: [
8714
- { pattern: " ", replacement: "&nbsp;", description: "non-breaking space" },
8715
- { pattern: "-", replacement: "&#8209;", description: "non-breaking hyphen" }
8716
- ],
8717
- ignoreClasses: [],
8718
- ignoreStyle: true
8649
+ const defaults$4 = {
8650
+ ignoreCase: false,
8651
+ requireSemicolon: true
8719
8652
  };
8720
- function constructRegex(characters) {
8721
- const disallowed = characters.map((it) => {
8722
- return it.pattern;
8723
- }).join("|");
8724
- const pattern = `(${disallowed})`;
8725
- return new RegExp(pattern, "g");
8726
- }
8727
- function getText(node) {
8728
- const match = node.textContent.match(/^(\s*)(.*)$/);
8729
- const [, leading, text] = match;
8730
- 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("&#");
8731
8657
  }
8732
- function matchAll(text, regexp) {
8733
- const copy = new RegExp(regexp);
8734
- const matches = [];
8735
- let match;
8736
- while (match = copy.exec(text)) {
8737
- matches.push(match);
8738
- }
8739
- return matches;
8658
+ function getLocation(location, entity, match) {
8659
+ const index = match.index ?? 0;
8660
+ return sliceLocation(location, index, index + entity.length);
8740
8661
  }
8741
- class TelNonBreaking extends Rule {
8742
- constructor(options) {
8743
- super({ ...defaults$5, ...options });
8744
- 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.`;
8745
8669
  }
8746
- static schema() {
8747
- return {
8748
- characters: {
8749
- type: "array",
8750
- items: {
8751
- type: "object",
8752
- additionalProperties: false,
8753
- properties: {
8754
- pattern: {
8755
- type: "string"
8756
- },
8757
- replacement: {
8758
- type: "string"
8759
- },
8760
- description: {
8761
- type: "string"
8762
- }
8763
- }
8764
- }
8765
- },
8766
- ignoreClasses: {
8767
- type: "array",
8768
- items: {
8769
- type: "string"
8770
- }
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"
8771
8689
  },
8772
- ignoreStyle: {
8690
+ requireSemicolon: {
8773
8691
  type: "boolean"
8774
8692
  }
8775
8693
  };
8776
8694
  }
8777
8695
  documentation(context) {
8778
- const { characters } = this.options;
8779
- const replacements = characters.map((it) => {
8780
- return ` - \`${it.pattern}\` - replace with \`${it.replacement}\` (${it.description}).`;
8781
- });
8782
8696
  return {
8783
- description: [
8784
- `The \`${context.pattern}\` character should be replaced with \`${context.replacement}\` character (${context.description}) when used in a telephone number.`,
8785
- "",
8786
- "Unless non-breaking characters is used there could be a line break inserted at that character.",
8787
- "Line breaks make is harder to read and understand the telephone number.",
8788
- "",
8789
- "The following characters should be avoided:",
8790
- "",
8791
- ...replacements
8792
- ].join("\n"),
8793
- 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"
8794
8699
  };
8795
8700
  }
8796
8701
  setup() {
8797
- this.on("element:ready", this.isRelevant, (event) => {
8798
- const { target } = event;
8799
- 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) {
8800
8715
  return;
8801
8716
  }
8802
- this.walk(target, target);
8717
+ this.findCharacterReferences(event.target, event.value.toString(), event.valueLocation, {
8718
+ isAttribute: true
8719
+ });
8803
8720
  });
8804
8721
  }
8805
- isRelevant(event) {
8806
- const { target } = event;
8807
- if (!target.is("a")) {
8808
- return false;
8722
+ get entities() {
8723
+ if (this.options.ignoreCase) {
8724
+ return lowercaseEntities;
8725
+ } else {
8726
+ return elements.entities;
8809
8727
  }
8810
- const attr = target.getAttribute("href");
8811
- if (!(attr == null ? void 0 : attr.valueMatches(/^tel:/, false))) {
8812
- 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 });
8813
8733
  }
8814
- return true;
8815
8734
  }
8816
- isIgnoredClass(node) {
8817
- const { ignoreClasses } = this.options;
8818
- const { classList } = node;
8819
- 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";
8820
8926
  }
8821
- isIgnoredStyle(node) {
8822
- const { ignoreStyle } = this.options;
8823
- const { style } = node;
8824
- if (!ignoreStyle) {
8825
- return false;
8826
- }
8827
- if (style["white-space"] === "nowrap" || style["white-space"] === "pre") {
8828
- return true;
8829
- }
8830
- return false;
8927
+ if (matchFieldNames1(token)) {
8928
+ return "field1";
8831
8929
  }
8832
- isIgnored(node) {
8833
- return this.isIgnoredClass(node) || this.isIgnoredStyle(node);
8930
+ if (matchFieldNames2(token)) {
8931
+ return "field2";
8834
8932
  }
8835
- walk(anchor, node) {
8836
- for (const child of node.childNodes) {
8837
- if (isTextNode(child)) {
8838
- this.detectDisallowed(anchor, child);
8839
- } else if (isElementNode(child)) {
8840
- this.walk(anchor, child);
8841
- }
8842
- }
8933
+ if (matchContact(token)) {
8934
+ return "contact";
8843
8935
  }
8844
- detectDisallowed(anchor, node) {
8845
- const [offset, text] = getText(node);
8846
- const matches = matchAll(text, this.regex);
8847
- for (const match of matches) {
8848
- const detected = match[0];
8849
- const entry = this.options.characters.find((it) => it.pattern === detected);
8850
- if (!entry) {
8851
- throw new Error(`Failed to find entry for "${detected}" when searching text "${text}"`);
8852
- }
8853
- const message = `"${detected}" should be replaced with "${entry.replacement}" (${entry.description}) in telephone number`;
8854
- const begin = offset + match.index;
8855
- const end = begin + detected.length;
8856
- const location = sliceLocation(node.location, begin, end);
8857
- const context = entry;
8858
- this.report(anchor, message, location, context);
8859
- }
8936
+ if (matchWebauthn(token)) {
8937
+ return "webauthn";
8860
8938
  }
8939
+ return null;
8861
8940
  }
8862
-
8863
- function hasNonEmptyAttribute(node, key) {
8864
- const attr = node.getAttribute(key);
8865
- 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] ?? [];
8866
8966
  }
8867
- function hasDefaultText(node) {
8967
+ function isDisallowedType(node, type) {
8868
8968
  if (!node.is("input")) {
8869
8969
  return false;
8870
8970
  }
8871
- if (node.hasAttribute("value")) {
8872
- return false;
8873
- }
8874
- const type = node.getAttribute("type");
8875
- return Boolean(type == null ? void 0 : type.valueMatches(/submit|reset/, false));
8971
+ return disallowedInputTypes.includes(type);
8876
8972
  }
8877
- function isNonEmptyText(node) {
8878
- if (isTextNode(node)) {
8879
- return node.isDynamic || node.textContent.trim() !== "";
8880
- } else {
8881
- 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";
8882
8987
  }
8883
8988
  }
8884
- function haveAccessibleText(node) {
8885
- if (!inAccessibilityTree(node)) {
8886
- return false;
8887
- }
8888
- const haveText = node.childNodes.some((child) => isNonEmptyText(child));
8889
- if (haveText) {
8890
- return true;
8891
- }
8892
- if (hasNonEmptyAttribute(node, "aria-label")) {
8893
- return true;
8894
- }
8895
- if (hasNonEmptyAttribute(node, "aria-labelledby")) {
8896
- return true;
8897
- }
8898
- if (node.is("img") && hasNonEmptyAttribute(node, "alt")) {
8899
- return true;
8900
- }
8901
- if (hasDefaultText(node)) {
8902
- 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";
8903
9045
  }
8904
- return node.childElements.some((child) => {
8905
- return haveAccessibleText(child);
8906
- });
8907
9046
  }
8908
- class TextContent extends Rule {
9047
+ class ValidAutocomplete extends Rule {
8909
9048
  documentation(context) {
8910
- const doc = {
8911
- description: `The textual content for this element is not valid.`,
8912
- 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"
8913
9052
  };
8914
- switch (context.textContent) {
8915
- case TextContent$1.NONE:
8916
- doc.description = `The \`<${context.tagName}>\` element must not have textual content.`;
8917
- break;
8918
- case TextContent$1.REQUIRED:
8919
- doc.description = `The \`<${context.tagName}>\` element must have textual content.`;
8920
- break;
8921
- case TextContent$1.ACCESSIBLE:
8922
- doc.description = `The \`<${context.tagName}>\` element must have accessible text.`;
8923
- break;
8924
- }
8925
- return doc;
8926
- }
8927
- static filter(event) {
8928
- const { target } = event;
8929
- if (!target.meta) {
8930
- return false;
8931
- }
8932
- const { textContent } = target.meta;
8933
- if (!textContent || textContent === TextContent$1.DEFAULT) {
8934
- return false;
8935
- }
8936
- return true;
8937
9053
  }
8938
9054
  setup() {
8939
- this.on("element:ready", TextContent.filter, (event) => {
8940
- const target = event.target;
8941
- const { textContent } = target.meta;
8942
- switch (textContent) {
8943
- case TextContent$1.NONE:
8944
- this.validateNone(target);
8945
- break;
8946
- case TextContent$1.REQUIRED:
8947
- this.validateRequired(target);
8948
- break;
8949
- case TextContent$1.ACCESSIBLE:
8950
- this.validateAccessible(target);
8951
- 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);
8952
9070
  }
8953
9071
  });
8954
9072
  }
8955
- /**
8956
- * Validate element has empty text (inter-element whitespace is not considered text)
8957
- */
8958
- validateNone(node) {
8959
- if (classifyNodeText(node) === TextClassification.EMPTY_TEXT) {
8960
- 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;
8961
9083
  }
8962
- this.reportError(node, node.meta, `${node.annotatedName} must not have text content`);
8963
9084
  }
8964
- /**
8965
- * Validate element has any text (inter-element whitespace is not considered text)
8966
- */
8967
- validateRequired(node) {
8968
- 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
+ });
8969
9099
  return;
8970
9100
  }
8971
- this.reportError(node, node.meta, `${node.annotatedName} must have text content`);
8972
- }
8973
- /**
8974
- * Validate element has accessible text (either regular text or text only
8975
- * exposed in accessibility tree via aria-label or similar)
8976
- */
8977
- validateAccessible(node) {
8978
- if (!inAccessibilityTree(node)) {
9101
+ if (tokens.includes("on") || tokens.includes("off")) {
9102
+ this.validateOnOff(node, mantle, tokens);
8979
9103
  return;
8980
9104
  }
8981
- 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)) {
8982
9110
  return;
8983
9111
  }
8984
- this.reportError(node, node.meta, `${node.annotatedName} must have accessible text`);
8985
- }
8986
- reportError(node, meta, message) {
8987
- this.report(node, message, null, {
8988
- tagName: node.tagName,
8989
- 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
8990
9123
  });
8991
9124
  }
8992
- }
8993
-
8994
- const roles = ["complementary", "contentinfo", "form", "banner", "main", "navigation", "region"];
8995
- const selectors = [
8996
- "aside",
8997
- "footer",
8998
- "form",
8999
- "header",
9000
- "main",
9001
- "nav",
9002
- "section",
9003
- ...roles.map((it) => `[role="${it}"]`)
9004
- /* <search> does not (yet?) require a unique name */
9005
- ];
9006
- function getTextFromReference(document, id) {
9007
- if (!id || id instanceof DynamicValue) {
9008
- return id;
9009
- }
9010
- const selector = `#${id}`;
9011
- const ref = document.querySelector(selector);
9012
- if (ref) {
9013
- return ref.textContent;
9014
- } else {
9015
- return selector;
9016
- }
9017
- }
9018
- function groupBy(values, callback) {
9019
- const result = {};
9020
- for (const value of values) {
9021
- const key = callback(value);
9022
- if (key in result) {
9023
- result[key].push(value);
9024
- } else {
9025
- 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
+ }
9026
9160
  }
9027
9161
  }
9028
- return result;
9029
- }
9030
- function getTextEntryFromElement(document, node) {
9031
- const ariaLabel = node.getAttribute("aria-label");
9032
- if (ariaLabel) {
9033
- return {
9034
- node,
9035
- text: ariaLabel.value,
9036
- location: ariaLabel.keyLocation
9037
- };
9038
- }
9039
- const ariaLabelledby = node.getAttribute("aria-labelledby");
9040
- if (ariaLabelledby) {
9041
- const text = getTextFromReference(document, ariaLabelledby.value);
9042
- return {
9043
- node,
9044
- text,
9045
- location: ariaLabelledby.keyLocation
9046
- };
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);
9047
9187
  }
9048
- return {
9049
- node,
9050
- text: null,
9051
- location: node.location
9052
- };
9053
- }
9054
- function isExcluded(entry) {
9055
- const { node, text } = entry;
9056
- if (text === null) {
9057
- 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
+ }
9058
9220
  }
9059
- return true;
9060
- }
9061
- class UniqueLandmark extends Rule {
9062
- documentation() {
9063
- return {
9064
- description: [
9065
- "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.",
9066
- "For instance, if the document has two `<nav>` elements each of them need an accessible name to be distinguished from each other.",
9067
- "",
9068
- "The following elements / roles are considered landmarks:",
9069
- "",
9070
- ' - `aside` or `[role="complementary"]`',
9071
- ' - `footer` or `[role="contentinfo"]`',
9072
- ' - `form` or `[role="form"]`',
9073
- ' - `header` or `[role="banner"]`',
9074
- ' - `main` or `[role="main"]`',
9075
- ' - `nav` or `[role="navigation"]`',
9076
- ' - `section` or `[role="region"]`',
9077
- "",
9078
- "To fix this either:",
9079
- "",
9080
- " - Add `aria-label`.",
9081
- " - Add `aria-labelledby`.",
9082
- " - Remove one of the landmarks."
9083
- ].join("\n"),
9084
- url: "https://html-validate.org/rules/unique-landmark.html"
9085
- };
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
+ }
9086
9242
  }
9087
- setup() {
9088
- this.on("dom:ready", (event) => {
9089
- const { document } = event;
9090
- const elements = document.querySelectorAll(selectors.join(",")).filter((it) => typeof it.role === "string" && roles.includes(it.role));
9091
- const grouped = groupBy(elements, (it) => it.role);
9092
- for (const nodes of Object.values(grouped)) {
9093
- if (nodes.length <= 1) {
9094
- continue;
9095
- }
9096
- const entries = nodes.map((it) => getTextEntryFromElement(document, it));
9097
- const filteredEntries = entries.filter(isExcluded);
9098
- for (const entry of filteredEntries) {
9099
- if (entry.text instanceof DynamicValue) {
9100
- continue;
9101
- }
9102
- const dup = entries.filter((it) => it.text === entry.text).length > 1;
9103
- if (!entry.text || dup) {
9104
- const message = `Landmarks must have a non-empty and unique accessible name (aria-label or aria-labelledby)`;
9105
- const location = entry.location;
9106
- this.report({
9107
- node: entry.node,
9108
- message,
9109
- location
9110
- });
9111
- }
9112
- }
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
+ });
9113
9260
  }
9114
- });
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
+ }
9115
9294
  }
9116
9295
  }
9117
9296
 
9118
- const defaults$4 = {
9119
- ignoreCase: false,
9120
- requireSemicolon: true
9297
+ const defaults$3 = {
9298
+ relaxed: false
9121
9299
  };
9122
- const regexp$1 = /&(?:[a-z0-9]+|#x?[0-9a-f]+)(;|[^a-z0-9]|$)/gi;
9123
- const lowercaseEntities = elements.entities.map((it) => it.toLowerCase());
9124
- function isNumerical(entity) {
9125
- return entity.startsWith("&#");
9126
- }
9127
- function getLocation(location, entity, match) {
9128
- const index = match.index ?? 0;
9129
- return sliceLocation(location, index, index + entity.length);
9130
- }
9131
- function getDescription(context, options) {
9132
- const url = "https://html.spec.whatwg.org/multipage/named-characters.html";
9133
- let message;
9134
- if (context.terminated) {
9135
- message = `Unrecognized character reference \`${context.entity}\`.`;
9136
- } else {
9137
- message = `Character reference \`${context.entity}\` must be terminated by a semicolon.`;
9138
- }
9139
- return [
9140
- message,
9141
- `HTML5 defines a set of [valid character references](${url}) but this is not a valid one.`,
9142
- "",
9143
- "Ensure that:",
9144
- "",
9145
- "1. The character is one of the listed names.",
9146
- ...options.ignoreCase ? [] : ["1. The case is correct (names are case sensitive)."],
9147
- ...options.requireSemicolon ? ["1. The name is terminated with a `;`."] : []
9148
- ].join("\n");
9149
- }
9150
- class UnknownCharReference extends Rule {
9300
+ class ValidID extends Rule {
9151
9301
  constructor(options) {
9152
- super({ ...defaults$4, ...options });
9302
+ super({ ...defaults$3, ...options });
9153
9303
  }
9154
9304
  static schema() {
9155
9305
  return {
9156
- ignoreCase: {
9157
- type: "boolean"
9158
- },
9159
- requireSemicolon: {
9306
+ relaxed: {
9160
9307
  type: "boolean"
9161
9308
  }
9162
9309
  };
9163
9310
  }
9164
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
+ ];
9165
9318
  return {
9166
- description: getDescription(context, this.options),
9167
- 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"
9168
9329
  };
9169
9330
  }
9170
9331
  setup() {
9171
- this.on("element:ready", (event) => {
9172
- const node = event.target;
9173
- for (const child of node.childNodes) {
9174
- if (child.nodeType !== NodeType.TEXT_NODE) {
9175
- continue;
9176
- }
9177
- this.findCharacterReferences(node, child.textContent, child.location, {
9178
- isAttribute: false
9179
- });
9332
+ this.on("attr", this.isRelevant, (event) => {
9333
+ const { value } = event;
9334
+ if (value === null || value instanceof DynamicValue) {
9335
+ return;
9180
9336
  }
9181
- });
9182
- this.on("attr", (event) => {
9183
- if (!event.value) {
9337
+ if (value === "") {
9338
+ const context = 1 /* EMPTY */;
9339
+ this.report(event.target, this.messages[context], event.location, context);
9184
9340
  return;
9185
9341
  }
9186
- this.findCharacterReferences(event.target, event.value.toString(), event.valueLocation, {
9187
- isAttribute: true
9188
- });
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
+ }
9189
9360
  });
9190
9361
  }
9191
- get entities() {
9192
- if (this.options.ignoreCase) {
9193
- return lowercaseEntities;
9194
- } else {
9195
- return elements.entities;
9196
- }
9197
- }
9198
- findCharacterReferences(node, text, location, { isAttribute }) {
9199
- const isQuerystring = isAttribute && text.includes("?");
9200
- for (const match of this.getMatches(text)) {
9201
- this.validateCharacterReference(node, location, match, { isQuerystring });
9202
- }
9203
- }
9204
- validateCharacterReference(node, location, foobar, { isQuerystring }) {
9205
- const { requireSemicolon } = this.options;
9206
- const { match, entity, raw, terminated } = foobar;
9207
- if (isNumerical(entity)) {
9208
- return;
9209
- }
9210
- if (isQuerystring && !terminated) {
9211
- return;
9212
- }
9213
- const found = this.entities.includes(entity);
9214
- if (found && (terminated || !requireSemicolon)) {
9215
- return;
9216
- }
9217
- if (found && !terminated) {
9218
- const entityLocation2 = getLocation(location, entity, match);
9219
- const message2 = `Character reference "{{ entity }}" must be terminated by a semicolon`;
9220
- const context2 = {
9221
- entity: raw,
9222
- terminated: false
9223
- };
9224
- this.report(node, message2, entityLocation2, context2);
9225
- return;
9226
- }
9227
- const entityLocation = getLocation(location, entity, match);
9228
- const message = `Unrecognized character reference "{{ entity }}"`;
9229
- const context = {
9230
- entity: raw,
9231
- 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"
9232
9368
  };
9233
- this.report(node, message, entityLocation, context);
9234
9369
  }
9235
- *getMatches(text) {
9236
- let match;
9237
- do {
9238
- match = regexp$1.exec(text);
9239
- if (match) {
9240
- const terminator = match[1];
9241
- const terminated = terminator === ";";
9242
- const needSlice = terminator !== ";" && terminator.length > 0;
9243
- const entity = needSlice ? match[0].slice(0, -1) : match[0];
9244
- if (this.options.ignoreCase) {
9245
- yield { match, entity: entity.toLowerCase(), raw: entity, terminated };
9246
- } else {
9247
- yield { match, entity, raw: entity, terminated };
9248
- }
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;
9249
9391
  }
9250
- } 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
+ });
9251
9404
  }
9252
9405
  }
9253
9406
 
9254
- const expectedOrder = ["section", "hint", "contact", "field1", "field2", "webauthn"];
9255
- const fieldNames1 = [
9256
- "name",
9257
- "honorific-prefix",
9258
- "given-name",
9259
- "additional-name",
9260
- "family-name",
9261
- "honorific-suffix",
9262
- "nickname",
9263
- "username",
9264
- "new-password",
9265
- "current-password",
9266
- "one-time-code",
9267
- "organization-title",
9268
- "organization",
9269
- "street-address",
9270
- "address-line1",
9271
- "address-line2",
9272
- "address-line3",
9273
- "address-level4",
9274
- "address-level3",
9275
- "address-level2",
9276
- "address-level1",
9277
- "country",
9278
- "country-name",
9279
- "postal-code",
9280
- "cc-name",
9281
- "cc-given-name",
9282
- "cc-additional-name",
9283
- "cc-family-name",
9284
- "cc-number",
9285
- "cc-exp",
9286
- "cc-exp-month",
9287
- "cc-exp-year",
9288
- "cc-csc",
9289
- "cc-type",
9290
- "transaction-currency",
9291
- "transaction-amount",
9292
- "language",
9293
- "bday",
9294
- "bday-day",
9295
- "bday-month",
9296
- "bday-year",
9297
- "sex",
9298
- "url",
9299
- "photo"
9300
- ];
9301
- const fieldNames2 = [
9302
- "tel",
9303
- "tel-country-code",
9304
- "tel-national",
9305
- "tel-area-code",
9306
- "tel-local",
9307
- "tel-local-prefix",
9308
- "tel-local-suffix",
9309
- "tel-extension",
9310
- "email",
9311
- "impp"
9312
- ];
9313
- const fieldNameGroup = {
9314
- name: "text",
9315
- "honorific-prefix": "text",
9316
- "given-name": "text",
9317
- "additional-name": "text",
9318
- "family-name": "text",
9319
- "honorific-suffix": "text",
9320
- nickname: "text",
9321
- username: "username",
9322
- "new-password": "password",
9323
- "current-password": "password",
9324
- "one-time-code": "password",
9325
- "organization-title": "text",
9326
- organization: "text",
9327
- "street-address": "multiline",
9328
- "address-line1": "text",
9329
- "address-line2": "text",
9330
- "address-line3": "text",
9331
- "address-level4": "text",
9332
- "address-level3": "text",
9333
- "address-level2": "text",
9334
- "address-level1": "text",
9335
- country: "text",
9336
- "country-name": "text",
9337
- "postal-code": "text",
9338
- "cc-name": "text",
9339
- "cc-given-name": "text",
9340
- "cc-additional-name": "text",
9341
- "cc-family-name": "text",
9342
- "cc-number": "text",
9343
- "cc-exp": "month",
9344
- "cc-exp-month": "numeric",
9345
- "cc-exp-year": "numeric",
9346
- "cc-csc": "text",
9347
- "cc-type": "text",
9348
- "transaction-currency": "text",
9349
- "transaction-amount": "numeric",
9350
- language: "text",
9351
- bday: "date",
9352
- "bday-day": "numeric",
9353
- "bday-month": "numeric",
9354
- "bday-year": "numeric",
9355
- sex: "text",
9356
- url: "url",
9357
- photo: "url",
9358
- tel: "tel",
9359
- "tel-country-code": "text",
9360
- "tel-national": "text",
9361
- "tel-area-code": "text",
9362
- "tel-local": "text",
9363
- "tel-local-prefix": "text",
9364
- "tel-local-suffix": "text",
9365
- "tel-extension": "text",
9366
- email: "username",
9367
- impp: "url"
9407
+ const defaults$2 = {
9408
+ style: "omit"
9368
9409
  };
9369
- const disallowedInputTypes = ["checkbox", "radio", "file", "submit", "image", "reset", "button"];
9370
- function matchSection(token) {
9371
- return token.startsWith("section-");
9372
- }
9373
- function matchHint(token) {
9374
- return token === "shipping" || token === "billing";
9375
- }
9376
- function matchFieldNames1(token) {
9377
- return fieldNames1.includes(token);
9378
- }
9379
- function matchContact(token) {
9380
- const haystack = ["home", "work", "mobile", "fax", "pager"];
9381
- return haystack.includes(token);
9382
- }
9383
- function matchFieldNames2(token) {
9384
- return fieldNames2.includes(token);
9385
- }
9386
- function matchWebauthn(token) {
9387
- return token === "webauthn";
9388
- }
9389
- function matchToken(token) {
9390
- if (matchSection(token)) {
9391
- return "section";
9410
+ class VoidStyle extends Rule {
9411
+ constructor(options) {
9412
+ super({ ...defaults$2, ...options });
9413
+ this.style = parseStyle(this.options.style);
9392
9414
  }
9393
- if (matchHint(token)) {
9394
- return "hint";
9415
+ static schema() {
9416
+ return {
9417
+ style: {
9418
+ enum: ["omit", "selfclose", "selfclosing"],
9419
+ type: "string"
9420
+ }
9421
+ };
9395
9422
  }
9396
- if (matchFieldNames1(token)) {
9397
- 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
+ };
9398
9429
  }
9399
- if (matchFieldNames2(token)) {
9400
- 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
+ });
9401
9437
  }
9402
- if (matchContact(token)) {
9403
- 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
+ }
9404
9454
  }
9405
- if (matchWebauthn(token)) {
9406
- 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;
9407
9467
  }
9408
- return null;
9409
9468
  }
9410
- function getControlGroups(type) {
9411
- const allGroups = [
9412
- "text",
9413
- "multiline",
9414
- "password",
9415
- "url",
9416
- "username",
9417
- "tel",
9418
- "numeric",
9419
- "month",
9420
- "date"
9421
- ];
9422
- const mapping = {
9423
- hidden: allGroups,
9424
- text: allGroups.filter((it) => it !== "multiline"),
9425
- search: allGroups.filter((it) => it !== "multiline"),
9426
- password: ["password"],
9427
- url: ["url"],
9428
- email: ["username"],
9429
- tel: ["tel"],
9430
- number: ["numeric"],
9431
- month: ["month"],
9432
- date: ["date"]
9433
- };
9434
- const groups = mapping[type];
9435
- if (groups) {
9436
- 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`);
9437
9478
  }
9438
- return [];
9439
9479
  }
9440
- function isDisallowedType(node, type) {
9441
- if (!node.is("input")) {
9442
- 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`);
9443
9488
  }
9444
- return disallowedInputTypes.includes(type);
9445
9489
  }
9446
- function getTerminalMessage(context) {
9447
- switch (context.msg) {
9448
- case 0 /* InvalidAttribute */:
9449
- return "autocomplete attribute cannot be used on {{ what }}";
9450
- case 1 /* InvalidValue */:
9451
- return '"{{ value }}" cannot be used on {{ what }}';
9452
- case 2 /* InvalidOrder */:
9453
- return '"{{ second }}" must appear before "{{ first }}"';
9454
- case 3 /* InvalidToken */:
9455
- return '"{{ token }}" is not a valid autocomplete token or field name';
9456
- case 4 /* InvalidCombination */:
9457
- return '"{{ second }}" cannot be combined with "{{ first }}"';
9458
- case 5 /* MissingField */:
9459
- 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
+ };
9460
9497
  }
9461
- }
9462
- function getMarkdownMessage(context) {
9463
- switch (context.msg) {
9464
- case 0 /* InvalidAttribute */:
9465
- return [
9466
- `\`autocomplete\` attribute cannot be used on \`${context.what}\``,
9467
- "",
9468
- "The following input types cannot use the `autocomplete` attribute:",
9469
- "",
9470
- ...disallowedInputTypes.map((it) => `- \`${it}\``)
9471
- ].join("\n");
9472
- case 1 /* InvalidValue */: {
9473
- const message = `\`"${context.value}"\` cannot be used on \`${context.what}\``;
9474
- if (context.type === "form") {
9475
- return [
9476
- message,
9477
- "",
9478
- 'The `<form>` element can only use the values `"on"` and `"off"`.'
9479
- ].join("\n");
9480
- }
9481
- if (context.type === "hidden") {
9482
- return [
9483
- message,
9484
- "",
9485
- '`<input type="hidden">` cannot use the values `"on"` and `"off"`.'
9486
- ].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");
9487
9521
  }
9488
- const controlGroups = getControlGroups(context.type);
9489
- const currentGroup = fieldNameGroup[context.value];
9490
- return [
9491
- message,
9492
- "",
9493
- `\`${context.what}\` allows autocomplete fields from the following group${controlGroups.length > 1 ? "s" : ""}:`,
9494
- "",
9495
- ...controlGroups.map((it) => `- ${it}`),
9496
- "",
9497
- `The field \`"${context.value}"\` belongs to the group /${currentGroup}/ which cannot be used with this input type.`
9498
- ].join("\n");
9499
- }
9500
- case 2 /* InvalidOrder */:
9501
- return [
9502
- `\`"${context.second}"\` must appear before \`"${context.first}"\``,
9503
- "",
9504
- "The autocomplete tokens must appear in the following order:",
9505
- "",
9506
- "- Optional section name (`section-` prefix).",
9507
- "- Optional `shipping` or `billing` token.",
9508
- "- Optional `home`, `work`, `mobile`, `fax` or `pager` token (for fields supporting it).",
9509
- "- Field name",
9510
- "- Optional `webauthn` token."
9511
- ].join("\n");
9512
- case 3 /* InvalidToken */:
9513
- return `\`"${context.token}"\` is not a valid autocomplete token or field name`;
9514
- case 4 /* InvalidCombination */:
9515
- return `\`"${context.second}"\` cannot be combined with \`"${context.first}"\``;
9516
- case 5 /* MissingField */:
9517
- return "Autocomplete attribute is missing field name";
9522
+ });
9518
9523
  }
9519
9524
  }
9520
- class ValidAutocomplete extends Rule {
9521
- documentation(context) {
9525
+
9526
+ class H32 extends Rule {
9527
+ documentation() {
9522
9528
  return {
9523
- description: getMarkdownMessage(context),
9524
- 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"
9525
9531
  };
9526
9532
  }
9527
9533
  setup() {
9534
+ const formTags = this.getTagsWithProperty("form");
9535
+ const formSelector = formTags.join(",");
9528
9536
  this.on("dom:ready", (event) => {
9529
9537
  const { document } = event;
9530
- const elements = document.querySelectorAll("[autocomplete]");
9531
- for (const element of elements) {
9532
- const autocomplete = element.getAttribute("autocomplete");
9533
- if (autocomplete.value === null || autocomplete.value instanceof DynamicValue) {
9538
+ const forms = document.querySelectorAll(formSelector);
9539
+ for (const form of forms) {
9540
+ if (hasNestedSubmit(form)) {
9534
9541
  continue;
9535
9542
  }
9536
- const location = autocomplete.valueLocation;
9537
- const value = autocomplete.value.toLowerCase();
9538
- const tokens = new DOMTokenList(value, location);
9539
- if (tokens.length === 0) {
9543
+ if (hasAssociatedSubmit(document, form)) {
9540
9544
  continue;
9541
9545
  }
9542
- this.validate(element, value, tokens, autocomplete.keyLocation, location);
9546
+ this.report(form, `<${form.tagName}> element must have a submit button`);
9543
9547
  }
9544
9548
  });
9545
9549
  }
9546
- validate(node, value, tokens, keyLocation, valueLocation) {
9547
- switch (node.tagName) {
9548
- case "form":
9549
- this.validateFormAutocomplete(node, value, valueLocation);
9550
- break;
9551
- case "input":
9552
- case "textarea":
9553
- case "select":
9554
- this.validateControlAutocomplete(node, tokens, keyLocation);
9555
- break;
9556
- }
9557
- }
9558
- validateControlAutocomplete(node, tokens, keyLocation) {
9559
- const type = node.getAttributeValue("type") ?? "text";
9560
- const mantle = type !== "hidden" ? "expectation" : "anchor";
9561
- if (isDisallowedType(node, type)) {
9562
- const context = {
9563
- msg: 0 /* InvalidAttribute */,
9564
- what: `<input type="${type}">`
9565
- };
9566
- this.report({
9567
- node,
9568
- message: getTerminalMessage(context),
9569
- location: keyLocation,
9570
- context
9571
- });
9572
- return;
9573
- }
9574
- if (tokens.includes("on") || tokens.includes("off")) {
9575
- this.validateOnOff(node, mantle, tokens);
9576
- return;
9577
- }
9578
- 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;
9579
9567
  }
9580
- validateFormAutocomplete(node, value, location) {
9581
- const trimmed = value.trim();
9582
- if (["on", "off"].includes(trimmed)) {
9583
- return;
9584
- }
9585
- const context = {
9586
- msg: 1 /* InvalidValue */,
9587
- type: "form",
9588
- value: trimmed,
9589
- 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"
9590
9580
  };
9591
- this.report({
9592
- node,
9593
- message: getTerminalMessage(context),
9594
- location,
9595
- context
9596
- });
9597
9581
  }
9598
- validateOnOff(node, mantle, tokens) {
9599
- const index = tokens.findIndex((it) => it === "on" || it === "off");
9600
- const value = tokens.item(index);
9601
- const location = tokens.location(index);
9602
- if (tokens.length > 1) {
9603
- const context = {
9604
- msg: 4 /* InvalidCombination */,
9605
- /* eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- it must be present of it wouldn't be found */
9606
- first: tokens.item(index > 0 ? 0 : 1),
9607
- second: value
9608
- };
9609
- this.report({
9610
- node,
9611
- message: getTerminalMessage(context),
9612
- location,
9613
- context
9614
- });
9615
- }
9616
- switch (mantle) {
9617
- case "expectation":
9618
- return;
9619
- case "anchor": {
9620
- const context = {
9621
- msg: 1 /* InvalidValue */,
9622
- type: "hidden",
9623
- value,
9624
- what: `<input type="hidden">`
9625
- };
9626
- this.report({
9627
- node,
9628
- message: getTerminalMessage(context),
9629
- location: tokens.location(0),
9630
- context
9631
- });
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;
9632
9589
  }
9633
- }
9634
- }
9635
- validateTokens(node, tokens, keyLocation) {
9636
- const order = [];
9637
- for (const { item, location } of tokens.iterator()) {
9638
- const tokenType = matchToken(item);
9639
- if (tokenType) {
9640
- order.push(tokenType);
9641
- } else {
9642
- const context = {
9643
- msg: 3 /* InvalidToken */,
9644
- token: item
9645
- };
9646
- this.report({
9647
- node,
9648
- message: getTerminalMessage(context),
9649
- location,
9650
- context
9651
- });
9590
+ if (!inAccessibilityTree(node)) {
9652
9591
  return;
9653
9592
  }
9654
- }
9655
- const fieldTokens = order.map((it) => it === "field1" || it === "field2");
9656
- this.validateFieldPresence(node, tokens, fieldTokens, keyLocation);
9657
- this.validateContact(node, tokens, order);
9658
- this.validateOrder(node, tokens, order);
9659
- this.validateControlGroup(node, tokens, fieldTokens);
9660
- }
9661
- /**
9662
- * Ensure that exactly one field name is present from the two field lists.
9663
- */
9664
- validateFieldPresence(node, tokens, fieldTokens, keyLocation) {
9665
- const numFields = fieldTokens.filter(Boolean).length;
9666
- if (numFields === 0) {
9667
- const context = {
9668
- msg: 5 /* MissingField */
9669
- };
9670
- this.report({
9671
- node,
9672
- message: getTerminalMessage(context),
9673
- location: keyLocation,
9674
- context
9675
- });
9676
- } else if (numFields > 1) {
9677
- const a = fieldTokens.indexOf(true);
9678
- const b = fieldTokens.lastIndexOf(true);
9679
- const context = {
9680
- msg: 4 /* InvalidCombination */,
9681
- /* eslint-disable @typescript-eslint/no-non-null-assertion -- it must be present of it wouldn't be found */
9682
- first: tokens.item(a),
9683
- second: tokens.item(b)
9684
- /* eslint-enable @typescript-eslint/no-non-null-assertion */
9685
- };
9686
- this.report({
9687
- node,
9688
- message: getTerminalMessage(context),
9689
- location: tokens.location(b),
9690
- context
9691
- });
9692
- }
9693
- }
9694
- /**
9695
- * Ensure contact token is only used with field names from the second list.
9696
- */
9697
- validateContact(node, tokens, order) {
9698
- if (order.includes("contact") && order.includes("field1")) {
9699
- const a = order.indexOf("field1");
9700
- const b = order.indexOf("contact");
9701
- const context = {
9702
- msg: 4 /* InvalidCombination */,
9703
- /* eslint-disable @typescript-eslint/no-non-null-assertion -- it must be present of it wouldn't be found */
9704
- first: tokens.item(a),
9705
- second: tokens.item(b)
9706
- /* eslint-enable @typescript-eslint/no-non-null-assertion */
9707
- };
9708
- this.report({
9709
- node,
9710
- message: getTerminalMessage(context),
9711
- location: tokens.location(b),
9712
- context
9713
- });
9714
- }
9715
- }
9716
- validateOrder(node, tokens, order) {
9717
- const indicies = order.map((it) => expectedOrder.indexOf(it));
9718
- for (let i = 0; i < indicies.length - 1; i++) {
9719
- if (indicies[0] > indicies[i + 1]) {
9720
- const context = {
9721
- msg: 2 /* InvalidOrder */,
9722
- /* eslint-disable @typescript-eslint/no-non-null-assertion -- it must be present of it wouldn't be found */
9723
- first: tokens.item(i),
9724
- second: tokens.item(i + 1)
9725
- /* eslint-enable @typescript-eslint/no-non-null-assertion */
9726
- };
9593
+ if (!hasAltText(node)) {
9594
+ const message = "image used as submit button must have non-empty alt text";
9595
+ const alt = node.getAttribute("alt");
9727
9596
  this.report({
9728
9597
  node,
9729
- message: getTerminalMessage(context),
9730
- location: tokens.location(i + 1),
9731
- context
9598
+ message,
9599
+ location: alt ? alt.keyLocation : node.location
9732
9600
  });
9733
9601
  }
9734
- }
9735
- }
9736
- validateControlGroup(node, tokens, fieldTokens) {
9737
- const numFields = fieldTokens.filter(Boolean).length;
9738
- if (numFields === 0) {
9739
- return;
9740
- }
9741
- if (!node.is("input")) {
9742
- return;
9743
- }
9744
- const attr = node.getAttribute("type");
9745
- const type = (attr == null ? void 0 : attr.value) ?? "text";
9746
- if (type instanceof DynamicValue) {
9747
- return;
9748
- }
9749
- const controlGroups = getControlGroups(type);
9750
- const fieldIndex = fieldTokens.indexOf(true);
9751
- const fieldToken = tokens.item(fieldIndex);
9752
- const fieldGroup = fieldNameGroup[fieldToken];
9753
- if (!controlGroups.includes(fieldGroup)) {
9754
- const context = {
9755
- msg: 1 /* InvalidValue */,
9756
- type,
9757
- value: fieldToken,
9758
- what: `<input type="${type}">`
9759
- };
9760
- this.report({
9761
- node,
9762
- message: getTerminalMessage(context),
9763
- location: tokens.location(fieldIndex),
9764
- context
9765
- });
9766
- }
9602
+ });
9767
9603
  }
9768
9604
  }
9769
9605
 
9770
- const defaults$3 = {
9771
- relaxed: false
9606
+ const defaults$1 = {
9607
+ allowEmpty: true,
9608
+ alias: []
9772
9609
  };
9773
- class ValidID extends Rule {
9610
+ class H37 extends Rule {
9774
9611
  constructor(options) {
9775
- super({ ...defaults$3, ...options });
9612
+ super({ ...defaults$1, ...options });
9613
+ if (!Array.isArray(this.options.alias)) {
9614
+ this.options.alias = [this.options.alias];
9615
+ }
9776
9616
  }
9777
9617
  static schema() {
9778
9618
  return {
9779
- 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: {
9780
9633
  type: "boolean"
9781
9634
  }
9782
9635
  };
9783
9636
  }
9784
- documentation(context) {
9785
- const { relaxed } = this.options;
9786
- const message = this.messages[context].replace("id", "ID").replace(/^(.)/, (m) => m.toUpperCase());
9787
- const relaxedDescription = relaxed ? [] : [
9788
- " - ID must begin with a letter",
9789
- " - ID must only contain letters, digits, `-` and `_`"
9790
- ];
9637
+ documentation() {
9791
9638
  return {
9792
- description: [
9793
- `${message}.`,
9794
- "",
9795
- "Under the current configuration the following rules are applied:",
9796
- "",
9797
- " - ID must not be empty",
9798
- " - ID must not contain any whitespace characters",
9799
- ...relaxedDescription
9800
- ].join("\n"),
9801
- 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"
9802
9641
  };
9803
9642
  }
9804
9643
  setup() {
9805
- this.on("attr", this.isRelevant, (event) => {
9806
- const { value } = event;
9807
- if (value === null || value instanceof DynamicValue) {
9808
- return;
9809
- }
9810
- if (value === "") {
9811
- const context = 1 /* EMPTY */;
9812
- this.report(event.target, this.messages[context], event.location, context);
9813
- return;
9814
- }
9815
- if (value.match(/\s/)) {
9816
- const context = 2 /* WHITESPACE */;
9817
- this.report(event.target, this.messages[context], event.valueLocation, context);
9818
- return;
9819
- }
9820
- const { relaxed } = this.options;
9821
- if (relaxed) {
9822
- return;
9823
- }
9824
- if (value.match(/^[^\p{L}]/u)) {
9825
- const context = 3 /* LEADING_CHARACTER */;
9826
- this.report(event.target, this.messages[context], event.valueLocation, context);
9827
- return;
9828
- }
9829
- if (value.match(/[^\p{L}\p{N}_-]/u)) {
9830
- const context = 4 /* DISALLOWED_CHARACTER */;
9831
- 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);
9832
9649
  }
9833
9650
  });
9834
9651
  }
9835
- get messages() {
9836
- return {
9837
- [1 /* EMPTY */]: "element id must not be empty",
9838
- [2 /* WHITESPACE */]: "element id must not contain whitespace",
9839
- [3 /* LEADING_CHARACTER */]: "element id must begin with a letter",
9840
- [4 /* DISALLOWED_CHARACTER */]: "element id must only contain letters, digits, dash and underscore characters"
9841
- };
9842
- }
9843
- isRelevant(event) {
9844
- 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
+ }
9845
9671
  }
9846
9672
  }
9847
9673
 
9848
- class VoidContent extends Rule {
9849
- documentation(tagName) {
9850
- const doc = {
9851
- description: "HTML void elements cannot have any content and must not have content or end tag.",
9852
- 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"
9853
9682
  };
9854
- if (tagName) {
9855
- doc.description = `<${tagName}> is a void element and must not have content or end tag.`;
9856
- }
9857
- return doc;
9858
9683
  }
9859
9684
  setup() {
9860
- this.on("tag:end", (event) => {
9685
+ this.on("tag:ready", (event) => {
9861
9686
  const node = event.target;
9862
- if (!node) {
9687
+ if (node.tagName !== "th") {
9863
9688
  return;
9864
9689
  }
9865
- if (!node.voidElement) {
9690
+ const scope = node.getAttribute("scope");
9691
+ const value = scope == null ? void 0 : scope.value;
9692
+ if (value instanceof DynamicValue) {
9866
9693
  return;
9867
9694
  }
9868
- if (node.closed === NodeClosed.EndTag) {
9869
- this.report(
9870
- null,
9871
- `End tag for <${node.tagName}> must be omitted`,
9872
- node.location,
9873
- node.tagName
9874
- );
9695
+ if (value && validScopes.includes(value)) {
9696
+ return;
9875
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);
9876
9701
  });
9877
9702
  }
9878
9703
  }
9879
9704
 
9880
- const defaults$2 = {
9881
- style: "omit"
9882
- };
9883
- class VoidStyle extends Rule {
9884
- constructor(options) {
9885
- super({ ...defaults$2, ...options });
9886
- this.style = parseStyle(this.options.style);
9887
- }
9888
- static schema() {
9889
- return {
9890
- style: {
9891
- enum: ["omit", "selfclose", "selfclosing"],
9892
- type: "string"
9893
- }
9894
- };
9895
- }
9896
- documentation(context) {
9897
- const [desc, end] = styleDescription(context.style);
9705
+ class H67 extends Rule {
9706
+ documentation() {
9898
9707
  return {
9899
- description: `The current configuration requires void elements to ${desc}, use <${context.tagName}${end}> instead.`,
9900
- 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"
9901
9710
  };
9902
9711
  }
9903
9712
  setup() {
9904
9713
  this.on("tag:end", (event) => {
9905
- const active = event.previous;
9906
- if (active.meta) {
9907
- 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;
9908
9725
  }
9726
+ this.report(node, "<img> with empty alt text cannot have title attribute", title.keyLocation);
9909
9727
  });
9910
9728
  }
9911
- validateActive(node) {
9912
- if (!node.voidElement) {
9913
- return;
9914
- }
9915
- if (this.shouldBeOmitted(node)) {
9916
- this.reportError(
9917
- node,
9918
- `Expected omitted end tag <${node.tagName}> instead of self-closing element <${node.tagName}/>`
9919
- );
9920
- }
9921
- if (this.shouldBeSelfClosed(node)) {
9922
- this.reportError(
9923
- node,
9924
- `Expected self-closing element <${node.tagName}/> instead of omitted end-tag <${node.tagName}>`
9925
- );
9926
- }
9927
- }
9928
- reportError(node, message) {
9929
- const context = {
9930
- style: this.style,
9931
- tagName: node.tagName
9932
- };
9933
- super.report(node, message, null, context);
9934
- }
9935
- shouldBeOmitted(node) {
9936
- return this.style === 1 /* AlwaysOmit */ && node.closed === NodeClosed.VoidSelfClosed;
9937
- }
9938
- shouldBeSelfClosed(node) {
9939
- return this.style === 2 /* AlwaysSelfclose */ && node.closed === NodeClosed.VoidOmitted;
9940
- }
9941
- }
9942
- function parseStyle(name) {
9943
- switch (name) {
9944
- case "omit":
9945
- return 1 /* AlwaysOmit */;
9946
- case "selfclose":
9947
- case "selfclosing":
9948
- return 2 /* AlwaysSelfclose */;
9949
- default:
9950
- throw new Error(`Invalid style "${name}" for "void-style" rule`);
9951
- }
9952
- }
9953
- function styleDescription(style) {
9954
- switch (style) {
9955
- case 1 /* AlwaysOmit */:
9956
- return ["omit end tag", ""];
9957
- case 2 /* AlwaysSelfclose */:
9958
- return ["be self-closed", "/"];
9959
- default:
9960
- throw new Error(`Unknown style`);
9961
- }
9962
9729
  }
9963
9730
 
9964
- class H30 extends Rule {
9731
+ class H71 extends Rule {
9965
9732
  documentation() {
9966
9733
  return {
9967
- 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.",
9968
- 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"
9969
9736
  };
9970
9737
  }
9971
9738
  setup() {
9972
9739
  this.on("dom:ready", (event) => {
9973
- const links = event.document.getElementsByTagName("a");
9974
- for (const link of links) {
9975
- if (!link.hasAttribute("href")) {
9976
- continue;
9977
- }
9978
- if (!inAccessibilityTree(link)) {
9979
- continue;
9980
- }
9981
- const textClassification = classifyNodeText(link, { ignoreHiddenRoot: true });
9982
- if (textClassification !== TextClassification.EMPTY_TEXT) {
9983
- continue;
9984
- }
9985
- const images = link.querySelectorAll("img");
9986
- if (images.some((image) => hasAltText(image))) {
9987
- continue;
9988
- }
9989
- const labels = link.querySelectorAll("[aria-label]");
9990
- if (hasAriaLabel(link) || labels.some((cur) => hasAriaLabel(cur))) {
9991
- continue;
9992
- }
9993
- 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);
9994
9744
  }
9995
9745
  });
9996
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);
9997
9861
  }
9998
9862
 
9999
- class H32 extends Rule {
10000
- documentation() {
10001
- return {
10002
- description: "WCAG 2.1 requires each `<form>` element to have at least one submit button.",
10003
- url: "https://html-validate.org/rules/wcag/h32.html"
10004
- };
10005
- }
10006
- setup() {
10007
- const formTags = this.getTagsWithProperty("form");
10008
- const formSelector = formTags.join(",");
10009
- this.on("dom:ready", (event) => {
10010
- const { document } = event;
10011
- const forms = document.querySelectorAll(formSelector);
10012
- for (const form of forms) {
10013
- if (hasNestedSubmit(form)) {
10014
- continue;
10015
- }
10016
- if (hasAssociatedSubmit(document, form)) {
10017
- continue;
10018
- }
10019
- this.report(form, `<${form.tagName}> element must have a submit button`);
10020
- }
10021
- });
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;
10022
9869
  }
10023
- }
10024
- function isSubmit(node) {
10025
- const type = node.getAttribute("type");
10026
- return Boolean(!type || type.valueMatches(/submit|image/));
10027
- }
10028
- function isAssociated(id, node) {
10029
- const form = node.getAttribute("form");
10030
- return Boolean(form == null ? void 0 : form.valueMatches(id, true));
10031
- }
10032
- function hasNestedSubmit(form) {
10033
- const matches = form.querySelectorAll("button,input").filter(isSubmit).filter((node) => !node.hasAttribute("form"));
10034
- return matches.length > 0;
10035
- }
10036
- function hasAssociatedSubmit(document, form) {
10037
- const { id } = form;
10038
- if (!id) {
10039
- return false;
9870
+ function visit(node) {
9871
+ node.childElements.forEach(visit);
9872
+ if (!node.isRootElement()) {
9873
+ callback(node);
9874
+ }
10040
9875
  }
10041
- const matches = document.querySelectorAll("button[form],input[form]").filter(isSubmit).filter((node) => isAssociated(id, node));
10042
- return matches.length > 0;
9876
+ visit(root);
10043
9877
  }
9878
+ const walk = {
9879
+ depthFirst
9880
+ };
10044
9881
 
10045
- class H36 extends Rule {
10046
- documentation() {
10047
- return {
10048
- description: [
10049
- "WCAG 2.1 requires all images used as submit buttons to have a non-empty textual description using the `alt` attribute.",
10050
- 'The alt text cannot be empty (`alt=""`).'
10051
- ].join("\n"),
10052
- url: "https://html-validate.org/rules/wcag/h36.html"
10053
- };
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";
10054
9891
  }
10055
- setup() {
10056
- this.on("tag:end", (event) => {
10057
- const node = event.previous;
10058
- if (node.tagName !== "input")
10059
- return;
10060
- if (node.getAttributeValue("type") !== "image") {
10061
- return;
10062
- }
10063
- if (!inAccessibilityTree(node)) {
10064
- return;
10065
- }
10066
- if (!hasAltText(node)) {
10067
- const message = "image used as submit button must have non-empty alt text";
10068
- const alt = node.getAttribute("alt");
10069
- this.report({
10070
- node,
10071
- message,
10072
- location: alt ? alt.keyLocation : node.location
10073
- });
10074
- }
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);
10075
9931
  });
10076
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
+ }
10077
9954
  }
10078
9955
 
10079
- const defaults$1 = {
10080
- allowEmpty: true,
10081
- alias: []
10082
- };
10083
- class H37 extends Rule {
10084
- constructor(options) {
10085
- super({ ...defaults$1, ...options });
10086
- if (!Array.isArray(this.options.alias)) {
10087
- 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;
10088
9968
  }
9969
+ return rules.some((rule) => {
9970
+ return Validator.validatePermittedRule(node, rule);
9971
+ });
10089
9972
  }
10090
- static schema() {
10091
- return {
10092
- alias: {
10093
- anyOf: [
10094
- {
10095
- items: {
10096
- type: "string"
10097
- },
10098
- type: "array"
10099
- },
10100
- {
10101
- 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);
10102
10003
  }
10103
- ]
10104
- },
10105
- allowEmpty: {
10106
- type: "boolean"
10004
+ valid = false;
10005
+ }
10107
10006
  }
10108
- };
10109
- }
10110
- documentation() {
10111
- return {
10112
- description: "Both HTML5 and WCAG 2.0 requires images to have a alternative text for each image.",
10113
- url: "https://html-validate.org/rules/wcag/h37.html"
10114
- };
10007
+ }
10008
+ return valid;
10115
10009
  }
10116
- setup() {
10117
- this.on("dom:ready", (event) => {
10118
- const { document } = event;
10119
- const nodes = document.querySelectorAll("img");
10120
- for (const node of nodes) {
10121
- 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;
10122
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;
10123
10077
  });
10124
10078
  }
10125
- validateNode(node) {
10126
- if (!inAccessibilityTree(node)) {
10127
- 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;
10128
10090
  }
10129
- if (Boolean(node.getAttributeValue("alt")) || Boolean(node.hasAttribute("alt") && this.options.allowEmpty)) {
10130
- return;
10091
+ const value = attr.value;
10092
+ if (value instanceof DynamicValue) {
10093
+ return true;
10131
10094
  }
10132
- for (const attr of this.options.alias) {
10133
- if (node.getAttribute(attr)) {
10134
- return;
10135
- }
10095
+ const empty = value === null || value === "";
10096
+ if (rule.boolean) {
10097
+ return empty || value === attr.key;
10136
10098
  }
10137
- const tag = node.annotatedName;
10138
- if (node.hasAttribute("alt")) {
10139
- const attr = node.getAttribute("alt");
10140
- this.report(node, `${tag} cannot have empty "alt" attribute`, attr.keyLocation);
10141
- } else {
10142
- this.report(node, `${tag} is missing required "alt" attribute`, node.location);
10099
+ if (rule.omit && empty) {
10100
+ return true;
10143
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);
10144
10109
  }
10145
- }
10146
-
10147
- var _a;
10148
- const { enum: validScopes } = (_a = elements.html5.th.attributes) == null ? void 0 : _a.scope;
10149
- const joinedScopes = utils_naturalJoin.naturalJoin(validScopes);
10150
- class H63 extends Rule {
10151
- documentation() {
10152
- return {
10153
- description: "H63: Using the scope attribute to associate header cells and data cells in data tables",
10154
- url: "https://html-validate.org/rules/wcag/h63.html"
10155
- };
10156
- }
10157
- setup() {
10158
- this.on("tag:ready", (event) => {
10159
- const node = event.target;
10160
- if (node.tagName !== "th") {
10161
- return;
10162
- }
10163
- const scope = node.getAttribute("scope");
10164
- const value = scope == null ? void 0 : scope.value;
10165
- if (value instanceof DynamicValue) {
10166
- return;
10167
- }
10168
- if (value && validScopes.includes(value)) {
10169
- return;
10170
- }
10171
- const message = `<th> element must have a valid scope attribute: ${joinedScopes}`;
10172
- const location = (scope == null ? void 0 : scope.valueLocation) ?? (scope == null ? void 0 : scope.keyLocation) ?? node.location;
10173
- this.report(node, message, location);
10174
- });
10175
- }
10176
- }
10177
-
10178
- class H67 extends Rule {
10179
- documentation() {
10180
- return {
10181
- description: "A decorative image cannot have a title attribute. Either remove `title` or add a descriptive `alt` text.",
10182
- url: "https://html-validate.org/rules/wcag/h67.html"
10183
- };
10184
- }
10185
- setup() {
10186
- this.on("tag:end", (event) => {
10187
- const node = event.target;
10188
- if (!node || node.tagName !== "img") {
10189
- return;
10190
- }
10191
- const title = node.getAttribute("title");
10192
- if (!title || title.value === "") {
10193
- return;
10194
- }
10195
- const alt = node.getAttributeValue("alt");
10196
- if (alt && alt !== "") {
10197
- 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;
10198
10123
  }
10199
- this.report(node, "<img> with empty alt text cannot have title attribute", title.keyLocation);
10200
10124
  });
10201
10125
  }
10202
- }
10203
-
10204
- class H71 extends Rule {
10205
- documentation() {
10206
- return {
10207
- description: "H71: Providing a description for groups of form controls using fieldset and legend elements",
10208
- url: "https://html-validate.org/rules/wcag/h71.html"
10209
- };
10210
- }
10211
- setup() {
10212
- this.on("dom:ready", (event) => {
10213
- const { document } = event;
10214
- const fieldsets = document.querySelectorAll(this.selector);
10215
- for (const fieldset of fieldsets) {
10216
- 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;
10217
10145
  }
10218
- });
10219
- }
10220
- validate(fieldset) {
10221
- const legend = fieldset.querySelectorAll("> legend");
10222
- if (legend.length === 0) {
10223
- this.reportNode(fieldset);
10224
10146
  }
10225
10147
  }
10226
- reportNode(node) {
10227
- 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
+ }
10228
10191
  }
10229
- get selector() {
10230
- 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
+ }
10231
10199
  }
10232
10200
  }
10233
-
10234
- const bundledRules$1 = {
10235
- "wcag/h30": H30,
10236
- "wcag/h32": H32,
10237
- "wcag/h36": H36,
10238
- "wcag/h37": H37,
10239
- "wcag/h63": H63,
10240
- "wcag/h67": H67,
10241
- "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
10242
10369
  };
10243
10370
 
10244
- const bundledRules = {
10245
- "allowed-links": AllowedLinks,
10246
- "area-alt": AreaAlt,
10247
- "aria-hidden-body": AriaHiddenBody,
10248
- "aria-label-misuse": AriaLabelMisuse,
10249
- "attr-case": AttrCase,
10250
- "attr-delimiter": AttrDelimiter,
10251
- "attr-pattern": AttrPattern,
10252
- "attr-quotes": AttrQuotes,
10253
- "attr-spacing": AttrSpacing,
10254
- "attribute-allowed-values": AttributeAllowedValues,
10255
- "attribute-boolean-style": AttributeBooleanStyle,
10256
- "attribute-empty-style": AttributeEmptyStyle,
10257
- "attribute-misuse": AttributeMisuse,
10258
- "class-pattern": ClassPattern,
10259
- "close-attr": CloseAttr,
10260
- "close-order": CloseOrder,
10261
- deprecated: Deprecated,
10262
- "deprecated-rule": DeprecatedRule,
10263
- "doctype-html": NoStyleTag$1,
10264
- "doctype-style": DoctypeStyle,
10265
- "element-case": ElementCase,
10266
- "element-name": ElementName,
10267
- "element-permitted-content": ElementPermittedContent,
10268
- "element-permitted-occurrences": ElementPermittedOccurrences,
10269
- "element-permitted-order": ElementPermittedOrder,
10270
- "element-permitted-parent": ElementPermittedParent,
10271
- "element-required-ancestor": ElementRequiredAncestor,
10272
- "element-required-attributes": ElementRequiredAttributes,
10273
- "element-required-content": ElementRequiredContent,
10274
- "empty-heading": EmptyHeading,
10275
- "empty-title": EmptyTitle,
10276
- "form-dup-name": FormDupName,
10277
- "heading-level": HeadingLevel,
10278
- "hidden-focusable": HiddenFocusable,
10279
- "id-pattern": IdPattern,
10280
- "input-attributes": InputAttributes,
10281
- "input-missing-label": InputMissingLabel,
10282
- "long-title": LongTitle,
10283
- "map-dup-name": MapDupName,
10284
- "map-id-name": MapIdName,
10285
- "meta-refresh": MetaRefresh,
10286
- "missing-doctype": MissingDoctype,
10287
- "multiple-labeled-controls": MultipleLabeledControls,
10288
- "name-pattern": NamePattern,
10289
- "no-abstract-role": NoAbstractRole,
10290
- "no-autoplay": NoAutoplay,
10291
- "no-conditional-comment": NoConditionalComment,
10292
- "no-deprecated-attr": NoDeprecatedAttr,
10293
- "no-dup-attr": NoDupAttr,
10294
- "no-dup-class": NoDupClass,
10295
- "no-dup-id": NoDupID,
10296
- "no-implicit-button-type": NoImplicitButtonType,
10297
- "no-implicit-input-type": NoImplicitInputType,
10298
- "no-implicit-close": NoImplicitClose,
10299
- "no-inline-style": NoInlineStyle,
10300
- "no-missing-references": NoMissingReferences,
10301
- "no-multiple-main": NoMultipleMain,
10302
- "no-raw-characters": NoRawCharacters,
10303
- "no-redundant-aria-label": NoRedundantAriaLabel,
10304
- "no-redundant-for": NoRedundantFor,
10305
- "no-redundant-role": NoRedundantRole,
10306
- "no-self-closing": NoSelfClosing,
10307
- "no-style-tag": NoStyleTag,
10308
- "no-trailing-whitespace": NoTrailingWhitespace,
10309
- "no-unknown-elements": NoUnknownElements,
10310
- "no-unused-disable": NoUnusedDisable,
10311
- "no-utf8-bom": NoUtf8Bom,
10312
- "prefer-button": PreferButton,
10313
- "prefer-native-element": PreferNativeElement,
10314
- "prefer-tbody": PreferTbody,
10315
- "require-csp-nonce": RequireCSPNonce,
10316
- "require-sri": RequireSri,
10317
- "script-element": ScriptElement,
10318
- "script-type": ScriptType,
10319
- "svg-focusable": SvgFocusable,
10320
- "tel-non-breaking": TelNonBreaking,
10321
- "text-content": TextContent,
10322
- "unique-landmark": UniqueLandmark,
10323
- "unrecognized-char-ref": UnknownCharReference,
10324
- "valid-autocomplete": ValidAutocomplete,
10325
- "valid-id": ValidID,
10326
- "void-content": VoidContent,
10327
- "void-style": VoidStyle,
10328
- ...bundledRules$1
10371
+ const TRANSFORMER_API = {
10372
+ VERSION: 1
10329
10373
  };
10330
10374
 
10331
10375
  var defaultConfig = {};
@@ -11453,7 +11497,7 @@ class Parser {
11453
11497
  location
11454
11498
  };
11455
11499
  this.trigger("tag:end", event);
11456
- if (active && !active.isRootElement()) {
11500
+ if (!active.isRootElement()) {
11457
11501
  this.trigger("element:ready", {
11458
11502
  target: active,
11459
11503
  location: active.location
@@ -11805,15 +11849,6 @@ class Parser {
11805
11849
  }
11806
11850
  }
11807
11851
 
11808
- function isThenable(value) {
11809
- return value && typeof value === "object" && "then" in value && typeof value.then === "function";
11810
- }
11811
-
11812
- const ruleIds = new Set(Object.keys(bundledRules));
11813
- function ruleExists(ruleId) {
11814
- return ruleIds.has(ruleId);
11815
- }
11816
-
11817
11852
  function freeze(src) {
11818
11853
  return {
11819
11854
  ...src,
@@ -11984,7 +12019,7 @@ class Engine {
11984
12019
  const directiveContext = {
11985
12020
  rules,
11986
12021
  reportUnused(rules2, unused, options, location2) {
11987
- if (noUnusedDisable && !rules2.has(noUnusedDisable.name)) {
12022
+ if (!rules2.has(noUnusedDisable.name)) {
11988
12023
  noUnusedDisable.reportUnused(unused, options, location2);
11989
12024
  }
11990
12025
  }
@@ -12115,7 +12150,9 @@ class Engine {
12115
12150
  return new this.ParserClass(this.config);
12116
12151
  }
12117
12152
  processDirective(event, parser, context) {
12118
- 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
+ });
12119
12156
  const location = event.optionsLocation ?? event.location;
12120
12157
  switch (event.action) {
12121
12158
  case "enable":
@@ -12139,7 +12176,7 @@ class Engine {
12139
12176
  rule.setServerity(Severity.ERROR);
12140
12177
  }
12141
12178
  }
12142
- parser.on("tag:start", (event, data) => {
12179
+ parser.on("tag:start", (_event, data) => {
12143
12180
  data.target.enableRules(rules.map((rule) => rule.name));
12144
12181
  });
12145
12182
  }
@@ -12147,7 +12184,7 @@ class Engine {
12147
12184
  for (const rule of rules) {
12148
12185
  rule.setEnabled(false);
12149
12186
  }
12150
- parser.on("tag:start", (event, data) => {
12187
+ parser.on("tag:start", (_event, data) => {
12151
12188
  data.target.disableRules(rules.map((rule) => rule.name));
12152
12189
  });
12153
12190
  }
@@ -12159,14 +12196,14 @@ class Engine {
12159
12196
  for (const rule of rules) {
12160
12197
  rule.block(blocker);
12161
12198
  }
12162
- const unregisterOpen = parser.on("tag:start", (event, data) => {
12199
+ const unregisterOpen = parser.on("tag:start", (_event, data) => {
12163
12200
  var _a;
12164
12201
  if (directiveBlock === null) {
12165
12202
  directiveBlock = ((_a = data.target.parent) == null ? void 0 : _a.unique) ?? null;
12166
12203
  }
12167
12204
  data.target.blockRules(ruleIds, blocker);
12168
12205
  });
12169
- const unregisterClose = parser.on("tag:end", (event, data) => {
12206
+ const unregisterClose = parser.on("tag:end", (_event, data) => {
12170
12207
  const lastNode = directiveBlock === null;
12171
12208
  const parentClosed = directiveBlock === data.previous.unique;
12172
12209
  if (lastNode || parentClosed) {
@@ -12177,7 +12214,7 @@ class Engine {
12177
12214
  }
12178
12215
  }
12179
12216
  });
12180
- parser.on("rule:error", (event, data) => {
12217
+ parser.on("rule:error", (_event, data) => {
12181
12218
  if (data.blockers.includes(blocker)) {
12182
12219
  unused.delete(data.ruleId);
12183
12220
  }
@@ -12193,10 +12230,10 @@ class Engine {
12193
12230
  for (const rule of rules) {
12194
12231
  rule.block(blocker);
12195
12232
  }
12196
- const unregister = parser.on("tag:start", (event, data) => {
12233
+ const unregister = parser.on("tag:start", (_event, data) => {
12197
12234
  data.target.blockRules(ruleIds, blocker);
12198
12235
  });
12199
- parser.on("rule:error", (event, data) => {
12236
+ parser.on("rule:error", (_event, data) => {
12200
12237
  if (data.blockers.includes(blocker)) {
12201
12238
  unused.delete(data.ruleId);
12202
12239
  }
@@ -12760,7 +12797,7 @@ class HtmlValidate {
12760
12797
  }
12761
12798
 
12762
12799
  const name = "html-validate";
12763
- const version = "8.20.0";
12800
+ const version = "8.21.0";
12764
12801
  const bugs = "https://gitlab.com/html-validate/html-validate/issues/new";
12765
12802
 
12766
12803
  function definePlugin(plugin) {
@@ -13682,4 +13719,5 @@ exports.ruleExists = ruleExists;
13682
13719
  exports.sliceLocation = sliceLocation;
13683
13720
  exports.staticResolver = staticResolver;
13684
13721
  exports.version = version;
13722
+ exports.walk = walk;
13685
13723
  //# sourceMappingURL=core.js.map