@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.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, HTMLCloseTagNode, isHTMLOpenTagNode, WhitespaceNode, filterWhitespaceNodes, HTMLOpenTagNode, isERBCommentNode } from '@herb-tools/core';
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,335 @@ 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 detectMissingParentheses(content) {
2320
+ return /^locals:\s*[^(]/.test(content);
2321
+ }
2322
+ function detectEmptyLocalsWithoutParens(content) {
2323
+ return /^locals:\s*$/.test(content);
2324
+ }
2325
+ function validateCommaUsage(inner) {
2326
+ if (inner.startsWith(",") || inner.endsWith(",") || /,,/.test(inner)) {
2327
+ return "Unexpected comma in `locals:` parameters.";
2328
+ }
2329
+ return null;
2330
+ }
2331
+ function validateBlockArgument(param) {
2332
+ if (param.startsWith("&")) {
2333
+ return `Block argument \`${param}\` is not allowed. Strict locals only support keyword arguments.`;
2334
+ }
2335
+ return null;
2336
+ }
2337
+ function validateSplatArgument(param) {
2338
+ if (param.startsWith("*") && !param.startsWith("**")) {
2339
+ return `Splat argument \`${param}\` is not allowed. Strict locals only support keyword arguments.`;
2340
+ }
2341
+ return null;
2342
+ }
2343
+ function validateDoubleSplatArgument(param) {
2344
+ if (param.startsWith("**")) {
2345
+ if (/^\*\*\w+$/.test(param)) {
2346
+ return null; // Valid double-splat
2347
+ }
2348
+ return `Invalid double-splat syntax \`${param}\`. Use \`**name\` format (e.g., \`**attributes\`).`;
2349
+ }
2350
+ return null;
2351
+ }
2352
+ function validateKeywordArgument(param) {
2353
+ if (!/^\w+:\s*/.test(param)) {
2354
+ if (/^\w+$/.test(param)) {
2355
+ return `Positional argument \`${param}\` is not allowed. Use keyword argument format: \`${param}:\`.`;
2356
+ }
2357
+ return `Invalid parameter \`${param}\`. Use keyword argument format: \`name:\` or \`name: default\`.`;
2358
+ }
2359
+ return null;
2360
+ }
2361
+ function validateParameter(param) {
2362
+ const trimmed = param.trim();
2363
+ if (!trimmed)
2364
+ return null;
2365
+ return (validateBlockArgument(trimmed) ||
2366
+ validateSplatArgument(trimmed) ||
2367
+ validateDoubleSplatArgument(trimmed) ||
2368
+ (trimmed.startsWith("**") ? null : validateKeywordArgument(trimmed)));
2369
+ }
2370
+ function validateLocalsSignature(paramsContent) {
2371
+ const match = paramsContent.match(/^\s*\(([\s\S]*)\)\s*$/);
2372
+ if (!match)
2373
+ return null;
2374
+ const inner = match[1].trim();
2375
+ if (!inner)
2376
+ return null; // Empty locals is valid: locals: ()
2377
+ const commaError = validateCommaUsage(inner);
2378
+ if (commaError)
2379
+ return commaError;
2380
+ const params = splitByTopLevelComma(inner);
2381
+ for (const param of params) {
2382
+ const error = validateParameter(param);
2383
+ if (error)
2384
+ return error;
2385
+ }
2386
+ return null;
2387
+ }
2388
+ class ERBStrictLocalsCommentSyntaxVisitor extends BaseRuleVisitor {
2389
+ seenStrictLocalsComment = false;
2390
+ firstStrictLocalsLocation = null;
2391
+ visitERBContentNode(node) {
2392
+ const openingTag = node.tag_opening?.value;
2393
+ const content = node.content?.value;
2394
+ if (!content)
2395
+ return;
2396
+ const commentContent = this.extractCommentContent(openingTag, content, node);
2397
+ if (!commentContent)
2398
+ return;
2399
+ const remainder = extractLocalsRemainder(commentContent);
2400
+ if (!remainder || !hasLocalsLikeSyntax(remainder))
2401
+ return;
2402
+ this.validateLocalsComment(commentContent, node);
2403
+ }
2404
+ extractCommentContent(openingTag, content, node) {
2405
+ if (openingTag === "<%#") {
2406
+ return extractERBCommentContent(content);
2407
+ }
2408
+ if (openingTag === "<%" || openingTag === "<%-") {
2409
+ const rubyComment = extractRubyCommentContent(content);
2410
+ if (rubyComment && looksLikeLocalsDeclaration(rubyComment)) {
2411
+ this.addOffense(`Use \`<%#\` instead of \`${openingTag} #\` for strict locals comments. Only ERB comment syntax is recognized by Rails.`, node.location);
2412
+ }
2413
+ }
2414
+ return null;
2415
+ }
2416
+ validateLocalsComment(commentContent, node) {
2417
+ this.checkPartialFile(node);
2418
+ if (!hasBalancedParentheses(commentContent)) {
2419
+ this.addOffense("Unbalanced parentheses in `locals:` comment. Ensure all opening parentheses have matching closing parentheses.", node.location);
2420
+ return;
2421
+ }
2422
+ if (isValidStrictLocalsFormat(commentContent)) {
2423
+ this.handleValidFormat(commentContent, node);
2424
+ return;
2425
+ }
2426
+ this.handleInvalidFormat(commentContent, node);
2427
+ }
2428
+ checkPartialFile(node) {
2429
+ const isPartial = isPartialFile(this.context.fileName);
2430
+ if (isPartial === false) {
2431
+ this.addOffense("Strict locals (`locals:`) only work in partials (files starting with `_`). This declaration will be ignored.", node.location);
2432
+ }
2433
+ }
2434
+ handleValidFormat(commentContent, node) {
2435
+ if (this.seenStrictLocalsComment) {
2436
+ this.addOffense(`Duplicate \`locals:\` declaration. Only one \`locals:\` comment is allowed per partial (first declaration at line ${this.firstStrictLocalsLocation?.line}).`, node.location);
2437
+ return;
2438
+ }
2439
+ this.seenStrictLocalsComment = true;
2440
+ this.firstStrictLocalsLocation = {
2441
+ line: node.location.start.line,
2442
+ column: node.location.start.column
2443
+ };
2444
+ const paramsMatch = commentContent.match(/^locals:\s*(\([\s\S]*\))\s*$/);
2445
+ if (paramsMatch) {
2446
+ const error = validateLocalsSignature(paramsMatch[1]);
2447
+ if (error) {
2448
+ this.addOffense(error, node.location);
2449
+ }
2450
+ }
2451
+ }
2452
+ handleInvalidFormat(commentContent, node) {
2453
+ if (detectLocalsWithoutColon(commentContent)) {
2454
+ this.addOffense("Use `locals:` with a colon, not `locals()`. Correct format: `<%# locals: (...) %>`.", node.location);
2455
+ return;
2456
+ }
2457
+ if (detectSingularLocal(commentContent)) {
2458
+ this.addOffense("Use `locals:` (plural), not `local:`.", node.location);
2459
+ return;
2460
+ }
2461
+ if (detectMissingColonBeforeParens(commentContent)) {
2462
+ this.addOffense("Use `locals:` with a colon before the parentheses, not `locals (`.", node.location);
2463
+ return;
2464
+ }
2465
+ if (detectMissingParentheses(commentContent)) {
2466
+ this.addOffense("Wrap parameters in parentheses: `locals: (name:)` or `locals: (name: default)`.", node.location);
2467
+ return;
2468
+ }
2469
+ if (detectEmptyLocalsWithoutParens(commentContent)) {
2470
+ this.addOffense("Add parameters after `locals:`. Use `locals: (name:)` or `locals: ()` for no locals.", node.location);
2471
+ return;
2472
+ }
2473
+ this.addOffense("Invalid `locals:` syntax. Use format: `locals: (name:, option: default)`.", node.location);
2474
+ }
2475
+ }
2476
+ class ERBStrictLocalsCommentSyntaxRule extends ParserRule {
2477
+ name = "erb-strict-locals-comment-syntax";
2478
+ get defaultConfig() {
2479
+ return {
2480
+ enabled: true,
2481
+ severity: "error"
2482
+ };
2483
+ }
2484
+ check(result, context) {
2485
+ const visitor = new ERBStrictLocalsCommentSyntaxVisitor(this.name, context);
2486
+ visitor.visit(result.value);
2487
+ return visitor.offenses;
2488
+ }
2489
+ }
2490
+
2491
+ function hasStrictLocals(source) {
2492
+ return source.includes("<%# locals:") || source.includes("<%#locals:");
2493
+ }
2494
+ class ERBStrictLocalsRequiredVisitor extends BaseSourceRuleVisitor {
2495
+ visitSource(source) {
2496
+ const isPartial = isPartialFile(this.context.fileName);
2497
+ if (isPartial !== true)
2498
+ return;
2499
+ if (hasStrictLocals(source))
2500
+ return;
2501
+ const firstLineLength = source.indexOf("\n") === -1 ? source.length : source.indexOf("\n");
2502
+ const location = Location.from(1, 0, 1, firstLineLength);
2503
+ this.addOffense("Partial is missing a strict locals declaration. Add `<%# locals: (...) %>` at the top of the file.", location);
2504
+ }
2505
+ }
2506
+ class ERBStrictLocalsRequiredRule extends SourceRule {
2507
+ static unsafeAutocorrectable = true;
2508
+ name = "erb-strict-locals-required";
2509
+ get defaultConfig() {
2510
+ return {
2511
+ enabled: false,
2512
+ severity: "error",
2513
+ };
2514
+ }
2515
+ check(source, context) {
2516
+ const visitor = new ERBStrictLocalsRequiredVisitor(this.name, context);
2517
+ visitor.visit(source);
2518
+ return visitor.offenses;
2519
+ }
2520
+ autofix(_offense, source, _context) {
2521
+ return `<%# locals: () %>\n\n${source}`;
2522
+ }
2523
+ }
2524
+
2190
2525
  /**
2191
2526
  * Utilities for parsing herb:disable comments
2192
2527
  */
@@ -3100,6 +3435,8 @@ class HeadOnlyElementsVisitor extends BaseRuleVisitor {
3100
3435
  return;
3101
3436
  if (tagName === "title" && this.insideSVG)
3102
3437
  return;
3438
+ if (tagName === "style" && this.insideSVG)
3439
+ return;
3103
3440
  if (tagName === "meta" && this.hasItempropAttribute(node))
3104
3441
  return;
3105
3442
  this.addOffense(`Element \`<${tagName}>\` must be placed inside the \`<head>\` tag.`, node.location);
@@ -3968,20 +4305,22 @@ class HTMLNoEmptyAttributesRule extends ParserRule {
3968
4305
 
3969
4306
  class NoEmptyHeadingsVisitor extends BaseRuleVisitor {
3970
4307
  visitHTMLElementNode(node) {
4308
+ const tagName = getTagName(node.open_tag)?.toLowerCase();
4309
+ if (tagName === "template")
4310
+ return;
3971
4311
  this.checkHeadingElement(node);
3972
4312
  super.visitHTMLElementNode(node);
3973
4313
  }
3974
4314
  checkHeadingElement(node) {
3975
- if (!node.open_tag || node.open_tag.type !== "AST_HTML_OPEN_TAG_NODE") {
4315
+ if (!node.open_tag)
3976
4316
  return;
3977
- }
3978
- const openTag = node.open_tag;
3979
- const tagName = getTagName(openTag);
3980
- if (!tagName) {
4317
+ if (!isHTMLOpenTagNode(node.open_tag))
4318
+ return;
4319
+ const tagName = getTagName(node.open_tag);
4320
+ if (!tagName)
3981
4321
  return;
3982
- }
3983
4322
  const isStandardHeading = HEADING_TAGS.has(tagName);
3984
- const isAriaHeading = this.hasHeadingRole(openTag);
4323
+ const isAriaHeading = this.hasHeadingRole(node.open_tag);
3985
4324
  if (!isStandardHeading && !isAriaHeading) {
3986
4325
  return;
3987
4326
  }
@@ -3998,23 +4337,14 @@ class NoEmptyHeadingsVisitor extends BaseRuleVisitor {
3998
4337
  }
3999
4338
  let hasAccessibleContent = false;
4000
4339
  for (const child of node.body) {
4001
- if (child.type === "AST_LITERAL_NODE") {
4002
- const literalNode = child;
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) {
4340
+ if (isLiteralNode(child) || isHTMLTextNode(child)) {
4341
+ if (child.content.trim().length > 0) {
4011
4342
  hasAccessibleContent = true;
4012
4343
  break;
4013
4344
  }
4014
4345
  }
4015
- else if (child.type === "AST_HTML_ELEMENT_NODE") {
4016
- const elementNode = child;
4017
- if (this.isElementAccessible(elementNode)) {
4346
+ else if (isHTMLElementNode(child)) {
4347
+ if (this.isElementAccessible(child)) {
4018
4348
  hasAccessibleContent = true;
4019
4349
  break;
4020
4350
  }
@@ -4036,11 +4366,11 @@ class NoEmptyHeadingsVisitor extends BaseRuleVisitor {
4036
4366
  return roleValue === "heading";
4037
4367
  }
4038
4368
  isElementAccessible(node) {
4039
- if (!node.open_tag || node.open_tag.type !== "AST_HTML_OPEN_TAG_NODE") {
4369
+ if (!node.open_tag)
4040
4370
  return true;
4041
- }
4042
- const openTag = node.open_tag;
4043
- const attributes = getAttributes(openTag);
4371
+ if (!isHTMLOpenTagNode(node.open_tag))
4372
+ return true;
4373
+ const attributes = getAttributes(node.open_tag);
4044
4374
  const ariaHiddenAttribute = findAttributeByName(attributes, "aria-hidden");
4045
4375
  if (ariaHiddenAttribute) {
4046
4376
  const ariaHiddenValue = getAttributeValue(ariaHiddenAttribute);
@@ -4052,21 +4382,13 @@ class NoEmptyHeadingsVisitor extends BaseRuleVisitor {
4052
4382
  return false;
4053
4383
  }
4054
4384
  for (const child of node.body) {
4055
- if (child.type === "AST_LITERAL_NODE") {
4056
- const literalNode = child;
4057
- if (literalNode.content.trim().length > 0) {
4385
+ if (isLiteralNode(child) || isHTMLTextNode(child)) {
4386
+ if (child.content.trim().length > 0) {
4058
4387
  return true;
4059
4388
  }
4060
4389
  }
4061
- else if (child.type === "AST_HTML_TEXT_NODE") {
4062
- const textNode = child;
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)) {
4390
+ else if (isHTMLElementNode(child)) {
4391
+ if (this.isElementAccessible(child)) {
4070
4392
  return true;
4071
4393
  }
4072
4394
  }
@@ -4671,6 +4993,8 @@ const rules = [
4671
4993
  ERBRequireTrailingNewlineRule,
4672
4994
  ERBRequireWhitespaceRule,
4673
4995
  ERBRightTrimRule,
4996
+ ERBStrictLocalsCommentSyntaxRule,
4997
+ ERBStrictLocalsRequiredRule,
4674
4998
  HerbDisableCommentValidRuleNameRule,
4675
4999
  HerbDisableCommentNoRedundantAllRule,
4676
5000
  HerbDisableCommentNoDuplicateRulesRule,
@@ -5084,9 +5408,12 @@ class Linter {
5084
5408
  * @param source - The source code to fix
5085
5409
  * @param context - Optional context for linting (e.g., fileName)
5086
5410
  * @param offensesToFix - Optional array of specific offenses to fix. If not provided, all fixable offenses will be fixed.
5411
+ * @param options - Options for autofix behavior
5412
+ * @param options.includeUnsafe - If true, also apply unsafe fixes (rules with unsafeAutocorrectable = true)
5087
5413
  * @returns AutofixResult containing the corrected source and lists of fixed/unfixed offenses
5088
5414
  */
5089
- autofix(source, context, offensesToFix) {
5415
+ autofix(source, context, offensesToFix, options) {
5416
+ const includeUnsafe = options?.includeUnsafe ?? false;
5090
5417
  const lintResult = offensesToFix ? { offenses: offensesToFix } : this.lint(source, context);
5091
5418
  const parserOffenses = [];
5092
5419
  const sourceOffenses = [];
@@ -5117,10 +5444,15 @@ class Linter {
5117
5444
  continue;
5118
5445
  }
5119
5446
  const rule = new RuleClass();
5447
+ const isUnsafe = RuleClass.unsafeAutocorrectable === true;
5120
5448
  if (!rule.autofix) {
5121
5449
  unfixed.push(offense);
5122
5450
  continue;
5123
5451
  }
5452
+ if (isUnsafe && !includeUnsafe) {
5453
+ unfixed.push(offense);
5454
+ continue;
5455
+ }
5124
5456
  if (offense.autofixContext) {
5125
5457
  const originalNodeType = offense.autofixContext.node.type;
5126
5458
  const location = offense.autofixContext.node.location ? Location.from(offense.autofixContext.node.location) : offense.location;
@@ -5160,10 +5492,15 @@ class Linter {
5160
5492
  continue;
5161
5493
  }
5162
5494
  const rule = new RuleClass();
5495
+ const isUnsafe = RuleClass.unsafeAutocorrectable === true;
5163
5496
  if (!rule.autofix) {
5164
5497
  unfixed.push(offense);
5165
5498
  continue;
5166
5499
  }
5500
+ if (isUnsafe && !includeUnsafe) {
5501
+ unfixed.push(offense);
5502
+ continue;
5503
+ }
5167
5504
  const correctedSource = rule.autofix(offense, currentSource, context);
5168
5505
  if (correctedSource) {
5169
5506
  currentSource = correctedSource;
@@ -5182,5 +5519,5 @@ class Linter {
5182
5519
  }
5183
5520
  }
5184
5521
 
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 };
5522
+ 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
5523
  //# sourceMappingURL=index.js.map