@herb-tools/linter 0.8.7 → 0.8.9
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 +28 -2
- package/dist/herb-lint.js +5413 -15659
- package/dist/herb-lint.js.map +1 -1
- package/dist/index.cjs +388 -37
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +383 -39
- package/dist/index.js.map +1 -1
- package/dist/loader.cjs +1238 -7911
- package/dist/loader.cjs.map +1 -1
- package/dist/loader.js +1232 -7912
- package/dist/loader.js.map +1 -1
- package/dist/package.json +7 -7
- package/dist/src/cli/argument-parser.js +5 -2
- package/dist/src/cli/argument-parser.js.map +1 -1
- package/dist/src/cli/file-processor.js +1 -1
- package/dist/src/cli/file-processor.js.map +1 -1
- package/dist/src/cli.js +14 -8
- package/dist/src/cli.js.map +1 -1
- package/dist/src/custom-rule-loader.js +2 -2
- package/dist/src/custom-rule-loader.js.map +1 -1
- package/dist/src/linter.js +14 -1
- package/dist/src/linter.js.map +1 -1
- package/dist/src/rules/erb-strict-locals-comment-syntax.js +213 -0
- package/dist/src/rules/erb-strict-locals-comment-syntax.js.map +1 -0
- package/dist/src/rules/erb-strict-locals-required.js +38 -0
- package/dist/src/rules/erb-strict-locals-required.js.map +1 -0
- package/dist/src/rules/file-utils.js +21 -0
- package/dist/src/rules/file-utils.js.map +1 -0
- package/dist/src/rules/html-head-only-elements.js +2 -0
- package/dist/src/rules/html-head-only-elements.js.map +1 -1
- package/dist/src/rules/html-no-empty-headings.js +22 -36
- package/dist/src/rules/html-no-empty-headings.js.map +1 -1
- package/dist/src/rules/index.js +4 -0
- package/dist/src/rules/index.js.map +1 -1
- package/dist/src/rules/string-utils.js +72 -0
- package/dist/src/rules/string-utils.js.map +1 -0
- package/dist/src/rules.js +4 -0
- package/dist/src/rules.js.map +1 -1
- package/dist/src/types.js +6 -0
- package/dist/src/types.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/dist/types/cli/argument-parser.d.ts +1 -0
- package/dist/types/cli/file-processor.d.ts +1 -0
- package/dist/types/cli.d.ts +1 -1
- package/dist/types/linter.d.ts +5 -1
- package/dist/types/rules/erb-strict-locals-comment-syntax.d.ts +9 -0
- package/dist/types/rules/erb-strict-locals-required.d.ts +9 -0
- package/dist/types/rules/file-utils.d.ts +13 -0
- package/dist/types/rules/index.d.ts +4 -0
- package/dist/types/rules/string-utils.d.ts +15 -0
- package/dist/types/src/cli/argument-parser.d.ts +1 -0
- package/dist/types/src/cli/file-processor.d.ts +1 -0
- package/dist/types/src/cli.d.ts +1 -1
- package/dist/types/src/linter.d.ts +5 -1
- package/dist/types/src/rules/erb-strict-locals-comment-syntax.d.ts +9 -0
- package/dist/types/src/rules/erb-strict-locals-required.d.ts +9 -0
- package/dist/types/src/rules/file-utils.d.ts +13 -0
- package/dist/types/src/rules/index.d.ts +4 -0
- package/dist/types/src/rules/string-utils.d.ts +15 -0
- package/dist/types/src/types.d.ts +6 -0
- package/dist/types/types.d.ts +6 -0
- package/docs/rules/README.md +1 -0
- package/docs/rules/erb-strict-locals-comment-syntax.md +153 -0
- package/docs/rules/erb-strict-locals-required.md +107 -0
- package/package.json +7 -7
- package/src/cli/argument-parser.ts +6 -2
- package/src/cli/file-processor.ts +2 -1
- package/src/cli.ts +18 -8
- package/src/custom-rule-loader.ts +2 -2
- package/src/linter.ts +17 -1
- package/src/rules/erb-strict-locals-comment-syntax.ts +283 -0
- package/src/rules/erb-strict-locals-required.ts +52 -0
- package/src/rules/file-utils.ts +23 -0
- package/src/rules/html-head-only-elements.ts +1 -0
- package/src/rules/html-no-empty-headings.ts +21 -44
- package/src/rules/index.ts +4 -0
- package/src/rules/string-utils.ts +72 -0
- package/src/rules.ts +4 -0
- package/src/types.ts +6 -0
package/dist/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { getNodesBeforePosition, getNodesAfterPosition, filterNodes, ERBContentNode, isERBOutputNode, Visitor, isToken, isParseResult, getStaticAttributeName, hasDynamicAttributeName as hasDynamicAttributeName$1, getCombinedAttributeName, hasStaticContent, getStaticContentFromNodes, Location, hasERBOutput, isEffectivelyStatic, getValidatableStaticContent, isERBNode, isWhitespaceNode, isCommentNode, isLiteralNode, isHTMLTextNode, Position, filterERBContentNodes, isNode, LiteralNode, didyoumean, filterLiteralNodes, Token, getTagName as getTagName$1, isHTMLElementNode,
|
|
1
|
+
import { getNodesBeforePosition, getNodesAfterPosition, filterNodes, ERBContentNode, isERBOutputNode, Visitor, isToken, isParseResult, getStaticAttributeName, hasDynamicAttributeName as hasDynamicAttributeName$1, getCombinedAttributeName, hasStaticContent, getStaticContentFromNodes, Location, hasERBOutput, isEffectivelyStatic, getValidatableStaticContent, isERBNode, isWhitespaceNode, isCommentNode, isLiteralNode, isHTMLTextNode, Position, filterERBContentNodes, isNode, LiteralNode, didyoumean, filterLiteralNodes, Token, getTagName as getTagName$1, isHTMLElementNode, isHTMLOpenTagNode, HTMLCloseTagNode, WhitespaceNode, filterWhitespaceNodes, HTMLOpenTagNode, isERBCommentNode } from '@herb-tools/core';
|
|
2
2
|
import picomatch from 'picomatch';
|
|
3
3
|
|
|
4
4
|
class PrintContext {
|
|
@@ -686,6 +686,8 @@ class ParserRule {
|
|
|
686
686
|
static type = "parser";
|
|
687
687
|
/** Indicates whether this rule supports autofix. Defaults to false. */
|
|
688
688
|
static autocorrectable = false;
|
|
689
|
+
/** Indicates whether this rule supports unsafe autofix (requires --fix-unsafely). Defaults to false. */
|
|
690
|
+
static unsafeAutocorrectable = false;
|
|
689
691
|
get defaultConfig() {
|
|
690
692
|
return DEFAULT_RULE_CONFIG;
|
|
691
693
|
}
|
|
@@ -697,6 +699,8 @@ class LexerRule {
|
|
|
697
699
|
static type = "lexer";
|
|
698
700
|
/** Indicates whether this rule supports autofix. Defaults to false. */
|
|
699
701
|
static autocorrectable = false;
|
|
702
|
+
/** Indicates whether this rule supports unsafe autofix (requires --fix-unsafely). Defaults to false. */
|
|
703
|
+
static unsafeAutocorrectable = false;
|
|
700
704
|
get defaultConfig() {
|
|
701
705
|
return DEFAULT_RULE_CONFIG;
|
|
702
706
|
}
|
|
@@ -714,6 +718,8 @@ class SourceRule {
|
|
|
714
718
|
static type = "source";
|
|
715
719
|
/** Indicates whether this rule supports autofix. Defaults to false. */
|
|
716
720
|
static autocorrectable = false;
|
|
721
|
+
/** Indicates whether this rule supports unsafe autofix (requires --fix-unsafely). Defaults to false. */
|
|
722
|
+
static unsafeAutocorrectable = false;
|
|
717
723
|
get defaultConfig() {
|
|
718
724
|
return DEFAULT_RULE_CONFIG;
|
|
719
725
|
}
|
|
@@ -2187,6 +2193,342 @@ class ERBRightTrimRule extends ParserRule {
|
|
|
2187
2193
|
}
|
|
2188
2194
|
}
|
|
2189
2195
|
|
|
2196
|
+
/**
|
|
2197
|
+
* File path and naming utilities for linter rules
|
|
2198
|
+
*/
|
|
2199
|
+
/**
|
|
2200
|
+
* Extracts the basename (filename) from a file path
|
|
2201
|
+
* Works with both forward slashes and backslashes
|
|
2202
|
+
*/
|
|
2203
|
+
function getBasename(filePath) {
|
|
2204
|
+
const lastSlash = Math.max(filePath.lastIndexOf("/"), filePath.lastIndexOf("\\"));
|
|
2205
|
+
return lastSlash === -1 ? filePath : filePath.slice(lastSlash + 1);
|
|
2206
|
+
}
|
|
2207
|
+
/**
|
|
2208
|
+
* Checks if a file is a Rails partial (filename starts with `_`)
|
|
2209
|
+
* Returns null if fileName is undefined (unknown context)
|
|
2210
|
+
*/
|
|
2211
|
+
function isPartialFile(fileName) {
|
|
2212
|
+
if (!fileName)
|
|
2213
|
+
return null;
|
|
2214
|
+
return getBasename(fileName).startsWith("_");
|
|
2215
|
+
}
|
|
2216
|
+
|
|
2217
|
+
/**
|
|
2218
|
+
* Checks if parentheses in a string are balanced
|
|
2219
|
+
* Returns false if there are more closing parens than opening at any point
|
|
2220
|
+
*/
|
|
2221
|
+
function hasBalancedParentheses(content) {
|
|
2222
|
+
let depth = 0;
|
|
2223
|
+
for (const char of content) {
|
|
2224
|
+
if (char === "(")
|
|
2225
|
+
depth++;
|
|
2226
|
+
if (char === ")")
|
|
2227
|
+
depth--;
|
|
2228
|
+
if (depth < 0)
|
|
2229
|
+
return false;
|
|
2230
|
+
}
|
|
2231
|
+
return depth === 0;
|
|
2232
|
+
}
|
|
2233
|
+
/**
|
|
2234
|
+
* Splits a string by commas at the top level only
|
|
2235
|
+
* Respects nested parentheses, brackets, braces, and strings
|
|
2236
|
+
*
|
|
2237
|
+
* @example
|
|
2238
|
+
* splitByTopLevelComma("a, b, c") // ["a", " b", " c"]
|
|
2239
|
+
* splitByTopLevelComma("a, (b, c), d") // ["a", " (b, c)", " d"]
|
|
2240
|
+
* splitByTopLevelComma('a, "b, c", d') // ["a", ' "b, c"', " d"]
|
|
2241
|
+
*/
|
|
2242
|
+
function splitByTopLevelComma(str) {
|
|
2243
|
+
const result = [];
|
|
2244
|
+
let current = "";
|
|
2245
|
+
let parenDepth = 0;
|
|
2246
|
+
let bracketDepth = 0;
|
|
2247
|
+
let braceDepth = 0;
|
|
2248
|
+
let inString = false;
|
|
2249
|
+
let stringChar = "";
|
|
2250
|
+
for (let i = 0; i < str.length; i++) {
|
|
2251
|
+
const char = str[i];
|
|
2252
|
+
const previousChar = i > 0 ? str[i - 1] : "";
|
|
2253
|
+
if ((char === '"' || char === "'") && previousChar !== "\\") {
|
|
2254
|
+
if (!inString) {
|
|
2255
|
+
inString = true;
|
|
2256
|
+
stringChar = char;
|
|
2257
|
+
}
|
|
2258
|
+
else if (char === stringChar) {
|
|
2259
|
+
inString = false;
|
|
2260
|
+
}
|
|
2261
|
+
}
|
|
2262
|
+
if (!inString) {
|
|
2263
|
+
if (char === "(")
|
|
2264
|
+
parenDepth++;
|
|
2265
|
+
if (char === ")")
|
|
2266
|
+
parenDepth--;
|
|
2267
|
+
if (char === "[")
|
|
2268
|
+
bracketDepth++;
|
|
2269
|
+
if (char === "]")
|
|
2270
|
+
bracketDepth--;
|
|
2271
|
+
if (char === "{")
|
|
2272
|
+
braceDepth++;
|
|
2273
|
+
if (char === "}")
|
|
2274
|
+
braceDepth--;
|
|
2275
|
+
if (char === "," && parenDepth === 0 && bracketDepth === 0 && braceDepth === 0) {
|
|
2276
|
+
result.push(current);
|
|
2277
|
+
current = "";
|
|
2278
|
+
continue;
|
|
2279
|
+
}
|
|
2280
|
+
}
|
|
2281
|
+
current += char;
|
|
2282
|
+
}
|
|
2283
|
+
if (current) {
|
|
2284
|
+
result.push(current);
|
|
2285
|
+
}
|
|
2286
|
+
return result;
|
|
2287
|
+
}
|
|
2288
|
+
|
|
2289
|
+
const STRICT_LOCALS_PATTERN = /^locals:\s+\([^)]*\)\s*$/;
|
|
2290
|
+
function isValidStrictLocalsFormat(content) {
|
|
2291
|
+
return STRICT_LOCALS_PATTERN.test(content);
|
|
2292
|
+
}
|
|
2293
|
+
function extractERBCommentContent(content) {
|
|
2294
|
+
return content.trim();
|
|
2295
|
+
}
|
|
2296
|
+
function extractRubyCommentContent(content) {
|
|
2297
|
+
const match = content.match(/^\s*#\s*(.*)$/);
|
|
2298
|
+
return match ? match[1].trim() : null;
|
|
2299
|
+
}
|
|
2300
|
+
function extractLocalsRemainder(content) {
|
|
2301
|
+
const match = content.match(/^locals?\b(.*)$/);
|
|
2302
|
+
return match ? match[1] : null;
|
|
2303
|
+
}
|
|
2304
|
+
function looksLikeLocalsDeclaration(content) {
|
|
2305
|
+
return /^locals?\b/.test(content) && /[(:)]/.test(content);
|
|
2306
|
+
}
|
|
2307
|
+
function hasLocalsLikeSyntax(remainder) {
|
|
2308
|
+
return /[(:)]/.test(remainder);
|
|
2309
|
+
}
|
|
2310
|
+
function detectLocalsWithoutColon(content) {
|
|
2311
|
+
return /^locals?\(/.test(content);
|
|
2312
|
+
}
|
|
2313
|
+
function detectSingularLocal(content) {
|
|
2314
|
+
return /^local:/.test(content);
|
|
2315
|
+
}
|
|
2316
|
+
function detectMissingColonBeforeParens(content) {
|
|
2317
|
+
return /^locals\s+\(/.test(content);
|
|
2318
|
+
}
|
|
2319
|
+
function detectMissingSpaceAfterColon(content) {
|
|
2320
|
+
return /^locals:\(/.test(content);
|
|
2321
|
+
}
|
|
2322
|
+
function detectMissingParentheses(content) {
|
|
2323
|
+
return /^locals:\s*[^(]/.test(content);
|
|
2324
|
+
}
|
|
2325
|
+
function detectEmptyLocalsWithoutParens(content) {
|
|
2326
|
+
return /^locals:\s*$/.test(content);
|
|
2327
|
+
}
|
|
2328
|
+
function validateCommaUsage(inner) {
|
|
2329
|
+
if (inner.startsWith(",") || inner.endsWith(",") || /,,/.test(inner)) {
|
|
2330
|
+
return "Unexpected comma in `locals:` parameters.";
|
|
2331
|
+
}
|
|
2332
|
+
return null;
|
|
2333
|
+
}
|
|
2334
|
+
function validateBlockArgument(param) {
|
|
2335
|
+
if (param.startsWith("&")) {
|
|
2336
|
+
return `Block argument \`${param}\` is not allowed. Strict locals only support keyword arguments.`;
|
|
2337
|
+
}
|
|
2338
|
+
return null;
|
|
2339
|
+
}
|
|
2340
|
+
function validateSplatArgument(param) {
|
|
2341
|
+
if (param.startsWith("*") && !param.startsWith("**")) {
|
|
2342
|
+
return `Splat argument \`${param}\` is not allowed. Strict locals only support keyword arguments.`;
|
|
2343
|
+
}
|
|
2344
|
+
return null;
|
|
2345
|
+
}
|
|
2346
|
+
function validateDoubleSplatArgument(param) {
|
|
2347
|
+
if (param.startsWith("**")) {
|
|
2348
|
+
if (/^\*\*\w+$/.test(param)) {
|
|
2349
|
+
return null; // Valid double-splat
|
|
2350
|
+
}
|
|
2351
|
+
return `Invalid double-splat syntax \`${param}\`. Use \`**name\` format (e.g., \`**attributes\`).`;
|
|
2352
|
+
}
|
|
2353
|
+
return null;
|
|
2354
|
+
}
|
|
2355
|
+
function validateKeywordArgument(param) {
|
|
2356
|
+
if (!/^\w+:\s*/.test(param)) {
|
|
2357
|
+
if (/^\w+$/.test(param)) {
|
|
2358
|
+
return `Positional argument \`${param}\` is not allowed. Use keyword argument format: \`${param}:\`.`;
|
|
2359
|
+
}
|
|
2360
|
+
return `Invalid parameter \`${param}\`. Use keyword argument format: \`name:\` or \`name: default\`.`;
|
|
2361
|
+
}
|
|
2362
|
+
return null;
|
|
2363
|
+
}
|
|
2364
|
+
function validateParameter(param) {
|
|
2365
|
+
const trimmed = param.trim();
|
|
2366
|
+
if (!trimmed)
|
|
2367
|
+
return null;
|
|
2368
|
+
return (validateBlockArgument(trimmed) ||
|
|
2369
|
+
validateSplatArgument(trimmed) ||
|
|
2370
|
+
validateDoubleSplatArgument(trimmed) ||
|
|
2371
|
+
(trimmed.startsWith("**") ? null : validateKeywordArgument(trimmed)));
|
|
2372
|
+
}
|
|
2373
|
+
function validateLocalsSignature(paramsContent) {
|
|
2374
|
+
const match = paramsContent.match(/^\s*\(([\s\S]*)\)\s*$/);
|
|
2375
|
+
if (!match)
|
|
2376
|
+
return null;
|
|
2377
|
+
const inner = match[1].trim();
|
|
2378
|
+
if (!inner)
|
|
2379
|
+
return null; // Empty locals is valid: locals: ()
|
|
2380
|
+
const commaError = validateCommaUsage(inner);
|
|
2381
|
+
if (commaError)
|
|
2382
|
+
return commaError;
|
|
2383
|
+
const params = splitByTopLevelComma(inner);
|
|
2384
|
+
for (const param of params) {
|
|
2385
|
+
const error = validateParameter(param);
|
|
2386
|
+
if (error)
|
|
2387
|
+
return error;
|
|
2388
|
+
}
|
|
2389
|
+
return null;
|
|
2390
|
+
}
|
|
2391
|
+
class ERBStrictLocalsCommentSyntaxVisitor extends BaseRuleVisitor {
|
|
2392
|
+
seenStrictLocalsComment = false;
|
|
2393
|
+
firstStrictLocalsLocation = null;
|
|
2394
|
+
visitERBContentNode(node) {
|
|
2395
|
+
const openingTag = node.tag_opening?.value;
|
|
2396
|
+
const content = node.content?.value;
|
|
2397
|
+
if (!content)
|
|
2398
|
+
return;
|
|
2399
|
+
const commentContent = this.extractCommentContent(openingTag, content, node);
|
|
2400
|
+
if (!commentContent)
|
|
2401
|
+
return;
|
|
2402
|
+
const remainder = extractLocalsRemainder(commentContent);
|
|
2403
|
+
if (!remainder || !hasLocalsLikeSyntax(remainder))
|
|
2404
|
+
return;
|
|
2405
|
+
this.validateLocalsComment(commentContent, node);
|
|
2406
|
+
}
|
|
2407
|
+
extractCommentContent(openingTag, content, node) {
|
|
2408
|
+
if (openingTag === "<%#") {
|
|
2409
|
+
return extractERBCommentContent(content);
|
|
2410
|
+
}
|
|
2411
|
+
if (openingTag === "<%" || openingTag === "<%-") {
|
|
2412
|
+
const rubyComment = extractRubyCommentContent(content);
|
|
2413
|
+
if (rubyComment && looksLikeLocalsDeclaration(rubyComment)) {
|
|
2414
|
+
this.addOffense(`Use \`<%#\` instead of \`${openingTag} #\` for strict locals comments. Only ERB comment syntax is recognized by Rails.`, node.location);
|
|
2415
|
+
}
|
|
2416
|
+
}
|
|
2417
|
+
return null;
|
|
2418
|
+
}
|
|
2419
|
+
validateLocalsComment(commentContent, node) {
|
|
2420
|
+
this.checkPartialFile(node);
|
|
2421
|
+
if (!hasBalancedParentheses(commentContent)) {
|
|
2422
|
+
this.addOffense("Unbalanced parentheses in `locals:` comment. Ensure all opening parentheses have matching closing parentheses.", node.location);
|
|
2423
|
+
return;
|
|
2424
|
+
}
|
|
2425
|
+
if (isValidStrictLocalsFormat(commentContent)) {
|
|
2426
|
+
this.handleValidFormat(commentContent, node);
|
|
2427
|
+
return;
|
|
2428
|
+
}
|
|
2429
|
+
this.handleInvalidFormat(commentContent, node);
|
|
2430
|
+
}
|
|
2431
|
+
checkPartialFile(node) {
|
|
2432
|
+
const isPartial = isPartialFile(this.context.fileName);
|
|
2433
|
+
if (isPartial === false) {
|
|
2434
|
+
this.addOffense("Strict locals (`locals:`) only work in partials (files starting with `_`). This declaration will be ignored.", node.location);
|
|
2435
|
+
}
|
|
2436
|
+
}
|
|
2437
|
+
handleValidFormat(commentContent, node) {
|
|
2438
|
+
if (this.seenStrictLocalsComment) {
|
|
2439
|
+
this.addOffense(`Duplicate \`locals:\` declaration. Only one \`locals:\` comment is allowed per partial (first declaration at line ${this.firstStrictLocalsLocation?.line}).`, node.location);
|
|
2440
|
+
return;
|
|
2441
|
+
}
|
|
2442
|
+
this.seenStrictLocalsComment = true;
|
|
2443
|
+
this.firstStrictLocalsLocation = {
|
|
2444
|
+
line: node.location.start.line,
|
|
2445
|
+
column: node.location.start.column
|
|
2446
|
+
};
|
|
2447
|
+
const paramsMatch = commentContent.match(/^locals:\s*(\([\s\S]*\))\s*$/);
|
|
2448
|
+
if (paramsMatch) {
|
|
2449
|
+
const error = validateLocalsSignature(paramsMatch[1]);
|
|
2450
|
+
if (error) {
|
|
2451
|
+
this.addOffense(error, node.location);
|
|
2452
|
+
}
|
|
2453
|
+
}
|
|
2454
|
+
}
|
|
2455
|
+
handleInvalidFormat(commentContent, node) {
|
|
2456
|
+
if (detectLocalsWithoutColon(commentContent)) {
|
|
2457
|
+
this.addOffense("Use `locals:` with a colon, not `locals()`. Correct format: `<%# locals: (...) %>`.", node.location);
|
|
2458
|
+
return;
|
|
2459
|
+
}
|
|
2460
|
+
if (detectSingularLocal(commentContent)) {
|
|
2461
|
+
this.addOffense("Use `locals:` (plural), not `local:`.", node.location);
|
|
2462
|
+
return;
|
|
2463
|
+
}
|
|
2464
|
+
if (detectMissingColonBeforeParens(commentContent)) {
|
|
2465
|
+
this.addOffense("Use `locals:` with a colon before the parentheses, not `locals (`.", node.location);
|
|
2466
|
+
return;
|
|
2467
|
+
}
|
|
2468
|
+
if (detectMissingSpaceAfterColon(commentContent)) {
|
|
2469
|
+
this.addOffense("Missing space after `locals:`. Rails Strict Locals require a space after the colon: `<%# locals: (...) %>`.", node.location);
|
|
2470
|
+
return;
|
|
2471
|
+
}
|
|
2472
|
+
if (detectMissingParentheses(commentContent)) {
|
|
2473
|
+
this.addOffense("Wrap parameters in parentheses: `locals: (name:)` or `locals: (name: default)`.", node.location);
|
|
2474
|
+
return;
|
|
2475
|
+
}
|
|
2476
|
+
if (detectEmptyLocalsWithoutParens(commentContent)) {
|
|
2477
|
+
this.addOffense("Add parameters after `locals:`. Use `locals: (name:)` or `locals: ()` for no locals.", node.location);
|
|
2478
|
+
return;
|
|
2479
|
+
}
|
|
2480
|
+
this.addOffense("Invalid `locals:` syntax. Use format: `locals: (name:, option: default)`.", node.location);
|
|
2481
|
+
}
|
|
2482
|
+
}
|
|
2483
|
+
class ERBStrictLocalsCommentSyntaxRule extends ParserRule {
|
|
2484
|
+
name = "erb-strict-locals-comment-syntax";
|
|
2485
|
+
get defaultConfig() {
|
|
2486
|
+
return {
|
|
2487
|
+
enabled: true,
|
|
2488
|
+
severity: "error"
|
|
2489
|
+
};
|
|
2490
|
+
}
|
|
2491
|
+
check(result, context) {
|
|
2492
|
+
const visitor = new ERBStrictLocalsCommentSyntaxVisitor(this.name, context);
|
|
2493
|
+
visitor.visit(result.value);
|
|
2494
|
+
return visitor.offenses;
|
|
2495
|
+
}
|
|
2496
|
+
}
|
|
2497
|
+
|
|
2498
|
+
function hasStrictLocals(source) {
|
|
2499
|
+
return source.includes("<%# locals:") || source.includes("<%#locals:");
|
|
2500
|
+
}
|
|
2501
|
+
class ERBStrictLocalsRequiredVisitor extends BaseSourceRuleVisitor {
|
|
2502
|
+
visitSource(source) {
|
|
2503
|
+
const isPartial = isPartialFile(this.context.fileName);
|
|
2504
|
+
if (isPartial !== true)
|
|
2505
|
+
return;
|
|
2506
|
+
if (hasStrictLocals(source))
|
|
2507
|
+
return;
|
|
2508
|
+
const firstLineLength = source.indexOf("\n") === -1 ? source.length : source.indexOf("\n");
|
|
2509
|
+
const location = Location.from(1, 0, 1, firstLineLength);
|
|
2510
|
+
this.addOffense("Partial is missing a strict locals declaration. Add `<%# locals: (...) %>` at the top of the file.", location);
|
|
2511
|
+
}
|
|
2512
|
+
}
|
|
2513
|
+
class ERBStrictLocalsRequiredRule extends SourceRule {
|
|
2514
|
+
static unsafeAutocorrectable = true;
|
|
2515
|
+
name = "erb-strict-locals-required";
|
|
2516
|
+
get defaultConfig() {
|
|
2517
|
+
return {
|
|
2518
|
+
enabled: false,
|
|
2519
|
+
severity: "error",
|
|
2520
|
+
};
|
|
2521
|
+
}
|
|
2522
|
+
check(source, context) {
|
|
2523
|
+
const visitor = new ERBStrictLocalsRequiredVisitor(this.name, context);
|
|
2524
|
+
visitor.visit(source);
|
|
2525
|
+
return visitor.offenses;
|
|
2526
|
+
}
|
|
2527
|
+
autofix(_offense, source, _context) {
|
|
2528
|
+
return `<%# locals: () %>\n\n${source}`;
|
|
2529
|
+
}
|
|
2530
|
+
}
|
|
2531
|
+
|
|
2190
2532
|
/**
|
|
2191
2533
|
* Utilities for parsing herb:disable comments
|
|
2192
2534
|
*/
|
|
@@ -3100,6 +3442,8 @@ class HeadOnlyElementsVisitor extends BaseRuleVisitor {
|
|
|
3100
3442
|
return;
|
|
3101
3443
|
if (tagName === "title" && this.insideSVG)
|
|
3102
3444
|
return;
|
|
3445
|
+
if (tagName === "style" && this.insideSVG)
|
|
3446
|
+
return;
|
|
3103
3447
|
if (tagName === "meta" && this.hasItempropAttribute(node))
|
|
3104
3448
|
return;
|
|
3105
3449
|
this.addOffense(`Element \`<${tagName}>\` must be placed inside the \`<head>\` tag.`, node.location);
|
|
@@ -3968,20 +4312,22 @@ class HTMLNoEmptyAttributesRule extends ParserRule {
|
|
|
3968
4312
|
|
|
3969
4313
|
class NoEmptyHeadingsVisitor extends BaseRuleVisitor {
|
|
3970
4314
|
visitHTMLElementNode(node) {
|
|
4315
|
+
const tagName = getTagName(node.open_tag)?.toLowerCase();
|
|
4316
|
+
if (tagName === "template")
|
|
4317
|
+
return;
|
|
3971
4318
|
this.checkHeadingElement(node);
|
|
3972
4319
|
super.visitHTMLElementNode(node);
|
|
3973
4320
|
}
|
|
3974
4321
|
checkHeadingElement(node) {
|
|
3975
|
-
if (!node.open_tag
|
|
4322
|
+
if (!node.open_tag)
|
|
3976
4323
|
return;
|
|
3977
|
-
|
|
3978
|
-
|
|
3979
|
-
const tagName = getTagName(
|
|
3980
|
-
if (!tagName)
|
|
4324
|
+
if (!isHTMLOpenTagNode(node.open_tag))
|
|
4325
|
+
return;
|
|
4326
|
+
const tagName = getTagName(node.open_tag);
|
|
4327
|
+
if (!tagName)
|
|
3981
4328
|
return;
|
|
3982
|
-
}
|
|
3983
4329
|
const isStandardHeading = HEADING_TAGS.has(tagName);
|
|
3984
|
-
const isAriaHeading = this.hasHeadingRole(
|
|
4330
|
+
const isAriaHeading = this.hasHeadingRole(node.open_tag);
|
|
3985
4331
|
if (!isStandardHeading && !isAriaHeading) {
|
|
3986
4332
|
return;
|
|
3987
4333
|
}
|
|
@@ -3998,23 +4344,14 @@ class NoEmptyHeadingsVisitor extends BaseRuleVisitor {
|
|
|
3998
4344
|
}
|
|
3999
4345
|
let hasAccessibleContent = false;
|
|
4000
4346
|
for (const child of node.body) {
|
|
4001
|
-
if (child
|
|
4002
|
-
|
|
4003
|
-
if (literalNode.content.trim().length > 0) {
|
|
4004
|
-
hasAccessibleContent = true;
|
|
4005
|
-
break;
|
|
4006
|
-
}
|
|
4007
|
-
}
|
|
4008
|
-
else if (child.type === "AST_HTML_TEXT_NODE") {
|
|
4009
|
-
const textNode = child;
|
|
4010
|
-
if (textNode.content.trim().length > 0) {
|
|
4347
|
+
if (isLiteralNode(child) || isHTMLTextNode(child)) {
|
|
4348
|
+
if (child.content.trim().length > 0) {
|
|
4011
4349
|
hasAccessibleContent = true;
|
|
4012
4350
|
break;
|
|
4013
4351
|
}
|
|
4014
4352
|
}
|
|
4015
|
-
else if (child
|
|
4016
|
-
|
|
4017
|
-
if (this.isElementAccessible(elementNode)) {
|
|
4353
|
+
else if (isHTMLElementNode(child)) {
|
|
4354
|
+
if (this.isElementAccessible(child)) {
|
|
4018
4355
|
hasAccessibleContent = true;
|
|
4019
4356
|
break;
|
|
4020
4357
|
}
|
|
@@ -4036,11 +4373,11 @@ class NoEmptyHeadingsVisitor extends BaseRuleVisitor {
|
|
|
4036
4373
|
return roleValue === "heading";
|
|
4037
4374
|
}
|
|
4038
4375
|
isElementAccessible(node) {
|
|
4039
|
-
if (!node.open_tag
|
|
4376
|
+
if (!node.open_tag)
|
|
4040
4377
|
return true;
|
|
4041
|
-
|
|
4042
|
-
|
|
4043
|
-
const attributes = getAttributes(
|
|
4378
|
+
if (!isHTMLOpenTagNode(node.open_tag))
|
|
4379
|
+
return true;
|
|
4380
|
+
const attributes = getAttributes(node.open_tag);
|
|
4044
4381
|
const ariaHiddenAttribute = findAttributeByName(attributes, "aria-hidden");
|
|
4045
4382
|
if (ariaHiddenAttribute) {
|
|
4046
4383
|
const ariaHiddenValue = getAttributeValue(ariaHiddenAttribute);
|
|
@@ -4052,21 +4389,13 @@ class NoEmptyHeadingsVisitor extends BaseRuleVisitor {
|
|
|
4052
4389
|
return false;
|
|
4053
4390
|
}
|
|
4054
4391
|
for (const child of node.body) {
|
|
4055
|
-
if (child
|
|
4056
|
-
|
|
4057
|
-
if (literalNode.content.trim().length > 0) {
|
|
4392
|
+
if (isLiteralNode(child) || isHTMLTextNode(child)) {
|
|
4393
|
+
if (child.content.trim().length > 0) {
|
|
4058
4394
|
return true;
|
|
4059
4395
|
}
|
|
4060
4396
|
}
|
|
4061
|
-
else if (child
|
|
4062
|
-
|
|
4063
|
-
if (textNode.content.trim().length > 0) {
|
|
4064
|
-
return true;
|
|
4065
|
-
}
|
|
4066
|
-
}
|
|
4067
|
-
else if (child.type === "AST_HTML_ELEMENT_NODE") {
|
|
4068
|
-
const elementNode = child;
|
|
4069
|
-
if (this.isElementAccessible(elementNode)) {
|
|
4397
|
+
else if (isHTMLElementNode(child)) {
|
|
4398
|
+
if (this.isElementAccessible(child)) {
|
|
4070
4399
|
return true;
|
|
4071
4400
|
}
|
|
4072
4401
|
}
|
|
@@ -4671,6 +5000,8 @@ const rules = [
|
|
|
4671
5000
|
ERBRequireTrailingNewlineRule,
|
|
4672
5001
|
ERBRequireWhitespaceRule,
|
|
4673
5002
|
ERBRightTrimRule,
|
|
5003
|
+
ERBStrictLocalsCommentSyntaxRule,
|
|
5004
|
+
ERBStrictLocalsRequiredRule,
|
|
4674
5005
|
HerbDisableCommentValidRuleNameRule,
|
|
4675
5006
|
HerbDisableCommentNoRedundantAllRule,
|
|
4676
5007
|
HerbDisableCommentNoDuplicateRulesRule,
|
|
@@ -5084,9 +5415,12 @@ class Linter {
|
|
|
5084
5415
|
* @param source - The source code to fix
|
|
5085
5416
|
* @param context - Optional context for linting (e.g., fileName)
|
|
5086
5417
|
* @param offensesToFix - Optional array of specific offenses to fix. If not provided, all fixable offenses will be fixed.
|
|
5418
|
+
* @param options - Options for autofix behavior
|
|
5419
|
+
* @param options.includeUnsafe - If true, also apply unsafe fixes (rules with unsafeAutocorrectable = true)
|
|
5087
5420
|
* @returns AutofixResult containing the corrected source and lists of fixed/unfixed offenses
|
|
5088
5421
|
*/
|
|
5089
|
-
autofix(source, context, offensesToFix) {
|
|
5422
|
+
autofix(source, context, offensesToFix, options) {
|
|
5423
|
+
const includeUnsafe = options?.includeUnsafe ?? false;
|
|
5090
5424
|
const lintResult = offensesToFix ? { offenses: offensesToFix } : this.lint(source, context);
|
|
5091
5425
|
const parserOffenses = [];
|
|
5092
5426
|
const sourceOffenses = [];
|
|
@@ -5117,10 +5451,15 @@ class Linter {
|
|
|
5117
5451
|
continue;
|
|
5118
5452
|
}
|
|
5119
5453
|
const rule = new RuleClass();
|
|
5454
|
+
const isUnsafe = RuleClass.unsafeAutocorrectable === true;
|
|
5120
5455
|
if (!rule.autofix) {
|
|
5121
5456
|
unfixed.push(offense);
|
|
5122
5457
|
continue;
|
|
5123
5458
|
}
|
|
5459
|
+
if (isUnsafe && !includeUnsafe) {
|
|
5460
|
+
unfixed.push(offense);
|
|
5461
|
+
continue;
|
|
5462
|
+
}
|
|
5124
5463
|
if (offense.autofixContext) {
|
|
5125
5464
|
const originalNodeType = offense.autofixContext.node.type;
|
|
5126
5465
|
const location = offense.autofixContext.node.location ? Location.from(offense.autofixContext.node.location) : offense.location;
|
|
@@ -5160,10 +5499,15 @@ class Linter {
|
|
|
5160
5499
|
continue;
|
|
5161
5500
|
}
|
|
5162
5501
|
const rule = new RuleClass();
|
|
5502
|
+
const isUnsafe = RuleClass.unsafeAutocorrectable === true;
|
|
5163
5503
|
if (!rule.autofix) {
|
|
5164
5504
|
unfixed.push(offense);
|
|
5165
5505
|
continue;
|
|
5166
5506
|
}
|
|
5507
|
+
if (isUnsafe && !includeUnsafe) {
|
|
5508
|
+
unfixed.push(offense);
|
|
5509
|
+
continue;
|
|
5510
|
+
}
|
|
5167
5511
|
const correctedSource = rule.autofix(offense, currentSource, context);
|
|
5168
5512
|
if (correctedSource) {
|
|
5169
5513
|
currentSource = correctedSource;
|
|
@@ -5182,5 +5526,5 @@ class Linter {
|
|
|
5182
5526
|
}
|
|
5183
5527
|
}
|
|
5184
5528
|
|
|
5185
|
-
export { ARIA_ATTRIBUTES, AttributeVisitorMixin, BaseLexerRuleVisitor, BaseRuleVisitor, BaseSourceRuleVisitor, ControlFlowTrackingVisitor, ControlFlowType, DEFAULT_LINT_CONTEXT, DEFAULT_RULE_CONFIG, DOCUMENT_ONLY_TAG_NAMES, ERBCommentSyntax, ERBNoCaseNodeChildrenRule, ERBNoEmptyTagsRule, ERBNoExtraNewLineRule, ERBNoExtraWhitespaceRule, ERBNoOutputControlFlowRule, ERBNoSilentTagInAttributeNameRule, ERBPreferImageTagHelperRule, ERBRequireTrailingNewlineRule, ERBRequireWhitespaceRule, ERBRightTrimRule, HEADING_TAGS, HEAD_AND_BODY_TAG_NAMES, HEAD_ONLY_TAG_NAMES, HTMLAnchorRequireHrefRule, HTMLAriaLabelIsWellFormattedRule, HTMLAriaLevelMustBeValidRule, HTMLAriaRoleHeadingRequiresLevelRule, HTMLAriaRoleMustBeValidRule, HTMLAttributeDoubleQuotesRule, HTMLAttributeEqualsSpacingRule, HTMLAttributeValuesRequireQuotesRule, HTMLAvoidBothDisabledAndAriaDisabledRule, HTMLBodyOnlyElementsRule, HTMLBooleanAttributesNoValueRule, HTMLHeadOnlyElementsRule, HTMLIframeHasTitleRule, HTMLImgRequireAltRule, HTMLInputRequireAutocompleteRule, HTMLNavigationHasLabelRule, HTMLNoAriaHiddenOnFocusableRule, HTMLNoBlockInsideInlineRule, HTMLNoDuplicateAttributesRule, HTMLNoDuplicateIdsRule, HTMLNoDuplicateMetaNamesRule, HTMLNoEmptyAttributesRule, HTMLNoEmptyHeadingsRule, HTMLNoNestedLinksRule, HTMLNoPositiveTabIndexRule, HTMLNoSelfClosingRule, HTMLNoSpaceInTagRule, HTMLNoTitleAttributeRule, HTMLNoUnderscoresInAttributeNamesRule, HTMLTagNameLowercaseRule, HTML_BLOCK_ELEMENTS, HTML_BOOLEAN_ATTRIBUTES, HTML_INLINE_ELEMENTS, HTML_ONLY_TAG_NAMES, HTML_VOID_ELEMENTS, HerbDisableCommentBaseVisitor, HerbDisableCommentMalformedRule, HerbDisableCommentMissingRulesRule, HerbDisableCommentNoDuplicateRulesRule, HerbDisableCommentNoRedundantAllRule, HerbDisableCommentParsedVisitor, HerbDisableCommentUnnecessaryRule, HerbDisableCommentValidRuleNameRule, LexerRule, Linter, ParserRule, SVGTagNameCapitalizationRule, SVG_CAMEL_CASE_ELEMENTS, SVG_LOWERCASE_TO_CAMELCASE, SourceRule, VALID_ARIA_ROLES, createEndOfFileLocation, findAttributeByName, findNodeByLocation, findParent, forEachAttribute, getAttribute, getAttributeName, getAttributeValue, getAttributeValueNodes, getAttributeValueQuoteType, getAttributes, getCombinedAttributeNameString, getStaticAttributeValue, getStaticAttributeValueContent, getTagName, hasAttribute, hasAttributeValue, hasDynamicAttributeName, hasDynamicAttributeValue, hasStaticAttributeValue, hasStaticAttributeValueContent, isAttributeValueQuoted, isBlockElement, isBodyOnlyTag, isBodyTag, isBooleanAttribute, isDocumentOnlyTag, isHeadAndBodyTag, isHeadOnlyTag, isHeadTag, isHtmlOnlyTag, isInlineElement, isVoidElement, locationsEqual, rules };
|
|
5529
|
+
export { ARIA_ATTRIBUTES, AttributeVisitorMixin, BaseLexerRuleVisitor, BaseRuleVisitor, BaseSourceRuleVisitor, ControlFlowTrackingVisitor, ControlFlowType, DEFAULT_LINT_CONTEXT, DEFAULT_RULE_CONFIG, DOCUMENT_ONLY_TAG_NAMES, ERBCommentSyntax, ERBNoCaseNodeChildrenRule, ERBNoEmptyTagsRule, ERBNoExtraNewLineRule, ERBNoExtraWhitespaceRule, ERBNoOutputControlFlowRule, ERBNoSilentTagInAttributeNameRule, ERBPreferImageTagHelperRule, ERBRequireTrailingNewlineRule, ERBRequireWhitespaceRule, ERBRightTrimRule, ERBStrictLocalsCommentSyntaxRule, ERBStrictLocalsRequiredRule, HEADING_TAGS, HEAD_AND_BODY_TAG_NAMES, HEAD_ONLY_TAG_NAMES, HTMLAnchorRequireHrefRule, HTMLAriaLabelIsWellFormattedRule, HTMLAriaLevelMustBeValidRule, HTMLAriaRoleHeadingRequiresLevelRule, HTMLAriaRoleMustBeValidRule, HTMLAttributeDoubleQuotesRule, HTMLAttributeEqualsSpacingRule, HTMLAttributeValuesRequireQuotesRule, HTMLAvoidBothDisabledAndAriaDisabledRule, HTMLBodyOnlyElementsRule, HTMLBooleanAttributesNoValueRule, HTMLHeadOnlyElementsRule, HTMLIframeHasTitleRule, HTMLImgRequireAltRule, HTMLInputRequireAutocompleteRule, HTMLNavigationHasLabelRule, HTMLNoAriaHiddenOnFocusableRule, HTMLNoBlockInsideInlineRule, HTMLNoDuplicateAttributesRule, HTMLNoDuplicateIdsRule, HTMLNoDuplicateMetaNamesRule, HTMLNoEmptyAttributesRule, HTMLNoEmptyHeadingsRule, HTMLNoNestedLinksRule, HTMLNoPositiveTabIndexRule, HTMLNoSelfClosingRule, HTMLNoSpaceInTagRule, HTMLNoTitleAttributeRule, HTMLNoUnderscoresInAttributeNamesRule, HTMLTagNameLowercaseRule, HTML_BLOCK_ELEMENTS, HTML_BOOLEAN_ATTRIBUTES, HTML_INLINE_ELEMENTS, HTML_ONLY_TAG_NAMES, HTML_VOID_ELEMENTS, HerbDisableCommentBaseVisitor, HerbDisableCommentMalformedRule, HerbDisableCommentMissingRulesRule, HerbDisableCommentNoDuplicateRulesRule, HerbDisableCommentNoRedundantAllRule, HerbDisableCommentParsedVisitor, HerbDisableCommentUnnecessaryRule, HerbDisableCommentValidRuleNameRule, LexerRule, Linter, ParserRule, STRICT_LOCALS_PATTERN, SVGTagNameCapitalizationRule, SVG_CAMEL_CASE_ELEMENTS, SVG_LOWERCASE_TO_CAMELCASE, SourceRule, VALID_ARIA_ROLES, createEndOfFileLocation, findAttributeByName, findNodeByLocation, findParent, forEachAttribute, getAttribute, getAttributeName, getAttributeValue, getAttributeValueNodes, getAttributeValueQuoteType, getAttributes, getBasename, getCombinedAttributeNameString, getStaticAttributeValue, getStaticAttributeValueContent, getTagName, hasAttribute, hasAttributeValue, hasBalancedParentheses, hasDynamicAttributeName, hasDynamicAttributeValue, hasStaticAttributeValue, hasStaticAttributeValueContent, isAttributeValueQuoted, isBlockElement, isBodyOnlyTag, isBodyTag, isBooleanAttribute, isDocumentOnlyTag, isHeadAndBodyTag, isHeadOnlyTag, isHeadTag, isHtmlOnlyTag, isInlineElement, isPartialFile, isVoidElement, locationsEqual, rules, splitByTopLevelComma };
|
|
5186
5530
|
//# sourceMappingURL=index.js.map
|