html-validate 10.13.0 → 10.14.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
@@ -591,12 +591,40 @@ const patternProperties = {
591
591
  },
592
592
  implicitClosed: {
593
593
  title: "List of elements which implicitly closes this element",
594
- description: "Some elements are automatically closed when another start tag occurs",
594
+ description: "Some elements are automatically closed when another start tag occurs. Entries may be explicit tag names or @category strings (e.g. \"@flow\").",
595
595
  type: "array",
596
596
  items: {
597
597
  type: "string"
598
598
  }
599
599
  },
600
+ implicitOpen: {
601
+ title: "Implicit-open rules for child elements",
602
+ description: "Describes intermediary elements (e.g. <head> or <body>) that should be implicitly opened when a child of a given category or tag is inserted directly under this element without a matching container being present.",
603
+ type: "array",
604
+ items: {
605
+ type: "object",
606
+ required: [
607
+ "for",
608
+ "open"
609
+ ],
610
+ additionalProperties: false,
611
+ properties: {
612
+ "for": {
613
+ title: "Selector list",
614
+ description: "Tag names or @category strings (e.g. \"@flow\") that trigger the implicit open.",
615
+ type: "array",
616
+ items: {
617
+ type: "string"
618
+ }
619
+ },
620
+ open: {
621
+ title: "Element to open",
622
+ description: "Tag name of the element to implicitly open.",
623
+ type: "string"
624
+ }
625
+ }
626
+ }
627
+ },
600
628
  optionalEnd: {
601
629
  title: "Mark element as having an optional end tag",
602
630
  description: "Elements whose end tag may be omitted per the HTML spec. Such an element is treated as implicitly closed at end-of-document and when a parent’s explicit end tag is encountered while it is still open.",
@@ -2092,6 +2120,26 @@ class Compound {
2092
2120
  }
2093
2121
  }
2094
2122
 
2123
+ const codepoints = {
2124
+ " ": "\\9 ",
2125
+ "\n": "\\a ",
2126
+ "\r": "\\d "
2127
+ };
2128
+ function escapeSelectorComponent(text) {
2129
+ return text.toString().replaceAll(/([\t\n\r]|[^\w-])/gi, (_, ch) => {
2130
+ if (codepoints[ch]) {
2131
+ return codepoints[ch];
2132
+ } else {
2133
+ return `\\${ch}`;
2134
+ }
2135
+ });
2136
+ }
2137
+
2138
+ function generateIdSelector(id) {
2139
+ const escaped = escapeSelectorComponent(id);
2140
+ return /^\d/.test(escaped) ? `[id="${escaped}"]` : `#${escaped}`;
2141
+ }
2142
+
2095
2143
  function* ancestors$1(element) {
2096
2144
  let current = element.parent;
2097
2145
  while (current && !current.isRootElement()) {
@@ -2214,24 +2262,6 @@ function unescapeCodepoint(value) {
2214
2262
  (_, codepoint) => replacement[codepoint]
2215
2263
  );
2216
2264
  }
2217
- function escapeSelectorComponent(text) {
2218
- const codepoints = {
2219
- " ": "\\9 ",
2220
- "\n": "\\a ",
2221
- "\r": "\\d "
2222
- };
2223
- return text.toString().replaceAll(/([\t\n\r]|[^\w-])/gi, (_, ch) => {
2224
- if (codepoints[ch]) {
2225
- return codepoints[ch];
2226
- } else {
2227
- return `\\${ch}`;
2228
- }
2229
- });
2230
- }
2231
- function generateIdSelector(id) {
2232
- const escaped = escapeSelectorComponent(id);
2233
- return /^\d/.test(escaped) ? `[id="${escaped}"]` : `#${escaped}`;
2234
- }
2235
2265
  class Selector {
2236
2266
  pattern;
2237
2267
  constructor(selector) {
@@ -2350,6 +2380,7 @@ class TextNode extends DOMNode {
2350
2380
  }
2351
2381
  }
2352
2382
 
2383
+ const CHILD_ELEMENTS = /* @__PURE__ */ Symbol("childElements");
2353
2384
  const ROLE = /* @__PURE__ */ Symbol("role");
2354
2385
  const TABINDEX = /* @__PURE__ */ Symbol("tabindex");
2355
2386
  var NodeClosed = /* @__PURE__ */ ((NodeClosed2) => {
@@ -2516,7 +2547,11 @@ class HtmlElement extends DOMNode {
2516
2547
  * Similar to childNodes but only elements.
2517
2548
  */
2518
2549
  get childElements() {
2519
- return this.childNodes.filter(isElementNode);
2550
+ const cached = this.cacheGet(CHILD_ELEMENTS);
2551
+ if (cached !== void 0) {
2552
+ return cached;
2553
+ }
2554
+ return this.cacheSet(CHILD_ELEMENTS, this.childNodes.filter(isElementNode));
2520
2555
  }
2521
2556
  /**
2522
2557
  * Find the first ancestor matching a selector.
@@ -2912,12 +2947,24 @@ class HtmlElement extends DOMNode {
2912
2947
  }
2913
2948
  return visit(this);
2914
2949
  }
2950
+ append(node) {
2951
+ super.append(node);
2952
+ this.cacheRemove(CHILD_ELEMENTS);
2953
+ }
2954
+ insertBefore(node, reference) {
2955
+ super.insertBefore(node, reference);
2956
+ this.cacheRemove(CHILD_ELEMENTS);
2957
+ }
2958
+ removeChild(node) {
2959
+ return super.removeChild(node);
2960
+ }
2915
2961
  /**
2916
2962
  * @internal
2917
2963
  */
2918
2964
  _setParent(node) {
2919
2965
  const oldParent = this._parent;
2920
2966
  this._parent = node instanceof HtmlElement ? node : null;
2967
+ oldParent?.cacheRemove(CHILD_ELEMENTS);
2921
2968
  return oldParent;
2922
2969
  }
2923
2970
  }
@@ -12485,7 +12532,7 @@ class EventHandler {
12485
12532
  }
12486
12533
 
12487
12534
  const name = "html-validate";
12488
- const version = "10.13.0";
12535
+ const version = "10.14.0";
12489
12536
  const bugs = "https://gitlab.com/html-validate/html-validate/issues/new";
12490
12537
 
12491
12538
  function freeze(src) {
@@ -12674,6 +12721,31 @@ class ParserError extends Error {
12674
12721
  }
12675
12722
  }
12676
12723
 
12724
+ function isStaticTrue(value) {
12725
+ return value === true;
12726
+ }
12727
+ function matchesContentCategory(meta, category) {
12728
+ switch (category) {
12729
+ case "@meta":
12730
+ return isStaticTrue(meta.metadata);
12731
+ case "@flow":
12732
+ return isStaticTrue(meta.flow);
12733
+ case "@flow-not-meta":
12734
+ return isStaticTrue(meta.flow) && !isStaticTrue(meta.metadata);
12735
+ case "@sectioning":
12736
+ return isStaticTrue(meta.sectioning);
12737
+ case "@heading":
12738
+ return isStaticTrue(meta.heading);
12739
+ case "@phrasing":
12740
+ return isStaticTrue(meta.phrasing);
12741
+ case "@embedded":
12742
+ return isStaticTrue(meta.embedded);
12743
+ case "@interactive":
12744
+ return isStaticTrue(meta.interactive);
12745
+ default:
12746
+ return false;
12747
+ }
12748
+ }
12677
12749
  function isAttrValueToken(token) {
12678
12750
  return token?.type === TokenType.ATTR_VALUE;
12679
12751
  }
@@ -12760,23 +12832,128 @@ class Parser {
12760
12832
  * valid). The parser handles this by checking if the element on top of the
12761
12833
  * stack when is allowed to omit.
12762
12834
  */
12835
+ /**
12836
+ * Check whether a given element would be implicitly closed by an incoming
12837
+ * start tag. Used both in `closeOptional` and in the multi-level lookahead.
12838
+ */
12839
+ wouldCloseElement(token, element) {
12840
+ if (!element.meta) {
12841
+ return false;
12842
+ }
12843
+ const implicitClosed = element.meta.implicitClosed;
12844
+ if (!implicitClosed) {
12845
+ return false;
12846
+ }
12847
+ const tagName = token.data[2];
12848
+ const incomingMeta = this.metaTable.getMetaFor(tagName);
12849
+ return implicitClosed.some((entry) => {
12850
+ if (!entry.startsWith("@")) {
12851
+ return entry === tagName;
12852
+ }
12853
+ return incomingMeta ? matchesContentCategory(incomingMeta, entry) : false;
12854
+ });
12855
+ }
12856
+ /**
12857
+ * Walk up the active stack to find the parent element that will remain
12858
+ * after all multi-level implicit closes triggered by the incoming start tag.
12859
+ * For a single-level close this is equivalent to `getActive().parent`.
12860
+ */
12861
+ getParentAfterImplicitClose(token) {
12862
+ let current = this.dom.getActive();
12863
+ while (!current.isRootElement() && this.wouldCloseElement(token, current)) {
12864
+ const { parent } = current;
12865
+ if (!parent) {
12866
+ break;
12867
+ }
12868
+ current = parent;
12869
+ }
12870
+ return current;
12871
+ }
12763
12872
  closeOptional(token) {
12764
12873
  const active = this.dom.getActive();
12765
12874
  if (!active.meta) {
12766
12875
  return false;
12767
12876
  }
12768
- const tagName = token.data[2];
12769
12877
  const open = !token.data[1];
12770
- const implicitClosed = active.meta.implicitClosed;
12771
12878
  if (open) {
12772
- return Boolean(implicitClosed?.includes(tagName));
12879
+ return this.wouldCloseElement(token, active);
12773
12880
  } else {
12774
- if (active.is(tagName)) {
12881
+ return this.closeOptionalEndTag(token, active);
12882
+ }
12883
+ }
12884
+ /**
12885
+ * Returns `true` if the element’s end tag may be omitted, either because
12886
+ * its `implicitClosed` list includes its own tag name (e.g. `<li>`, `<td>`)
12887
+ * or because `optionalEnd` is set.
12888
+ */
12889
+ canOmitEndTag(element) {
12890
+ if (!element.meta) {
12891
+ return false;
12892
+ }
12893
+ const { implicitClosed, optionalEnd } = element.meta;
12894
+ return Boolean(implicitClosed?.includes(element.tagName)) || Boolean(optionalEnd);
12895
+ }
12896
+ /**
12897
+ * Check whether the active element can be implicitly closed by an incoming
12898
+ * end tag. The end tag may close a direct parent or any ancestor, as long as
12899
+ * every intermediate element can also have its end tag omitted.
12900
+ * This handles cases like `</table>` implicitly closing `<td>`, `<tr>`, `<tbody>`.
12901
+ */
12902
+ closeOptionalEndTag(token, active) {
12903
+ const tagName = token.data[2];
12904
+ if (active.is(tagName)) {
12905
+ return false;
12906
+ }
12907
+ if (!this.canOmitEndTag(active)) {
12908
+ return false;
12909
+ }
12910
+ let ancestor = active.parent;
12911
+ while (ancestor && !ancestor.isRootElement()) {
12912
+ if (ancestor.is(tagName)) {
12913
+ return true;
12914
+ }
12915
+ if (!this.canOmitEndTag(ancestor)) {
12775
12916
  return false;
12776
12917
  }
12777
- const canOmitEnd = Boolean(implicitClosed?.includes(active.tagName)) || Boolean(active.meta.optionalEnd);
12778
- return Boolean(active.parent && active.parent.is(tagName) && canOmitEnd);
12918
+ ancestor = ancestor.parent;
12919
+ }
12920
+ return false;
12921
+ }
12922
+ /**
12923
+ * Check whether an intermediary element (e.g. `<head>` or `<body>`) should
12924
+ * be implicitly opened before the incoming element is inserted under
12925
+ * `parent`.
12926
+ *
12927
+ * If the parent's metadata defines an `implicitOpen` rule that matches the
12928
+ * incoming element, a new `HtmlElement` for the intermediary is created and
12929
+ * returned (with `parent` as its parent). The caller is responsible for
12930
+ * pushing it onto the active stack and firing the relevant events.
12931
+ *
12932
+ * Returns `null` when no implicit open is required.
12933
+ */
12934
+ peekImplicitOpen(token, parent) {
12935
+ if (!parent?.meta?.implicitOpen) {
12936
+ return null;
12937
+ }
12938
+ const tagName = token.data[2];
12939
+ const incomingMeta = this.metaTable.getMetaFor(tagName);
12940
+ for (const entry of parent.meta.implicitOpen) {
12941
+ const matches = entry.for.some((selector) => {
12942
+ if (!selector.startsWith("@")) {
12943
+ return selector === tagName;
12944
+ }
12945
+ return incomingMeta ? matchesContentCategory(incomingMeta, selector) : false;
12946
+ });
12947
+ if (matches) {
12948
+ const intermediaryMeta = this.metaTable.getMetaFor(entry.open);
12949
+ return HtmlElement.createElement(entry.open, token.location, {
12950
+ closed: NodeClosed.Open,
12951
+ meta: intermediaryMeta,
12952
+ parent
12953
+ });
12954
+ }
12779
12955
  }
12956
+ return null;
12780
12957
  }
12781
12958
  /**
12782
12959
  * @internal
@@ -12828,8 +13005,24 @@ class Parser {
12828
13005
  this.consumeUntil(tokenStream, TokenType.TAG_CLOSE, startToken.location)
12829
13006
  );
12830
13007
  const endToken = tokens.at(-1);
12831
- const closeOptional = this.closeOptional(startToken);
12832
- const parent = closeOptional ? this.dom.getActive().parent : this.dom.getActive();
13008
+ const isStartTag = !startToken.data[1];
13009
+ let baseParent;
13010
+ if (isStartTag) {
13011
+ baseParent = this.getParentAfterImplicitClose(startToken);
13012
+ } else {
13013
+ const tagName = startToken.data[2];
13014
+ let cur = this.dom.getActive();
13015
+ while (!cur.isRootElement() && !cur.is(tagName)) {
13016
+ const { parent: parent2 } = cur;
13017
+ if (!parent2) {
13018
+ break;
13019
+ }
13020
+ cur = parent2;
13021
+ }
13022
+ baseParent = cur;
13023
+ }
13024
+ const implicitParent = isStartTag ? this.peekImplicitOpen(startToken, baseParent) : null;
13025
+ const parent = implicitParent ?? baseParent;
12833
13026
  const node = HtmlElement.fromTokens(
12834
13027
  startToken,
12835
13028
  endToken,
@@ -12837,16 +13030,26 @@ class Parser {
12837
13030
  this.metaTable,
12838
13031
  this.currentNamespace
12839
13032
  );
12840
- const isStartTag = !startToken.data[1];
12841
13033
  const isClosing = !isStartTag || node.closed !== NodeClosed.Open;
12842
13034
  const isForeign = node.meta?.foreign;
12843
- if (closeOptional) {
13035
+ while (this.closeOptional(startToken)) {
12844
13036
  const active = this.dom.getActive();
12845
13037
  active.closed = NodeClosed.ImplicitClosed;
12846
13038
  this.closeElement(source, node, active, startToken.location);
12847
13039
  this.dom.popActive();
12848
13040
  }
12849
13041
  if (isStartTag) {
13042
+ if (implicitParent) {
13043
+ this.dom.pushActive(implicitParent);
13044
+ this.trigger("tag:start", {
13045
+ target: implicitParent,
13046
+ location: startToken.location
13047
+ });
13048
+ this.trigger("tag:ready", {
13049
+ target: implicitParent,
13050
+ location: startToken.location
13051
+ });
13052
+ }
12850
13053
  this.dom.pushActive(node);
12851
13054
  this.trigger("tag:start", {
12852
13055
  target: node,