diff --git a/javascript/packages/linter/docs/rules/README.md b/javascript/packages/linter/docs/rules/README.md
index a506bccaf..e00d1a14b 100644
--- a/javascript/packages/linter/docs/rules/README.md
+++ b/javascript/packages/linter/docs/rules/README.md
@@ -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
@@ -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 `
+
+```
+
+```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)
diff --git a/javascript/packages/linter/src/rules.ts b/javascript/packages/linter/src/rules.ts
index 7a4f935b3..6e36f3e23 100644
--- a/javascript/packages/linter/src/rules.ts
+++ b/javascript/packages/linter/src/rules.ts
@@ -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"
@@ -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"
@@ -116,11 +118,11 @@ export const rules: RuleClass[] = [
ERBCommentSyntax,
ERBNoCaseNodeChildrenRule,
- ERBNoDebugOutputRule,
- ERBNoEmptyControlFlowRule,
ERBNoConditionalHTMLElementRule,
ERBNoConditionalOpenTagRule,
+ ERBNoDebugOutputRule,
ERBNoDuplicateBranchElementsRule,
+ ERBNoEmptyControlFlowRule,
ERBNoEmptyTagsRule,
ERBNoExtraNewLineRule,
ERBNoExtraWhitespaceRule,
@@ -133,8 +135,6 @@ export const rules: RuleClass[] = [
ERBNoOutputInAttributePositionRule,
ERBNoRawOutputInAttributeValueRule,
ERBNoSilentStatementRule,
- ERBNoUnusedExpressionsRule,
- ERBNoUnusedLiteralsRule,
ERBNoSilentTagInAttributeNameRule,
ERBNoStatementInScriptRule,
ERBNoThenInControlFlowRule,
@@ -142,6 +142,8 @@ export const rules: RuleClass[] = [
ERBNoUnsafeJSAttributeRule,
ERBNoUnsafeRawRule,
ERBNoUnsafeScriptInterpolationRule,
+ ERBNoUnusedExpressionsRule,
+ ERBNoUnusedLiteralsRule,
ERBPreferDirectOutputRule,
ERBPreferImageTagHelperRule,
ERBRequireTrailingNewlineRule,
@@ -188,11 +190,13 @@ export const rules: RuleClass[] = [
HTMLNoNestedLinksRule,
HTMLNoPositiveTabIndexRule,
HTMLNoSelfClosingRule,
- HTMLNoUnescapedEntitiesRule,
- HTMLNoUnknownTagRule,
HTMLNoSpaceInTagRule,
+ HTMLNoStyleAttributesRule,
+ HTMLNoStyleElementsRule,
HTMLNoTitleAttributeRule,
HTMLNoUnderscoresInAttributeNamesRule,
+ HTMLNoUnescapedEntitiesRule,
+ HTMLNoUnknownTagRule,
HTMLRequireClosingTagsRule,
HTMLRequireScriptNonceRule,
HTMLTagNameLowercaseRule,
diff --git a/javascript/packages/linter/src/rules/html-no-style-attributes.ts b/javascript/packages/linter/src/rules/html-no-style-attributes.ts
new file mode 100644
index 000000000..cbb4790ba
--- /dev/null
+++ b/javascript/packages/linter/src/rules/html-no-style-attributes.ts
@@ -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 {
+ return {
+ action_view_helpers: true,
+ }
+ }
+
+ check(result: ParseResult, context?: Partial): UnboundLintOffense[] {
+ const visitor = new HTMLNoStyleAttributesVisitor(this.ruleName, context)
+
+ visitor.visit(result.value)
+
+ return visitor.offenses
+ }
+}
diff --git a/javascript/packages/linter/src/rules/html-no-style-elements.ts b/javascript/packages/linter/src/rules/html-no-style-elements.ts
new file mode 100644
index 000000000..feb17551f
--- /dev/null
+++ b/javascript/packages/linter/src/rules/html-no-style-elements.ts
@@ -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 `")
+ })
+
+ test("fails with style tag", () => {
+ expectWarning("Avoid inline `
+ `)
+ })
+
+ test("fails with style tag containing ERB comment", () => {
+ expectWarning("Avoid inline `
+ `)
+ })
+ })
+
+ describe("action view helpers", () => {
+ test("passes with stylesheet_link_tag", () => {
+ expectNoOffenses(dedent`
+ <%= stylesheet_link_tag "application" %>
+ `)
+ })
+
+ test("fails with content_tag helper", () => {
+ expectWarning("Avoid inline `