@reteps/tree-sitter-htmlmustache 0.4.1 → 0.5.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.
Files changed (3) hide show
  1. package/README.md +35 -0
  2. package/cli/out/main.js +171 -42
  3. package/package.json +3 -2
package/README.md CHANGED
@@ -212,6 +212,41 @@ Create a `.htmlmustache.jsonc` file in your project root to configure formatting
212
212
  }
213
213
  ```
214
214
 
215
+ ### Lint Rules
216
+
217
+ The following checks are always enabled and report as errors:
218
+
219
+ - **Syntax errors** — invalid or unparseable template syntax
220
+ - **Missing tokens** — e.g. a missing closing `>`
221
+ - **Mismatched mustache sections** — `{{/wrong}}` closing a different section than was opened
222
+ - **Mismatched HTML tags** — closing tags that don't match their opening tag, including across mustache branches
223
+ - **Unclosed HTML tags** — non-void elements that are never closed
224
+
225
+ Additionally, the following rules are configurable. Set their severities (`"error"`, `"warning"`, or `"off"`) in the `rules` object:
226
+
227
+ ```jsonc
228
+ {
229
+ "rules": {
230
+ "consecutiveDuplicateSections": "off",
231
+ "preferMustacheComments": "warning"
232
+ }
233
+ }
234
+ ```
235
+
236
+ <!-- RULES_TABLE_START -->
237
+
238
+ | Rule | Default | Description |
239
+ | --- | --- | --- |
240
+ | `nestedDuplicateSections` | `error` | Flags `{{#name}}` nested inside another `{{#name}}` with the same name |
241
+ | `unquotedMustacheAttributes` | `error` | Requires quotes around mustache expressions used as attribute values |
242
+ | `consecutiveDuplicateSections` | `warning` | Warns when adjacent same-name sections can be merged |
243
+ | `selfClosingNonVoidTags` | `error` | Disallows self-closing syntax on non-void HTML elements (e.g. `<div/>`) |
244
+ | `duplicateAttributes` | `error` | Detects duplicate HTML attributes on the same element |
245
+ | `unescapedEntities` | `warning` | Flags unescaped `&` and `>` characters in text content |
246
+ | `preferMustacheComments` | `off` | Suggests replacing HTML comments with mustache comments |
247
+
248
+ <!-- RULES_TABLE_END -->
249
+
215
250
  ### EditorConfig
216
251
 
217
252
  Both the CLI and VS Code extension respect your `.editorconfig` file for indentation settings (`indent_style`, `indent_size`). EditorConfig values override `.htmlmustache.jsonc` for indentation, and CLI flags override everything.
package/cli/out/main.js CHANGED
@@ -544,6 +544,51 @@ function parseDocument(source) {
544
544
  // lsp/server/src/configFile.ts
545
545
  var fs = __toESM(require("fs"));
546
546
  var path2 = __toESM(require("path"));
547
+
548
+ // lsp/server/src/ruleMetadata.ts
549
+ var RULES = [
550
+ {
551
+ name: "nestedDuplicateSections",
552
+ defaultSeverity: "error",
553
+ description: "Flags `{{#name}}` nested inside another `{{#name}}` with the same name"
554
+ },
555
+ {
556
+ name: "unquotedMustacheAttributes",
557
+ defaultSeverity: "error",
558
+ description: "Requires quotes around mustache expressions used as attribute values"
559
+ },
560
+ {
561
+ name: "consecutiveDuplicateSections",
562
+ defaultSeverity: "warning",
563
+ description: "Warns when adjacent same-name sections can be merged"
564
+ },
565
+ {
566
+ name: "selfClosingNonVoidTags",
567
+ defaultSeverity: "error",
568
+ description: "Disallows self-closing syntax on non-void HTML elements (e.g. `<div/>`)"
569
+ },
570
+ {
571
+ name: "duplicateAttributes",
572
+ defaultSeverity: "error",
573
+ description: "Detects duplicate HTML attributes on the same element"
574
+ },
575
+ {
576
+ name: "unescapedEntities",
577
+ defaultSeverity: "warning",
578
+ description: "Flags unescaped `&` and `>` characters in text content"
579
+ },
580
+ {
581
+ name: "preferMustacheComments",
582
+ defaultSeverity: "off",
583
+ description: "Suggests replacing HTML comments with mustache comments"
584
+ }
585
+ ];
586
+ var KNOWN_RULE_NAMES = new Set(RULES.map((r) => r.name));
587
+ var RULE_DEFAULTS = Object.fromEntries(
588
+ RULES.map((r) => [r.name, r.defaultSeverity])
589
+ );
590
+
591
+ // lsp/server/src/configFile.ts
547
592
  var VALID_CSS_DISPLAY_VALUES = /* @__PURE__ */ new Set([
548
593
  "block",
549
594
  "inline",
@@ -563,6 +608,7 @@ var VALID_CSS_DISPLAY_VALUES = /* @__PURE__ */ new Set([
563
608
  "ruby-text",
564
609
  "none"
565
610
  ]);
611
+ var VALID_RULE_SEVERITIES = /* @__PURE__ */ new Set(["error", "warning", "off"]);
566
612
  var CONFIG_FILENAME = ".htmlmustache.jsonc";
567
613
  function parseJsonc(text2) {
568
614
  let result = "";
@@ -682,6 +728,18 @@ function validateConfig(raw) {
682
728
  }
683
729
  config.customTags = Array.from(mergedMap.values());
684
730
  }
731
+ if (obj.rules && typeof obj.rules === "object" && !Array.isArray(obj.rules)) {
732
+ const rawRules = obj.rules;
733
+ const rules = {};
734
+ let hasRules = false;
735
+ for (const [key, value] of Object.entries(rawRules)) {
736
+ if (KNOWN_RULE_NAMES.has(key) && typeof value === "string" && VALID_RULE_SEVERITIES.has(value)) {
737
+ rules[key] = value;
738
+ hasRules = true;
739
+ }
740
+ }
741
+ if (hasRules) config.rules = rules;
742
+ }
685
743
  return config;
686
744
  }
687
745
  function loadConfigFileForPath(filePath) {
@@ -1274,6 +1332,35 @@ function checkUnescapedEntities(rootNode) {
1274
1332
  visit(rootNode);
1275
1333
  return errors;
1276
1334
  }
1335
+ function checkHtmlComments(rootNode) {
1336
+ const errors = [];
1337
+ function visit(node) {
1338
+ if (node.type === "html_comment") {
1339
+ const raw = node.text;
1340
+ let content = raw;
1341
+ if (content.startsWith("<!--")) content = content.slice(4);
1342
+ if (content.endsWith("-->")) content = content.slice(0, -3);
1343
+ content = content.trim();
1344
+ errors.push({
1345
+ node,
1346
+ message: `HTML comment found \u2014 use mustache comment {{! ... }} instead`,
1347
+ severity: "warning",
1348
+ fix: [{
1349
+ startIndex: node.startIndex,
1350
+ endIndex: node.endIndex,
1351
+ newText: `{{! ${content} }}`
1352
+ }],
1353
+ fixDescription: "Replace HTML comment with mustache comment"
1354
+ });
1355
+ return;
1356
+ }
1357
+ for (const child of node.children) {
1358
+ visit(child);
1359
+ }
1360
+ }
1361
+ visit(rootNode);
1362
+ return errors;
1363
+ }
1277
1364
  function checkDuplicateAttributes(rootNode) {
1278
1365
  const errors = [];
1279
1366
  function visit(node) {
@@ -1335,7 +1422,10 @@ function errorMessageForNode(nodeType, node) {
1335
1422
  }
1336
1423
  return `Missing ${nodeType}`;
1337
1424
  }
1338
- function collectErrors(tree) {
1425
+ function resolveRuleSeverity(rules, ruleName) {
1426
+ return rules?.[ruleName] ?? RULE_DEFAULTS[ruleName] ?? "off";
1427
+ }
1428
+ function collectErrors(tree, rules) {
1339
1429
  const errors = [];
1340
1430
  const cursor = tree.walk();
1341
1431
  function visit() {
@@ -1365,29 +1455,34 @@ function collectErrors(tree) {
1365
1455
  errors.push({ node: error.node, message: error.message });
1366
1456
  }
1367
1457
  const sourceText = tree.rootNode.text;
1368
- const mustacheChecks = [
1369
- ...checkNestedSameNameSections(tree.rootNode),
1370
- ...checkUnquotedMustacheAttributes(tree.rootNode),
1371
- ...checkConsecutiveSameNameSections(tree.rootNode, sourceText),
1372
- ...checkSelfClosingNonVoidTags(tree.rootNode),
1373
- ...checkDuplicateAttributes(tree.rootNode),
1374
- ...checkUnescapedEntities(tree.rootNode)
1458
+ const ruleChecks = [
1459
+ { rule: "nestedDuplicateSections", errors: () => checkNestedSameNameSections(tree.rootNode) },
1460
+ { rule: "unquotedMustacheAttributes", errors: () => checkUnquotedMustacheAttributes(tree.rootNode) },
1461
+ { rule: "consecutiveDuplicateSections", errors: () => checkConsecutiveSameNameSections(tree.rootNode, sourceText) },
1462
+ { rule: "selfClosingNonVoidTags", errors: () => checkSelfClosingNonVoidTags(tree.rootNode) },
1463
+ { rule: "duplicateAttributes", errors: () => checkDuplicateAttributes(tree.rootNode) },
1464
+ { rule: "unescapedEntities", errors: () => checkUnescapedEntities(tree.rootNode) },
1465
+ { rule: "preferMustacheComments", errors: () => checkHtmlComments(tree.rootNode) }
1375
1466
  ];
1376
- for (const error of mustacheChecks) {
1377
- errors.push({
1378
- node: error.node,
1379
- message: error.message,
1380
- severity: error.severity,
1381
- fix: error.fix,
1382
- fixDescription: error.fixDescription
1383
- });
1467
+ for (const { rule, errors: getErrors } of ruleChecks) {
1468
+ const severity = resolveRuleSeverity(rules, rule);
1469
+ if (severity === "off") continue;
1470
+ for (const error of getErrors()) {
1471
+ errors.push({
1472
+ node: error.node,
1473
+ message: error.message,
1474
+ severity,
1475
+ fix: error.fix,
1476
+ fixDescription: error.fixDescription
1477
+ });
1478
+ }
1384
1479
  }
1385
1480
  return errors;
1386
1481
  }
1387
1482
 
1388
1483
  // cli/src/check.ts
1389
- function collectErrors2(tree, file) {
1390
- const errors = collectErrors(tree);
1484
+ function collectErrors2(tree, file, rules) {
1485
+ const errors = collectErrors(tree, rules);
1391
1486
  return errors.map((error) => ({
1392
1487
  file,
1393
1488
  line: error.node.startPosition.row + 1,
@@ -1570,12 +1665,13 @@ async function run(args) {
1570
1665
  let filesWithErrors = 0;
1571
1666
  const cwd = process.cwd();
1572
1667
  const errorOutput = [];
1668
+ const rules = config?.rules;
1573
1669
  for (const file of files) {
1574
1670
  const displayPath = import_node_path.default.relative(cwd, file) || file;
1575
1671
  let source = import_node_fs.default.readFileSync(file, "utf-8");
1576
1672
  if (fixMode) {
1577
1673
  const tree2 = parseDocument(source);
1578
- const errors2 = collectErrors2(tree2, displayPath);
1674
+ const errors2 = collectErrors2(tree2, displayPath, rules);
1579
1675
  const fixed = applyFixes(source, errors2);
1580
1676
  if (fixed !== source) {
1581
1677
  import_node_fs.default.writeFileSync(file, fixed, "utf-8");
@@ -1583,7 +1679,7 @@ async function run(args) {
1583
1679
  }
1584
1680
  }
1585
1681
  const tree = parseDocument(source);
1586
- const errors = collectErrors2(tree, displayPath);
1682
+ const errors = collectErrors2(tree, displayPath, rules);
1587
1683
  const fileErrors = errors.filter((e) => e.severity !== "warning");
1588
1684
  const fileWarnings = errors.filter((e) => e.severity === "warning");
1589
1685
  if (errors.length > 0) {
@@ -2764,25 +2860,21 @@ function formatScriptStyleElement(node, context) {
2764
2860
  const dedented = dedentContent(child.text);
2765
2861
  if (dedented.length > 0) {
2766
2862
  const contentLines = dedented.split("\n");
2767
- if (contentLines.length === 1) {
2768
- parts.push(text(contentLines[0]));
2769
- } else {
2770
- const lineDocs = [];
2771
- for (let j = 0; j < contentLines.length; j++) {
2772
- if (j > 0) {
2773
- if (contentLines[j] === "") {
2774
- lineDocs.push("\n");
2775
- } else {
2776
- lineDocs.push(hardline);
2777
- }
2778
- }
2779
- if (contentLines[j] !== "") {
2780
- lineDocs.push(text(contentLines[j]));
2863
+ const lineDocs = [];
2864
+ for (let j = 0; j < contentLines.length; j++) {
2865
+ if (j > 0) {
2866
+ if (contentLines[j] === "") {
2867
+ lineDocs.push("\n");
2868
+ } else {
2869
+ lineDocs.push(hardline);
2781
2870
  }
2782
2871
  }
2783
- parts.push(indent(concat([hardline, ...lineDocs])));
2784
- parts.push(hardline);
2872
+ if (contentLines[j] !== "") {
2873
+ lineDocs.push(text(contentLines[j]));
2874
+ }
2785
2875
  }
2876
+ parts.push(indent(concat([hardline, ...lineDocs])));
2877
+ parts.push(hardline);
2786
2878
  }
2787
2879
  }
2788
2880
  }
@@ -2813,13 +2905,50 @@ function formatMustacheSection(node, context) {
2813
2905
  parts.push(text(mustacheText(beginNode.text, context)));
2814
2906
  }
2815
2907
  const hasImplicit = hasImplicitEndTags(contentNodes);
2816
- const isStaircase = !hasImplicit && contentNodes.length > 0 && contentNodes.every((n) => n.type === "html_erroneous_end_tag");
2817
- if (isStaircase) {
2818
- const E = contentNodes.length;
2819
- for (let i = 0; i < E; i++) {
2820
- const formatted = formatNode(contentNodes[i], context);
2821
- parts.push(indentN(concat([hardline, formatted]), E - i));
2908
+ const erroneousCount = contentNodes.filter((n) => n.type === "html_erroneous_end_tag").length;
2909
+ const hasStaircase = !hasImplicit && erroneousCount > 0;
2910
+ if (hasStaircase) {
2911
+ let virtualDepth = erroneousCount - 1;
2912
+ const groupNodes = [];
2913
+ let lastNodeEnd = -1;
2914
+ let pendingBlankLine = false;
2915
+ let groupBlankLine = false;
2916
+ const emitGroup = () => {
2917
+ if (groupNodes.length === 0) return;
2918
+ const formatted = formatBlockChildren(groupNodes, context);
2919
+ if (hasDocContent(formatted)) {
2920
+ if (groupBlankLine) parts.push("\n");
2921
+ const depth = Math.max(0, virtualDepth + 1);
2922
+ parts.push(depth > 0 ? indentN(concat([hardline, formatted]), depth) : concat([hardline, formatted]));
2923
+ }
2924
+ groupNodes.length = 0;
2925
+ groupBlankLine = false;
2926
+ };
2927
+ for (const node2 of contentNodes) {
2928
+ if (lastNodeEnd >= 0 && node2.startIndex > lastNodeEnd) {
2929
+ const gap = context.document.getText().slice(lastNodeEnd, node2.startIndex);
2930
+ if ((gap.match(/\n/g) || []).length >= 2) {
2931
+ pendingBlankLine = true;
2932
+ }
2933
+ }
2934
+ if (node2.type === "html_erroneous_end_tag") {
2935
+ emitGroup();
2936
+ if (pendingBlankLine) parts.push("\n");
2937
+ pendingBlankLine = false;
2938
+ const formatted = formatNode(node2, context);
2939
+ const depth = Math.max(0, virtualDepth);
2940
+ parts.push(depth > 0 ? indentN(concat([hardline, formatted]), depth) : concat([hardline, formatted]));
2941
+ virtualDepth--;
2942
+ } else {
2943
+ if (groupNodes.length === 0) {
2944
+ groupBlankLine = pendingBlankLine;
2945
+ pendingBlankLine = false;
2946
+ }
2947
+ groupNodes.push(node2);
2948
+ }
2949
+ lastNodeEnd = node2.endIndex;
2822
2950
  }
2951
+ emitGroup();
2823
2952
  parts.push(hardline);
2824
2953
  } else {
2825
2954
  const formattedContent = formatBlockChildren(contentNodes, context);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reteps/tree-sitter-htmlmustache",
3
- "version": "0.4.1",
3
+ "version": "0.5.0",
4
4
  "description": "HTML with Mustache/Handlebars template syntax grammar for tree-sitter",
5
5
  "repository": {
6
6
  "type": "git",
@@ -89,6 +89,7 @@
89
89
  "prestart": "tree-sitter build --wasm",
90
90
  "start": "tree-sitter playground",
91
91
  "test": "tree-sitter test && node --test bindings/node/*_test.js",
92
- "test:cli": "vitest run --config cli/vitest.config.ts"
92
+ "test:cli": "vitest run --config cli/vitest.config.ts",
93
+ "generate:rule-docs": "tsx scripts/generate-rule-docs.ts"
93
94
  }
94
95
  }