Skip to content
Open
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
8 changes: 5 additions & 3 deletions javascript/packages/linter/docs/rules/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@ This page contains documentation for all Herb Linter rules.

- [`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-debug-output`](./erb-no-debug-output.md) - Disallow debug output methods (`p`, `pp`, `puts`, `print`, `debug`) in ERB templates
- [`erb-no-conditional-html-element`](./erb-no-conditional-html-element.md) - Disallow conditional HTML elements
- [`erb-no-debug-output`](./erb-no-debug-output.md) - Disallow debug output methods (`p`, `pp`, `puts`, `print`, `debug`) in ERB templates
- [`erb-no-duplicate-branch-elements`](./erb-no-duplicate-branch-elements.md) - Disallow duplicate elements across conditional branches
- [`erb-no-empty-control-flow`](./erb-no-empty-control-flow.md) - Disallow empty ERB control flow blocks
- [`erb-no-empty-tags`](./erb-no-empty-tags.md) - Disallow empty ERB tags
Expand Down Expand Up @@ -100,11 +100,13 @@ This page contains documentation for all Herb Linter rules.
- [`html-no-nested-links`](./html-no-nested-links.md) - Prevents nested anchor tags
- [`html-no-positive-tab-index`](./html-no-positive-tab-index.md) - Avoid positive `tabindex` values
- [`html-no-self-closing`](./html-no-self-closing.md) - Disallow self closing tags
- [`html-no-unescaped-entities`](./html-no-unescaped-entities.md) - Disallow unescaped HTML entities
- [`html-no-unknown-tag`](./html-no-unknown-tag.md) - Disallow unknown HTML tags
- [`html-no-space-in-tag`](./html-no-space-in-tag.md) - Disallow spaces in HTML tags
- [`html-no-style-attributes`](./html-no-style-attributes.md) - Disallow inline `style` attributes
- [`html-no-style-elements`](./html-no-style-elements.md) - Disallow inline `<style>` tags
- [`html-no-title-attribute`](./html-no-title-attribute.md) - Avoid using the `title` attribute
- [`html-no-underscores-in-attribute-names`](./html-no-underscores-in-attribute-names.md) - Disallow underscores in HTML attribute names
- [`html-no-unescaped-entities`](./html-no-unescaped-entities.md) - Disallow unescaped HTML entities
- [`html-no-unknown-tag`](./html-no-unknown-tag.md) - Disallow unknown HTML tags
- [`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

Expand Down
38 changes: 38 additions & 0 deletions javascript/packages/linter/docs/rules/html-no-style-attributes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# Linter Rule: Disallow inline `style` attributes

**Rule:** `html-no-style-attributes`

## Description

Disallow the use of inline `style` attributes on HTML elements. All styling should be applied via external stylesheets or class-based styling.

## Rationale

Inline `style` attributes make templates harder to maintain, promote duplication, and prevent adoption of strict CSP policies (`style-src 'self'`). Using CSS classes and external stylesheets improves maintainability and security.

## Examples

### ✅ Good

```erb
<button class="btn btn-primary">Submit</button>
```

```erb
<div data-controller="hello" data-action="click->hello#greet">Content</div>
```

### 🚫 Bad

```erb
<button style="color: red;">Submit</button>
```

```erb
<div style="<%= custom_styles %>">Content</div>
```

## References

- Inspired by [@pushcx](https://bsky.app/profile/push.cx/post/3lsfddauapk2o)
- [MDN: Content Security Policy (CSP)](https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP)
53 changes: 53 additions & 0 deletions javascript/packages/linter/docs/rules/html-no-style-elements.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# Linter Rule: Disallow inline `<style>` tags

**Rule:** `html-no-style-elements`

## Description

Disallow the use of inline `<style>` tags in HTML templates. All styling should be applied via external stylesheets.

## Rationale

Inline `<style>` tags make templates harder to maintain, promote duplication, and prevent adoption of strict CSP policies (`style-src 'self'`). Centralizing styles via external stylesheets improves maintainability and security.

This rule enforces:

- No `<style>` tags embedded directly in templates.
- No `style` tags generated via `content_tag` or `tag` helpers.

## Examples

### ✅ Good

```erb
<head>
<%= stylesheet_link_tag "application" %>
</head>
```

### 🚫 Bad

```erb
<head>
<style>
.danger { color: red; }
</style>
</head>
```

```erb
<%= content_tag :style do %>
.danger { color: red; }
<% end %>
```

```erb
<%= tag.style do %>
.danger { color: red; }
<% end %>
```

## References

- Inspired by [@pushcx](https://bsky.app/profile/push.cx/post/3lsfddauapk2o)
- [MDN: Content Security Policy (CSP)](https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP)
22 changes: 13 additions & 9 deletions javascript/packages/linter/src/rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,9 @@ import { ActionViewStrictLocalsPartialOnlyRule } from "./rules/actionview-strict

import { ERBCommentSyntax } from "./rules/erb-comment-syntax.js";
import { ERBNoCaseNodeChildrenRule } from "./rules/erb-no-case-node-children.js"
import { ERBNoDebugOutputRule } from "./rules/erb-no-debug-output.js"
import { ERBNoConditionalHTMLElementRule } from "./rules/erb-no-conditional-html-element.js"
import { ERBNoConditionalOpenTagRule } from "./rules/erb-no-conditional-open-tag.js"
import { ERBNoDebugOutputRule } from "./rules/erb-no-debug-output.js"
import { ERBNoDuplicateBranchElementsRule } from "./rules/erb-no-duplicate-branch-elements.js"
import { ERBNoEmptyControlFlowRule } from "./rules/erb-no-empty-control-flow.js"
import { ERBNoEmptyTagsRule } from "./rules/erb-no-empty-tags.js"
Expand Down Expand Up @@ -85,11 +85,13 @@ import { HTMLNoEmptyHeadingsRule } from "./rules/html-no-empty-headings.js"
import { HTMLNoNestedLinksRule } from "./rules/html-no-nested-links.js"
import { HTMLNoPositiveTabIndexRule } from "./rules/html-no-positive-tab-index.js"
import { HTMLNoSelfClosingRule } from "./rules/html-no-self-closing.js"
import { HTMLNoUnescapedEntitiesRule } from "./rules/html-no-unescaped-entities.js"
import { HTMLNoUnknownTagRule } from "./rules/html-no-unknown-tag.js"
import { HTMLNoSpaceInTagRule } from "./rules/html-no-space-in-tag.js"
import { HTMLNoStyleAttributesRule } from "./rules/html-no-style-attributes.js"
import { HTMLNoStyleElementsRule } from "./rules/html-no-style-elements.js"
import { HTMLNoTitleAttributeRule } from "./rules/html-no-title-attribute.js"
import { HTMLNoUnderscoresInAttributeNamesRule } from "./rules/html-no-underscores-in-attribute-names.js"
import { HTMLNoUnescapedEntitiesRule } from "./rules/html-no-unescaped-entities.js"
import { HTMLNoUnknownTagRule } from "./rules/html-no-unknown-tag.js"
import { HTMLRequireClosingTagsRule } from "./rules/html-require-closing-tags.js"
import { HTMLRequireScriptNonceRule } from "./rules/html-require-script-nonce.js"
import { HTMLTagNameLowercaseRule } from "./rules/html-tag-name-lowercase.js"
Expand All @@ -116,11 +118,11 @@ export const rules: RuleClass[] = [

ERBCommentSyntax,
ERBNoCaseNodeChildrenRule,
ERBNoDebugOutputRule,
ERBNoEmptyControlFlowRule,
ERBNoConditionalHTMLElementRule,
ERBNoConditionalOpenTagRule,
ERBNoDebugOutputRule,
ERBNoDuplicateBranchElementsRule,
ERBNoEmptyControlFlowRule,
ERBNoEmptyTagsRule,
ERBNoExtraNewLineRule,
ERBNoExtraWhitespaceRule,
Expand All @@ -133,15 +135,15 @@ export const rules: RuleClass[] = [
ERBNoOutputInAttributePositionRule,
ERBNoRawOutputInAttributeValueRule,
ERBNoSilentStatementRule,
ERBNoUnusedExpressionsRule,
ERBNoUnusedLiteralsRule,
ERBNoSilentTagInAttributeNameRule,
ERBNoStatementInScriptRule,
ERBNoThenInControlFlowRule,
ERBNoTrailingWhitespaceRule,
ERBNoUnsafeJSAttributeRule,
ERBNoUnsafeRawRule,
ERBNoUnsafeScriptInterpolationRule,
ERBNoUnusedExpressionsRule,
ERBNoUnusedLiteralsRule,
ERBPreferDirectOutputRule,
ERBPreferImageTagHelperRule,
ERBRequireTrailingNewlineRule,
Expand Down Expand Up @@ -188,11 +190,13 @@ export const rules: RuleClass[] = [
HTMLNoNestedLinksRule,
HTMLNoPositiveTabIndexRule,
HTMLNoSelfClosingRule,
HTMLNoUnescapedEntitiesRule,
HTMLNoUnknownTagRule,
HTMLNoSpaceInTagRule,
HTMLNoStyleAttributesRule,
HTMLNoStyleElementsRule,
HTMLNoTitleAttributeRule,
HTMLNoUnderscoresInAttributeNamesRule,
HTMLNoUnescapedEntitiesRule,
HTMLNoUnknownTagRule,
HTMLRequireClosingTagsRule,
HTMLRequireScriptNonceRule,
HTMLTagNameLowercaseRule,
Expand Down
47 changes: 47 additions & 0 deletions javascript/packages/linter/src/rules/html-no-style-attributes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { getAttributeName } from "@herb-tools/core"
import type { ParseResult, ParserOptions, HTMLAttributeNode } from "@herb-tools/core"

import { BaseRuleVisitor } from "./rule-utils.js"
import type { UnboundLintOffense, LintContext, FullRuleConfig } from "../types.js"
import { ParserRule } from "../types.js"

class HTMLNoStyleAttributesVisitor extends BaseRuleVisitor {
visitHTMLAttributeNode(node: HTMLAttributeNode): void {
const attributeName = getAttributeName(node)

if (attributeName && attributeName.toLowerCase() === "style") {
this.addOffense(
"Avoid inline `style` attribute. Use CSS classes or an external stylesheet instead.",
node.location,
)
}

super.visitHTMLAttributeNode(node)
}
}

export class HTMLNoStyleAttributesRule extends ParserRule {
static ruleName = "html-no-style-attributes"
static introducedIn = this.version("unreleased")

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

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

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

visitor.visit(result.value)

return visitor.offenses
}
}
59 changes: 59 additions & 0 deletions javascript/packages/linter/src/rules/html-no-style-elements.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { getTagLocalName, isERBOpenTagNode } from "@herb-tools/core"
import type { ParseResult, ParserOptions, HTMLElementNode } from "@herb-tools/core"

import { BaseRuleVisitor } from "./rule-utils.js"
import type { UnboundLintOffense, LintContext, FullRuleConfig } from "../types.js"
import { ParserRule } from "../types.js"

const STYLESHEET_LINK_TAG_ELEMENT_SOURCE = "ActionView::Helpers::AssetTagHelper#stylesheet_link_tag"

class HTMLNoStyleElementsVisitor extends BaseRuleVisitor {
visitHTMLElementNode(node: HTMLElementNode): void {
if (getTagLocalName(node) === "style") {
this.checkInlineStyle(node)
}

super.visitHTMLElementNode(node)
}

private checkInlineStyle(node: HTMLElementNode): void {
if (this.isStylesheetLinkTagElement(node)) return

this.addOffense(
"Avoid inline `<style>` tags. Use `stylesheet_link_tag` to include external stylesheets instead.",
node.open_tag!.location,
)
}

private isStylesheetLinkTagElement(node: HTMLElementNode): boolean {
if (!isERBOpenTagNode(node.open_tag)) return false

return node.element_source === STYLESHEET_LINK_TAG_ELEMENT_SOURCE
}
}

export class HTMLNoStyleElementsRule extends ParserRule {
static ruleName = "html-no-style-elements"
static introducedIn = this.version("unreleased")

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

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

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

visitor.visit(result.value)

return visitor.offenses
}
}
8 changes: 5 additions & 3 deletions javascript/packages/linter/src/rules/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ export * from "./actionview-strict-locals-partial-only.js"

export * from "./erb-comment-syntax.js"
export * from "./erb-no-case-node-children.js"
export * from "./erb-no-debug-output.js"
export * from "./erb-no-conditional-open-tag.js"
export * from "./erb-no-debug-output.js"
export * from "./erb-no-duplicate-branch-elements.js"
export * from "./erb-no-empty-control-flow.js"
export * from "./erb-no-empty-tags.js"
Expand Down Expand Up @@ -87,11 +87,13 @@ export * from "./html-no-empty-headings.js"
export * from "./html-no-nested-links.js"
export * from "./html-no-positive-tab-index.js"
export * from "./html-no-self-closing.js"
export * from "./html-no-unescaped-entities.js"
export * from "./html-no-unknown-tag.js"
export * from "./html-no-space-in-tag.js"
export * from "./html-no-style-attributes.js"
export * from "./html-no-style-elements.js"
export * from "./html-no-title-attribute.js"
export * from "./html-no-underscores-in-attribute-names.js"
export * from "./html-no-unescaped-entities.js"
export * from "./html-no-unknown-tag.js"
export * from "./html-require-closing-tags.js"
export * from "./html-require-script-nonce.js"
export * from "./html-tag-name-lowercase.js"
Expand Down
Loading
Loading