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
33 changes: 33 additions & 0 deletions javascript/packages/linter/docs/rules/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,16 @@ This page contains documentation for all Herb Linter rules.

## Available Rules

#### Action View

- [`actionview-no-silent-helper`](./actionview-no-silent-helper.md) - Disallow silent ERB tags for Action View helpers
- [`actionview-no-silent-render`](./actionview-no-silent-render.md) - Disallow calling `render` without outputting the result
- [`actionview-no-void-element-content`](./actionview-no-void-element-content.md) - Disallow content arguments for void Action View elements
- [`actionview-strict-locals-first-line`](./actionview-strict-locals-first-line.md) - Require strict locals on the first line of partials with a blank line after.


#### ERB

- [`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 Expand Up @@ -36,12 +43,21 @@ This page contains documentation for all Herb Linter rules.
- [`erb-require-whitespace-inside-tags`](./erb-require-whitespace-inside-tags.md) - Requires whitespace around ERB tags
- [`erb-right-trim`](./erb-right-trim.md) - Enforce consistent right-trimming syntax.
- [`erb-strict-locals-comment-syntax`](./erb-strict-locals-comment-syntax.md) - Enforce strict locals comment syntax.
- [`erb-strict-locals-required`](./erb-strict-locals-required.md) - Require strict locals in Rails partials.


#### Herb

- [`herb-disable-comment-malformed`](./herb-disable-comment-malformed.md) - Detect malformed `herb:disable` comments.
- [`herb-disable-comment-missing-rules`](./herb-disable-comment-missing-rules.md) - Require rule names in `herb:disable` comments.
- [`herb-disable-comment-no-duplicate-rules`](./herb-disable-comment-no-duplicate-rules.md) - Disallow duplicate rule names in `herb:disable` comments.
- [`herb-disable-comment-no-redundant-all`](./herb-disable-comment-no-redundant-all.md) - Disallow redundant use of `all` in `herb:disable` comments.
- [`herb-disable-comment-unnecessary`](./herb-disable-comment-unnecessary.md) - Detect unnecessary `herb:disable` comments.
- [`herb-disable-comment-valid-rule-name`](./herb-disable-comment-valid-rule-name.md) - Validate rule names in `herb:disable` comments.


#### HTML

- [`html-allowed-script-type`](./html-allowed-script-type.md) - Restrict allowed `type` attributes for `<script>` tags
- [`html-anchor-require-href`](./html-anchor-require-href.md) - Requires an href attribute on anchor tags
- [`html-aria-attribute-must-be-valid`](./html-aria-attribute-must-be-valid.md) - Disallow invalid or unknown `aria-*` attributes.
Expand Down Expand Up @@ -77,11 +93,28 @@ This page contains documentation for all Herb Linter rules.
- [`html-no-underscores-in-attribute-names`](./html-no-underscores-in-attribute-names.md) - Disallow underscores in HTML attribute names
- [`html-require-script-nonce`](./html-require-script-nonce.md) - Require `nonce` attribute on script tags and helpers
- [`html-tag-name-lowercase`](./html-tag-name-lowercase.md) - Enforces lowercase tag names in HTML


#### Parser

- [`parser-no-errors`](./parser-no-errors.md) - Disallow parser errors in HTML+ERB documents


#### Source

- [`source-indentation`](./source-indentation.md) - Indent with spaces instead of tabs.


#### SVG

- [`svg-tag-name-capitalization`](./svg-tag-name-capitalization.md) - Enforces proper camelCase capitalization for SVG elements


#### Turbo

- [`turbo-permanent-require-id`](./turbo-permanent-require-id.md) - Require `id` attribute on elements with `data-turbo-permanent`


## Contributing

To add a new linter rule you can scaffold a new rule by running:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# Linter Rule: Require strict locals on the first line with a blank line after

**Rule:** `actionview-strict-locals-first-line`

## Description

Requires that the strict locals declaration:

1. Appears on the **first line** of a Rails partial template
2. Is followed by a **blank line** before any content

A partial is any template whose filename begins with an underscore (e.g. `_card.html.erb`).

## Rationale

While Rails accepts strict locals declarations anywhere in a partial, placing them at the very top, followed by a blank line, makes the partial's expected locals immediately visible and visually separated from the template body. This mirrors conventions like `# frozen_string_literal: true` in Ruby files.

Enforcing this placement ensures that locals are the first thing you see when opening the file, that the partial's public API is clearly separated from its content, and consistent across all partials in the codebase.

## Examples

### ✅ Good

```erb [app/views/users/_card.html.erb]
<%# locals: (user:) %>

<div class="user-card">
<%= user.name %>
</div>
```

### 🚫 Bad

Strict locals not on the first line:

```erb [app/views/users/_card.html.erb]
<div class="user-card">
<%# locals: (user:) %>
<%= user.name %>
</div>
```

Strict locals after a leading blank line:

```erb [app/views/users/_card.html.erb]

<%# locals: (user:) %>

<div class="user-card">
<%= user.name %>
</div>
```

Strict locals on line 1 but no blank line before content:

```erb [app/views/users/_card.html.erb]
<%# locals: (user:) %>
<div class="user-card">
<%= user.name %>
</div>
```

## References

- [Action View - Strict Locals](https://guides.rubyonrails.org/action_view_overview.html#strict-locals)
2 changes: 2 additions & 0 deletions javascript/packages/linter/src/rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { RuleClass } from "./types.js"
import { ActionViewNoSilentHelperRule } from "./rules/actionview-no-silent-helper.js"
import { ActionViewNoSilentRenderRule } from "./rules/actionview-no-silent-render.js"
import { ActionViewNoVoidElementContentRule } from "./rules/actionview-no-void-element-content.js"
import { ActionViewStrictLocalsFirstLineRule } from "./rules/actionview-strict-locals-first-line.js"

import { ERBCommentSyntax } from "./rules/erb-comment-syntax.js";
import { ERBNoCaseNodeChildrenRule } from "./rules/erb-no-case-node-children.js"
Expand Down Expand Up @@ -93,6 +94,7 @@ export const rules: RuleClass[] = [
ActionViewNoSilentHelperRule,
ActionViewNoSilentRenderRule,
ActionViewNoVoidElementContentRule,
ActionViewStrictLocalsFirstLineRule,

ERBCommentSyntax,
ERBNoCaseNodeChildrenRule,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { BaseRuleVisitor } from "./rule-utils.js"
import { ParserRule } from "../types.js"
import { createLiteral } from "@herb-tools/core"

import { isERBStrictLocalsNode, isHTMLTextNode } from "@herb-tools/core"
import { isPartialFile } from "./file-utils.js"

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

class ActionViewStrictLocalsFirstLineVisitor extends BaseRuleVisitor {
visitDocumentNode(node: DocumentNode) {
const { children } = node

for (let i = 0; i < children.length; i++) {
const child = children[i]
if (!isERBStrictLocalsNode(child)) continue

const next = children[i + 1]
if (!next) break

if (isHTMLTextNode(next)) {
if (!next.content.startsWith("\n\n") && children[i + 2]) {
this.addOffense(
"Add a blank line after the strict locals declaration.",
child.location
)
}
} else {
this.addOffense(
"Add a blank line after the strict locals declaration.",
child.location
)
}

break
}

this.visitChildNodes(node)
}

visitERBStrictLocalsNode(node: ERBStrictLocalsNode): void {
if (isPartialFile(this.context.fileName) !== true) return

if (node.location.start.line !== 1) {
this.addOffense(
"Strict locals declaration must be on the first line of the partial.",
node.location
)
}
}
}

export class ActionViewStrictLocalsFirstLineRule extends ParserRule {
static autocorrectable = true
static ruleName = "actionview-strict-locals-first-line"
static introducedIn = this.version("unreleased")

get parserOptions() {
return { strict_locals: true }
}

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

check(result: ParseResult, context?: Partial<LintContext>): UnboundLintOffense[] {
if (isPartialFile(context?.fileName) !== true) return []

const visitor = new ActionViewStrictLocalsFirstLineVisitor(this.ruleName, context)
visitor.visit(result.value)

return visitor.offenses
}

autofix(offense: LintOffense, result: ParseResult): ParseResult | null {
const children = result.value.children

const index = children.findIndex(child =>
child.location.start.line === offense.location.start.line &&
child.location.start.column === offense.location.start.column
)

if (index === -1) return null

if (offense.location.start.line === 1) {
const next = children[index + 1]

if (isHTMLTextNode(next)) {
children.splice(index + 1, 1, createLiteral("\n\n"))
} else {
children.splice(index + 1, 0, createLiteral("\n\n"))
}
} else {
const [node] = children.splice(index, 1)

if (index > 0) {
const previous = children[index - 1]

if (isHTMLTextNode(previous) && /^\s*$/.test(previous.content)) {
children.splice(index - 1, 1)
}
}

const firstChild = children[0]

if (!firstChild || !isHTMLTextNode(firstChild) || !firstChild.content.startsWith("\n\n")) {
children.unshift(createLiteral("\n\n"))
}

children.unshift(node)
}

return result
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { ParserRule } from "../types.js"
import { Location, ERBStrictLocalsNode, LiteralNode } from "@herb-tools/core"
import { Location, ERBStrictLocalsNode, createLiteral } from "@herb-tools/core"
import { BaseRuleVisitor } from "./rule-utils.js"

import { isPartialFile } from "./file-utils.js"
Expand Down Expand Up @@ -39,7 +39,7 @@ export class ERBStrictLocalsRequiredRule extends ParserRule {

if (visitor.foundStrictLocals) return []

const document = result.value as DocumentNode
const document = result.value
const firstChild = document.children[0]
const end = firstChild ? firstChild.location.end : Location.zero.end

Expand All @@ -52,12 +52,7 @@ export class ERBStrictLocalsRequiredRule extends ParserRule {
}

autofix(_offense: LintOffense, result: ParseResult): ParseResult | null {
(result.value.children as unknown[]).unshift(LiteralNode.from({
type: "AST_LITERAL_NODE",
location: Location.zero,
errors: [],
content: "<%# locals: () %>\n\n",
}))
result.value.children.unshift(createLiteral("<%# locals: () %>\n\n"))

return result
}
Expand Down
1 change: 1 addition & 0 deletions javascript/packages/linter/src/rules/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export * from "./herb-disable-comment-base.js"
export * from "./actionview-no-silent-helper.js"
export * from "./actionview-no-silent-render.js"
export * from "./actionview-no-void-element-content.js"
export * from "./actionview-strict-locals-first-line.js"

export * from "./erb-comment-syntax.js"
export * from "./erb-no-case-node-children.js"
Expand Down
Loading
Loading