@herb-tools/linter 0.8.7 → 0.8.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +28 -2
- package/dist/herb-lint.js +5413 -15659
- package/dist/herb-lint.js.map +1 -1
- package/dist/index.cjs +388 -37
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +383 -39
- package/dist/index.js.map +1 -1
- package/dist/loader.cjs +1238 -7911
- package/dist/loader.cjs.map +1 -1
- package/dist/loader.js +1232 -7912
- package/dist/loader.js.map +1 -1
- package/dist/package.json +7 -7
- package/dist/src/cli/argument-parser.js +5 -2
- package/dist/src/cli/argument-parser.js.map +1 -1
- package/dist/src/cli/file-processor.js +1 -1
- package/dist/src/cli/file-processor.js.map +1 -1
- package/dist/src/cli.js +14 -8
- package/dist/src/cli.js.map +1 -1
- package/dist/src/custom-rule-loader.js +2 -2
- package/dist/src/custom-rule-loader.js.map +1 -1
- package/dist/src/linter.js +14 -1
- package/dist/src/linter.js.map +1 -1
- package/dist/src/rules/erb-strict-locals-comment-syntax.js +213 -0
- package/dist/src/rules/erb-strict-locals-comment-syntax.js.map +1 -0
- package/dist/src/rules/erb-strict-locals-required.js +38 -0
- package/dist/src/rules/erb-strict-locals-required.js.map +1 -0
- package/dist/src/rules/file-utils.js +21 -0
- package/dist/src/rules/file-utils.js.map +1 -0
- package/dist/src/rules/html-head-only-elements.js +2 -0
- package/dist/src/rules/html-head-only-elements.js.map +1 -1
- package/dist/src/rules/html-no-empty-headings.js +22 -36
- package/dist/src/rules/html-no-empty-headings.js.map +1 -1
- package/dist/src/rules/index.js +4 -0
- package/dist/src/rules/index.js.map +1 -1
- package/dist/src/rules/string-utils.js +72 -0
- package/dist/src/rules/string-utils.js.map +1 -0
- package/dist/src/rules.js +4 -0
- package/dist/src/rules.js.map +1 -1
- package/dist/src/types.js +6 -0
- package/dist/src/types.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/dist/types/cli/argument-parser.d.ts +1 -0
- package/dist/types/cli/file-processor.d.ts +1 -0
- package/dist/types/cli.d.ts +1 -1
- package/dist/types/linter.d.ts +5 -1
- package/dist/types/rules/erb-strict-locals-comment-syntax.d.ts +9 -0
- package/dist/types/rules/erb-strict-locals-required.d.ts +9 -0
- package/dist/types/rules/file-utils.d.ts +13 -0
- package/dist/types/rules/index.d.ts +4 -0
- package/dist/types/rules/string-utils.d.ts +15 -0
- package/dist/types/src/cli/argument-parser.d.ts +1 -0
- package/dist/types/src/cli/file-processor.d.ts +1 -0
- package/dist/types/src/cli.d.ts +1 -1
- package/dist/types/src/linter.d.ts +5 -1
- package/dist/types/src/rules/erb-strict-locals-comment-syntax.d.ts +9 -0
- package/dist/types/src/rules/erb-strict-locals-required.d.ts +9 -0
- package/dist/types/src/rules/file-utils.d.ts +13 -0
- package/dist/types/src/rules/index.d.ts +4 -0
- package/dist/types/src/rules/string-utils.d.ts +15 -0
- package/dist/types/src/types.d.ts +6 -0
- package/dist/types/types.d.ts +6 -0
- package/docs/rules/README.md +1 -0
- package/docs/rules/erb-strict-locals-comment-syntax.md +153 -0
- package/docs/rules/erb-strict-locals-required.md +107 -0
- package/package.json +7 -7
- package/src/cli/argument-parser.ts +6 -2
- package/src/cli/file-processor.ts +2 -1
- package/src/cli.ts +18 -8
- package/src/custom-rule-loader.ts +2 -2
- package/src/linter.ts +17 -1
- package/src/rules/erb-strict-locals-comment-syntax.ts +283 -0
- package/src/rules/erb-strict-locals-required.ts +52 -0
- package/src/rules/file-utils.ts +23 -0
- package/src/rules/html-head-only-elements.ts +1 -0
- package/src/rules/html-no-empty-headings.ts +21 -44
- package/src/rules/index.ts +4 -0
- package/src/rules/string-utils.ts +72 -0
- package/src/rules.ts +4 -0
- package/src/types.ts +6 -0
package/dist/index.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
|
|
4324
|
+
if (!node.open_tag)
|
|
3978
4325
|
return;
|
|
3979
|
-
|
|
3980
|
-
|
|
3981
|
-
const tagName = getTagName(
|
|
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(
|
|
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
|
|
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) {
|
|
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
|
|
4018
|
-
|
|
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
|
|
4378
|
+
if (!node.open_tag)
|
|
4042
4379
|
return true;
|
|
4043
|
-
|
|
4044
|
-
|
|
4045
|
-
const attributes = getAttributes(
|
|
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
|
|
4058
|
-
|
|
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
|
|
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)) {
|
|
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
|