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
1 change: 1 addition & 0 deletions javascript/packages/linter/docs/rules/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<body>`.
- [`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 `<head>`.
- [`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 `<input>` tags.
- [`html-img-require-alt`](./html-img-require-alt.md) - Requires `alt` attributes on `<img>` tags
Expand Down
81 changes: 81 additions & 0 deletions javascript/packages/linter/docs/rules/html-head-only-elements.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
# Linter Rule: Require head-scoped elements inside `<head>`

**Rule:** `html-head-only-elements`

## Description

Enforce that certain elements only appear inside the `<head>` section of the document.

Elements like `<title>`, `<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</title>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="/styles.css">
</head>

<body>
<h1>Welcome</h1>
</body>
```

```erb
<head>
<%= 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 %>

<title><%= content_for?(:title) ? yield(:title) : "Default Title" %></title>
</head>
```

```erb
<body>
<svg>
<title>Chart Title</title>
<rect width="100" height="100" />
</svg>
</body>
```

### 🚫 Bad

```erb
<body>
<title>My Page</title>

<meta charset="UTF-8">

<link rel="stylesheet" href="/styles.css">

<h1>Welcome</h1>
</body>
```

```erb
<body>
<title><%= content_for?(:title) ? yield(:title) : "Default Title" %></title>
</body>
```

## References

* [HTML Living Standard - The `head` element](https://html.spec.whatwg.org/multipage/semantics.html#the-head-element)
2 changes: 2 additions & 0 deletions javascript/packages/linter/src/default-rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -69,6 +70,7 @@ export const defaultRules: RuleClass[] = [
HTMLAvoidBothDisabledAndAriaDisabledRule,
HTMLBodyOnlyElementsRule,
HTMLBooleanAttributesNoValueRule,
HTMLHeadOnlyElementsRule,
HTMLIframeHasTitleRule,
HTMLImgRequireAltRule,
HTMLInputRequireAutocompleteRule,
Expand Down
44 changes: 31 additions & 13 deletions javascript/packages/linter/src/rules/html-body-only-elements.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,33 +5,51 @@ 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 \`<body>\` 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 \`<body>\` 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")
}
}

export class HTMLBodyOnlyElementsRule extends ParserRule {
static autocorrectable = false
name = "html-body-only-elements"

isEnabled(_result: ParseResult, context?: Partial<LintContext>): boolean {
if (context?.fileName?.endsWith(".xml")) return false
if (context?.fileName?.endsWith(".xml.erb")) return false

return true
}

check(result: ParseResult, context?: Partial<LintContext>): LintOffense[] {
const visitor = new HTMLBodyOnlyElementsVisitor(this.name, context)

Expand Down
60 changes: 60 additions & 0 deletions javascript/packages/linter/src/rules/html-head-only-elements.ts
Original file line number Diff line number Diff line change
@@ -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 \`<head>\` 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<LintContext>): boolean {
if (context?.fileName?.endsWith(".xml")) return false
if (context?.fileName?.endsWith(".xml.erb")) return false

return true
}

check(result: ParseResult, context?: Partial<LintContext>): LintOffense[] {
const visitor = new HeadOnlyElementsVisitor(this.name, context)

visitor.visit(result.value)

return visitor.offenses
}
}
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 @@ -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"
Expand Down
3 changes: 2 additions & 1 deletion javascript/packages/linter/src/rules/rule-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import type {
HTMLAttributeNameNode,
HTMLAttributeNode,
HTMLAttributeValueNode,
HTMLElementNode,
HTMLOpenTagNode,
LiteralNode,
LexResult,
Expand Down Expand Up @@ -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
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading