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 `