html-validate 10.13.1 → 10.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cjs/core.js CHANGED
@@ -1204,9 +1204,6 @@ function clone(value) {
1204
1204
  return JSON.parse(JSON.stringify(value));
1205
1205
  }
1206
1206
  }
1207
- function overwriteMerge$1(_a, b) {
1208
- return b;
1209
- }
1210
1207
  class MetaTable {
1211
1208
  elements;
1212
1209
  schema;
@@ -1379,15 +1376,22 @@ class MetaTable {
1379
1376
  }
1380
1377
  }
1381
1378
  mergeElement(a, b) {
1382
- const merged = deepmerge(a, b, { arrayMerge: overwriteMerge$1 });
1383
- const filteredAttrs = Object.entries(
1384
- merged.attributes
1385
- ).filter(([, attr]) => {
1386
- const val = !attr.delete;
1387
- delete attr.delete;
1388
- return val;
1389
- });
1390
- merged.attributes = Object.fromEntries(filteredAttrs);
1379
+ const merged = { ...a, ...b };
1380
+ const mergedAttrs = {
1381
+ ...a.attributes,
1382
+ ...b.attributes
1383
+ };
1384
+ for (const [name, attr] of Object.entries(mergedAttrs)) {
1385
+ if (attr.delete) {
1386
+ delete mergedAttrs[name];
1387
+ } else {
1388
+ delete attr.delete;
1389
+ }
1390
+ }
1391
+ merged.attributes = mergedAttrs;
1392
+ if (a.aria) {
1393
+ merged.aria = { ...a.aria, ...b.aria };
1394
+ }
1391
1395
  return merged;
1392
1396
  }
1393
1397
  /**
@@ -1968,71 +1972,68 @@ function factory(name, context) {
1968
1972
  function stripslashes(value) {
1969
1973
  return value.replaceAll(/\\(.)/g, "$1");
1970
1974
  }
1971
- class Condition {
1975
+ function createClassCondition(classname) {
1976
+ return {
1977
+ kind: "class",
1978
+ classname,
1979
+ match(node) {
1980
+ return node.classList.contains(classname);
1981
+ }
1982
+ };
1972
1983
  }
1973
- class ClassCondition extends Condition {
1974
- classname;
1975
- constructor(classname) {
1976
- super();
1977
- this.classname = classname;
1978
- }
1979
- match(node) {
1980
- return node.classList.contains(this.classname);
1981
- }
1984
+ function createIdCondition(raw) {
1985
+ const id = stripslashes(raw);
1986
+ return {
1987
+ kind: "id",
1988
+ id,
1989
+ match(node) {
1990
+ return node.id === id;
1991
+ }
1992
+ };
1982
1993
  }
1983
- class IdCondition extends Condition {
1984
- id;
1985
- constructor(id) {
1986
- super();
1987
- this.id = stripslashes(id);
1988
- }
1989
- match(node) {
1990
- return node.id === this.id;
1991
- }
1994
+ function createAttributeCondition(attr) {
1995
+ const match = /^(.+?)(?:([$*^|~]?=)"([^"]+?)")?$/.exec(attr);
1996
+ const key = match[1];
1997
+ const op = match[2];
1998
+ const rawValue = match[3];
1999
+ const value = typeof rawValue === "string" ? stripslashes(rawValue) : rawValue;
2000
+ return {
2001
+ kind: "attribute",
2002
+ key,
2003
+ op,
2004
+ value,
2005
+ match(node) {
2006
+ const attrs = node.getAttribute(key, true);
2007
+ return attrs.some((cur) => {
2008
+ switch (op) {
2009
+ case void 0:
2010
+ return true;
2011
+ /* attribute exists */
2012
+ case "=":
2013
+ return cur.value === value;
2014
+ default:
2015
+ throw new Error(`Attribute selector operator ${op} is not implemented yet`);
2016
+ }
2017
+ });
2018
+ }
2019
+ };
1992
2020
  }
1993
- class AttributeCondition extends Condition {
1994
- key;
1995
- op;
1996
- value;
1997
- constructor(attr) {
1998
- super();
1999
- const [, key, op, value] = /^(.+?)(?:([$*^|~]?=)"([^"]+?)")?$/.exec(attr);
2000
- this.key = key;
2001
- this.op = op;
2002
- this.value = typeof value === "string" ? stripslashes(value) : value;
2003
- }
2004
- match(node) {
2005
- const attr = node.getAttribute(this.key, true);
2006
- return attr.some((cur) => {
2007
- switch (this.op) {
2008
- case void 0:
2009
- return true;
2010
- /* attribute exists */
2011
- case "=":
2012
- return cur.value === this.value;
2013
- default:
2014
- throw new Error(`Attribute selector operator ${this.op} is not implemented yet`);
2015
- }
2016
- });
2021
+ function createPseudoClassCondition(pseudoclass, context) {
2022
+ const match = /^([^(]+)(?:\((.*)\))?$/.exec(pseudoclass);
2023
+ if (!match) {
2024
+ throw new Error(`Missing pseudo-class after colon in selector pattern "${context}"`);
2017
2025
  }
2018
- }
2019
- class PseudoClassCondition extends Condition {
2020
- name;
2021
- args;
2022
- constructor(pseudoclass, context) {
2023
- super();
2024
- const match = /^([^(]+)(?:\((.*)\))?$/.exec(pseudoclass);
2025
- if (!match) {
2026
- throw new Error(`Missing pseudo-class after colon in selector pattern "${context}"`);
2026
+ const name = match[1];
2027
+ const args = match[2];
2028
+ return {
2029
+ kind: "pseudo",
2030
+ name,
2031
+ args,
2032
+ match(node, selectorContext) {
2033
+ const fn = factory(name, selectorContext);
2034
+ return fn(node, args);
2027
2035
  }
2028
- const [, name, args] = match;
2029
- this.name = name;
2030
- this.args = args;
2031
- }
2032
- match(node, context) {
2033
- const fn = factory(this.name, context);
2034
- return fn(node, this.args);
2035
- }
2036
+ };
2036
2037
  }
2037
2038
 
2038
2039
  function isDelimiter(ch) {
@@ -2107,19 +2108,86 @@ class Compound {
2107
2108
  createCondition(pattern) {
2108
2109
  switch (pattern[0]) {
2109
2110
  case ".":
2110
- return new ClassCondition(pattern.slice(1));
2111
+ return createClassCondition(pattern.slice(1));
2111
2112
  case "#":
2112
- return new IdCondition(pattern.slice(1));
2113
+ return createIdCondition(pattern.slice(1));
2113
2114
  case "[":
2114
- return new AttributeCondition(pattern.slice(1, -1));
2115
+ return createAttributeCondition(pattern.slice(1, -1));
2115
2116
  case ":":
2116
- return new PseudoClassCondition(pattern.slice(1), this.selector);
2117
+ return createPseudoClassCondition(pattern.slice(1), this.selector);
2117
2118
  default:
2118
2119
  throw new Error(`Failed to create selector condition for "${pattern}"`);
2119
2120
  }
2120
2121
  }
2121
2122
  }
2122
2123
 
2124
+ const escapedCodepoints = /* @__PURE__ */ new Set(["9", "a", "d"]);
2125
+ function* splitSelectorElements(selector) {
2126
+ let begin = 0;
2127
+ let end = 0;
2128
+ function initialState(ch, p) {
2129
+ if (ch === "\\") {
2130
+ return 1 /* ESCAPED */;
2131
+ }
2132
+ if (ch === " ") {
2133
+ end = p;
2134
+ return 2 /* WHITESPACE */;
2135
+ }
2136
+ return 0 /* INITIAL */;
2137
+ }
2138
+ function escapedState(ch) {
2139
+ if (escapedCodepoints.has(ch)) {
2140
+ return 1 /* ESCAPED */;
2141
+ }
2142
+ return 0 /* INITIAL */;
2143
+ }
2144
+ function* whitespaceState(ch, p) {
2145
+ if (ch === " ") {
2146
+ return 2 /* WHITESPACE */;
2147
+ }
2148
+ yield selector.slice(begin, end);
2149
+ begin = p;
2150
+ end = p;
2151
+ return 0 /* INITIAL */;
2152
+ }
2153
+ let state = 0 /* INITIAL */;
2154
+ for (let p = 0; p < selector.length; p++) {
2155
+ const ch = selector[p];
2156
+ switch (state) {
2157
+ case 0 /* INITIAL */:
2158
+ state = initialState(ch, p);
2159
+ break;
2160
+ case 1 /* ESCAPED */:
2161
+ state = escapedState(ch);
2162
+ break;
2163
+ case 2 /* WHITESPACE */:
2164
+ state = yield* whitespaceState(ch, p);
2165
+ break;
2166
+ }
2167
+ }
2168
+ if (begin !== selector.length) {
2169
+ yield selector.slice(begin);
2170
+ }
2171
+ }
2172
+
2173
+ function unescapeCodepoint(value) {
2174
+ const replacement = {
2175
+ "\\9 ": " ",
2176
+ "\\a ": "\n",
2177
+ "\\d ": "\r"
2178
+ };
2179
+ return value.replaceAll(
2180
+ /(\\[9ad] )/g,
2181
+ (_, codepoint) => replacement[codepoint]
2182
+ );
2183
+ }
2184
+ function getCompounds(selector) {
2185
+ selector = selector.replaceAll(/([+>~]) /g, "$1");
2186
+ return Array.from(splitSelectorElements(selector), (element) => {
2187
+ return new Compound(unescapeCodepoint(element));
2188
+ });
2189
+ }
2190
+
2123
2191
  function* ancestors$1(element) {
2124
2192
  let current = element.parent;
2125
2193
  while (current && !current.isRootElement()) {
@@ -2182,88 +2250,16 @@ function matchElement(element, compounds, context) {
2182
2250
  return false;
2183
2251
  }
2184
2252
 
2185
- const escapedCodepoints = /* @__PURE__ */ new Set(["9", "a", "d"]);
2186
- function* splitSelectorElements(selector) {
2187
- let begin = 0;
2188
- let end = 0;
2189
- function initialState(ch, p) {
2190
- if (ch === "\\") {
2191
- return 1 /* ESCAPED */;
2192
- }
2193
- if (ch === " ") {
2194
- end = p;
2195
- return 2 /* WHITESPACE */;
2196
- }
2197
- return 0 /* INITIAL */;
2198
- }
2199
- function escapedState(ch) {
2200
- if (escapedCodepoints.has(ch)) {
2201
- return 1 /* ESCAPED */;
2202
- }
2203
- return 0 /* INITIAL */;
2204
- }
2205
- function* whitespaceState(ch, p) {
2206
- if (ch === " ") {
2207
- return 2 /* WHITESPACE */;
2208
- }
2209
- yield selector.slice(begin, end);
2210
- begin = p;
2211
- end = p;
2212
- return 0 /* INITIAL */;
2253
+ class ComplexSelector {
2254
+ compounds;
2255
+ constructor(compounds) {
2256
+ this.compounds = compounds;
2213
2257
  }
2214
- let state = 0 /* INITIAL */;
2215
- for (let p = 0; p < selector.length; p++) {
2216
- const ch = selector[p];
2217
- switch (state) {
2218
- case 0 /* INITIAL */:
2219
- state = initialState(ch, p);
2220
- break;
2221
- case 1 /* ESCAPED */:
2222
- state = escapedState(ch);
2223
- break;
2224
- case 2 /* WHITESPACE */:
2225
- state = yield* whitespaceState(ch, p);
2226
- break;
2227
- }
2258
+ static fromString(selector) {
2259
+ return new ComplexSelector(getCompounds(selector));
2228
2260
  }
2229
- if (begin !== selector.length) {
2230
- yield selector.slice(begin);
2231
- }
2232
- }
2233
-
2234
- function unescapeCodepoint(value) {
2235
- const replacement = {
2236
- "\\9 ": " ",
2237
- "\\a ": "\n",
2238
- "\\d ": "\r"
2239
- };
2240
- return value.replaceAll(
2241
- /(\\[9ad] )/g,
2242
- (_, codepoint) => replacement[codepoint]
2243
- );
2244
- }
2245
- function escapeSelectorComponent(text) {
2246
- const codepoints = {
2247
- " ": "\\9 ",
2248
- "\n": "\\a ",
2249
- "\r": "\\d "
2250
- };
2251
- return text.toString().replaceAll(/([\t\n\r]|[^\w-])/gi, (_, ch) => {
2252
- if (codepoints[ch]) {
2253
- return codepoints[ch];
2254
- } else {
2255
- return `\\${ch}`;
2256
- }
2257
- });
2258
- }
2259
- function generateIdSelector(id) {
2260
- const escaped = escapeSelectorComponent(id);
2261
- return /^\d/.test(escaped) ? `[id="${escaped}"]` : `#${escaped}`;
2262
- }
2263
- class Selector {
2264
- pattern;
2265
- constructor(selector) {
2266
- this.pattern = Selector.parse(selector);
2261
+ static fromCompounds(compounds) {
2262
+ return new ComplexSelector(compounds);
2267
2263
  }
2268
2264
  /**
2269
2265
  * Match this selector against a HtmlElement.
@@ -2280,15 +2276,15 @@ class Selector {
2280
2276
  */
2281
2277
  matchElement(element) {
2282
2278
  const context = { scope: null };
2283
- return matchElement(element, this.pattern, context);
2279
+ return matchElement(element, this.compounds, context);
2284
2280
  }
2285
2281
  *matchInternal(root, level, context) {
2286
- if (level >= this.pattern.length) {
2282
+ if (level >= this.compounds.length) {
2287
2283
  yield root;
2288
2284
  return;
2289
2285
  }
2290
- const pattern = this.pattern[level];
2291
- const matches = Selector.findCandidates(root, pattern);
2286
+ const pattern = this.compounds[level];
2287
+ const matches = ComplexSelector.findCandidates(root, pattern);
2292
2288
  for (const node of matches) {
2293
2289
  if (!pattern.match(node, context)) {
2294
2290
  continue;
@@ -2296,12 +2292,6 @@ class Selector {
2296
2292
  yield* this.matchInternal(node, level + 1, context);
2297
2293
  }
2298
2294
  }
2299
- static parse(selector) {
2300
- selector = selector.replaceAll(/([+>~]) /g, "$1");
2301
- return Array.from(splitSelectorElements(selector), (element) => {
2302
- return new Compound(unescapeCodepoint(element));
2303
- });
2304
- }
2305
2295
  static findCandidates(root, pattern) {
2306
2296
  switch (pattern.combinator) {
2307
2297
  case Combinator.DESCENDANT:
@@ -2309,9 +2299,9 @@ class Selector {
2309
2299
  case Combinator.CHILD:
2310
2300
  return root.childElements.filter((node) => node.is(pattern.tagName));
2311
2301
  case Combinator.ADJACENT_SIBLING:
2312
- return Selector.findAdjacentSibling(root);
2302
+ return ComplexSelector.findAdjacentSibling(root);
2313
2303
  case Combinator.GENERAL_SIBLING:
2314
- return Selector.findGeneralSibling(root);
2304
+ return ComplexSelector.findGeneralSibling(root);
2315
2305
  case Combinator.SCOPE:
2316
2306
  return [root];
2317
2307
  }
@@ -2323,7 +2313,7 @@ class Selector {
2323
2313
  adjacent = false;
2324
2314
  return true;
2325
2315
  }
2326
- if (cur === node) {
2316
+ if (cur.isSameNode(node)) {
2327
2317
  adjacent = true;
2328
2318
  }
2329
2319
  return false;
@@ -2335,7 +2325,7 @@ class Selector {
2335
2325
  if (after) {
2336
2326
  return true;
2337
2327
  }
2338
- if (cur === node) {
2328
+ if (cur.isSameNode(node)) {
2339
2329
  after = true;
2340
2330
  }
2341
2331
  return false;
@@ -2343,6 +2333,31 @@ class Selector {
2343
2333
  }
2344
2334
  }
2345
2335
 
2336
+ const codepoints = {
2337
+ " ": "\\9 ",
2338
+ "\n": "\\a ",
2339
+ "\r": "\\d "
2340
+ };
2341
+ function escapeSelectorComponent(text) {
2342
+ return text.toString().replaceAll(/([\t\n\r]|[^\w-])/gi, (_, ch) => {
2343
+ if (codepoints[ch]) {
2344
+ return codepoints[ch];
2345
+ } else {
2346
+ return `\\${ch}`;
2347
+ }
2348
+ });
2349
+ }
2350
+
2351
+ function generateIdSelector(id) {
2352
+ const escaped = escapeSelectorComponent(id);
2353
+ return /^\d/.test(escaped) ? `[id="${escaped}"]` : `#${escaped}`;
2354
+ }
2355
+
2356
+ function parseSelector(selector) {
2357
+ const compounds = getCompounds(selector);
2358
+ return ComplexSelector.fromCompounds(compounds);
2359
+ }
2360
+
2346
2361
  const TEXT_NODE_NAME = "#text";
2347
2362
  function isTextNode(node) {
2348
2363
  return node?.nodeType === NodeType.TEXT_NODE;
@@ -2378,6 +2393,7 @@ class TextNode extends DOMNode {
2378
2393
  }
2379
2394
  }
2380
2395
 
2396
+ const CHILD_ELEMENTS = /* @__PURE__ */ Symbol("childElements");
2381
2397
  const ROLE = /* @__PURE__ */ Symbol("role");
2382
2398
  const TABINDEX = /* @__PURE__ */ Symbol("tabindex");
2383
2399
  var NodeClosed = /* @__PURE__ */ ((NodeClosed2) => {
@@ -2544,7 +2560,11 @@ class HtmlElement extends DOMNode {
2544
2560
  * Similar to childNodes but only elements.
2545
2561
  */
2546
2562
  get childElements() {
2547
- return this.childNodes.filter(isElementNode);
2563
+ const cached = this.cacheGet(CHILD_ELEMENTS);
2564
+ if (cached !== void 0) {
2565
+ return cached;
2566
+ }
2567
+ return this.cacheSet(CHILD_ELEMENTS, this.childNodes.filter(isElementNode));
2548
2568
  }
2549
2569
  /**
2550
2570
  * Find the first ancestor matching a selector.
@@ -2651,7 +2671,7 @@ class HtmlElement extends DOMNode {
2651
2671
  */
2652
2672
  matches(selectorList) {
2653
2673
  return selectorList.split(",").some((it) => {
2654
- const selector = new Selector(it.trim());
2674
+ const selector = parseSelector(it.trim());
2655
2675
  return selector.matchElement(this);
2656
2676
  });
2657
2677
  }
@@ -2884,9 +2904,9 @@ class HtmlElement extends DOMNode {
2884
2904
  if (!selectorList) {
2885
2905
  return;
2886
2906
  }
2887
- for (const selector of selectorList.split(/(?<!\\),\s*/)) {
2888
- const pattern = new Selector(selector);
2889
- yield* pattern.match(this);
2907
+ for (const selectorString of selectorList.split(/(?<!\\),\s*/)) {
2908
+ const selector = parseSelector(selectorString);
2909
+ yield* selector.match(this);
2890
2910
  }
2891
2911
  }
2892
2912
  /**
@@ -2940,12 +2960,24 @@ class HtmlElement extends DOMNode {
2940
2960
  }
2941
2961
  return visit(this);
2942
2962
  }
2963
+ append(node) {
2964
+ super.append(node);
2965
+ this.cacheRemove(CHILD_ELEMENTS);
2966
+ }
2967
+ insertBefore(node, reference) {
2968
+ super.insertBefore(node, reference);
2969
+ this.cacheRemove(CHILD_ELEMENTS);
2970
+ }
2971
+ removeChild(node) {
2972
+ return super.removeChild(node);
2973
+ }
2943
2974
  /**
2944
2975
  * @internal
2945
2976
  */
2946
2977
  _setParent(node) {
2947
2978
  const oldParent = this._parent;
2948
2979
  this._parent = node instanceof HtmlElement ? node : null;
2980
+ oldParent?.cacheRemove(CHILD_ELEMENTS);
2949
2981
  return oldParent;
2950
2982
  }
2951
2983
  }
@@ -3727,6 +3759,7 @@ class Rule {
3727
3759
  severity;
3728
3760
  // rule severity
3729
3761
  event;
3762
+ tracker;
3730
3763
  /**
3731
3764
  * Rule name. Defaults to filename without extension but can be overwritten by
3732
3765
  * subclasses.
@@ -3746,6 +3779,7 @@ class Rule {
3746
3779
  this.blockers = [];
3747
3780
  this.severity = Severity.DISABLED;
3748
3781
  this.name = "";
3782
+ this.tracker = null;
3749
3783
  }
3750
3784
  getSeverity() {
3751
3785
  return this.severity;
@@ -3921,7 +3955,15 @@ class Rule {
3921
3955
  return this.parser.on(event, (_event, data) => {
3922
3956
  if (this.isEnabled() && filter(data)) {
3923
3957
  this.event = data;
3924
- callback(data);
3958
+ const { tracker } = this;
3959
+ if (tracker) {
3960
+ const start = performance.now();
3961
+ callback(data);
3962
+ const end = performance.now();
3963
+ tracker.trackRule(this.name, end - start);
3964
+ } else {
3965
+ callback(data);
3966
+ }
3925
3967
  }
3926
3968
  });
3927
3969
  }
@@ -3938,6 +3980,14 @@ class Rule {
3938
3980
  this.severity = severity;
3939
3981
  this.meta = meta;
3940
3982
  }
3983
+ /**
3984
+ * Set (or clear) the performance tracker.
3985
+ *
3986
+ * @internal
3987
+ */
3988
+ setTracker(tracker) {
3989
+ this.tracker = tracker;
3990
+ }
3941
3991
  /**
3942
3992
  * Validate rule options against schema. Throws error if object does not validate.
3943
3993
  *
@@ -12452,8 +12502,10 @@ class StaticConfigLoader extends ConfigLoader {
12452
12502
 
12453
12503
  class EventHandler {
12454
12504
  listeners;
12505
+ tracker;
12455
12506
  constructor() {
12456
12507
  this.listeners = {};
12508
+ this.tracker = null;
12457
12509
  }
12458
12510
  /**
12459
12511
  * Add an event listener.
@@ -12492,6 +12544,14 @@ class EventHandler {
12492
12544
  });
12493
12545
  return deregister;
12494
12546
  }
12547
+ /**
12548
+ * Set (or clear) the performance tracker.
12549
+ *
12550
+ * @internal
12551
+ */
12552
+ setTracker(tracker) {
12553
+ this.tracker = tracker;
12554
+ }
12495
12555
  /**
12496
12556
  * Trigger event causing all listeners to be called.
12497
12557
  *
@@ -12500,8 +12560,18 @@ class EventHandler {
12500
12560
  */
12501
12561
  /* eslint-disable-next-line @typescript-eslint/no-explicit-any -- technical debt, should be made typesafe */
12502
12562
  trigger(event, data) {
12503
- for (const listener of this.getCallbacks(event)) {
12504
- listener.call(null, event, data);
12563
+ const { tracker } = this;
12564
+ if (tracker) {
12565
+ const start = performance.now();
12566
+ for (const listener of this.getCallbacks(event)) {
12567
+ listener.call(null, event, data);
12568
+ }
12569
+ const end = performance.now();
12570
+ tracker.trackEvent(event, end - start);
12571
+ } else {
12572
+ for (const listener of this.getCallbacks(event)) {
12573
+ listener.call(null, event, data);
12574
+ }
12505
12575
  }
12506
12576
  }
12507
12577
  getCallbacks(event) {
@@ -12513,7 +12583,7 @@ class EventHandler {
12513
12583
  }
12514
12584
 
12515
12585
  const name = "html-validate";
12516
- const version = "10.13.1";
12586
+ const version = "10.15.0";
12517
12587
  const bugs = "https://gitlab.com/html-validate/html-validate/issues/new";
12518
12588
 
12519
12589
  function freeze(src) {
@@ -12813,32 +12883,92 @@ class Parser {
12813
12883
  * valid). The parser handles this by checking if the element on top of the
12814
12884
  * stack when is allowed to omit.
12815
12885
  */
12886
+ /**
12887
+ * Check whether a given element would be implicitly closed by an incoming
12888
+ * start tag. Used both in `closeOptional` and in the multi-level lookahead.
12889
+ */
12890
+ wouldCloseElement(token, element) {
12891
+ if (!element.meta) {
12892
+ return false;
12893
+ }
12894
+ const implicitClosed = element.meta.implicitClosed;
12895
+ if (!implicitClosed) {
12896
+ return false;
12897
+ }
12898
+ const tagName = token.data[2];
12899
+ const incomingMeta = this.metaTable.getMetaFor(tagName);
12900
+ return implicitClosed.some((entry) => {
12901
+ if (!entry.startsWith("@")) {
12902
+ return entry === tagName;
12903
+ }
12904
+ return incomingMeta ? matchesContentCategory(incomingMeta, entry) : false;
12905
+ });
12906
+ }
12907
+ /**
12908
+ * Walk up the active stack to find the parent element that will remain
12909
+ * after all multi-level implicit closes triggered by the incoming start tag.
12910
+ * For a single-level close this is equivalent to `getActive().parent`.
12911
+ */
12912
+ getParentAfterImplicitClose(token) {
12913
+ let current = this.dom.getActive();
12914
+ while (!current.isRootElement() && this.wouldCloseElement(token, current)) {
12915
+ const { parent } = current;
12916
+ if (!parent) {
12917
+ break;
12918
+ }
12919
+ current = parent;
12920
+ }
12921
+ return current;
12922
+ }
12816
12923
  closeOptional(token) {
12817
12924
  const active = this.dom.getActive();
12818
12925
  if (!active.meta) {
12819
12926
  return false;
12820
12927
  }
12821
- const tagName = token.data[2];
12822
12928
  const open = !token.data[1];
12823
- const implicitClosed = active.meta.implicitClosed;
12824
12929
  if (open) {
12825
- if (!implicitClosed) {
12826
- return false;
12827
- }
12828
- const incomingMeta = this.metaTable.getMetaFor(tagName);
12829
- return implicitClosed.some((entry) => {
12830
- if (!entry.startsWith("@")) {
12831
- return entry === tagName;
12832
- }
12833
- return incomingMeta ? matchesContentCategory(incomingMeta, entry) : false;
12834
- });
12930
+ return this.wouldCloseElement(token, active);
12835
12931
  } else {
12836
- if (active.is(tagName)) {
12932
+ return this.closeOptionalEndTag(token, active);
12933
+ }
12934
+ }
12935
+ /**
12936
+ * Returns `true` if the element’s end tag may be omitted, either because
12937
+ * its `implicitClosed` list includes its own tag name (e.g. `<li>`, `<td>`)
12938
+ * or because `optionalEnd` is set.
12939
+ */
12940
+ canOmitEndTag(element) {
12941
+ if (!element.meta) {
12942
+ return false;
12943
+ }
12944
+ const { implicitClosed, optionalEnd } = element.meta;
12945
+ return Boolean(implicitClosed?.includes(element.tagName)) || Boolean(optionalEnd);
12946
+ }
12947
+ /**
12948
+ * Check whether the active element can be implicitly closed by an incoming
12949
+ * end tag. The end tag may close a direct parent or any ancestor, as long as
12950
+ * every intermediate element can also have its end tag omitted.
12951
+ * This handles cases like `</table>` implicitly closing `<td>`, `<tr>`, `<tbody>`.
12952
+ */
12953
+ closeOptionalEndTag(token, active) {
12954
+ const tagName = token.data[2];
12955
+ if (active.is(tagName)) {
12956
+ return false;
12957
+ }
12958
+ if (!this.canOmitEndTag(active)) {
12959
+ return false;
12960
+ }
12961
+ let ancestor = active.parent;
12962
+ while (ancestor && !ancestor.isRootElement()) {
12963
+ if (ancestor.is(tagName)) {
12964
+ return true;
12965
+ }
12966
+ if (!this.canOmitEndTag(ancestor)) {
12837
12967
  return false;
12838
12968
  }
12839
- const canOmitEnd = Boolean(implicitClosed?.includes(active.tagName)) || Boolean(active.meta.optionalEnd);
12840
- return Boolean(active.parent && active.parent.is(tagName) && canOmitEnd);
12969
+ ancestor = ancestor.parent;
12841
12970
  }
12971
+ return false;
12842
12972
  }
12843
12973
  /**
12844
12974
  * Check whether an intermediary element (e.g. `<head>` or `<body>`) should
@@ -12927,8 +13057,21 @@ class Parser {
12927
13057
  );
12928
13058
  const endToken = tokens.at(-1);
12929
13059
  const isStartTag = !startToken.data[1];
12930
- const closeOptional = this.closeOptional(startToken);
12931
- const baseParent = closeOptional ? this.dom.getActive().parent : this.dom.getActive();
13060
+ let baseParent;
13061
+ if (isStartTag) {
13062
+ baseParent = this.getParentAfterImplicitClose(startToken);
13063
+ } else {
13064
+ const tagName = startToken.data[2];
13065
+ let cur = this.dom.getActive();
13066
+ while (!cur.isRootElement() && !cur.is(tagName)) {
13067
+ const { parent: parent2 } = cur;
13068
+ if (!parent2) {
13069
+ break;
13070
+ }
13071
+ cur = parent2;
13072
+ }
13073
+ baseParent = cur;
13074
+ }
12932
13075
  const implicitParent = isStartTag ? this.peekImplicitOpen(startToken, baseParent) : null;
12933
13076
  const parent = implicitParent ?? baseParent;
12934
13077
  const node = HtmlElement.fromTokens(
@@ -12940,7 +13083,7 @@ class Parser {
12940
13083
  );
12941
13084
  const isClosing = !isStartTag || node.closed !== NodeClosed.Open;
12942
13085
  const isForeign = node.meta?.foreign;
12943
- if (closeOptional) {
13086
+ while (this.closeOptional(startToken)) {
12944
13087
  const active = this.dom.getActive();
12945
13088
  active.closed = NodeClosed.ImplicitClosed;
12946
13089
  this.closeElement(source, node, active, startToken.location);
@@ -13374,6 +13517,78 @@ class Parser {
13374
13517
  }
13375
13518
  }
13376
13519
 
13520
+ class PerformanceTracker {
13521
+ eventData;
13522
+ ruleData;
13523
+ startTime;
13524
+ accConfigTime;
13525
+ accTransformTime;
13526
+ constructor() {
13527
+ this.eventData = /* @__PURE__ */ new Map();
13528
+ this.ruleData = /* @__PURE__ */ new Map();
13529
+ this.startTime = performance.now();
13530
+ this.accConfigTime = 0;
13531
+ this.accTransformTime = 0;
13532
+ }
13533
+ /**
13534
+ * Record a single event trigger with the time it took to run all listeners.
13535
+ */
13536
+ trackEvent(name, time) {
13537
+ const existing = this.eventData.get(name);
13538
+ if (existing) {
13539
+ existing.count += 1;
13540
+ existing.time += time;
13541
+ } else {
13542
+ this.eventData.set(name, { count: 1, time });
13543
+ }
13544
+ }
13545
+ /**
13546
+ * Record time spent loading configuration.
13547
+ */
13548
+ trackConfig(time) {
13549
+ this.accConfigTime += time;
13550
+ }
13551
+ /**
13552
+ * Record time spent in transformers.
13553
+ */
13554
+ trackTransform(time) {
13555
+ this.accTransformTime += time;
13556
+ }
13557
+ /**
13558
+ * Record a single rule callback invocation with its execution time.
13559
+ */
13560
+ trackRule(ruleName, time) {
13561
+ const existing = this.ruleData.get(ruleName);
13562
+ if (existing) {
13563
+ existing.count += 1;
13564
+ existing.time += time;
13565
+ } else {
13566
+ this.ruleData.set(ruleName, { count: 1, time });
13567
+ }
13568
+ }
13569
+ /**
13570
+ * Returns a snapshot of the recorded performance data, with both arrays
13571
+ * sorted by time (descending).
13572
+ */
13573
+ getResult() {
13574
+ const events = Array.from(
13575
+ this.eventData.entries(),
13576
+ ([event, { count, time }]) => ({ event, count, time })
13577
+ ).toSorted((a, b) => b.time - a.time);
13578
+ const rules = Array.from(
13579
+ this.ruleData.entries(),
13580
+ ([rule, { count, time }]) => ({ rule, count, time })
13581
+ ).toSorted((a, b) => b.time - a.time);
13582
+ return {
13583
+ events,
13584
+ rules,
13585
+ configTime: this.accConfigTime,
13586
+ transformTime: this.accTransformTime,
13587
+ totalTime: performance.now() - this.startTime
13588
+ };
13589
+ }
13590
+ }
13591
+
13377
13592
  const ruleIds = new Set(Object.keys(bundledRules));
13378
13593
  function ruleExists(ruleId) {
13379
13594
  return ruleIds.has(ruleId);
@@ -13421,10 +13636,12 @@ class Engine {
13421
13636
  config;
13422
13637
  ParserClass;
13423
13638
  availableRules;
13424
- constructor(config, ParserClass) {
13639
+ tracker;
13640
+ constructor(config, ParserClass, options) {
13425
13641
  this.report = new Reporter();
13426
13642
  this.config = config;
13427
13643
  this.ParserClass = ParserClass;
13644
+ this.tracker = options.tracker;
13428
13645
  const result = this.initPlugins(this.config);
13429
13646
  this.availableRules = {
13430
13647
  ...bundledRules,
@@ -13440,6 +13657,9 @@ class Engine {
13440
13657
  lint(sources) {
13441
13658
  for (const source of sources) {
13442
13659
  const parser = this.instantiateParser();
13660
+ if (this.tracker) {
13661
+ parser.getEventHandler().setTracker(this.tracker);
13662
+ }
13443
13663
  const { rules } = this.setupPlugins(source, this.config, parser);
13444
13664
  const noUnusedDisable = rules["no-unused-disable"];
13445
13665
  const directiveContext = {
@@ -13716,6 +13936,7 @@ class Engine {
13716
13936
  const rule = this.instantiateRule(ruleId, options);
13717
13937
  rule.name = ruleId;
13718
13938
  rule.init(parser, report, severity, meta);
13939
+ rule.setTracker(this.tracker);
13719
13940
  if (rule.setup) {
13720
13941
  rule.setup();
13721
13942
  }
@@ -15091,6 +15312,7 @@ exports.NestedError = NestedError;
15091
15312
  exports.NodeClosed = NodeClosed;
15092
15313
  exports.NodeType = NodeType;
15093
15314
  exports.Parser = Parser;
15315
+ exports.PerformanceTracker = PerformanceTracker;
15094
15316
  exports.Reporter = Reporter;
15095
15317
  exports.ResolvedConfig = ResolvedConfig;
15096
15318
  exports.Rule = Rule;