@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.
Files changed (79) hide show
  1. package/README.md +28 -2
  2. package/dist/herb-lint.js +5406 -15659
  3. package/dist/herb-lint.js.map +1 -1
  4. package/dist/index.cjs +381 -37
  5. package/dist/index.cjs.map +1 -1
  6. package/dist/index.js +376 -39
  7. package/dist/index.js.map +1 -1
  8. package/dist/loader.cjs +1231 -7911
  9. package/dist/loader.cjs.map +1 -1
  10. package/dist/loader.js +1225 -7912
  11. package/dist/loader.js.map +1 -1
  12. package/dist/package.json +7 -7
  13. package/dist/src/cli/argument-parser.js +5 -2
  14. package/dist/src/cli/argument-parser.js.map +1 -1
  15. package/dist/src/cli/file-processor.js +1 -1
  16. package/dist/src/cli/file-processor.js.map +1 -1
  17. package/dist/src/cli.js +14 -8
  18. package/dist/src/cli.js.map +1 -1
  19. package/dist/src/custom-rule-loader.js +2 -2
  20. package/dist/src/custom-rule-loader.js.map +1 -1
  21. package/dist/src/linter.js +14 -1
  22. package/dist/src/linter.js.map +1 -1
  23. package/dist/src/rules/erb-strict-locals-comment-syntax.js +206 -0
  24. package/dist/src/rules/erb-strict-locals-comment-syntax.js.map +1 -0
  25. package/dist/src/rules/erb-strict-locals-required.js +38 -0
  26. package/dist/src/rules/erb-strict-locals-required.js.map +1 -0
  27. package/dist/src/rules/file-utils.js +21 -0
  28. package/dist/src/rules/file-utils.js.map +1 -0
  29. package/dist/src/rules/html-head-only-elements.js +2 -0
  30. package/dist/src/rules/html-head-only-elements.js.map +1 -1
  31. package/dist/src/rules/html-no-empty-headings.js +22 -36
  32. package/dist/src/rules/html-no-empty-headings.js.map +1 -1
  33. package/dist/src/rules/index.js +4 -0
  34. package/dist/src/rules/index.js.map +1 -1
  35. package/dist/src/rules/string-utils.js +72 -0
  36. package/dist/src/rules/string-utils.js.map +1 -0
  37. package/dist/src/rules.js +4 -0
  38. package/dist/src/rules.js.map +1 -1
  39. package/dist/src/types.js +6 -0
  40. package/dist/src/types.js.map +1 -1
  41. package/dist/tsconfig.tsbuildinfo +1 -1
  42. package/dist/types/cli/argument-parser.d.ts +1 -0
  43. package/dist/types/cli/file-processor.d.ts +1 -0
  44. package/dist/types/cli.d.ts +1 -1
  45. package/dist/types/linter.d.ts +5 -1
  46. package/dist/types/rules/erb-strict-locals-comment-syntax.d.ts +9 -0
  47. package/dist/types/rules/erb-strict-locals-required.d.ts +9 -0
  48. package/dist/types/rules/file-utils.d.ts +13 -0
  49. package/dist/types/rules/index.d.ts +4 -0
  50. package/dist/types/rules/string-utils.d.ts +15 -0
  51. package/dist/types/src/cli/argument-parser.d.ts +1 -0
  52. package/dist/types/src/cli/file-processor.d.ts +1 -0
  53. package/dist/types/src/cli.d.ts +1 -1
  54. package/dist/types/src/linter.d.ts +5 -1
  55. package/dist/types/src/rules/erb-strict-locals-comment-syntax.d.ts +9 -0
  56. package/dist/types/src/rules/erb-strict-locals-required.d.ts +9 -0
  57. package/dist/types/src/rules/file-utils.d.ts +13 -0
  58. package/dist/types/src/rules/index.d.ts +4 -0
  59. package/dist/types/src/rules/string-utils.d.ts +15 -0
  60. package/dist/types/src/types.d.ts +6 -0
  61. package/dist/types/types.d.ts +6 -0
  62. package/docs/rules/README.md +1 -0
  63. package/docs/rules/erb-strict-locals-comment-syntax.md +153 -0
  64. package/docs/rules/erb-strict-locals-required.md +107 -0
  65. package/package.json +7 -7
  66. package/src/cli/argument-parser.ts +6 -2
  67. package/src/cli/file-processor.ts +2 -1
  68. package/src/cli.ts +18 -8
  69. package/src/custom-rule-loader.ts +2 -2
  70. package/src/linter.ts +17 -1
  71. package/src/rules/erb-strict-locals-comment-syntax.ts +274 -0
  72. package/src/rules/erb-strict-locals-required.ts +52 -0
  73. package/src/rules/file-utils.ts +23 -0
  74. package/src/rules/html-head-only-elements.ts +1 -0
  75. package/src/rules/html-no-empty-headings.ts +21 -44
  76. package/src/rules/index.ts +4 -0
  77. package/src/rules/string-utils.ts +72 -0
  78. package/src/rules.ts +4 -0
  79. 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 || node.open_tag.type !== "AST_HTML_OPEN_TAG_NODE") {
4317
+ if (!node.open_tag)
3978
4318
  return;
3979
- }
3980
- const openTag = node.open_tag;
3981
- const tagName = getTagName(openTag);
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(openTag);
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.type === "AST_LITERAL_NODE") {
4004
- const literalNode = child;
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.type === "AST_HTML_ELEMENT_NODE") {
4018
- const elementNode = child;
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 || node.open_tag.type !== "AST_HTML_OPEN_TAG_NODE") {
4371
+ if (!node.open_tag)
4042
4372
  return true;
4043
- }
4044
- const openTag = node.open_tag;
4045
- const attributes = getAttributes(openTag);
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.type === "AST_LITERAL_NODE") {
4058
- const literalNode = child;
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.type === "AST_HTML_TEXT_NODE") {
4064
- const textNode = child;
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