html-validate 8.20.0 → 8.21.0

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