Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions javascript/packages/linter/docs/rules/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ This page contains documentation for all Herb Linter rules.

## Available Rules

- [`actionview-no-silent-helper`](./actionview-no-silent-helper.md) - Disallow silent ERB tags for Action View helpers
- [`erb-comment-syntax`](./erb-comment-syntax.md) - Disallow Ruby comments immediately after ERB tags
- [`erb-no-case-node-children`](./erb-no-case-node-children.md) - Don't use `children` for `case/when` and `case/in` nodes
- [`erb-no-conditional-html-element`](./erb-no-conditional-html-element.md) - Disallow conditional HTML elements
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# Linter Rule: Disallow silent ERB tags for Action View helpers

**Rule:** `actionview-no-silent-helper`

## Description

Action View helpers like `link_to`, `form_with`, and `content_tag` must use output ERB tags (`<%= %>`) so their rendered HTML is included in the page. Using silent ERB tags (`<% %>`) discards the helper's output entirely.

## Rationale

Action View helpers generate HTML that needs to be rendered into the template. When a helper is called with a silent ERB tag (`<% %>`), the return value is evaluated but silently discarded, meaning the generated HTML never appears in the output. This is almost always a mistake and can lead to confusing bugs where elements are missing from the page with no obvious cause. Using `<%= %>` ensures the helper's output is properly rendered.

## Examples

### ✅ Good

```erb
<%= link_to "Home", root_path %>
```

```erb
<%= form_with model: @user do |f| %>
<%= f.text_field :name %>
<% end %>
```

```erb
<%= button_to "Delete", user_path(@user), method: :delete %>
```

```erb
<%= content_tag :div, "Hello", class: "greeting" %>
```

### 🚫 Bad

```erb
<% link_to "Home", root_path %>
```

```erb
<% form_with model: @user do |f| %>
<%= f.text_field :name %>
<% end %>
```

```erb
<% button_to "Delete", user_path(@user), method: :delete %>
```

```erb
<% content_tag :div, "Hello", class: "greeting" %>
```

## References

* [Rails Action View Helpers documentation](https://api.rubyonrails.org/classes/ActionView/Helpers.html)
82 changes: 44 additions & 38 deletions javascript/packages/linter/src/rules.ts
Original file line number Diff line number Diff line change
@@ -1,41 +1,43 @@
import type { RuleClass } from "./types.js"

import { ActionViewNoSilentHelperRule } from "./rules/actionview-no-silent-helper.js"

import { ERBCommentSyntax } from "./rules/erb-comment-syntax.js";
import { ERBNoCaseNodeChildrenRule } from "./rules/erb-no-case-node-children.js"
import { ERBNoInlineCaseConditionsRule } from "./rules/erb-no-inline-case-conditions.js"
import { ERBNoConditionalHTMLElementRule } from "./rules/erb-no-conditional-html-element.js"
import { ERBNoDuplicateBranchElementsRule } from "./rules/erb-no-duplicate-branch-elements.js"
import { ERBNoConditionalOpenTagRule } from "./rules/erb-no-conditional-open-tag.js"
import { ERBNoDuplicateBranchElementsRule } from "./rules/erb-no-duplicate-branch-elements.js"
import { ERBNoEmptyTagsRule } from "./rules/erb-no-empty-tags.js"
import { ERBNoInterpolatedClassNamesRule } from "./rules/erb-no-interpolated-class-names.js"
import { ERBNoExtraNewLineRule } from "./rules/erb-no-extra-newline.js"
import { ERBNoExtraWhitespaceRule } from "./rules/erb-no-extra-whitespace-inside-tags.js"
import { ERBNoOutputControlFlowRule } from "./rules/erb-no-output-control-flow.js"
import { ERBNoThenInControlFlowRule } from "./rules/erb-no-then-in-control-flow.js"
import { ERBNoSilentTagInAttributeNameRule } from "./rules/erb-no-silent-tag-in-attribute-name.js"
import { ERBNoTrailingWhitespaceRule } from "./rules/erb-no-trailing-whitespace.js"
import { ERBPreferImageTagHelperRule } from "./rules/erb-prefer-image-tag-helper.js"
import { ERBRequireTrailingNewlineRule } from "./rules/erb-require-trailing-newline.js"
import { ERBRequireWhitespaceRule } from "./rules/erb-require-whitespace-inside-tags.js"
import { ERBNoInlineCaseConditionsRule } from "./rules/erb-no-inline-case-conditions.js"
import { ERBNoInstanceVariablesInPartialsRule } from "./rules/erb-no-instance-variables-in-partials.js"
import { ERBNoInterpolatedClassNamesRule } from "./rules/erb-no-interpolated-class-names.js"
import { ERBNoJavascriptTagHelperRule } from "./rules/erb-no-javascript-tag-helper.js"
import { ERBNoOutputControlFlowRule } from "./rules/erb-no-output-control-flow.js"
import { ERBNoOutputInAttributeNameRule } from "./rules/erb-no-output-in-attribute-name.js"
import { ERBNoOutputInAttributePositionRule } from "./rules/erb-no-output-in-attribute-position.js"
import { ERBNoRawOutputInAttributeValueRule } from "./rules/erb-no-raw-output-in-attribute-value.js"
import { ERBNoSilentTagInAttributeNameRule } from "./rules/erb-no-silent-tag-in-attribute-name.js"
import { ERBNoStatementInScriptRule } from "./rules/erb-no-statement-in-script.js"
import { ERBNoThenInControlFlowRule } from "./rules/erb-no-then-in-control-flow.js"
import { ERBNoTrailingWhitespaceRule } from "./rules/erb-no-trailing-whitespace.js"
import { ERBNoUnsafeJSAttributeRule } from "./rules/erb-no-unsafe-js-attribute.js"
import { ERBNoUnsafeRawRule } from "./rules/erb-no-unsafe-raw.js"
import { ERBNoUnsafeScriptInterpolationRule } from "./rules/erb-no-unsafe-script-interpolation.js"
import { ERBPreferImageTagHelperRule } from "./rules/erb-prefer-image-tag-helper.js"
import { ERBRequireTrailingNewlineRule } from "./rules/erb-require-trailing-newline.js"
import { ERBRequireWhitespaceRule } from "./rules/erb-require-whitespace-inside-tags.js"
import { ERBRightTrimRule } from "./rules/erb-right-trim.js"
import { ERBNoOutputInAttributePositionRule } from "./rules/erb-no-output-in-attribute-position.js"
import { ERBNoOutputInAttributeNameRule } from "./rules/erb-no-output-in-attribute-name.js"
import { ERBStrictLocalsCommentSyntaxRule } from "./rules/erb-strict-locals-comment-syntax.js"
import { ERBNoInstanceVariablesInPartialsRule } from "./rules/erb-no-instance-variables-in-partials.js"
import { ERBStrictLocalsRequiredRule } from "./rules/erb-strict-locals-required.js"

import { HerbDisableCommentValidRuleNameRule } from "./rules/herb-disable-comment-valid-rule-name.js"
import { HerbDisableCommentNoRedundantAllRule } from "./rules/herb-disable-comment-no-redundant-all.js"
import { HerbDisableCommentNoDuplicateRulesRule } from "./rules/herb-disable-comment-no-duplicate-rules.js"
import { HerbDisableCommentMissingRulesRule } from "./rules/herb-disable-comment-missing-rules.js"
import { HerbDisableCommentMalformedRule } from "./rules/herb-disable-comment-malformed.js"
import { HerbDisableCommentMissingRulesRule } from "./rules/herb-disable-comment-missing-rules.js"
import { HerbDisableCommentNoDuplicateRulesRule } from "./rules/herb-disable-comment-no-duplicate-rules.js"
import { HerbDisableCommentNoRedundantAllRule } from "./rules/herb-disable-comment-no-redundant-all.js"
import { HerbDisableCommentUnnecessaryRule } from "./rules/herb-disable-comment-unnecessary.js"
import { HerbDisableCommentValidRuleNameRule } from "./rules/herb-disable-comment-valid-rule-name.js"

import { HTMLAllowedScriptTypeRule } from "./rules/html-allowed-script-type.js"
import { HTMLAnchorRequireHrefRule } from "./rules/html-anchor-require-href.js"
Expand Down Expand Up @@ -72,49 +74,52 @@ import { HTMLNoTitleAttributeRule } from "./rules/html-no-title-attribute.js"
import { HTMLNoUnderscoresInAttributeNamesRule } from "./rules/html-no-underscores-in-attribute-names.js"
import { HTMLRequireClosingTagsRule } from "./rules/html-require-closing-tags.js"
import { HTMLTagNameLowercaseRule } from "./rules/html-tag-name-lowercase.js"
import { TurboPermanentRequireIdRule } from "./rules/turbo-permanent-require-id.js"

import { ParserNoErrorsRule } from "./rules/parser-no-errors.js"

import { SVGTagNameCapitalizationRule } from "./rules/svg-tag-name-capitalization.js"

import { ParserNoErrorsRule } from "./rules/parser-no-errors.js"
import { TurboPermanentRequireIdRule } from "./rules/turbo-permanent-require-id.js"

export const rules: RuleClass[] = [
ActionViewNoSilentHelperRule,

ERBCommentSyntax,
ERBNoCaseNodeChildrenRule,
ERBNoInlineCaseConditionsRule,
ERBNoConditionalHTMLElementRule,
ERBNoDuplicateBranchElementsRule,
ERBNoConditionalOpenTagRule,
ERBNoDuplicateBranchElementsRule,
ERBNoEmptyTagsRule,
ERBNoInterpolatedClassNamesRule,
ERBNoExtraNewLineRule,
ERBNoExtraWhitespaceRule,
ERBNoOutputControlFlowRule,
ERBNoThenInControlFlowRule,
ERBNoSilentTagInAttributeNameRule,
ERBNoTrailingWhitespaceRule,
ERBPreferImageTagHelperRule,
ERBRequireTrailingNewlineRule,
ERBRequireWhitespaceRule,
ERBNoInlineCaseConditionsRule,
ERBNoInstanceVariablesInPartialsRule,
ERBNoInterpolatedClassNamesRule,
ERBNoJavascriptTagHelperRule,
ERBNoOutputControlFlowRule,
ERBNoOutputInAttributeNameRule,
ERBNoOutputInAttributePositionRule,
ERBNoRawOutputInAttributeValueRule,
ERBNoSilentTagInAttributeNameRule,
ERBNoStatementInScriptRule,
ERBNoThenInControlFlowRule,
ERBNoTrailingWhitespaceRule,
ERBNoUnsafeJSAttributeRule,
ERBNoUnsafeRawRule,
ERBNoUnsafeScriptInterpolationRule,
ERBPreferImageTagHelperRule,
ERBRequireTrailingNewlineRule,
ERBRequireWhitespaceRule,
ERBRightTrimRule,
ERBNoOutputInAttributePositionRule,
ERBNoOutputInAttributeNameRule,
ERBNoInstanceVariablesInPartialsRule,
ERBStrictLocalsCommentSyntaxRule,
ERBStrictLocalsRequiredRule,

HerbDisableCommentValidRuleNameRule,
HerbDisableCommentNoRedundantAllRule,
HerbDisableCommentNoDuplicateRulesRule,
HerbDisableCommentMissingRulesRule,
HerbDisableCommentMalformedRule,
HerbDisableCommentMissingRulesRule,
HerbDisableCommentNoDuplicateRulesRule,
HerbDisableCommentNoRedundantAllRule,
HerbDisableCommentUnnecessaryRule,
HerbDisableCommentValidRuleNameRule,

HTMLAllowedScriptTypeRule,
HTMLAnchorRequireHrefRule,
Expand Down Expand Up @@ -151,9 +156,10 @@ export const rules: RuleClass[] = [
HTMLNoUnderscoresInAttributeNamesRule,
HTMLRequireClosingTagsRule,
HTMLTagNameLowercaseRule,
TurboPermanentRequireIdRule,

ParserNoErrorsRule,

SVGTagNameCapitalizationRule,

ParserNoErrorsRule,
TurboPermanentRequireIdRule,
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { ParserRule } from "../types.js"
import { BaseRuleVisitor } from "./rule-utils.js"
import { isERBOpenTagNode, isERBOutputNode } from "@herb-tools/core"

import type { UnboundLintOffense, LintContext, FullRuleConfig } from "../types.js"
import type { ParseResult, HTMLElementNode, ParserOptions } from "@herb-tools/core"

class ActionViewNoSilentHelperVisitor extends BaseRuleVisitor {
visitHTMLElementNode(node: HTMLElementNode): void {
this.checkActionViewHelper(node)
super.visitHTMLElementNode(node)
}

private checkActionViewHelper(node: HTMLElementNode): void {
if (!node.element_source || node.element_source === "HTML") return
if (!isERBOpenTagNode(node.open_tag)) return
if (isERBOutputNode(node.open_tag)) return

const tagOpening = node.open_tag.tag_opening?.value

if (!tagOpening) return

const helperName = node.element_source.includes("#")
? node.element_source.split("#").pop()
: node.element_source

this.addOffense(
`Avoid using \`${tagOpening} %>\` with \`${helperName}\`. Use \`<%= %>\` to ensure the helper's output is rendered.`,
node.open_tag.location,
)
}
}

export class ActionViewNoSilentHelperRule extends ParserRule {
static ruleName = "actionview-no-silent-helper"

get defaultConfig(): FullRuleConfig {
return {
enabled: true,
severity: "error"
}
}

get parserOptions(): Partial<ParserOptions> {
return {
track_whitespace: true,
action_view_helpers: true,
}
}

check(result: ParseResult, context?: Partial<LintContext>): UnboundLintOffense[] {
const visitor = new ActionViewNoSilentHelperVisitor(this.ruleName, context)

visitor.visit(result.value)

return visitor.offenses
}
}
Loading
Loading