@reteps/tree-sitter-htmlmustache 0.8.0 → 0.9.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/README.md +49 -33
- package/browser/out/browser/index.d.ts +43 -0
- package/browser/out/browser/index.d.ts.map +1 -0
- package/browser/out/browser/index.mjs +3612 -0
- package/browser/out/browser/index.mjs.map +7 -0
- package/browser/out/core/collectErrors.d.ts +36 -0
- package/browser/out/core/collectErrors.d.ts.map +1 -0
- package/browser/out/core/configSchema.d.ts +63 -0
- package/browser/out/core/configSchema.d.ts.map +1 -0
- package/browser/out/core/customCodeTags.d.ts +34 -0
- package/browser/out/core/customCodeTags.d.ts.map +1 -0
- package/browser/out/core/diagnostic.d.ts +24 -0
- package/browser/out/core/diagnostic.d.ts.map +1 -0
- package/browser/out/core/embeddedRegions.d.ts +12 -0
- package/browser/out/core/embeddedRegions.d.ts.map +1 -0
- package/browser/out/core/formatting/classifier.d.ts +68 -0
- package/browser/out/core/formatting/classifier.d.ts.map +1 -0
- package/browser/out/core/formatting/embedded.d.ts +19 -0
- package/browser/out/core/formatting/embedded.d.ts.map +1 -0
- package/browser/out/core/formatting/formatters.d.ts +85 -0
- package/browser/out/core/formatting/formatters.d.ts.map +1 -0
- package/browser/out/core/formatting/index.d.ts +44 -0
- package/browser/out/core/formatting/index.d.ts.map +1 -0
- package/browser/out/core/formatting/ir.d.ts +100 -0
- package/browser/out/core/formatting/ir.d.ts.map +1 -0
- package/browser/out/core/formatting/mergeOptions.d.ts +18 -0
- package/browser/out/core/formatting/mergeOptions.d.ts.map +1 -0
- package/browser/out/core/formatting/printer.d.ts +18 -0
- package/browser/out/core/formatting/printer.d.ts.map +1 -0
- package/browser/out/core/formatting/utils.d.ts +39 -0
- package/browser/out/core/formatting/utils.d.ts.map +1 -0
- package/browser/out/core/grammar.d.ts +3 -0
- package/browser/out/core/grammar.d.ts.map +1 -0
- package/browser/out/core/htmlBalanceChecker.d.ts +23 -0
- package/browser/out/core/htmlBalanceChecker.d.ts.map +1 -0
- package/browser/out/core/mustacheChecks.d.ts +24 -0
- package/browser/out/core/mustacheChecks.d.ts.map +1 -0
- package/browser/out/core/nodeHelpers.d.ts +54 -0
- package/browser/out/core/nodeHelpers.d.ts.map +1 -0
- package/browser/out/core/ruleMetadata.d.ts +12 -0
- package/browser/out/core/ruleMetadata.d.ts.map +1 -0
- package/browser/out/core/selectorMatcher.d.ts +74 -0
- package/browser/out/core/selectorMatcher.d.ts.map +1 -0
- package/cli/out/main.js +168 -122
- package/package.json +21 -3
- package/src/browser/browser.test.ts +207 -0
- package/src/browser/index.ts +128 -0
- package/src/browser/tsconfig.json +18 -0
- package/src/core/collectErrors.ts +233 -0
- package/src/core/configSchema.ts +273 -0
- package/src/core/customCodeTags.ts +159 -0
- package/src/core/diagnostic.ts +45 -0
- package/src/core/embeddedRegions.ts +70 -0
- package/src/core/formatting/classifier.ts +549 -0
- package/src/core/formatting/embedded.ts +56 -0
- package/src/core/formatting/formatters.ts +1272 -0
- package/src/core/formatting/index.ts +185 -0
- package/src/core/formatting/ir.ts +202 -0
- package/src/core/formatting/mergeOptions.ts +34 -0
- package/src/core/formatting/printer.ts +242 -0
- package/src/core/formatting/utils.ts +193 -0
- package/src/core/grammar.ts +2 -0
- package/src/core/htmlBalanceChecker.ts +382 -0
- package/src/core/mustacheChecks.ts +504 -0
- package/src/core/nodeHelpers.ts +126 -0
- package/src/core/ruleMetadata.ts +63 -0
- package/src/core/selectorMatcher.ts +719 -0
|
@@ -0,0 +1,504 @@
|
|
|
1
|
+
import type { BalanceNode, BalanceError } from './htmlBalanceChecker.js';
|
|
2
|
+
import { getSectionName } from './htmlBalanceChecker.js';
|
|
3
|
+
import { isMustacheSection } from './nodeHelpers.js';
|
|
4
|
+
|
|
5
|
+
export interface TextReplacement {
|
|
6
|
+
startIndex: number;
|
|
7
|
+
endIndex: number;
|
|
8
|
+
newText: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface FixableError extends BalanceError {
|
|
12
|
+
severity?: 'error' | 'warning';
|
|
13
|
+
fix?: TextReplacement[];
|
|
14
|
+
fixDescription?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// 1. Nested same-name sections
|
|
18
|
+
export function checkNestedSameNameSections(rootNode: BalanceNode): FixableError[] {
|
|
19
|
+
const errors: FixableError[] = [];
|
|
20
|
+
|
|
21
|
+
function visit(node: BalanceNode, ancestors: Set<string>) {
|
|
22
|
+
if (isMustacheSection(node)) {
|
|
23
|
+
const name = getSectionName(node);
|
|
24
|
+
if (name) {
|
|
25
|
+
if (ancestors.has(name)) {
|
|
26
|
+
const beginNode = node.children.find(
|
|
27
|
+
c => c.type === 'mustache_section_begin' || c.type === 'mustache_inverted_section_begin',
|
|
28
|
+
);
|
|
29
|
+
errors.push({
|
|
30
|
+
node: beginNode ?? node,
|
|
31
|
+
message: `Nested duplicate section: {{#${name}}} is already open in an ancestor`,
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
const next = new Set(ancestors);
|
|
35
|
+
next.add(name);
|
|
36
|
+
for (const child of node.children) {
|
|
37
|
+
visit(child, next);
|
|
38
|
+
}
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
for (const child of node.children) {
|
|
44
|
+
visit(child, ancestors);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
visit(rootNode, new Set());
|
|
49
|
+
return errors;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// 2. Unquoted mustache attribute value
|
|
53
|
+
export function checkUnquotedMustacheAttributes(rootNode: BalanceNode): FixableError[] {
|
|
54
|
+
const errors: FixableError[] = [];
|
|
55
|
+
|
|
56
|
+
function visit(node: BalanceNode) {
|
|
57
|
+
if (node.type === 'html_attribute') {
|
|
58
|
+
const mustacheNode = node.children.find(c => c.type === 'mustache_interpolation');
|
|
59
|
+
if (mustacheNode) {
|
|
60
|
+
errors.push({
|
|
61
|
+
node: mustacheNode,
|
|
62
|
+
message: `Unquoted mustache attribute value: ${mustacheNode.text}`,
|
|
63
|
+
fix: [{
|
|
64
|
+
startIndex: mustacheNode.startIndex,
|
|
65
|
+
endIndex: mustacheNode.endIndex,
|
|
66
|
+
newText: `"${mustacheNode.text}"`,
|
|
67
|
+
}],
|
|
68
|
+
fixDescription: 'Wrap mustache value in quotes',
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
for (const child of node.children) {
|
|
75
|
+
visit(child);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
visit(rootNode);
|
|
80
|
+
return errors;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// 3. Consecutive same-name same-type sections
|
|
84
|
+
export function checkConsecutiveSameNameSections(rootNode: BalanceNode, sourceText: string): FixableError[] {
|
|
85
|
+
const errors: FixableError[] = [];
|
|
86
|
+
|
|
87
|
+
function visit(node: BalanceNode) {
|
|
88
|
+
const children = node.children;
|
|
89
|
+
for (let i = 0; i < children.length - 1; i++) {
|
|
90
|
+
const current = children[i];
|
|
91
|
+
const next = children[i + 1];
|
|
92
|
+
|
|
93
|
+
// Both must be the same section type
|
|
94
|
+
if (!isMustacheSection(current) || current.type !== next.type) {
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const currentName = getSectionName(current);
|
|
99
|
+
const nextName = getSectionName(next);
|
|
100
|
+
if (!currentName || !nextName || currentName !== nextName) continue;
|
|
101
|
+
|
|
102
|
+
// Check that the gap between them is whitespace-only
|
|
103
|
+
const gap = sourceText.slice(current.endIndex, next.startIndex);
|
|
104
|
+
if (gap.length > 0 && !/^\s*$/.test(gap)) continue;
|
|
105
|
+
|
|
106
|
+
// Find the end tag of current section and begin tag of next section
|
|
107
|
+
const endTagType = current.type === 'mustache_section'
|
|
108
|
+
? 'mustache_section_end'
|
|
109
|
+
: 'mustache_inverted_section_end';
|
|
110
|
+
const beginTagType = next.type === 'mustache_section'
|
|
111
|
+
? 'mustache_section_begin'
|
|
112
|
+
: 'mustache_inverted_section_begin';
|
|
113
|
+
|
|
114
|
+
const currentEndTag = current.children.find(c => c.type === endTagType);
|
|
115
|
+
const nextBeginTag = next.children.find(c => c.type === beginTagType);
|
|
116
|
+
|
|
117
|
+
if (!currentEndTag || !nextBeginTag) continue;
|
|
118
|
+
|
|
119
|
+
const sectionTypeStr = current.type === 'mustache_section' ? '#' : '^';
|
|
120
|
+
const nextBeginNode = next.children.find(
|
|
121
|
+
c => c.type === 'mustache_section_begin' || c.type === 'mustache_inverted_section_begin',
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
errors.push({
|
|
125
|
+
node: nextBeginNode ?? next,
|
|
126
|
+
message: `Consecutive duplicate section: {{${sectionTypeStr}${nextName}}} can be merged with previous {{${sectionTypeStr}${nextName}}}`,
|
|
127
|
+
severity: 'warning',
|
|
128
|
+
fix: [{
|
|
129
|
+
startIndex: currentEndTag.startIndex,
|
|
130
|
+
endIndex: nextBeginTag.endIndex,
|
|
131
|
+
newText: '',
|
|
132
|
+
}],
|
|
133
|
+
fixDescription: 'Merge consecutive sections',
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Recurse into children
|
|
138
|
+
for (const child of children) {
|
|
139
|
+
visit(child);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
visit(rootNode);
|
|
144
|
+
return errors;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// 4. Self-closing non-void tags
|
|
148
|
+
const VOID_ELEMENTS = new Set([
|
|
149
|
+
'area', 'base', 'basefont', 'bgsound', 'br', 'col', 'command',
|
|
150
|
+
'embed', 'frame', 'hr', 'image', 'img', 'input', 'isindex',
|
|
151
|
+
'keygen', 'link', 'menuitem', 'meta', 'nextid', 'param',
|
|
152
|
+
'source', 'track', 'wbr',
|
|
153
|
+
]);
|
|
154
|
+
|
|
155
|
+
export function checkSelfClosingNonVoidTags(rootNode: BalanceNode): FixableError[] {
|
|
156
|
+
const errors: FixableError[] = [];
|
|
157
|
+
|
|
158
|
+
function visit(node: BalanceNode) {
|
|
159
|
+
if (node.type === 'html_self_closing_tag') {
|
|
160
|
+
const tagNameNode = node.children.find(c => c.type === 'html_tag_name');
|
|
161
|
+
const tagName = tagNameNode?.text.toLowerCase();
|
|
162
|
+
if (tagName && !VOID_ELEMENTS.has(tagName)) {
|
|
163
|
+
errors.push({
|
|
164
|
+
node,
|
|
165
|
+
message: `Self-closing non-void element: <${tagNameNode!.text}/>`,
|
|
166
|
+
fix: [{
|
|
167
|
+
startIndex: node.startIndex,
|
|
168
|
+
endIndex: node.endIndex,
|
|
169
|
+
newText: node.text.replace(/\s*\/>$/, '>') + `</${tagNameNode!.text}>`,
|
|
170
|
+
}],
|
|
171
|
+
fixDescription: 'Replace self-closing syntax with explicit close tag',
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
for (const child of node.children) {
|
|
178
|
+
visit(child);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
visit(rootNode);
|
|
183
|
+
return errors;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// 5. Duplicate attributes (including across mustache conditionals)
|
|
187
|
+
interface Condition {
|
|
188
|
+
name: string;
|
|
189
|
+
inverted: boolean;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
interface AttributeOccurrence {
|
|
193
|
+
nameNode: BalanceNode;
|
|
194
|
+
conditions: Condition[];
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function areMutuallyExclusive(a: Condition[], b: Condition[]): boolean {
|
|
198
|
+
// Two condition chains are mutually exclusive if some variable X
|
|
199
|
+
// appears as truthy in one and falsy in the other
|
|
200
|
+
for (const ac of a) {
|
|
201
|
+
for (const bc of b) {
|
|
202
|
+
if (ac.name === bc.name && ac.inverted !== bc.inverted) {
|
|
203
|
+
return true;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return false;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function formatConditionClause(a: Condition[], b: Condition[]): string {
|
|
211
|
+
// Combine conditions from both occurrences, deduplicating
|
|
212
|
+
const seen = new Map<string, boolean>(); // name -> inverted
|
|
213
|
+
for (const c of [...a, ...b]) {
|
|
214
|
+
if (!seen.has(c.name)) {
|
|
215
|
+
seen.set(c.name, c.inverted);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
if (seen.size === 0) return '';
|
|
219
|
+
const parts: string[] = [];
|
|
220
|
+
for (const [name, inverted] of seen) {
|
|
221
|
+
parts.push(`${name} is ${inverted ? 'falsy' : 'truthy'}`);
|
|
222
|
+
}
|
|
223
|
+
return ` (when ${parts.join(', ')})`;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function collectAttributes(node: BalanceNode, conditions: Condition[], out: AttributeOccurrence[]) {
|
|
227
|
+
for (const child of node.children) {
|
|
228
|
+
if (child.type === 'html_attribute') {
|
|
229
|
+
const nameNode = child.children.find(c => c.type === 'html_attribute_name');
|
|
230
|
+
if (nameNode) {
|
|
231
|
+
out.push({ nameNode, conditions: [...conditions] });
|
|
232
|
+
}
|
|
233
|
+
} else if (child.type === 'mustache_attribute') {
|
|
234
|
+
// Descend into the mustache section/inverted section inside
|
|
235
|
+
const section = child.children.find(c => isMustacheSection(c));
|
|
236
|
+
if (section) {
|
|
237
|
+
const name = getSectionName(section);
|
|
238
|
+
if (name) {
|
|
239
|
+
const inverted = section.type === 'mustache_inverted_section';
|
|
240
|
+
collectAttributes(section, [...conditions, { name, inverted }], out);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
// Skip mustache_interpolation / mustache_triple — dynamic, names unknown
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// 6. Unescaped HTML entities in text content
|
|
249
|
+
export function checkUnescapedEntities(rootNode: BalanceNode): FixableError[] {
|
|
250
|
+
const errors: FixableError[] = [];
|
|
251
|
+
|
|
252
|
+
function visit(node: BalanceNode) {
|
|
253
|
+
if (node.type === 'text') {
|
|
254
|
+
// Bare & (from _text_ampersand rule — node text is exactly "&")
|
|
255
|
+
if (node.text === '&') {
|
|
256
|
+
errors.push({
|
|
257
|
+
node,
|
|
258
|
+
message: 'Unescaped "&" in text content — use & instead',
|
|
259
|
+
severity: 'warning',
|
|
260
|
+
fix: [{
|
|
261
|
+
startIndex: node.startIndex,
|
|
262
|
+
endIndex: node.endIndex,
|
|
263
|
+
newText: '&',
|
|
264
|
+
}],
|
|
265
|
+
fixDescription: 'Replace & with &',
|
|
266
|
+
});
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// > characters in text (from the text rule which allows >)
|
|
271
|
+
if (node.text.includes('>')) {
|
|
272
|
+
const fixes: TextReplacement[] = [];
|
|
273
|
+
let searchFrom = 0;
|
|
274
|
+
const text = node.text;
|
|
275
|
+
while (true) {
|
|
276
|
+
const idx = text.indexOf('>', searchFrom);
|
|
277
|
+
if (idx === -1) break;
|
|
278
|
+
fixes.push({
|
|
279
|
+
startIndex: node.startIndex + idx,
|
|
280
|
+
endIndex: node.startIndex + idx + 1,
|
|
281
|
+
newText: '>',
|
|
282
|
+
});
|
|
283
|
+
searchFrom = idx + 1;
|
|
284
|
+
}
|
|
285
|
+
errors.push({
|
|
286
|
+
node,
|
|
287
|
+
message: 'Unescaped ">" in text content — use > instead',
|
|
288
|
+
severity: 'warning',
|
|
289
|
+
fix: fixes,
|
|
290
|
+
fixDescription: 'Replace > with >',
|
|
291
|
+
});
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
for (const child of node.children) {
|
|
297
|
+
visit(child);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
visit(rootNode);
|
|
302
|
+
return errors;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// 7. Prefer mustache comments over HTML comments
|
|
306
|
+
export function checkHtmlComments(rootNode: BalanceNode): FixableError[] {
|
|
307
|
+
const errors: FixableError[] = [];
|
|
308
|
+
|
|
309
|
+
function visit(node: BalanceNode) {
|
|
310
|
+
if (node.type === 'html_comment') {
|
|
311
|
+
// Extract text between <!-- and -->
|
|
312
|
+
const raw = node.text;
|
|
313
|
+
let content = raw;
|
|
314
|
+
if (content.startsWith('<!--')) content = content.slice(4);
|
|
315
|
+
if (content.endsWith('-->')) content = content.slice(0, -3);
|
|
316
|
+
content = content.trim();
|
|
317
|
+
|
|
318
|
+
errors.push({
|
|
319
|
+
node,
|
|
320
|
+
message: `HTML comment found — use mustache comment {{! ... }} instead`,
|
|
321
|
+
severity: 'warning',
|
|
322
|
+
fix: [{
|
|
323
|
+
startIndex: node.startIndex,
|
|
324
|
+
endIndex: node.endIndex,
|
|
325
|
+
newText: `{{! ${content} }}`,
|
|
326
|
+
}],
|
|
327
|
+
fixDescription: 'Replace HTML comment with mustache comment',
|
|
328
|
+
});
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
for (const child of node.children) {
|
|
333
|
+
visit(child);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
visit(rootNode);
|
|
338
|
+
return errors;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// 8. Unrecognized HTML tags
|
|
342
|
+
const KNOWN_HTML_TAGS = new Set([
|
|
343
|
+
// Void elements
|
|
344
|
+
'area', 'base', 'basefont', 'bgsound', 'br', 'col', 'command',
|
|
345
|
+
'embed', 'frame', 'hr', 'image', 'img', 'input', 'isindex',
|
|
346
|
+
'keygen', 'link', 'menuitem', 'meta', 'nextid', 'param',
|
|
347
|
+
'source', 'track', 'wbr',
|
|
348
|
+
// Non-void elements
|
|
349
|
+
'a', 'abbr', 'address', 'article', 'aside', 'audio',
|
|
350
|
+
'b', 'bdi', 'bdo', 'blockquote', 'body', 'button',
|
|
351
|
+
'canvas', 'caption', 'cite', 'code', 'colgroup',
|
|
352
|
+
'data', 'datalist', 'dd', 'del', 'details', 'dfn', 'dialog', 'div', 'dl', 'dt',
|
|
353
|
+
'em',
|
|
354
|
+
'fieldset', 'figcaption', 'figure', 'footer', 'form',
|
|
355
|
+
'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'head', 'header', 'hgroup', 'html',
|
|
356
|
+
'i', 'iframe', 'ins',
|
|
357
|
+
'kbd',
|
|
358
|
+
'label', 'legend', 'li',
|
|
359
|
+
'main', 'map', 'mark', 'math', 'menu', 'meter',
|
|
360
|
+
'nav', 'noscript',
|
|
361
|
+
'object', 'ol', 'optgroup', 'option', 'output',
|
|
362
|
+
'p', 'picture', 'pre', 'progress',
|
|
363
|
+
'q',
|
|
364
|
+
'rb', 'rp', 'rt', 'rtc', 'ruby',
|
|
365
|
+
's', 'samp', 'script', 'search', 'section', 'select', 'slot', 'small', 'span', 'strong', 'style', 'sub', 'summary', 'sup', 'svg',
|
|
366
|
+
'table', 'tbody', 'td', 'template', 'textarea', 'tfoot', 'th', 'thead', 'time', 'title', 'tr',
|
|
367
|
+
'u', 'ul',
|
|
368
|
+
'var', 'video',
|
|
369
|
+
]);
|
|
370
|
+
|
|
371
|
+
export function checkUnrecognizedHtmlTags(rootNode: BalanceNode, customTagNames?: string[]): FixableError[] {
|
|
372
|
+
const errors: FixableError[] = [];
|
|
373
|
+
const customSet = customTagNames ? new Set(customTagNames.map(n => n.toLowerCase())) : undefined;
|
|
374
|
+
|
|
375
|
+
function visit(node: BalanceNode) {
|
|
376
|
+
if (node.type === 'html_element' || node.type === 'html_self_closing_tag') {
|
|
377
|
+
// Check tag name for svg/math to skip their subtrees
|
|
378
|
+
const tagNameNode = node.type === 'html_self_closing_tag'
|
|
379
|
+
? node.children.find(c => c.type === 'html_tag_name')
|
|
380
|
+
: node.children.find(c => c.type === 'html_start_tag')?.children.find(c => c.type === 'html_tag_name');
|
|
381
|
+
const tagName = tagNameNode?.text.toLowerCase();
|
|
382
|
+
if (tagName === 'svg' || tagName === 'math') return;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
if (node.type === 'html_start_tag' || node.type === 'html_self_closing_tag') {
|
|
386
|
+
const tagNameNode = node.children.find(c => c.type === 'html_tag_name');
|
|
387
|
+
if (tagNameNode) {
|
|
388
|
+
const tagName = tagNameNode.text.toLowerCase();
|
|
389
|
+
if (
|
|
390
|
+
!KNOWN_HTML_TAGS.has(tagName) &&
|
|
391
|
+
!customSet?.has(tagName)
|
|
392
|
+
) {
|
|
393
|
+
errors.push({
|
|
394
|
+
node: tagNameNode,
|
|
395
|
+
message: `Unrecognized HTML tag: <${tagNameNode.text}>`,
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
for (const child of node.children) {
|
|
403
|
+
visit(child);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
visit(rootNode);
|
|
408
|
+
return errors;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
export function checkDuplicateAttributes(rootNode: BalanceNode): FixableError[] {
|
|
412
|
+
const errors: FixableError[] = [];
|
|
413
|
+
|
|
414
|
+
function visit(node: BalanceNode) {
|
|
415
|
+
if (node.type === 'html_start_tag' || node.type === 'html_self_closing_tag') {
|
|
416
|
+
const occurrences: AttributeOccurrence[] = [];
|
|
417
|
+
collectAttributes(node, [], occurrences);
|
|
418
|
+
|
|
419
|
+
// Group by attribute name (case-insensitive)
|
|
420
|
+
const groups = new Map<string, AttributeOccurrence[]>();
|
|
421
|
+
for (const occ of occurrences) {
|
|
422
|
+
const key = occ.nameNode.text.toLowerCase();
|
|
423
|
+
let group = groups.get(key);
|
|
424
|
+
if (!group) {
|
|
425
|
+
group = [];
|
|
426
|
+
groups.set(key, group);
|
|
427
|
+
}
|
|
428
|
+
group.push(occ);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
for (const [, group] of groups) {
|
|
432
|
+
if (group.length < 2) continue;
|
|
433
|
+
// Check each pair — report the later one if any non-exclusive pair exists
|
|
434
|
+
for (let i = 1; i < group.length; i++) {
|
|
435
|
+
let conflictIdx = -1;
|
|
436
|
+
for (let j = 0; j < i; j++) {
|
|
437
|
+
if (!areMutuallyExclusive(group[i].conditions, group[j].conditions)) {
|
|
438
|
+
conflictIdx = j;
|
|
439
|
+
break;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
if (conflictIdx >= 0) {
|
|
443
|
+
const clause = formatConditionClause(group[conflictIdx].conditions, group[i].conditions);
|
|
444
|
+
errors.push({
|
|
445
|
+
node: group[i].nameNode,
|
|
446
|
+
message: `Duplicate attribute "${group[i].nameNode.text}"${clause}`,
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
return; // Don't recurse into tag children (already processed)
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
for (const child of node.children) {
|
|
455
|
+
visit(child);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
visit(rootNode);
|
|
460
|
+
return errors;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
export function checkElementContentTooLong(
|
|
464
|
+
rootNode: BalanceNode,
|
|
465
|
+
elements: ReadonlyArray<{ tag: string; maxBytes: number }>,
|
|
466
|
+
): FixableError[] {
|
|
467
|
+
const errors: FixableError[] = [];
|
|
468
|
+
if (elements.length === 0) return errors;
|
|
469
|
+
|
|
470
|
+
const thresholds = new Map<string, number>();
|
|
471
|
+
for (const { tag, maxBytes } of elements) {
|
|
472
|
+
const key = tag.toLowerCase();
|
|
473
|
+
const existing = thresholds.get(key);
|
|
474
|
+
if (existing === undefined || maxBytes < existing) thresholds.set(key, maxBytes);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function visit(node: BalanceNode) {
|
|
478
|
+
if (node.type === 'html_element') {
|
|
479
|
+
const startTag = node.children.find(c => c.type === 'html_start_tag');
|
|
480
|
+
const endTag = node.children.find(c => c.type === 'html_end_tag');
|
|
481
|
+
const tagNameNode = startTag?.children.find(c => c.type === 'html_tag_name');
|
|
482
|
+
const tagName = tagNameNode?.text.toLowerCase();
|
|
483
|
+
if (tagName && startTag && endTag) {
|
|
484
|
+
const maxBytes = thresholds.get(tagName);
|
|
485
|
+
if (maxBytes !== undefined) {
|
|
486
|
+
const innerBytes = endTag.startIndex - startTag.endIndex;
|
|
487
|
+
if (innerBytes > maxBytes) {
|
|
488
|
+
errors.push({
|
|
489
|
+
node: startTag,
|
|
490
|
+
message: `<${tagName}> content is ${innerBytes} bytes, exceeds limit of ${maxBytes}`,
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
for (const child of node.children) {
|
|
498
|
+
visit(child);
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
visit(rootNode);
|
|
503
|
+
return errors;
|
|
504
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared node helper functions and type constants for working with
|
|
3
|
+
* tree-sitter syntax nodes across the LSP and CLI.
|
|
4
|
+
*
|
|
5
|
+
* Uses a minimal interface compatible with both web-tree-sitter's SyntaxNode
|
|
6
|
+
* and the BalanceNode interface from htmlBalanceChecker.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** Minimal node interface compatible with both SyntaxNode and BalanceNode. */
|
|
10
|
+
export interface MinimalNode {
|
|
11
|
+
type: string;
|
|
12
|
+
text: string;
|
|
13
|
+
children: MinimalNode[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// --- Node type constants ---
|
|
17
|
+
|
|
18
|
+
export const MUSTACHE_SECTION_TYPES = new Set([
|
|
19
|
+
'mustache_section',
|
|
20
|
+
'mustache_inverted_section',
|
|
21
|
+
]);
|
|
22
|
+
|
|
23
|
+
export const INTERPOLATION_TYPES = new Set([
|
|
24
|
+
'mustache_interpolation', // {{foo}}
|
|
25
|
+
'mustache_triple', // {{{foo}}} — unescaped
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
export const RAW_CONTENT_ELEMENT_TYPES = new Set([
|
|
29
|
+
'html_script_element',
|
|
30
|
+
'html_style_element',
|
|
31
|
+
'html_raw_element',
|
|
32
|
+
]);
|
|
33
|
+
|
|
34
|
+
export const HTML_ELEMENT_TYPES = new Set([
|
|
35
|
+
'html_element',
|
|
36
|
+
'html_script_element',
|
|
37
|
+
'html_style_element',
|
|
38
|
+
'html_raw_element',
|
|
39
|
+
]);
|
|
40
|
+
|
|
41
|
+
// --- Node type predicates ---
|
|
42
|
+
|
|
43
|
+
export function isMustacheSection(node: MinimalNode): boolean {
|
|
44
|
+
return MUSTACHE_SECTION_TYPES.has(node.type);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function isRawContentElement(node: MinimalNode): boolean {
|
|
48
|
+
return RAW_CONTENT_ELEMENT_TYPES.has(node.type);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function isHtmlElementType(node: MinimalNode): boolean {
|
|
52
|
+
return HTML_ELEMENT_TYPES.has(node.type);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// --- Node name extraction ---
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Get the tag name from an HTML element node.
|
|
59
|
+
* Works with any node that has children with type 'html_start_tag' or
|
|
60
|
+
* 'html_self_closing_tag' containing an 'html_tag_name' child.
|
|
61
|
+
*/
|
|
62
|
+
export function getTagName(node: MinimalNode): string | null {
|
|
63
|
+
for (const child of node.children) {
|
|
64
|
+
if (child.type === 'html_start_tag' || child.type === 'html_self_closing_tag') {
|
|
65
|
+
const tagNameNode = child.children.find(c => c.type === 'html_tag_name');
|
|
66
|
+
if (tagNameNode) return tagNameNode.text;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Get the section name from a mustache section node.
|
|
74
|
+
* Looks for mustache_tag_name inside mustache_section_begin or
|
|
75
|
+
* mustache_inverted_section_begin.
|
|
76
|
+
*/
|
|
77
|
+
export function getSectionName(node: MinimalNode): string | null {
|
|
78
|
+
const beginNode = node.children.find(
|
|
79
|
+
c => c.type === 'mustache_section_begin' || c.type === 'mustache_inverted_section_begin',
|
|
80
|
+
);
|
|
81
|
+
if (!beginNode) return null;
|
|
82
|
+
const tagNameNode = beginNode.children.find(c => c.type === 'mustache_tag_name');
|
|
83
|
+
return tagNameNode?.text ?? null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Get the tag name from an erroneous end tag node.
|
|
88
|
+
*/
|
|
89
|
+
export function getErroneousEndTagName(node: MinimalNode): string | null {
|
|
90
|
+
const nameNode = node.children.find(c => c.type === 'html_erroneous_end_tag_name');
|
|
91
|
+
return nameNode?.text?.toLowerCase() ?? null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Get the dotted path text from a mustache_interpolation or mustache_triple
|
|
96
|
+
* node — e.g. `{{data.foo}}` → `"data.foo"`. Returns `"."` for the
|
|
97
|
+
* context-marker interpolation `{{.}}`. Returns null if the node has no
|
|
98
|
+
* recognisable expression child.
|
|
99
|
+
*/
|
|
100
|
+
export function getInterpolationPath(node: MinimalNode): string | null {
|
|
101
|
+
for (const child of node.children) {
|
|
102
|
+
if (child.type === 'mustache_path_expression' || child.type === 'mustache_identifier') {
|
|
103
|
+
return child.text;
|
|
104
|
+
}
|
|
105
|
+
if (child.type === '.') return '.';
|
|
106
|
+
}
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Get the content text of a mustache_comment node (`{{!foo}}` → `"foo"`).
|
|
112
|
+
* Returns null if no content child is present.
|
|
113
|
+
*/
|
|
114
|
+
export function getCommentContent(node: MinimalNode): string | null {
|
|
115
|
+
const child = node.children.find(c => c.type === 'mustache_comment_content');
|
|
116
|
+
return child ? child.text.trim() : null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Get the partial name of a mustache_partial node (`{{>header}}` → `"header"`).
|
|
121
|
+
* Returns null if no content child is present.
|
|
122
|
+
*/
|
|
123
|
+
export function getPartialName(node: MinimalNode): string | null {
|
|
124
|
+
const child = node.children.find(c => c.type === 'mustache_partial_content');
|
|
125
|
+
return child ? child.text.trim() : null;
|
|
126
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { RuleSeverity } from './configSchema.js';
|
|
2
|
+
|
|
3
|
+
export interface RuleDefinition {
|
|
4
|
+
name: string;
|
|
5
|
+
defaultSeverity: RuleSeverity;
|
|
6
|
+
description: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export const RULES: RuleDefinition[] = [
|
|
10
|
+
{
|
|
11
|
+
name: 'nestedDuplicateSections',
|
|
12
|
+
defaultSeverity: 'error',
|
|
13
|
+
description: 'Flags `{{#name}}` nested inside another `{{#name}}` with the same name',
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
name: 'unquotedMustacheAttributes',
|
|
17
|
+
defaultSeverity: 'error',
|
|
18
|
+
description: 'Requires quotes around mustache expressions used as attribute values',
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
name: 'consecutiveDuplicateSections',
|
|
22
|
+
defaultSeverity: 'warning',
|
|
23
|
+
description: 'Warns when adjacent same-name sections can be merged',
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
name: 'selfClosingNonVoidTags',
|
|
27
|
+
defaultSeverity: 'error',
|
|
28
|
+
description: 'Disallows self-closing syntax on non-void HTML elements (e.g. `<div/>`)',
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
name: 'duplicateAttributes',
|
|
32
|
+
defaultSeverity: 'error',
|
|
33
|
+
description: 'Detects duplicate HTML attributes on the same element',
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
name: 'unescapedEntities',
|
|
37
|
+
defaultSeverity: 'warning',
|
|
38
|
+
description: 'Flags unescaped `&` and `>` characters in text content',
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
name: 'preferMustacheComments',
|
|
42
|
+
defaultSeverity: 'off',
|
|
43
|
+
description: 'Suggests replacing HTML comments with mustache comments',
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
name: 'unrecognizedHtmlTags',
|
|
47
|
+
defaultSeverity: 'error',
|
|
48
|
+
description: 'Flags HTML tags that are not standard HTML elements or valid custom elements',
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
name: 'elementContentTooLong',
|
|
52
|
+
defaultSeverity: 'off',
|
|
53
|
+
description: 'Flags configured elements whose inner content exceeds a byte-length threshold (opt-in; requires `elements: [{ tag, maxBytes }]` option)',
|
|
54
|
+
},
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
/** Set of all known rule names (for config validation). */
|
|
58
|
+
export const KNOWN_RULE_NAMES = new Set<string>(RULES.map(r => r.name));
|
|
59
|
+
|
|
60
|
+
/** Default severity for each rule (for runtime resolution). */
|
|
61
|
+
export const RULE_DEFAULTS: Record<string, RuleSeverity> = Object.fromEntries(
|
|
62
|
+
RULES.map(r => [r.name, r.defaultSeverity]),
|
|
63
|
+
);
|