html-validate 8.20.1 → 8.21.0

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