@reteps/tree-sitter-htmlmustache 0.4.2 → 0.5.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.
Files changed (3) hide show
  1. package/README.md +46 -0
  2. package/cli/out/main.js +167 -38
  3. package/package.json +3 -2
package/README.md CHANGED
@@ -212,6 +212,52 @@ 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
+
250
+ ### Disabling Lint Rules
251
+
252
+ Disable a configurable lint rule for an entire file with an inline comment:
253
+
254
+ ```html
255
+ <!-- htmlmustache-disable preferMustacheComments -->
256
+ {{! htmlmustache-disable selfClosingNonVoidTags }}
257
+ ```
258
+
259
+ The comment can appear anywhere in the file. Only configurable rules (listed above) can be disabled. Use multiple comments to disable multiple rules.
260
+
215
261
  ### EditorConfig
216
262
 
217
263
  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,39 @@ 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 parseDisableDirective(node) {
1429
+ if (node.type !== "html_comment" && node.type !== "mustache_comment") return null;
1430
+ let inner = null;
1431
+ if (node.type === "html_comment") {
1432
+ const match = node.text.match(/^<!--([\s\S]*)-->$/);
1433
+ if (match) inner = match[1].trim();
1434
+ } else {
1435
+ const match = node.text.match(/^\{\{!([\s\S]*)\}\}$/);
1436
+ if (match) inner = match[1].trim();
1437
+ }
1438
+ if (!inner) return null;
1439
+ const prefix = "htmlmustache-disable ";
1440
+ if (!inner.startsWith(prefix)) return null;
1441
+ const ruleName = inner.slice(prefix.length).trim();
1442
+ return KNOWN_RULE_NAMES.has(ruleName) ? ruleName : null;
1443
+ }
1444
+ function collectDisabledRules(rootNode) {
1445
+ const disabled = /* @__PURE__ */ new Set();
1446
+ function walk(node) {
1447
+ const rule = parseDisableDirective(node);
1448
+ if (rule) {
1449
+ disabled.add(rule);
1450
+ return;
1451
+ }
1452
+ for (const child of node.children) walk(child);
1453
+ }
1454
+ walk(rootNode);
1455
+ return disabled;
1456
+ }
1457
+ function collectErrors(tree, rules) {
1339
1458
  const errors = [];
1340
1459
  const cursor = tree.walk();
1341
1460
  function visit() {
@@ -1364,30 +1483,42 @@ function collectErrors(tree) {
1364
1483
  for (const error of unclosedErrors) {
1365
1484
  errors.push({ node: error.node, message: error.message });
1366
1485
  }
1486
+ const disabledRules = collectDisabledRules(tree.rootNode);
1487
+ const effectiveRules = { ...rules };
1488
+ for (const rule of disabledRules) {
1489
+ effectiveRules[rule] = "off";
1490
+ }
1367
1491
  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)
1492
+ const ruleChecks = [
1493
+ { rule: "nestedDuplicateSections", errors: () => checkNestedSameNameSections(tree.rootNode) },
1494
+ { rule: "unquotedMustacheAttributes", errors: () => checkUnquotedMustacheAttributes(tree.rootNode) },
1495
+ { rule: "consecutiveDuplicateSections", errors: () => checkConsecutiveSameNameSections(tree.rootNode, sourceText) },
1496
+ { rule: "selfClosingNonVoidTags", errors: () => checkSelfClosingNonVoidTags(tree.rootNode) },
1497
+ { rule: "duplicateAttributes", errors: () => checkDuplicateAttributes(tree.rootNode) },
1498
+ { rule: "unescapedEntities", errors: () => checkUnescapedEntities(tree.rootNode) },
1499
+ { rule: "preferMustacheComments", errors: () => checkHtmlComments(tree.rootNode) }
1375
1500
  ];
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
- });
1501
+ for (const { rule, errors: getErrors } of ruleChecks) {
1502
+ const severity = resolveRuleSeverity(effectiveRules, rule);
1503
+ if (severity === "off") continue;
1504
+ for (const error of getErrors()) {
1505
+ errors.push({
1506
+ node: error.node,
1507
+ message: error.message,
1508
+ severity,
1509
+ fix: error.fix,
1510
+ fixDescription: error.fixDescription
1511
+ });
1512
+ }
1384
1513
  }
1385
- return errors;
1514
+ return errors.filter(
1515
+ (e) => !(e.message.includes("HTML comment found") && parseDisableDirective(e.node) !== null)
1516
+ );
1386
1517
  }
1387
1518
 
1388
1519
  // cli/src/check.ts
1389
- function collectErrors2(tree, file) {
1390
- const errors = collectErrors(tree);
1520
+ function collectErrors2(tree, file, rules) {
1521
+ const errors = collectErrors(tree, rules);
1391
1522
  return errors.map((error) => ({
1392
1523
  file,
1393
1524
  line: error.node.startPosition.row + 1,
@@ -1570,12 +1701,13 @@ async function run(args) {
1570
1701
  let filesWithErrors = 0;
1571
1702
  const cwd = process.cwd();
1572
1703
  const errorOutput = [];
1704
+ const rules = config?.rules;
1573
1705
  for (const file of files) {
1574
1706
  const displayPath = import_node_path.default.relative(cwd, file) || file;
1575
1707
  let source = import_node_fs.default.readFileSync(file, "utf-8");
1576
1708
  if (fixMode) {
1577
1709
  const tree2 = parseDocument(source);
1578
- const errors2 = collectErrors2(tree2, displayPath);
1710
+ const errors2 = collectErrors2(tree2, displayPath, rules);
1579
1711
  const fixed = applyFixes(source, errors2);
1580
1712
  if (fixed !== source) {
1581
1713
  import_node_fs.default.writeFileSync(file, fixed, "utf-8");
@@ -1583,7 +1715,7 @@ async function run(args) {
1583
1715
  }
1584
1716
  }
1585
1717
  const tree = parseDocument(source);
1586
- const errors = collectErrors2(tree, displayPath);
1718
+ const errors = collectErrors2(tree, displayPath, rules);
1587
1719
  const fileErrors = errors.filter((e) => e.severity !== "warning");
1588
1720
  const fileWarnings = errors.filter((e) => e.severity === "warning");
1589
1721
  if (errors.length > 0) {
@@ -2764,25 +2896,21 @@ function formatScriptStyleElement(node, context) {
2764
2896
  const dedented = dedentContent(child.text);
2765
2897
  if (dedented.length > 0) {
2766
2898
  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]));
2899
+ const lineDocs = [];
2900
+ for (let j = 0; j < contentLines.length; j++) {
2901
+ if (j > 0) {
2902
+ if (contentLines[j] === "") {
2903
+ lineDocs.push("\n");
2904
+ } else {
2905
+ lineDocs.push(hardline);
2781
2906
  }
2782
2907
  }
2783
- parts.push(indent(concat([hardline, ...lineDocs])));
2784
- parts.push(hardline);
2908
+ if (contentLines[j] !== "") {
2909
+ lineDocs.push(text(contentLines[j]));
2910
+ }
2785
2911
  }
2912
+ parts.push(indent(concat([hardline, ...lineDocs])));
2913
+ parts.push(hardline);
2786
2914
  }
2787
2915
  }
2788
2916
  }
@@ -3160,7 +3288,8 @@ function formatBlockChildren(nodes, context) {
3160
3288
  pendingBlankLine = false;
3161
3289
  } else if (node.type === "html_comment" || node.type === "mustache_comment") {
3162
3290
  const isMultiline = node.startPosition.row !== node.endPosition.row;
3163
- if (isMultiline) {
3291
+ const isOnOwnLine = i > 0 && node.startPosition.row > nodes[i - 1].endPosition.row;
3292
+ if (isMultiline || isOnOwnLine) {
3164
3293
  if (currentLine.length > 0) {
3165
3294
  const lineContent = trimDoc(flushCurrentLine());
3166
3295
  if (hasDocContent(lineContent)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reteps/tree-sitter-htmlmustache",
3
- "version": "0.4.2",
3
+ "version": "0.5.1",
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
  }