@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.
Files changed (79) hide show
  1. package/README.md +28 -2
  2. package/dist/herb-lint.js +5413 -15659
  3. package/dist/herb-lint.js.map +1 -1
  4. package/dist/index.cjs +388 -37
  5. package/dist/index.cjs.map +1 -1
  6. package/dist/index.js +383 -39
  7. package/dist/index.js.map +1 -1
  8. package/dist/loader.cjs +1238 -7911
  9. package/dist/loader.cjs.map +1 -1
  10. package/dist/loader.js +1232 -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 +213 -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 +283 -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,342 @@ 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 detectMissingSpaceAfterColon(content) {
2322
+ return /^locals:\(/.test(content);
2323
+ }
2324
+ function detectMissingParentheses(content) {
2325
+ return /^locals:\s*[^(]/.test(content);
2326
+ }
2327
+ function detectEmptyLocalsWithoutParens(content) {
2328
+ return /^locals:\s*$/.test(content);
2329
+ }
2330
+ function validateCommaUsage(inner) {
2331
+ if (inner.startsWith(",") || inner.endsWith(",") || /,,/.test(inner)) {
2332
+ return "Unexpected comma in `locals:` parameters.";
2333
+ }
2334
+ return null;
2335
+ }
2336
+ function validateBlockArgument(param) {
2337
+ if (param.startsWith("&")) {
2338
+ return `Block argument \`${param}\` is not allowed. Strict locals only support keyword arguments.`;
2339
+ }
2340
+ return null;
2341
+ }
2342
+ function validateSplatArgument(param) {
2343
+ if (param.startsWith("*") && !param.startsWith("**")) {
2344
+ return `Splat argument \`${param}\` is not allowed. Strict locals only support keyword arguments.`;
2345
+ }
2346
+ return null;
2347
+ }
2348
+ function validateDoubleSplatArgument(param) {
2349
+ if (param.startsWith("**")) {
2350
+ if (/^\*\*\w+$/.test(param)) {
2351
+ return null; // Valid double-splat
2352
+ }
2353
+ return `Invalid double-splat syntax \`${param}\`. Use \`**name\` format (e.g., \`**attributes\`).`;
2354
+ }
2355
+ return null;
2356
+ }
2357
+ function validateKeywordArgument(param) {
2358
+ if (!/^\w+:\s*/.test(param)) {
2359
+ if (/^\w+$/.test(param)) {
2360
+ return `Positional argument \`${param}\` is not allowed. Use keyword argument format: \`${param}:\`.`;
2361
+ }
2362
+ return `Invalid parameter \`${param}\`. Use keyword argument format: \`name:\` or \`name: default\`.`;
2363
+ }
2364
+ return null;
2365
+ }
2366
+ function validateParameter(param) {
2367
+ const trimmed = param.trim();
2368
+ if (!trimmed)
2369
+ return null;
2370
+ return (validateBlockArgument(trimmed) ||
2371
+ validateSplatArgument(trimmed) ||
2372
+ validateDoubleSplatArgument(trimmed) ||
2373
+ (trimmed.startsWith("**") ? null : validateKeywordArgument(trimmed)));
2374
+ }
2375
+ function validateLocalsSignature(paramsContent) {
2376
+ const match = paramsContent.match(/^\s*\(([\s\S]*)\)\s*$/);
2377
+ if (!match)
2378
+ return null;
2379
+ const inner = match[1].trim();
2380
+ if (!inner)
2381
+ return null; // Empty locals is valid: locals: ()
2382
+ const commaError = validateCommaUsage(inner);
2383
+ if (commaError)
2384
+ return commaError;
2385
+ const params = splitByTopLevelComma(inner);
2386
+ for (const param of params) {
2387
+ const error = validateParameter(param);
2388
+ if (error)
2389
+ return error;
2390
+ }
2391
+ return null;
2392
+ }
2393
+ class ERBStrictLocalsCommentSyntaxVisitor extends BaseRuleVisitor {
2394
+ seenStrictLocalsComment = false;
2395
+ firstStrictLocalsLocation = null;
2396
+ visitERBContentNode(node) {
2397
+ const openingTag = node.tag_opening?.value;
2398
+ const content = node.content?.value;
2399
+ if (!content)
2400
+ return;
2401
+ const commentContent = this.extractCommentContent(openingTag, content, node);
2402
+ if (!commentContent)
2403
+ return;
2404
+ const remainder = extractLocalsRemainder(commentContent);
2405
+ if (!remainder || !hasLocalsLikeSyntax(remainder))
2406
+ return;
2407
+ this.validateLocalsComment(commentContent, node);
2408
+ }
2409
+ extractCommentContent(openingTag, content, node) {
2410
+ if (openingTag === "<%#") {
2411
+ return extractERBCommentContent(content);
2412
+ }
2413
+ if (openingTag === "<%" || openingTag === "<%-") {
2414
+ const rubyComment = extractRubyCommentContent(content);
2415
+ if (rubyComment && looksLikeLocalsDeclaration(rubyComment)) {
2416
+ this.addOffense(`Use \`<%#\` instead of \`${openingTag} #\` for strict locals comments. Only ERB comment syntax is recognized by Rails.`, node.location);
2417
+ }
2418
+ }
2419
+ return null;
2420
+ }
2421
+ validateLocalsComment(commentContent, node) {
2422
+ this.checkPartialFile(node);
2423
+ if (!hasBalancedParentheses(commentContent)) {
2424
+ this.addOffense("Unbalanced parentheses in `locals:` comment. Ensure all opening parentheses have matching closing parentheses.", node.location);
2425
+ return;
2426
+ }
2427
+ if (isValidStrictLocalsFormat(commentContent)) {
2428
+ this.handleValidFormat(commentContent, node);
2429
+ return;
2430
+ }
2431
+ this.handleInvalidFormat(commentContent, node);
2432
+ }
2433
+ checkPartialFile(node) {
2434
+ const isPartial = isPartialFile(this.context.fileName);
2435
+ if (isPartial === false) {
2436
+ this.addOffense("Strict locals (`locals:`) only work in partials (files starting with `_`). This declaration will be ignored.", node.location);
2437
+ }
2438
+ }
2439
+ handleValidFormat(commentContent, node) {
2440
+ if (this.seenStrictLocalsComment) {
2441
+ this.addOffense(`Duplicate \`locals:\` declaration. Only one \`locals:\` comment is allowed per partial (first declaration at line ${this.firstStrictLocalsLocation?.line}).`, node.location);
2442
+ return;
2443
+ }
2444
+ this.seenStrictLocalsComment = true;
2445
+ this.firstStrictLocalsLocation = {
2446
+ line: node.location.start.line,
2447
+ column: node.location.start.column
2448
+ };
2449
+ const paramsMatch = commentContent.match(/^locals:\s*(\([\s\S]*\))\s*$/);
2450
+ if (paramsMatch) {
2451
+ const error = validateLocalsSignature(paramsMatch[1]);
2452
+ if (error) {
2453
+ this.addOffense(error, node.location);
2454
+ }
2455
+ }
2456
+ }
2457
+ handleInvalidFormat(commentContent, node) {
2458
+ if (detectLocalsWithoutColon(commentContent)) {
2459
+ this.addOffense("Use `locals:` with a colon, not `locals()`. Correct format: `<%# locals: (...) %>`.", node.location);
2460
+ return;
2461
+ }
2462
+ if (detectSingularLocal(commentContent)) {
2463
+ this.addOffense("Use `locals:` (plural), not `local:`.", node.location);
2464
+ return;
2465
+ }
2466
+ if (detectMissingColonBeforeParens(commentContent)) {
2467
+ this.addOffense("Use `locals:` with a colon before the parentheses, not `locals (`.", node.location);
2468
+ return;
2469
+ }
2470
+ if (detectMissingSpaceAfterColon(commentContent)) {
2471
+ this.addOffense("Missing space after `locals:`. Rails Strict Locals require a space after the colon: `<%# locals: (...) %>`.", node.location);
2472
+ return;
2473
+ }
2474
+ if (detectMissingParentheses(commentContent)) {
2475
+ this.addOffense("Wrap parameters in parentheses: `locals: (name:)` or `locals: (name: default)`.", node.location);
2476
+ return;
2477
+ }
2478
+ if (detectEmptyLocalsWithoutParens(commentContent)) {
2479
+ this.addOffense("Add parameters after `locals:`. Use `locals: (name:)` or `locals: ()` for no locals.", node.location);
2480
+ return;
2481
+ }
2482
+ this.addOffense("Invalid `locals:` syntax. Use format: `locals: (name:, option: default)`.", node.location);
2483
+ }
2484
+ }
2485
+ class ERBStrictLocalsCommentSyntaxRule extends ParserRule {
2486
+ name = "erb-strict-locals-comment-syntax";
2487
+ get defaultConfig() {
2488
+ return {
2489
+ enabled: true,
2490
+ severity: "error"
2491
+ };
2492
+ }
2493
+ check(result, context) {
2494
+ const visitor = new ERBStrictLocalsCommentSyntaxVisitor(this.name, context);
2495
+ visitor.visit(result.value);
2496
+ return visitor.offenses;
2497
+ }
2498
+ }
2499
+
2500
+ function hasStrictLocals(source) {
2501
+ return source.includes("<%# locals:") || source.includes("<%#locals:");
2502
+ }
2503
+ class ERBStrictLocalsRequiredVisitor extends BaseSourceRuleVisitor {
2504
+ visitSource(source) {
2505
+ const isPartial = isPartialFile(this.context.fileName);
2506
+ if (isPartial !== true)
2507
+ return;
2508
+ if (hasStrictLocals(source))
2509
+ return;
2510
+ const firstLineLength = source.indexOf("\n") === -1 ? source.length : source.indexOf("\n");
2511
+ const location = core.Location.from(1, 0, 1, firstLineLength);
2512
+ this.addOffense("Partial is missing a strict locals declaration. Add `<%# locals: (...) %>` at the top of the file.", location);
2513
+ }
2514
+ }
2515
+ class ERBStrictLocalsRequiredRule extends SourceRule {
2516
+ static unsafeAutocorrectable = true;
2517
+ name = "erb-strict-locals-required";
2518
+ get defaultConfig() {
2519
+ return {
2520
+ enabled: false,
2521
+ severity: "error",
2522
+ };
2523
+ }
2524
+ check(source, context) {
2525
+ const visitor = new ERBStrictLocalsRequiredVisitor(this.name, context);
2526
+ visitor.visit(source);
2527
+ return visitor.offenses;
2528
+ }
2529
+ autofix(_offense, source, _context) {
2530
+ return `<%# locals: () %>\n\n${source}`;
2531
+ }
2532
+ }
2533
+
2192
2534
  /**
2193
2535
  * Utilities for parsing herb:disable comments
2194
2536
  */
@@ -3102,6 +3444,8 @@ class HeadOnlyElementsVisitor extends BaseRuleVisitor {
3102
3444
  return;
3103
3445
  if (tagName === "title" && this.insideSVG)
3104
3446
  return;
3447
+ if (tagName === "style" && this.insideSVG)
3448
+ return;
3105
3449
  if (tagName === "meta" && this.hasItempropAttribute(node))
3106
3450
  return;
3107
3451
  this.addOffense(`Element \`<${tagName}>\` must be placed inside the \`<head>\` tag.`, node.location);
@@ -3970,20 +4314,22 @@ class HTMLNoEmptyAttributesRule extends ParserRule {
3970
4314
 
3971
4315
  class NoEmptyHeadingsVisitor extends BaseRuleVisitor {
3972
4316
  visitHTMLElementNode(node) {
4317
+ const tagName = getTagName(node.open_tag)?.toLowerCase();
4318
+ if (tagName === "template")
4319
+ return;
3973
4320
  this.checkHeadingElement(node);
3974
4321
  super.visitHTMLElementNode(node);
3975
4322
  }
3976
4323
  checkHeadingElement(node) {
3977
- if (!node.open_tag || node.open_tag.type !== "AST_HTML_OPEN_TAG_NODE") {
4324
+ if (!node.open_tag)
3978
4325
  return;
3979
- }
3980
- const openTag = node.open_tag;
3981
- const tagName = getTagName(openTag);
3982
- if (!tagName) {
4326
+ if (!core.isHTMLOpenTagNode(node.open_tag))
4327
+ return;
4328
+ const tagName = getTagName(node.open_tag);
4329
+ if (!tagName)
3983
4330
  return;
3984
- }
3985
4331
  const isStandardHeading = HEADING_TAGS.has(tagName);
3986
- const isAriaHeading = this.hasHeadingRole(openTag);
4332
+ const isAriaHeading = this.hasHeadingRole(node.open_tag);
3987
4333
  if (!isStandardHeading && !isAriaHeading) {
3988
4334
  return;
3989
4335
  }
@@ -4000,23 +4346,14 @@ class NoEmptyHeadingsVisitor extends BaseRuleVisitor {
4000
4346
  }
4001
4347
  let hasAccessibleContent = false;
4002
4348
  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) {
4349
+ if (core.isLiteralNode(child) || core.isHTMLTextNode(child)) {
4350
+ if (child.content.trim().length > 0) {
4013
4351
  hasAccessibleContent = true;
4014
4352
  break;
4015
4353
  }
4016
4354
  }
4017
- else if (child.type === "AST_HTML_ELEMENT_NODE") {
4018
- const elementNode = child;
4019
- if (this.isElementAccessible(elementNode)) {
4355
+ else if (core.isHTMLElementNode(child)) {
4356
+ if (this.isElementAccessible(child)) {
4020
4357
  hasAccessibleContent = true;
4021
4358
  break;
4022
4359
  }
@@ -4038,11 +4375,11 @@ class NoEmptyHeadingsVisitor extends BaseRuleVisitor {
4038
4375
  return roleValue === "heading";
4039
4376
  }
4040
4377
  isElementAccessible(node) {
4041
- if (!node.open_tag || node.open_tag.type !== "AST_HTML_OPEN_TAG_NODE") {
4378
+ if (!node.open_tag)
4042
4379
  return true;
4043
- }
4044
- const openTag = node.open_tag;
4045
- const attributes = getAttributes(openTag);
4380
+ if (!core.isHTMLOpenTagNode(node.open_tag))
4381
+ return true;
4382
+ const attributes = getAttributes(node.open_tag);
4046
4383
  const ariaHiddenAttribute = findAttributeByName(attributes, "aria-hidden");
4047
4384
  if (ariaHiddenAttribute) {
4048
4385
  const ariaHiddenValue = getAttributeValue(ariaHiddenAttribute);
@@ -4054,21 +4391,13 @@ class NoEmptyHeadingsVisitor extends BaseRuleVisitor {
4054
4391
  return false;
4055
4392
  }
4056
4393
  for (const child of node.body) {
4057
- if (child.type === "AST_LITERAL_NODE") {
4058
- const literalNode = child;
4059
- if (literalNode.content.trim().length > 0) {
4394
+ if (core.isLiteralNode(child) || core.isHTMLTextNode(child)) {
4395
+ if (child.content.trim().length > 0) {
4060
4396
  return true;
4061
4397
  }
4062
4398
  }
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)) {
4399
+ else if (core.isHTMLElementNode(child)) {
4400
+ if (this.isElementAccessible(child)) {
4072
4401
  return true;
4073
4402
  }
4074
4403
  }
@@ -4673,6 +5002,8 @@ const rules = [
4673
5002
  ERBRequireTrailingNewlineRule,
4674
5003
  ERBRequireWhitespaceRule,
4675
5004
  ERBRightTrimRule,
5005
+ ERBStrictLocalsCommentSyntaxRule,
5006
+ ERBStrictLocalsRequiredRule,
4676
5007
  HerbDisableCommentValidRuleNameRule,
4677
5008
  HerbDisableCommentNoRedundantAllRule,
4678
5009
  HerbDisableCommentNoDuplicateRulesRule,
@@ -5086,9 +5417,12 @@ class Linter {
5086
5417
  * @param source - The source code to fix
5087
5418
  * @param context - Optional context for linting (e.g., fileName)
5088
5419
  * @param offensesToFix - Optional array of specific offenses to fix. If not provided, all fixable offenses will be fixed.
5420
+ * @param options - Options for autofix behavior
5421
+ * @param options.includeUnsafe - If true, also apply unsafe fixes (rules with unsafeAutocorrectable = true)
5089
5422
  * @returns AutofixResult containing the corrected source and lists of fixed/unfixed offenses
5090
5423
  */
5091
- autofix(source, context, offensesToFix) {
5424
+ autofix(source, context, offensesToFix, options) {
5425
+ const includeUnsafe = options?.includeUnsafe ?? false;
5092
5426
  const lintResult = offensesToFix ? { offenses: offensesToFix } : this.lint(source, context);
5093
5427
  const parserOffenses = [];
5094
5428
  const sourceOffenses = [];
@@ -5119,10 +5453,15 @@ class Linter {
5119
5453
  continue;
5120
5454
  }
5121
5455
  const rule = new RuleClass();
5456
+ const isUnsafe = RuleClass.unsafeAutocorrectable === true;
5122
5457
  if (!rule.autofix) {
5123
5458
  unfixed.push(offense);
5124
5459
  continue;
5125
5460
  }
5461
+ if (isUnsafe && !includeUnsafe) {
5462
+ unfixed.push(offense);
5463
+ continue;
5464
+ }
5126
5465
  if (offense.autofixContext) {
5127
5466
  const originalNodeType = offense.autofixContext.node.type;
5128
5467
  const location = offense.autofixContext.node.location ? core.Location.from(offense.autofixContext.node.location) : offense.location;
@@ -5162,10 +5501,15 @@ class Linter {
5162
5501
  continue;
5163
5502
  }
5164
5503
  const rule = new RuleClass();
5504
+ const isUnsafe = RuleClass.unsafeAutocorrectable === true;
5165
5505
  if (!rule.autofix) {
5166
5506
  unfixed.push(offense);
5167
5507
  continue;
5168
5508
  }
5509
+ if (isUnsafe && !includeUnsafe) {
5510
+ unfixed.push(offense);
5511
+ continue;
5512
+ }
5169
5513
  const correctedSource = rule.autofix(offense, currentSource, context);
5170
5514
  if (correctedSource) {
5171
5515
  currentSource = correctedSource;
@@ -5204,6 +5548,8 @@ exports.ERBPreferImageTagHelperRule = ERBPreferImageTagHelperRule;
5204
5548
  exports.ERBRequireTrailingNewlineRule = ERBRequireTrailingNewlineRule;
5205
5549
  exports.ERBRequireWhitespaceRule = ERBRequireWhitespaceRule;
5206
5550
  exports.ERBRightTrimRule = ERBRightTrimRule;
5551
+ exports.ERBStrictLocalsCommentSyntaxRule = ERBStrictLocalsCommentSyntaxRule;
5552
+ exports.ERBStrictLocalsRequiredRule = ERBStrictLocalsRequiredRule;
5207
5553
  exports.HEADING_TAGS = HEADING_TAGS;
5208
5554
  exports.HEAD_AND_BODY_TAG_NAMES = HEAD_AND_BODY_TAG_NAMES;
5209
5555
  exports.HEAD_ONLY_TAG_NAMES = HEAD_ONLY_TAG_NAMES;
@@ -5253,6 +5599,7 @@ exports.HerbDisableCommentValidRuleNameRule = HerbDisableCommentValidRuleNameRul
5253
5599
  exports.LexerRule = LexerRule;
5254
5600
  exports.Linter = Linter;
5255
5601
  exports.ParserRule = ParserRule;
5602
+ exports.STRICT_LOCALS_PATTERN = STRICT_LOCALS_PATTERN;
5256
5603
  exports.SVGTagNameCapitalizationRule = SVGTagNameCapitalizationRule;
5257
5604
  exports.SVG_CAMEL_CASE_ELEMENTS = SVG_CAMEL_CASE_ELEMENTS;
5258
5605
  exports.SVG_LOWERCASE_TO_CAMELCASE = SVG_LOWERCASE_TO_CAMELCASE;
@@ -5269,12 +5616,14 @@ exports.getAttributeValue = getAttributeValue;
5269
5616
  exports.getAttributeValueNodes = getAttributeValueNodes;
5270
5617
  exports.getAttributeValueQuoteType = getAttributeValueQuoteType;
5271
5618
  exports.getAttributes = getAttributes;
5619
+ exports.getBasename = getBasename;
5272
5620
  exports.getCombinedAttributeNameString = getCombinedAttributeNameString;
5273
5621
  exports.getStaticAttributeValue = getStaticAttributeValue;
5274
5622
  exports.getStaticAttributeValueContent = getStaticAttributeValueContent;
5275
5623
  exports.getTagName = getTagName;
5276
5624
  exports.hasAttribute = hasAttribute;
5277
5625
  exports.hasAttributeValue = hasAttributeValue;
5626
+ exports.hasBalancedParentheses = hasBalancedParentheses;
5278
5627
  exports.hasDynamicAttributeName = hasDynamicAttributeName;
5279
5628
  exports.hasDynamicAttributeValue = hasDynamicAttributeValue;
5280
5629
  exports.hasStaticAttributeValue = hasStaticAttributeValue;
@@ -5290,7 +5639,9 @@ exports.isHeadOnlyTag = isHeadOnlyTag;
5290
5639
  exports.isHeadTag = isHeadTag;
5291
5640
  exports.isHtmlOnlyTag = isHtmlOnlyTag;
5292
5641
  exports.isInlineElement = isInlineElement;
5642
+ exports.isPartialFile = isPartialFile;
5293
5643
  exports.isVoidElement = isVoidElement;
5294
5644
  exports.locationsEqual = locationsEqual;
5295
5645
  exports.rules = rules;
5646
+ exports.splitByTopLevelComma = splitByTopLevelComma;
5296
5647
  //# sourceMappingURL=index.cjs.map