@reteps/tree-sitter-htmlmustache 0.8.0 → 0.8.1

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/README.md CHANGED
@@ -294,41 +294,57 @@ Each custom rule requires an `id`, `selector`, and `message`. The `severity` def
294
294
 
295
295
  **Selector syntax:**
296
296
 
297
- | Selector | Matches |
298
- | ----------------------------------------- | --------------------------------------------- |
299
- | `div` | HTML elements by tag name |
300
- | `*` | Any HTML element |
301
- | `#main` | ID (shorthand for `[id="main"]`) |
302
- | `.panel` | Class (shorthand for `[class~="panel"]`) |
303
- | `div span` | Descendant (span anywhere inside div) |
304
- | `div > span` | Direct child |
305
- | `[style]` | Attribute presence |
306
- | `input[type=hidden]` | Attribute value (exact) |
307
- | `[src^="prefix/"]` | Attribute starts with |
308
- | `[href*="substring"]` | Attribute contains |
309
- | `[src$=".png"]` | Attribute ends with |
310
- | `[class~="warning"]` | Attribute contains whitespace-token |
311
- | `img:not([alt])` | Negated attribute / class / id |
312
- | `{{foo}}` | Escaped variable `{{foo}}` |
313
- | `{{data.foo}}` | Variable with a dotted path |
314
- | `{{{foo}}}` | Triple / unescaped variable |
315
- | `{{options.*}}` | Variable path prefix match |
316
- | `{{*.deprecated}}` | Variable path suffix match |
317
- | `{{*}}` | Any escaped variable |
318
- | `{{{*}}}` | Any triple |
319
- | `{{#items}}` | Section `{{#items}}...{{/items}}` |
320
- | `{{^items}}` | Inverted section `{{^items}}...{{/items}}` |
321
- | `{{#items}} > li` | Direct child inside a section |
322
- | `{{!TODO}}` | Comment with exact content |
323
- | `{{!*TODO*}}` | Comment containing "TODO" |
324
- | `{{>header}}` | Partial invocation |
325
- | `{{>legacy_*}}` | Partial name prefix |
326
- | `pl-multiple-choice:has({{foo}})` | Element containing a given variable |
327
- | `pl-multiple-choice:not(:has(pl-answer))` | Element missing a required descendant |
328
- | `div, span` | Comma-separated alternatives |
297
+ | Selector | Matches |
298
+ | ----------------------------------------- | ------------------------------------------ |
299
+ | `div` | HTML elements by tag name |
300
+ | `*` | Any HTML element |
301
+ | `#main` | ID (shorthand for `[id="main"]`) |
302
+ | `.panel` | Class (shorthand for `[class~="panel"]`) |
303
+ | `div span` | Descendant (span anywhere inside div) |
304
+ | `div > span` | Direct child |
305
+ | `[style]` | Attribute presence |
306
+ | `input[type=hidden]` | Attribute value (exact) |
307
+ | `[src^="prefix/"]` | Attribute starts with |
308
+ | `[href*="substring"]` | Attribute contains |
309
+ | `[src$=".png"]` | Attribute ends with |
310
+ | `[class~="warning"]` | Attribute contains whitespace-token |
311
+ | `img:not([alt])` | Negated attribute / class / id |
312
+ | `{{foo}}` | Escaped variable `{{foo}}` |
313
+ | `{{data.foo}}` | Variable with a dotted path |
314
+ | `{{{foo}}}` | Triple / unescaped variable |
315
+ | `{{options.*}}` | Variable path prefix match |
316
+ | `{{*.deprecated}}` | Variable path suffix match |
317
+ | `{{*}}` | Any escaped variable |
318
+ | `{{{*}}}` | Any triple |
319
+ | `{{#items}}` | Section `{{#items}}...{{/items}}` |
320
+ | `{{^items}}` | Inverted section `{{^items}}...{{/items}}` |
321
+ | `{{#items}} > li` | Direct child inside a section |
322
+ | `{{!TODO}}` | Comment with exact content |
323
+ | `{{!*TODO*}}` | Comment containing "TODO" |
324
+ | `{{>header}}` | Partial invocation |
325
+ | `{{>legacy_*}}` | Partial name prefix |
326
+ | `pl-multiple-choice:has({{foo}})` | Element containing a given variable |
327
+ | `pl-multiple-choice:not(:has(pl-answer))` | Element missing a required descendant |
328
+ | `div, span` | Comma-separated alternatives |
329
+ | `:root` | The document root (the whole parse tree) |
330
+ | `:root:has(pl-answer-panel)` | Document contains a descendant anywhere |
331
+ | `:root:not(:has(pl-answer-panel))` | Document is missing a descendant anywhere |
332
+ | `:root > section` | Top-level element (direct child of root) |
329
333
 
330
334
  The `>` (child) combinator is kind-transparent: `div > span` matches even if a Mustache section sits between them (e.g. `<div>{{#show}}<span>{{/show}}</div>`), and `{{#a}} > {{#b}}` matches across intervening HTML elements. `{{#foo}}` matches only positive sections — to target inverted sections use `{{^foo}}`.
331
335
 
336
+ **Document-scoped conditional rules.** Use `:root` with `:has(...)` / `:not(:has(...))` to express rules that depend on the overall document. Chained `:has(...)` acts as AND, so you can combine "contains X" and "missing Y" in one selector:
337
+
338
+ ```jsonc
339
+ {
340
+ "id": "question-needs-answer-panel",
341
+ "selector": ":root:has(pl-question-panel):not(:has(pl-answer-panel))",
342
+ "message": "A question-panel document must also declare a pl-answer-panel.",
343
+ }
344
+ ```
345
+
346
+ When `:root` matches, the diagnostic is reported at the start of the document (row 0, column 0) so the squiggle doesn't span the whole file. `:root` here is the tree-sitter fragment root, so it works on partial templates and fragments — unlike browser CSS, which anchors `:root` on `<html>`. Inside `:has(...)`, `:root` refers to the element being has-checked, not the document.
347
+
332
348
  ### Disabling Lint Rules
333
349
 
334
350
  Disable a lint rule for an entire file with an inline comment:
package/cli/out/main.js CHANGED
@@ -1963,6 +1963,7 @@ function segmentFromCompound(ast) {
1963
1963
  let kind;
1964
1964
  let name = null;
1965
1965
  let pathRegex;
1966
+ let rootOnly = false;
1966
1967
  const attributes = [];
1967
1968
  const descendantChecks = [];
1968
1969
  const forbidChange = (requested) => {
@@ -2021,6 +2022,11 @@ function segmentFromCompound(ast) {
2021
2022
  if (!applyNegatedSubtree(token.subtree, attributes, descendantChecks)) return null;
2022
2023
  break;
2023
2024
  }
2025
+ if (token.name === "root") {
2026
+ rootOnly = true;
2027
+ if (kind === void 0) kind = "html";
2028
+ break;
2029
+ }
2024
2030
  return null;
2025
2031
  }
2026
2032
  default:
@@ -2030,9 +2036,12 @@ function segmentFromCompound(ast) {
2030
2036
  if (kind === void 0) {
2031
2037
  kind = "html";
2032
2038
  }
2039
+ if (rootOnly) {
2040
+ if (name !== null || attributes.length > 0 || kind !== "html") return null;
2041
+ }
2033
2042
  const isHtml = kind === "html";
2034
2043
  const finalAttrs = isHtml ? attributes : [];
2035
- return { kind, name, pathRegex, attributes: finalAttrs, descendantChecks, combinator: "descendant" };
2044
+ return { kind, rootOnly, name, pathRegex, attributes: finalAttrs, descendantChecks, combinator: "descendant" };
2036
2045
  }
2037
2046
  function mustacheKindFromMarker(name) {
2038
2047
  switch (name) {
@@ -2227,7 +2236,11 @@ function matchesName(actual, segment) {
2227
2236
  if (segment.pathRegex) return segment.pathRegex.test(actual);
2228
2237
  return actual === segment.name;
2229
2238
  }
2230
- function nodeMatchesSegment(node, segment) {
2239
+ function nodeMatchesSegment(node, segment, rootNode) {
2240
+ if (segment.rootOnly) {
2241
+ if (node !== rootNode) return false;
2242
+ return checkDescendants(node, segment.descendantChecks);
2243
+ }
2231
2244
  switch (segment.kind) {
2232
2245
  case "html": {
2233
2246
  if (!HTML_ELEMENT_TYPES.has(node.type)) return false;
@@ -2271,7 +2284,10 @@ function checkAncestors(ancestors, segments, segIdx, childCombinator) {
2271
2284
  if (childCombinator === "child") {
2272
2285
  for (let a2 = ancestors.length - 1; a2 >= 0; a2--) {
2273
2286
  const entry = ancestors[a2];
2274
- if (entry.kind !== ancestorKind) continue;
2287
+ if (entry.kind !== ancestorKind) {
2288
+ if (ancestorKind === "root" && entry.kind === "html") return false;
2289
+ continue;
2290
+ }
2275
2291
  if (!matchesName(entry.name, segment)) return false;
2276
2292
  if (segment.kind === "html" && !checkAttributes(entry.node, segment.attributes)) return false;
2277
2293
  if (!checkDescendants(entry.node, segment.descendantChecks)) return false;
@@ -2292,12 +2308,13 @@ function checkAncestors(ancestors, segments, segIdx, childCombinator) {
2292
2308
  return false;
2293
2309
  }
2294
2310
  function ancestorKindForSegment(segment) {
2311
+ if (segment.rootOnly) return "root";
2295
2312
  if (segment.kind === "html") return "html";
2296
2313
  if (segment.kind === "section") return "section";
2297
2314
  if (segment.kind === "inverted") return "inverted";
2298
2315
  return null;
2299
2316
  }
2300
- function getReportNode(node) {
2317
+ function getReportNode(node, rootNode) {
2301
2318
  if (HTML_ELEMENT_TYPES.has(node.type)) {
2302
2319
  const startTag = node.children.find(
2303
2320
  (c2) => c2.type === "html_start_tag" || c2.type === "html_self_closing_tag"
@@ -2310,15 +2327,26 @@ function getReportNode(node) {
2310
2327
  );
2311
2328
  return begin ?? node;
2312
2329
  }
2330
+ if (rootNode && node === rootNode) {
2331
+ return {
2332
+ type: node.type,
2333
+ text: "",
2334
+ startPosition: node.startPosition,
2335
+ endPosition: { row: node.startPosition.row, column: node.startPosition.column + 1 },
2336
+ startIndex: node.startIndex,
2337
+ endIndex: Math.min(node.startIndex + 1, node.endIndex),
2338
+ children: []
2339
+ };
2340
+ }
2313
2341
  return node;
2314
2342
  }
2315
2343
  function matchAlternative(rootNode, segments) {
2316
2344
  const results = [];
2317
2345
  const lastSegment = segments[segments.length - 1];
2318
2346
  function walk(node, ancestors) {
2319
- if (nodeMatchesSegment(node, lastSegment)) {
2347
+ if (nodeMatchesSegment(node, lastSegment, rootNode)) {
2320
2348
  if (segments.length === 1 || checkAncestors(ancestors, segments, segments.length - 2, lastSegment.combinator)) {
2321
- results.push(getReportNode(node));
2349
+ results.push(getReportNode(node, rootNode));
2322
2350
  }
2323
2351
  }
2324
2352
  let newAncestors = ancestors;
@@ -2331,7 +2359,7 @@ function matchAlternative(rootNode, segments) {
2331
2359
  }
2332
2360
  for (const child of node.children) walk(child, newAncestors);
2333
2361
  }
2334
- walk(rootNode, []);
2362
+ walk(rootNode, [{ kind: "root", name: "", node: rootNode }]);
2335
2363
  return results;
2336
2364
  }
2337
2365
  function matchSelector(rootNode, selector) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reteps/tree-sitter-htmlmustache",
3
- "version": "0.8.0",
3
+ "version": "0.8.1",
4
4
  "description": "HTML with Mustache/Handlebars template syntax grammar for tree-sitter",
5
5
  "repository": {
6
6
  "type": "git",