@herb-tools/linter 0.8.7 → 0.8.8
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 +5406 -15659
- package/dist/herb-lint.js.map +1 -1
- package/dist/index.cjs +381 -37
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +376 -39
- package/dist/index.js.map +1 -1
- package/dist/loader.cjs +1231 -7911
- package/dist/loader.cjs.map +1 -1
- package/dist/loader.js +1225 -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 +206 -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 +274 -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.cjs
CHANGED
|
@@ -688,6 +688,8 @@ class ParserRule {
|
|
|
688
688
|
static type = "parser";
|
|
689
689
|
/** Indicates whether this rule supports autofix. Defaults to false. */
|
|
690
690
|
static autocorrectable = false;
|
|
691
|
+
/** Indicates whether this rule supports unsafe autofix (requires --fix-unsafely). Defaults to false. */
|
|
692
|
+
static unsafeAutocorrectable = false;
|
|
691
693
|
get defaultConfig() {
|
|
692
694
|
return DEFAULT_RULE_CONFIG;
|
|
693
695
|
}
|
|
@@ -699,6 +701,8 @@ class LexerRule {
|
|
|
699
701
|
static type = "lexer";
|
|
700
702
|
/** Indicates whether this rule supports autofix. Defaults to false. */
|
|
701
703
|
static autocorrectable = false;
|
|
704
|
+
/** Indicates whether this rule supports unsafe autofix (requires --fix-unsafely). Defaults to false. */
|
|
705
|
+
static unsafeAutocorrectable = false;
|
|
702
706
|
get defaultConfig() {
|
|
703
707
|
return DEFAULT_RULE_CONFIG;
|
|
704
708
|
}
|
|
@@ -716,6 +720,8 @@ class SourceRule {
|
|
|
716
720
|
static type = "source";
|
|
717
721
|
/** Indicates whether this rule supports autofix. Defaults to false. */
|
|
718
722
|
static autocorrectable = false;
|
|
723
|
+
/** Indicates whether this rule supports unsafe autofix (requires --fix-unsafely). Defaults to false. */
|
|
724
|
+
static unsafeAutocorrectable = false;
|
|
719
725
|
get defaultConfig() {
|
|
720
726
|
return DEFAULT_RULE_CONFIG;
|
|
721
727
|
}
|
|
@@ -2189,6 +2195,335 @@ class ERBRightTrimRule extends ParserRule {
|
|
|
2189
2195
|
}
|
|
2190
2196
|
}
|
|
2191
2197
|
|
|
2198
|
+
/**
|
|
2199
|
+
* File path and naming utilities for linter rules
|
|
2200
|
+
*/
|
|
2201
|
+
/**
|
|
2202
|
+
* Extracts the basename (filename) from a file path
|
|
2203
|
+
* Works with both forward slashes and backslashes
|
|
2204
|
+
*/
|
|
2205
|
+
function getBasename(filePath) {
|
|
2206
|
+
const lastSlash = Math.max(filePath.lastIndexOf("/"), filePath.lastIndexOf("\\"));
|
|
2207
|
+
return lastSlash === -1 ? filePath : filePath.slice(lastSlash + 1);
|
|
2208
|
+
}
|
|
2209
|
+
/**
|
|
2210
|
+
* Checks if a file is a Rails partial (filename starts with `_`)
|
|
2211
|
+
* Returns null if fileName is undefined (unknown context)
|
|
2212
|
+
*/
|
|
2213
|
+
function isPartialFile(fileName) {
|
|
2214
|
+
if (!fileName)
|
|
2215
|
+
return null;
|
|
2216
|
+
return getBasename(fileName).startsWith("_");
|
|
2217
|
+
}
|
|
2218
|
+
|
|
2219
|
+
/**
|
|
2220
|
+
* Checks if parentheses in a string are balanced
|
|
2221
|
+
* Returns false if there are more closing parens than opening at any point
|
|
2222
|
+
*/
|
|
2223
|
+
function hasBalancedParentheses(content) {
|
|
2224
|
+
let depth = 0;
|
|
2225
|
+
for (const char of content) {
|
|
2226
|
+
if (char === "(")
|
|
2227
|
+
depth++;
|
|
2228
|
+
if (char === ")")
|
|
2229
|
+
depth--;
|
|
2230
|
+
if (depth < 0)
|
|
2231
|
+
return false;
|
|
2232
|
+
}
|
|
2233
|
+
return depth === 0;
|
|
2234
|
+
}
|
|
2235
|
+
/**
|
|
2236
|
+
* Splits a string by commas at the top level only
|
|
2237
|
+
* Respects nested parentheses, brackets, braces, and strings
|
|
2238
|
+
*
|
|
2239
|
+
* @example
|
|
2240
|
+
* splitByTopLevelComma("a, b, c") // ["a", " b", " c"]
|
|
2241
|
+
* splitByTopLevelComma("a, (b, c), d") // ["a", " (b, c)", " d"]
|
|
2242
|
+
* splitByTopLevelComma('a, "b, c", d') // ["a", ' "b, c"', " d"]
|
|
2243
|
+
*/
|
|
2244
|
+
function splitByTopLevelComma(str) {
|
|
2245
|
+
const result = [];
|
|
2246
|
+
let current = "";
|
|
2247
|
+
let parenDepth = 0;
|
|
2248
|
+
let bracketDepth = 0;
|
|
2249
|
+
let braceDepth = 0;
|
|
2250
|
+
let inString = false;
|
|
2251
|
+
let stringChar = "";
|
|
2252
|
+
for (let i = 0; i < str.length; i++) {
|
|
2253
|
+
const char = str[i];
|
|
2254
|
+
const previousChar = i > 0 ? str[i - 1] : "";
|
|
2255
|
+
if ((char === '"' || char === "'") && previousChar !== "\\") {
|
|
2256
|
+
if (!inString) {
|
|
2257
|
+
inString = true;
|
|
2258
|
+
stringChar = char;
|
|
2259
|
+
}
|
|
2260
|
+
else if (char === stringChar) {
|
|
2261
|
+
inString = false;
|
|
2262
|
+
}
|
|
2263
|
+
}
|
|
2264
|
+
if (!inString) {
|
|
2265
|
+
if (char === "(")
|
|
2266
|
+
parenDepth++;
|
|
2267
|
+
if (char === ")")
|
|
2268
|
+
parenDepth--;
|
|
2269
|
+
if (char === "[")
|
|
2270
|
+
bracketDepth++;
|
|
2271
|
+
if (char === "]")
|
|
2272
|
+
bracketDepth--;
|
|
2273
|
+
if (char === "{")
|
|
2274
|
+
braceDepth++;
|
|
2275
|
+
if (char === "}")
|
|
2276
|
+
braceDepth--;
|
|
2277
|
+
if (char === "," && parenDepth === 0 && bracketDepth === 0 && braceDepth === 0) {
|
|
2278
|
+
result.push(current);
|
|
2279
|
+
current = "";
|
|
2280
|
+
continue;
|
|
2281
|
+
}
|
|
2282
|
+
}
|
|
2283
|
+
current += char;
|
|
2284
|
+
}
|
|
2285
|
+
if (current) {
|
|
2286
|
+
result.push(current);
|
|
2287
|
+
}
|
|
2288
|
+
return result;
|
|
2289
|
+
}
|
|
2290
|
+
|
|
2291
|
+
const STRICT_LOCALS_PATTERN = /^locals:\s*\([^)]*\)\s*$/;
|
|
2292
|
+
function isValidStrictLocalsFormat(content) {
|
|
2293
|
+
return STRICT_LOCALS_PATTERN.test(content);
|
|
2294
|
+
}
|
|
2295
|
+
function extractERBCommentContent(content) {
|
|
2296
|
+
return content.trim();
|
|
2297
|
+
}
|
|
2298
|
+
function extractRubyCommentContent(content) {
|
|
2299
|
+
const match = content.match(/^\s*#\s*(.*)$/);
|
|
2300
|
+
return match ? match[1].trim() : null;
|
|
2301
|
+
}
|
|
2302
|
+
function extractLocalsRemainder(content) {
|
|
2303
|
+
const match = content.match(/^locals?\b(.*)$/);
|
|
2304
|
+
return match ? match[1] : null;
|
|
2305
|
+
}
|
|
2306
|
+
function looksLikeLocalsDeclaration(content) {
|
|
2307
|
+
return /^locals?\b/.test(content) && /[(:)]/.test(content);
|
|
2308
|
+
}
|
|
2309
|
+
function hasLocalsLikeSyntax(remainder) {
|
|
2310
|
+
return /[(:)]/.test(remainder);
|
|
2311
|
+
}
|
|
2312
|
+
function detectLocalsWithoutColon(content) {
|
|
2313
|
+
return /^locals?\(/.test(content);
|
|
2314
|
+
}
|
|
2315
|
+
function detectSingularLocal(content) {
|
|
2316
|
+
return /^local:/.test(content);
|
|
2317
|
+
}
|
|
2318
|
+
function detectMissingColonBeforeParens(content) {
|
|
2319
|
+
return /^locals\s+\(/.test(content);
|
|
2320
|
+
}
|
|
2321
|
+
function detectMissingParentheses(content) {
|
|
2322
|
+
return /^locals:\s*[^(]/.test(content);
|
|
2323
|
+
}
|
|
2324
|
+
function detectEmptyLocalsWithoutParens(content) {
|
|
2325
|
+
return /^locals:\s*$/.test(content);
|
|
2326
|
+
}
|
|
2327
|
+
function validateCommaUsage(inner) {
|
|
2328
|
+
if (inner.startsWith(",") || inner.endsWith(",") || /,,/.test(inner)) {
|
|
2329
|
+
return "Unexpected comma in `locals:` parameters.";
|
|
2330
|
+
}
|
|
2331
|
+
return null;
|
|
2332
|
+
}
|
|
2333
|
+
function validateBlockArgument(param) {
|
|
2334
|
+
if (param.startsWith("&")) {
|
|
2335
|
+
return `Block argument \`${param}\` is not allowed. Strict locals only support keyword arguments.`;
|
|
2336
|
+
}
|
|
2337
|
+
return null;
|
|
2338
|
+
}
|
|
2339
|
+
function validateSplatArgument(param) {
|
|
2340
|
+
if (param.startsWith("*") && !param.startsWith("**")) {
|
|
2341
|
+
return `Splat argument \`${param}\` is not allowed. Strict locals only support keyword arguments.`;
|
|
2342
|
+
}
|
|
2343
|
+
return null;
|
|
2344
|
+
}
|
|
2345
|
+
function validateDoubleSplatArgument(param) {
|
|
2346
|
+
if (param.startsWith("**")) {
|
|
2347
|
+
if (/^\*\*\w+$/.test(param)) {
|
|
2348
|
+
return null; // Valid double-splat
|
|
2349
|
+
}
|
|
2350
|
+
return `Invalid double-splat syntax \`${param}\`. Use \`**name\` format (e.g., \`**attributes\`).`;
|
|
2351
|
+
}
|
|
2352
|
+
return null;
|
|
2353
|
+
}
|
|
2354
|
+
function validateKeywordArgument(param) {
|
|
2355
|
+
if (!/^\w+:\s*/.test(param)) {
|
|
2356
|
+
if (/^\w+$/.test(param)) {
|
|
2357
|
+
return `Positional argument \`${param}\` is not allowed. Use keyword argument format: \`${param}:\`.`;
|
|
2358
|
+
}
|
|
2359
|
+
return `Invalid parameter \`${param}\`. Use keyword argument format: \`name:\` or \`name: default\`.`;
|
|
2360
|
+
}
|
|
2361
|
+
return null;
|
|
2362
|
+
}
|
|
2363
|
+
function validateParameter(param) {
|
|
2364
|
+
const trimmed = param.trim();
|
|
2365
|
+
if (!trimmed)
|
|
2366
|
+
return null;
|
|
2367
|
+
return (validateBlockArgument(trimmed) ||
|
|
2368
|
+
validateSplatArgument(trimmed) ||
|
|
2369
|
+
validateDoubleSplatArgument(trimmed) ||
|
|
2370
|
+
(trimmed.startsWith("**") ? null : validateKeywordArgument(trimmed)));
|
|
2371
|
+
}
|
|
2372
|
+
function validateLocalsSignature(paramsContent) {
|
|
2373
|
+
const match = paramsContent.match(/^\s*\(([\s\S]*)\)\s*$/);
|
|
2374
|
+
if (!match)
|
|
2375
|
+
return null;
|
|
2376
|
+
const inner = match[1].trim();
|
|
2377
|
+
if (!inner)
|
|
2378
|
+
return null; // Empty locals is valid: locals: ()
|
|
2379
|
+
const commaError = validateCommaUsage(inner);
|
|
2380
|
+
if (commaError)
|
|
2381
|
+
return commaError;
|
|
2382
|
+
const params = splitByTopLevelComma(inner);
|
|
2383
|
+
for (const param of params) {
|
|
2384
|
+
const error = validateParameter(param);
|
|
2385
|
+
if (error)
|
|
2386
|
+
return error;
|
|
2387
|
+
}
|
|
2388
|
+
return null;
|
|
2389
|
+
}
|
|
2390
|
+
class ERBStrictLocalsCommentSyntaxVisitor extends BaseRuleVisitor {
|
|
2391
|
+
seenStrictLocalsComment = false;
|
|
2392
|
+
firstStrictLocalsLocation = null;
|
|
2393
|
+
visitERBContentNode(node) {
|
|
2394
|
+
const openingTag = node.tag_opening?.value;
|
|
2395
|
+
const content = node.content?.value;
|
|
2396
|
+
if (!content)
|
|
2397
|
+
return;
|
|
2398
|
+
const commentContent = this.extractCommentContent(openingTag, content, node);
|
|
2399
|
+
if (!commentContent)
|
|
2400
|
+
return;
|
|
2401
|
+
const remainder = extractLocalsRemainder(commentContent);
|
|
2402
|
+
if (!remainder || !hasLocalsLikeSyntax(remainder))
|
|
2403
|
+
return;
|
|
2404
|
+
this.validateLocalsComment(commentContent, node);
|
|
2405
|
+
}
|
|
2406
|
+
extractCommentContent(openingTag, content, node) {
|
|
2407
|
+
if (openingTag === "<%#") {
|
|
2408
|
+
return extractERBCommentContent(content);
|
|
2409
|
+
}
|
|
2410
|
+
if (openingTag === "<%" || openingTag === "<%-") {
|
|
2411
|
+
const rubyComment = extractRubyCommentContent(content);
|
|
2412
|
+
if (rubyComment && looksLikeLocalsDeclaration(rubyComment)) {
|
|
2413
|
+
this.addOffense(`Use \`<%#\` instead of \`${openingTag} #\` for strict locals comments. Only ERB comment syntax is recognized by Rails.`, node.location);
|
|
2414
|
+
}
|
|
2415
|
+
}
|
|
2416
|
+
return null;
|
|
2417
|
+
}
|
|
2418
|
+
validateLocalsComment(commentContent, node) {
|
|
2419
|
+
this.checkPartialFile(node);
|
|
2420
|
+
if (!hasBalancedParentheses(commentContent)) {
|
|
2421
|
+
this.addOffense("Unbalanced parentheses in `locals:` comment. Ensure all opening parentheses have matching closing parentheses.", node.location);
|
|
2422
|
+
return;
|
|
2423
|
+
}
|
|
2424
|
+
if (isValidStrictLocalsFormat(commentContent)) {
|
|
2425
|
+
this.handleValidFormat(commentContent, node);
|
|
2426
|
+
return;
|
|
2427
|
+
}
|
|
2428
|
+
this.handleInvalidFormat(commentContent, node);
|
|
2429
|
+
}
|
|
2430
|
+
checkPartialFile(node) {
|
|
2431
|
+
const isPartial = isPartialFile(this.context.fileName);
|
|
2432
|
+
if (isPartial === false) {
|
|
2433
|
+
this.addOffense("Strict locals (`locals:`) only work in partials (files starting with `_`). This declaration will be ignored.", node.location);
|
|
2434
|
+
}
|
|
2435
|
+
}
|
|
2436
|
+
handleValidFormat(commentContent, node) {
|
|
2437
|
+
if (this.seenStrictLocalsComment) {
|
|
2438
|
+
this.addOffense(`Duplicate \`locals:\` declaration. Only one \`locals:\` comment is allowed per partial (first declaration at line ${this.firstStrictLocalsLocation?.line}).`, node.location);
|
|
2439
|
+
return;
|
|
2440
|
+
}
|
|
2441
|
+
this.seenStrictLocalsComment = true;
|
|
2442
|
+
this.firstStrictLocalsLocation = {
|
|
2443
|
+
line: node.location.start.line,
|
|
2444
|
+
column: node.location.start.column
|
|
2445
|
+
};
|
|
2446
|
+
const paramsMatch = commentContent.match(/^locals:\s*(\([\s\S]*\))\s*$/);
|
|
2447
|
+
if (paramsMatch) {
|
|
2448
|
+
const error = validateLocalsSignature(paramsMatch[1]);
|
|
2449
|
+
if (error) {
|
|
2450
|
+
this.addOffense(error, node.location);
|
|
2451
|
+
}
|
|
2452
|
+
}
|
|
2453
|
+
}
|
|
2454
|
+
handleInvalidFormat(commentContent, node) {
|
|
2455
|
+
if (detectLocalsWithoutColon(commentContent)) {
|
|
2456
|
+
this.addOffense("Use `locals:` with a colon, not `locals()`. Correct format: `<%# locals: (...) %>`.", node.location);
|
|
2457
|
+
return;
|
|
2458
|
+
}
|
|
2459
|
+
if (detectSingularLocal(commentContent)) {
|
|
2460
|
+
this.addOffense("Use `locals:` (plural), not `local:`.", node.location);
|
|
2461
|
+
return;
|
|
2462
|
+
}
|
|
2463
|
+
if (detectMissingColonBeforeParens(commentContent)) {
|
|
2464
|
+
this.addOffense("Use `locals:` with a colon before the parentheses, not `locals (`.", node.location);
|
|
2465
|
+
return;
|
|
2466
|
+
}
|
|
2467
|
+
if (detectMissingParentheses(commentContent)) {
|
|
2468
|
+
this.addOffense("Wrap parameters in parentheses: `locals: (name:)` or `locals: (name: default)`.", node.location);
|
|
2469
|
+
return;
|
|
2470
|
+
}
|
|
2471
|
+
if (detectEmptyLocalsWithoutParens(commentContent)) {
|
|
2472
|
+
this.addOffense("Add parameters after `locals:`. Use `locals: (name:)` or `locals: ()` for no locals.", node.location);
|
|
2473
|
+
return;
|
|
2474
|
+
}
|
|
2475
|
+
this.addOffense("Invalid `locals:` syntax. Use format: `locals: (name:, option: default)`.", node.location);
|
|
2476
|
+
}
|
|
2477
|
+
}
|
|
2478
|
+
class ERBStrictLocalsCommentSyntaxRule extends ParserRule {
|
|
2479
|
+
name = "erb-strict-locals-comment-syntax";
|
|
2480
|
+
get defaultConfig() {
|
|
2481
|
+
return {
|
|
2482
|
+
enabled: true,
|
|
2483
|
+
severity: "error"
|
|
2484
|
+
};
|
|
2485
|
+
}
|
|
2486
|
+
check(result, context) {
|
|
2487
|
+
const visitor = new ERBStrictLocalsCommentSyntaxVisitor(this.name, context);
|
|
2488
|
+
visitor.visit(result.value);
|
|
2489
|
+
return visitor.offenses;
|
|
2490
|
+
}
|
|
2491
|
+
}
|
|
2492
|
+
|
|
2493
|
+
function hasStrictLocals(source) {
|
|
2494
|
+
return source.includes("<%# locals:") || source.includes("<%#locals:");
|
|
2495
|
+
}
|
|
2496
|
+
class ERBStrictLocalsRequiredVisitor extends BaseSourceRuleVisitor {
|
|
2497
|
+
visitSource(source) {
|
|
2498
|
+
const isPartial = isPartialFile(this.context.fileName);
|
|
2499
|
+
if (isPartial !== true)
|
|
2500
|
+
return;
|
|
2501
|
+
if (hasStrictLocals(source))
|
|
2502
|
+
return;
|
|
2503
|
+
const firstLineLength = source.indexOf("\n") === -1 ? source.length : source.indexOf("\n");
|
|
2504
|
+
const location = core.Location.from(1, 0, 1, firstLineLength);
|
|
2505
|
+
this.addOffense("Partial is missing a strict locals declaration. Add `<%# locals: (...) %>` at the top of the file.", location);
|
|
2506
|
+
}
|
|
2507
|
+
}
|
|
2508
|
+
class ERBStrictLocalsRequiredRule extends SourceRule {
|
|
2509
|
+
static unsafeAutocorrectable = true;
|
|
2510
|
+
name = "erb-strict-locals-required";
|
|
2511
|
+
get defaultConfig() {
|
|
2512
|
+
return {
|
|
2513
|
+
enabled: false,
|
|
2514
|
+
severity: "error",
|
|
2515
|
+
};
|
|
2516
|
+
}
|
|
2517
|
+
check(source, context) {
|
|
2518
|
+
const visitor = new ERBStrictLocalsRequiredVisitor(this.name, context);
|
|
2519
|
+
visitor.visit(source);
|
|
2520
|
+
return visitor.offenses;
|
|
2521
|
+
}
|
|
2522
|
+
autofix(_offense, source, _context) {
|
|
2523
|
+
return `<%# locals: () %>\n\n${source}`;
|
|
2524
|
+
}
|
|
2525
|
+
}
|
|
2526
|
+
|
|
2192
2527
|
/**
|
|
2193
2528
|
* Utilities for parsing herb:disable comments
|
|
2194
2529
|
*/
|
|
@@ -3102,6 +3437,8 @@ class HeadOnlyElementsVisitor extends BaseRuleVisitor {
|
|
|
3102
3437
|
return;
|
|
3103
3438
|
if (tagName === "title" && this.insideSVG)
|
|
3104
3439
|
return;
|
|
3440
|
+
if (tagName === "style" && this.insideSVG)
|
|
3441
|
+
return;
|
|
3105
3442
|
if (tagName === "meta" && this.hasItempropAttribute(node))
|
|
3106
3443
|
return;
|
|
3107
3444
|
this.addOffense(`Element \`<${tagName}>\` must be placed inside the \`<head>\` tag.`, node.location);
|
|
@@ -3970,20 +4307,22 @@ class HTMLNoEmptyAttributesRule extends ParserRule {
|
|
|
3970
4307
|
|
|
3971
4308
|
class NoEmptyHeadingsVisitor extends BaseRuleVisitor {
|
|
3972
4309
|
visitHTMLElementNode(node) {
|
|
4310
|
+
const tagName = getTagName(node.open_tag)?.toLowerCase();
|
|
4311
|
+
if (tagName === "template")
|
|
4312
|
+
return;
|
|
3973
4313
|
this.checkHeadingElement(node);
|
|
3974
4314
|
super.visitHTMLElementNode(node);
|
|
3975
4315
|
}
|
|
3976
4316
|
checkHeadingElement(node) {
|
|
3977
|
-
if (!node.open_tag
|
|
4317
|
+
if (!node.open_tag)
|
|
3978
4318
|
return;
|
|
3979
|
-
|
|
3980
|
-
|
|
3981
|
-
const tagName = getTagName(
|
|
3982
|
-
if (!tagName)
|
|
4319
|
+
if (!core.isHTMLOpenTagNode(node.open_tag))
|
|
4320
|
+
return;
|
|
4321
|
+
const tagName = getTagName(node.open_tag);
|
|
4322
|
+
if (!tagName)
|
|
3983
4323
|
return;
|
|
3984
|
-
}
|
|
3985
4324
|
const isStandardHeading = HEADING_TAGS.has(tagName);
|
|
3986
|
-
const isAriaHeading = this.hasHeadingRole(
|
|
4325
|
+
const isAriaHeading = this.hasHeadingRole(node.open_tag);
|
|
3987
4326
|
if (!isStandardHeading && !isAriaHeading) {
|
|
3988
4327
|
return;
|
|
3989
4328
|
}
|
|
@@ -4000,23 +4339,14 @@ class NoEmptyHeadingsVisitor extends BaseRuleVisitor {
|
|
|
4000
4339
|
}
|
|
4001
4340
|
let hasAccessibleContent = false;
|
|
4002
4341
|
for (const child of node.body) {
|
|
4003
|
-
if (child
|
|
4004
|
-
|
|
4005
|
-
if (literalNode.content.trim().length > 0) {
|
|
4006
|
-
hasAccessibleContent = true;
|
|
4007
|
-
break;
|
|
4008
|
-
}
|
|
4009
|
-
}
|
|
4010
|
-
else if (child.type === "AST_HTML_TEXT_NODE") {
|
|
4011
|
-
const textNode = child;
|
|
4012
|
-
if (textNode.content.trim().length > 0) {
|
|
4342
|
+
if (core.isLiteralNode(child) || core.isHTMLTextNode(child)) {
|
|
4343
|
+
if (child.content.trim().length > 0) {
|
|
4013
4344
|
hasAccessibleContent = true;
|
|
4014
4345
|
break;
|
|
4015
4346
|
}
|
|
4016
4347
|
}
|
|
4017
|
-
else if (child
|
|
4018
|
-
|
|
4019
|
-
if (this.isElementAccessible(elementNode)) {
|
|
4348
|
+
else if (core.isHTMLElementNode(child)) {
|
|
4349
|
+
if (this.isElementAccessible(child)) {
|
|
4020
4350
|
hasAccessibleContent = true;
|
|
4021
4351
|
break;
|
|
4022
4352
|
}
|
|
@@ -4038,11 +4368,11 @@ class NoEmptyHeadingsVisitor extends BaseRuleVisitor {
|
|
|
4038
4368
|
return roleValue === "heading";
|
|
4039
4369
|
}
|
|
4040
4370
|
isElementAccessible(node) {
|
|
4041
|
-
if (!node.open_tag
|
|
4371
|
+
if (!node.open_tag)
|
|
4042
4372
|
return true;
|
|
4043
|
-
|
|
4044
|
-
|
|
4045
|
-
const attributes = getAttributes(
|
|
4373
|
+
if (!core.isHTMLOpenTagNode(node.open_tag))
|
|
4374
|
+
return true;
|
|
4375
|
+
const attributes = getAttributes(node.open_tag);
|
|
4046
4376
|
const ariaHiddenAttribute = findAttributeByName(attributes, "aria-hidden");
|
|
4047
4377
|
if (ariaHiddenAttribute) {
|
|
4048
4378
|
const ariaHiddenValue = getAttributeValue(ariaHiddenAttribute);
|
|
@@ -4054,21 +4384,13 @@ class NoEmptyHeadingsVisitor extends BaseRuleVisitor {
|
|
|
4054
4384
|
return false;
|
|
4055
4385
|
}
|
|
4056
4386
|
for (const child of node.body) {
|
|
4057
|
-
if (child
|
|
4058
|
-
|
|
4059
|
-
if (literalNode.content.trim().length > 0) {
|
|
4387
|
+
if (core.isLiteralNode(child) || core.isHTMLTextNode(child)) {
|
|
4388
|
+
if (child.content.trim().length > 0) {
|
|
4060
4389
|
return true;
|
|
4061
4390
|
}
|
|
4062
4391
|
}
|
|
4063
|
-
else if (child
|
|
4064
|
-
|
|
4065
|
-
if (textNode.content.trim().length > 0) {
|
|
4066
|
-
return true;
|
|
4067
|
-
}
|
|
4068
|
-
}
|
|
4069
|
-
else if (child.type === "AST_HTML_ELEMENT_NODE") {
|
|
4070
|
-
const elementNode = child;
|
|
4071
|
-
if (this.isElementAccessible(elementNode)) {
|
|
4392
|
+
else if (core.isHTMLElementNode(child)) {
|
|
4393
|
+
if (this.isElementAccessible(child)) {
|
|
4072
4394
|
return true;
|
|
4073
4395
|
}
|
|
4074
4396
|
}
|
|
@@ -4673,6 +4995,8 @@ const rules = [
|
|
|
4673
4995
|
ERBRequireTrailingNewlineRule,
|
|
4674
4996
|
ERBRequireWhitespaceRule,
|
|
4675
4997
|
ERBRightTrimRule,
|
|
4998
|
+
ERBStrictLocalsCommentSyntaxRule,
|
|
4999
|
+
ERBStrictLocalsRequiredRule,
|
|
4676
5000
|
HerbDisableCommentValidRuleNameRule,
|
|
4677
5001
|
HerbDisableCommentNoRedundantAllRule,
|
|
4678
5002
|
HerbDisableCommentNoDuplicateRulesRule,
|
|
@@ -5086,9 +5410,12 @@ class Linter {
|
|
|
5086
5410
|
* @param source - The source code to fix
|
|
5087
5411
|
* @param context - Optional context for linting (e.g., fileName)
|
|
5088
5412
|
* @param offensesToFix - Optional array of specific offenses to fix. If not provided, all fixable offenses will be fixed.
|
|
5413
|
+
* @param options - Options for autofix behavior
|
|
5414
|
+
* @param options.includeUnsafe - If true, also apply unsafe fixes (rules with unsafeAutocorrectable = true)
|
|
5089
5415
|
* @returns AutofixResult containing the corrected source and lists of fixed/unfixed offenses
|
|
5090
5416
|
*/
|
|
5091
|
-
autofix(source, context, offensesToFix) {
|
|
5417
|
+
autofix(source, context, offensesToFix, options) {
|
|
5418
|
+
const includeUnsafe = options?.includeUnsafe ?? false;
|
|
5092
5419
|
const lintResult = offensesToFix ? { offenses: offensesToFix } : this.lint(source, context);
|
|
5093
5420
|
const parserOffenses = [];
|
|
5094
5421
|
const sourceOffenses = [];
|
|
@@ -5119,10 +5446,15 @@ class Linter {
|
|
|
5119
5446
|
continue;
|
|
5120
5447
|
}
|
|
5121
5448
|
const rule = new RuleClass();
|
|
5449
|
+
const isUnsafe = RuleClass.unsafeAutocorrectable === true;
|
|
5122
5450
|
if (!rule.autofix) {
|
|
5123
5451
|
unfixed.push(offense);
|
|
5124
5452
|
continue;
|
|
5125
5453
|
}
|
|
5454
|
+
if (isUnsafe && !includeUnsafe) {
|
|
5455
|
+
unfixed.push(offense);
|
|
5456
|
+
continue;
|
|
5457
|
+
}
|
|
5126
5458
|
if (offense.autofixContext) {
|
|
5127
5459
|
const originalNodeType = offense.autofixContext.node.type;
|
|
5128
5460
|
const location = offense.autofixContext.node.location ? core.Location.from(offense.autofixContext.node.location) : offense.location;
|
|
@@ -5162,10 +5494,15 @@ class Linter {
|
|
|
5162
5494
|
continue;
|
|
5163
5495
|
}
|
|
5164
5496
|
const rule = new RuleClass();
|
|
5497
|
+
const isUnsafe = RuleClass.unsafeAutocorrectable === true;
|
|
5165
5498
|
if (!rule.autofix) {
|
|
5166
5499
|
unfixed.push(offense);
|
|
5167
5500
|
continue;
|
|
5168
5501
|
}
|
|
5502
|
+
if (isUnsafe && !includeUnsafe) {
|
|
5503
|
+
unfixed.push(offense);
|
|
5504
|
+
continue;
|
|
5505
|
+
}
|
|
5169
5506
|
const correctedSource = rule.autofix(offense, currentSource, context);
|
|
5170
5507
|
if (correctedSource) {
|
|
5171
5508
|
currentSource = correctedSource;
|
|
@@ -5204,6 +5541,8 @@ exports.ERBPreferImageTagHelperRule = ERBPreferImageTagHelperRule;
|
|
|
5204
5541
|
exports.ERBRequireTrailingNewlineRule = ERBRequireTrailingNewlineRule;
|
|
5205
5542
|
exports.ERBRequireWhitespaceRule = ERBRequireWhitespaceRule;
|
|
5206
5543
|
exports.ERBRightTrimRule = ERBRightTrimRule;
|
|
5544
|
+
exports.ERBStrictLocalsCommentSyntaxRule = ERBStrictLocalsCommentSyntaxRule;
|
|
5545
|
+
exports.ERBStrictLocalsRequiredRule = ERBStrictLocalsRequiredRule;
|
|
5207
5546
|
exports.HEADING_TAGS = HEADING_TAGS;
|
|
5208
5547
|
exports.HEAD_AND_BODY_TAG_NAMES = HEAD_AND_BODY_TAG_NAMES;
|
|
5209
5548
|
exports.HEAD_ONLY_TAG_NAMES = HEAD_ONLY_TAG_NAMES;
|
|
@@ -5253,6 +5592,7 @@ exports.HerbDisableCommentValidRuleNameRule = HerbDisableCommentValidRuleNameRul
|
|
|
5253
5592
|
exports.LexerRule = LexerRule;
|
|
5254
5593
|
exports.Linter = Linter;
|
|
5255
5594
|
exports.ParserRule = ParserRule;
|
|
5595
|
+
exports.STRICT_LOCALS_PATTERN = STRICT_LOCALS_PATTERN;
|
|
5256
5596
|
exports.SVGTagNameCapitalizationRule = SVGTagNameCapitalizationRule;
|
|
5257
5597
|
exports.SVG_CAMEL_CASE_ELEMENTS = SVG_CAMEL_CASE_ELEMENTS;
|
|
5258
5598
|
exports.SVG_LOWERCASE_TO_CAMELCASE = SVG_LOWERCASE_TO_CAMELCASE;
|
|
@@ -5269,12 +5609,14 @@ exports.getAttributeValue = getAttributeValue;
|
|
|
5269
5609
|
exports.getAttributeValueNodes = getAttributeValueNodes;
|
|
5270
5610
|
exports.getAttributeValueQuoteType = getAttributeValueQuoteType;
|
|
5271
5611
|
exports.getAttributes = getAttributes;
|
|
5612
|
+
exports.getBasename = getBasename;
|
|
5272
5613
|
exports.getCombinedAttributeNameString = getCombinedAttributeNameString;
|
|
5273
5614
|
exports.getStaticAttributeValue = getStaticAttributeValue;
|
|
5274
5615
|
exports.getStaticAttributeValueContent = getStaticAttributeValueContent;
|
|
5275
5616
|
exports.getTagName = getTagName;
|
|
5276
5617
|
exports.hasAttribute = hasAttribute;
|
|
5277
5618
|
exports.hasAttributeValue = hasAttributeValue;
|
|
5619
|
+
exports.hasBalancedParentheses = hasBalancedParentheses;
|
|
5278
5620
|
exports.hasDynamicAttributeName = hasDynamicAttributeName;
|
|
5279
5621
|
exports.hasDynamicAttributeValue = hasDynamicAttributeValue;
|
|
5280
5622
|
exports.hasStaticAttributeValue = hasStaticAttributeValue;
|
|
@@ -5290,7 +5632,9 @@ exports.isHeadOnlyTag = isHeadOnlyTag;
|
|
|
5290
5632
|
exports.isHeadTag = isHeadTag;
|
|
5291
5633
|
exports.isHtmlOnlyTag = isHtmlOnlyTag;
|
|
5292
5634
|
exports.isInlineElement = isInlineElement;
|
|
5635
|
+
exports.isPartialFile = isPartialFile;
|
|
5293
5636
|
exports.isVoidElement = isVoidElement;
|
|
5294
5637
|
exports.locationsEqual = locationsEqual;
|
|
5295
5638
|
exports.rules = rules;
|
|
5639
|
+
exports.splitByTopLevelComma = splitByTopLevelComma;
|
|
5296
5640
|
//# sourceMappingURL=index.cjs.map
|