diff --git a/javascript/packages/linter/docs/rules/README.md b/javascript/packages/linter/docs/rules/README.md index 08503a87a..f8d511966 100644 --- a/javascript/packages/linter/docs/rules/README.md +++ b/javascript/packages/linter/docs/rules/README.md @@ -26,6 +26,7 @@ This page contains documentation for all Herb Linter rules. - [`html-avoid-both-disabled-and-aria-disabled`](./html-avoid-both-disabled-and-aria-disabled.md) - Avoid using both `disabled` and `aria-disabled` attributes - [`html-body-only-elements`](./html-body-only-elements.md) - Require content elements inside ``. - [`html-boolean-attributes-no-value`](./html-boolean-attributes-no-value.md) - Prevents values on boolean attributes +- [`html-head-only-elements`](./html-head-only-elements.md) - Require head-scoped elements inside ``. - [`html-iframe-has-title`](./html-iframe-has-title.md) - `iframe` elements must have a `title` attribute - [`html-input-require-autocomplete`](./html-input-require-autocomplete.md) - Require `autocomplete` attributes on `` tags. - [`html-img-require-alt`](./html-img-require-alt.md) - Requires `alt` attributes on `` tags diff --git a/javascript/packages/linter/docs/rules/html-head-only-elements.md b/javascript/packages/linter/docs/rules/html-head-only-elements.md new file mode 100644 index 000000000..8a12b47f8 --- /dev/null +++ b/javascript/packages/linter/docs/rules/html-head-only-elements.md @@ -0,0 +1,81 @@ +# Linter Rule: Require head-scoped elements inside `` + +**Rule:** `html-head-only-elements` + +## Description + +Enforce that certain elements only appear inside the `` section of the document. + +Elements like ``, `<meta>`, `<base>`, `<link>`, and `<style>` are permitted only inside the `<head>` element. They must not appear inside `<body>` or outside of `<html>`. Placing them elsewhere produces invalid HTML and relies on browser error correction. + +> [!NOTE] Exception +> `<title>` elements are allowed inside `<svg>` elements for accessibility purposes. + +## Rationale + +The HTML specification requires certain elements to appear only in the `<head>` section because they affect document metadata, resource loading, or global behavior: + +Placing these elements outside `<head>` leads to invalid HTML and undefined behavior across browsers. + + +## Examples + +### ✅ Good + +```erb +<head> + <title>My Page + + + + + + +

Welcome

+ +``` + +```erb + + <%= csrf_meta_tags %> + <%= csp_meta_tag %> + <%= favicon_link_tag 'favicon.ico' %> + <%= stylesheet_link_tag "application", "data-turbo-track": "reload" %> + <%= javascript_include_tag "application", "data-turbo-track": "reload", defer: true %> + + <%= content_for?(:title) ? yield(:title) : "Default Title" %> + +``` + +```erb + + + Chart Title + + + +``` + +### 🚫 Bad + +```erb + + My Page + + + + + +

Welcome

+ +``` + +```erb + + <%= content_for?(:title) ? yield(:title) : "Default Title" %> + +``` + +## References + +* [HTML Living Standard - The `head` element](https://html.spec.whatwg.org/multipage/semantics.html#the-head-element) diff --git a/javascript/packages/linter/src/default-rules.ts b/javascript/packages/linter/src/default-rules.ts index 2cbadb3f4..1b9866520 100644 --- a/javascript/packages/linter/src/default-rules.ts +++ b/javascript/packages/linter/src/default-rules.ts @@ -23,6 +23,7 @@ import { HTMLAttributeValuesRequireQuotesRule } from "./rules/html-attribute-val import { HTMLAvoidBothDisabledAndAriaDisabledRule } from "./rules/html-avoid-both-disabled-and-aria-disabled.js" import { HTMLBodyOnlyElementsRule } from "./rules/html-body-only-elements.js" import { HTMLBooleanAttributesNoValueRule } from "./rules/html-boolean-attributes-no-value.js" +import { HTMLHeadOnlyElementsRule } from "./rules/html-head-only-elements.js" import { HTMLIframeHasTitleRule } from "./rules/html-iframe-has-title.js" import { HTMLImgRequireAltRule } from "./rules/html-img-require-alt.js" import { HTMLInputRequireAutocompleteRule } from "./rules/html-input-require-autocomplete.js" @@ -69,6 +70,7 @@ export const defaultRules: RuleClass[] = [ HTMLAvoidBothDisabledAndAriaDisabledRule, HTMLBodyOnlyElementsRule, HTMLBooleanAttributesNoValueRule, + HTMLHeadOnlyElementsRule, HTMLIframeHasTitleRule, HTMLImgRequireAltRule, HTMLInputRequireAutocompleteRule, diff --git a/javascript/packages/linter/src/rules/html-body-only-elements.ts b/javascript/packages/linter/src/rules/html-body-only-elements.ts index b1978ae50..302140bb7 100644 --- a/javascript/packages/linter/src/rules/html-body-only-elements.ts +++ b/javascript/packages/linter/src/rules/html-body-only-elements.ts @@ -5,26 +5,37 @@ import type { LintOffense, LintContext } from "../types.js" import type { HTMLElementNode, ParseResult } from "@herb-tools/core" class HTMLBodyOnlyElementsVisitor extends BaseRuleVisitor { - private isInHead = false + private elementStack: string[] = [] visitHTMLElementNode(node: HTMLElementNode): void { - const tagName = getTagName(node.open_tag) + const tagName = getTagName(node.open_tag)?.toLowerCase() if (!tagName) return - const previousIsInHead = this.isInHead - if (tagName.toLowerCase() === "head") this.isInHead = true - - if (this.isInHead && isBodyOnlyTag(tagName)) { - this.addOffense( - `Element \`<${tagName}>\` must be placed inside the \`\` tag.`, - node.location, - "error" - ) - } + this.checkBodyOnlyElement(node, tagName) + this.elementStack.push(tagName) this.visitChildNodes(node) + this.elementStack.pop() + } + + private checkBodyOnlyElement(node: HTMLElementNode, tagName: string): void { + if (this.insideBody) return + if (!this.insideHead) return + if (!isBodyOnlyTag(tagName)) return + + this.addOffense( + `Element \`<${tagName}>\` must be placed inside the \`\` tag.`, + node.location, + "error" + ) + } - this.isInHead = previousIsInHead + private get insideBody(): boolean { + return this.elementStack.includes("body") + } + + private get insideHead(): boolean { + return this.elementStack.includes("head") } } @@ -32,6 +43,13 @@ export class HTMLBodyOnlyElementsRule extends ParserRule { static autocorrectable = false name = "html-body-only-elements" + isEnabled(_result: ParseResult, context?: Partial): boolean { + if (context?.fileName?.endsWith(".xml")) return false + if (context?.fileName?.endsWith(".xml.erb")) return false + + return true + } + check(result: ParseResult, context?: Partial): LintOffense[] { const visitor = new HTMLBodyOnlyElementsVisitor(this.name, context) diff --git a/javascript/packages/linter/src/rules/html-head-only-elements.ts b/javascript/packages/linter/src/rules/html-head-only-elements.ts new file mode 100644 index 000000000..35ee2638f --- /dev/null +++ b/javascript/packages/linter/src/rules/html-head-only-elements.ts @@ -0,0 +1,60 @@ +import { ParserRule } from "../types" +import { BaseRuleVisitor, getTagName, isHeadOnlyTag } from "./rule-utils" + +import type { ParseResult, HTMLElementNode } from "@herb-tools/core" +import type { LintOffense, LintContext } from "../types" + +class HeadOnlyElementsVisitor extends BaseRuleVisitor { + private elementStack: string[] = [] + + visitHTMLElementNode(node: HTMLElementNode): void { + const tagName = getTagName(node)?.toLowerCase() + if (!tagName) return + + this.checkHeadOnlyElement(node, tagName) + + this.elementStack.push(tagName) + this.visitChildNodes(node) + this.elementStack.pop() + } + + private checkHeadOnlyElement(node: HTMLElementNode, tagName: string): void { + if (this.insideHead) return + if (!isHeadOnlyTag(tagName)) return + if (tagName === "title" && this.insideSVG) return + + this.addOffense( + `Element \`<${tagName}>\` must be placed inside the \`\` tag.`, + node.location, + "error" + ) + } + + private get insideHead(): boolean { + return this.elementStack.includes("head") + } + + private get insideSVG(): boolean { + return this.elementStack.includes("svg") + } +} + +export class HTMLHeadOnlyElementsRule extends ParserRule { + static autocorrectable = false + name = "html-head-only-elements" + + isEnabled(_result: ParseResult, context?: Partial): boolean { + if (context?.fileName?.endsWith(".xml")) return false + if (context?.fileName?.endsWith(".xml.erb")) return false + + return true + } + + check(result: ParseResult, context?: Partial): LintOffense[] { + const visitor = new HeadOnlyElementsVisitor(this.name, context) + + visitor.visit(result.value) + + return visitor.offenses + } +} diff --git a/javascript/packages/linter/src/rules/index.ts b/javascript/packages/linter/src/rules/index.ts index f3f7490e1..2fc6f2208 100644 --- a/javascript/packages/linter/src/rules/index.ts +++ b/javascript/packages/linter/src/rules/index.ts @@ -21,6 +21,7 @@ export * from "./html-attribute-values-require-quotes.js" export * from "./html-avoid-both-disabled-and-aria-disabled.js" export * from "./html-body-only-elements.js" export * from "./html-boolean-attributes-no-value.js" +export * from "./html-head-only-elements.js" export * from "./html-iframe-has-title.js" export * from "./html-img-require-alt.js" export * from "./html-input-require-autocomplete.js" diff --git a/javascript/packages/linter/src/rules/rule-utils.ts b/javascript/packages/linter/src/rules/rule-utils.ts index 5d45f1f9a..56cbdf1b2 100644 --- a/javascript/packages/linter/src/rules/rule-utils.ts +++ b/javascript/packages/linter/src/rules/rule-utils.ts @@ -17,6 +17,7 @@ import type { HTMLAttributeNameNode, HTMLAttributeNode, HTMLAttributeValueNode, + HTMLElementNode, HTMLOpenTagNode, LiteralNode, LexResult, @@ -172,7 +173,7 @@ export function getAttributes(node: HTMLOpenTagNode): HTMLAttributeNode[] { /** * Gets the tag name from an HTML tag node (lowercased) */ -export function getTagName(node: HTMLOpenTagNode | null | undefined): string | null { +export function getTagName(node: HTMLElementNode | HTMLOpenTagNode | null | undefined): string | null { if (!node) return null return node.tag_name?.value.toLowerCase() || null diff --git a/javascript/packages/linter/test/__snapshots__/cli.test.ts.snap b/javascript/packages/linter/test/__snapshots__/cli.test.ts.snap index b0b58f9dd..c75359a0c 100644 --- a/javascript/packages/linter/test/__snapshots__/cli.test.ts.snap +++ b/javascript/packages/linter/test/__snapshots__/cli.test.ts.snap @@ -574,7 +574,7 @@ exports[`CLI Output Formatting > formats JSON output correctly for bad file 1`] "summary": { "filesChecked": 1, "filesWithOffenses": 1, - "ruleCount": 38, + "ruleCount": 39, "totalErrors": 2, "totalOffenses": 2, "totalWarnings": 0, @@ -592,7 +592,7 @@ exports[`CLI Output Formatting > formats JSON output correctly for clean file 1` "summary": { "filesChecked": 1, "filesWithOffenses": 0, - "ruleCount": 38, + "ruleCount": 39, "totalErrors": 0, "totalOffenses": 0, "totalWarnings": 0, @@ -662,7 +662,7 @@ exports[`CLI Output Formatting > formats JSON output correctly for file with err "summary": { "filesChecked": 1, "filesWithOffenses": 1, - "ruleCount": 38, + "ruleCount": 39, "totalErrors": 3, "totalOffenses": 3, "totalWarnings": 0, diff --git a/javascript/packages/linter/test/rules/html-head-only-elements.test.ts b/javascript/packages/linter/test/rules/html-head-only-elements.test.ts new file mode 100644 index 000000000..36d1ea16d --- /dev/null +++ b/javascript/packages/linter/test/rules/html-head-only-elements.test.ts @@ -0,0 +1,260 @@ +import dedent from "dedent" + +import { describe, test } from "vitest" +import { createLinterTest } from "../helpers/linter-test-helper.js" +import { HTMLHeadOnlyElementsRule } from "../../src/rules/html-head-only-elements.js" + +const { expectNoOffenses, expectError, assertOffenses } = createLinterTest(HTMLHeadOnlyElementsRule) + +describe("html-head-only-elements", () => { + test("passes when head-only elements are inside head", () => { + expectNoOffenses(dedent` + + + My Page + + + + + + + +

Welcome

+ + + `) + }) + + test("passes when ERB helpers are inside head", () => { + expectNoOffenses(dedent` + + + <%= csrf_meta_tags %> + <%= csp_meta_tag %> + <%= favicon_link_tag 'favicon.ico' %> + <%= stylesheet_link_tag "application", "data-turbo-track": "reload" %> + <%= content_for?(:title) ? yield(:title) : "Default Title" %> + + +

Welcome

+ + + `) + }) + + test("fails when title is in body", () => { + expectError("Element `` must be placed inside the `<head>` tag.") + + assertOffenses(dedent` + <html> + <head> + </head> + <body> + <title>My Page +

Welcome

+ + + `) + }) + + test("fails when meta is in body", () => { + expectError("Element `` must be placed inside the `` tag.") + + assertOffenses(dedent` + + + + + +

Welcome

+ + + `) + }) + + test("fails when link is in body", () => { + expectError("Element `` must be placed inside the `` tag.") + + assertOffenses(dedent` + + + + + +

Welcome

+ + + `) + }) + + test("fails when style is in body", () => { + expectError("Element ` +

Welcome

+ + + `) + }) + + test("fails when base is in body", () => { + expectError("Element `` must be placed inside the `` tag.") + + assertOffenses(dedent` + + + + + +

Welcome

+ + + `) + }) + + test("fails for multiple head-only elements in body", () => { + expectError("Element `` must be placed inside the `<head>` tag.") + expectError("Element `<meta>` must be placed inside the `<head>` tag.") + expectError("Element `<link>` must be placed inside the `<head>` tag.") + + assertOffenses(dedent` + <html> + <head> + </head> + <body> + <title>My Page + + +

Welcome

+ + + `) + }) + + test("fails when elements are outside html structure", () => { + expectError("Element `` must be placed inside the `<head>` tag.") + expectError("Element `<meta>` must be placed inside the `<head>` tag.") + + assertOffenses(dedent` + <title>My Page + + + + + + +

Welcome

+ + + `) + }) + + test("works with ERB templates in body", () => { + expectError("Element `` must be placed inside the `<head>` tag.") + + assertOffenses(dedent` + <html> + <head> + </head> + <body> + <%= csrf_meta_tags %> + <%= csp_meta_tag %> + <%= favicon_link_tag 'favicon.ico' %> + <%= stylesheet_link_tag "application", "data-turbo-track": "reload" %> + <title><%= content_for?(:title) ? yield(:title) : "Default Title" %> +

Welcome

+ + + `) + }) + + test("allows other elements in body", () => { + expectNoOffenses(dedent` + + + My Page + + +

Welcome

+

This is content

+
+ Some text +
+ + + `) + }) + + test("allows title element inside SVG", () => { + expectNoOffenses(dedent` + + + My Page + + + + Chart Title + + + + + `) + }) + + test("allows nested title elements inside nested SVG", () => { + expectNoOffenses(dedent` + + + My Page + + +
+ + + Group Title + + + +
+ + + `) + }) + + test("still fails for other head-only elements inside SVG", () => { + expectError("Element `` must be placed inside the `` tag.") + expectError("Element `` must be placed inside the `` tag.") + + assertOffenses(dedent` + + + My Page + + + + + + Chart Title + + + + `) + }) + + test.todo("head in body", () => { + expectError("Element `` must be placed inside the `` tag.") + + assertOffenses(dedent` + + + + + + `) + }) +}) diff --git a/javascript/packages/vscode/package.json b/javascript/packages/vscode/package.json index 5b11c55d1..cb860960a 100644 --- a/javascript/packages/vscode/package.json +++ b/javascript/packages/vscode/package.json @@ -83,6 +83,7 @@ "html-avoid-both-disabled-and-aria-disabled", "html-body-only-elements", "html-boolean-attributes-no-value", + "html-head-only-elements", "html-iframe-has-title", "html-img-require-alt", "html-input-require-autocomplete",