@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.
- package/README.md +46 -0
- package/cli/out/main.js +167 -38
- 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
|
|
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
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
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
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
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
|
-
|
|
2768
|
-
|
|
2769
|
-
|
|
2770
|
-
|
|
2771
|
-
|
|
2772
|
-
|
|
2773
|
-
|
|
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
|
-
|
|
2784
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
}
|