@herb-tools/linter 0.8.7 → 0.8.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +28 -2
- package/dist/herb-lint.js +5406 -15659
- package/dist/herb-lint.js.map +1 -1
- package/dist/index.cjs +381 -37
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +376 -39
- package/dist/index.js.map +1 -1
- package/dist/loader.cjs +1231 -7911
- package/dist/loader.cjs.map +1 -1
- package/dist/loader.js +1225 -7912
- package/dist/loader.js.map +1 -1
- package/dist/package.json +7 -7
- package/dist/src/cli/argument-parser.js +5 -2
- package/dist/src/cli/argument-parser.js.map +1 -1
- package/dist/src/cli/file-processor.js +1 -1
- package/dist/src/cli/file-processor.js.map +1 -1
- package/dist/src/cli.js +14 -8
- package/dist/src/cli.js.map +1 -1
- package/dist/src/custom-rule-loader.js +2 -2
- package/dist/src/custom-rule-loader.js.map +1 -1
- package/dist/src/linter.js +14 -1
- package/dist/src/linter.js.map +1 -1
- package/dist/src/rules/erb-strict-locals-comment-syntax.js +206 -0
- package/dist/src/rules/erb-strict-locals-comment-syntax.js.map +1 -0
- package/dist/src/rules/erb-strict-locals-required.js +38 -0
- package/dist/src/rules/erb-strict-locals-required.js.map +1 -0
- package/dist/src/rules/file-utils.js +21 -0
- package/dist/src/rules/file-utils.js.map +1 -0
- package/dist/src/rules/html-head-only-elements.js +2 -0
- package/dist/src/rules/html-head-only-elements.js.map +1 -1
- package/dist/src/rules/html-no-empty-headings.js +22 -36
- package/dist/src/rules/html-no-empty-headings.js.map +1 -1
- package/dist/src/rules/index.js +4 -0
- package/dist/src/rules/index.js.map +1 -1
- package/dist/src/rules/string-utils.js +72 -0
- package/dist/src/rules/string-utils.js.map +1 -0
- package/dist/src/rules.js +4 -0
- package/dist/src/rules.js.map +1 -1
- package/dist/src/types.js +6 -0
- package/dist/src/types.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/dist/types/cli/argument-parser.d.ts +1 -0
- package/dist/types/cli/file-processor.d.ts +1 -0
- package/dist/types/cli.d.ts +1 -1
- package/dist/types/linter.d.ts +5 -1
- package/dist/types/rules/erb-strict-locals-comment-syntax.d.ts +9 -0
- package/dist/types/rules/erb-strict-locals-required.d.ts +9 -0
- package/dist/types/rules/file-utils.d.ts +13 -0
- package/dist/types/rules/index.d.ts +4 -0
- package/dist/types/rules/string-utils.d.ts +15 -0
- package/dist/types/src/cli/argument-parser.d.ts +1 -0
- package/dist/types/src/cli/file-processor.d.ts +1 -0
- package/dist/types/src/cli.d.ts +1 -1
- package/dist/types/src/linter.d.ts +5 -1
- package/dist/types/src/rules/erb-strict-locals-comment-syntax.d.ts +9 -0
- package/dist/types/src/rules/erb-strict-locals-required.d.ts +9 -0
- package/dist/types/src/rules/file-utils.d.ts +13 -0
- package/dist/types/src/rules/index.d.ts +4 -0
- package/dist/types/src/rules/string-utils.d.ts +15 -0
- package/dist/types/src/types.d.ts +6 -0
- package/dist/types/types.d.ts +6 -0
- package/docs/rules/README.md +1 -0
- package/docs/rules/erb-strict-locals-comment-syntax.md +153 -0
- package/docs/rules/erb-strict-locals-required.md +107 -0
- package/package.json +7 -7
- package/src/cli/argument-parser.ts +6 -2
- package/src/cli/file-processor.ts +2 -1
- package/src/cli.ts +18 -8
- package/src/custom-rule-loader.ts +2 -2
- package/src/linter.ts +17 -1
- package/src/rules/erb-strict-locals-comment-syntax.ts +274 -0
- package/src/rules/erb-strict-locals-required.ts +52 -0
- package/src/rules/file-utils.ts +23 -0
- package/src/rules/html-head-only-elements.ts +1 -0
- package/src/rules/html-no-empty-headings.ts +21 -44
- package/src/rules/index.ts +4 -0
- package/src/rules/string-utils.ts +72 -0
- package/src/rules.ts +4 -0
- package/src/types.ts +6 -0
package/dist/index.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,
|
|
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
|
|
4315
|
+
if (!node.open_tag)
|
|
3976
4316
|
return;
|
|
3977
|
-
|
|
3978
|
-
|
|
3979
|
-
const tagName = getTagName(
|
|
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(
|
|
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
|
|
4002
|
-
|
|
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
|
|
4016
|
-
|
|
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
|
|
4369
|
+
if (!node.open_tag)
|
|
4040
4370
|
return true;
|
|
4041
|
-
|
|
4042
|
-
|
|
4043
|
-
const attributes = getAttributes(
|
|
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
|
|
4056
|
-
|
|
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
|
|
4062
|
-
|
|
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
|