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
26 changes: 24 additions & 2 deletions javascript/packages/linter/docs/rules/html-head-only-elements.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@ Enforce that certain elements only appear inside the `<head>` section of the doc

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.
> [!NOTE] Exceptions
> - `<title>` elements are allowed inside `<svg>` elements for accessibility purposes.
> - `<meta>` elements with the `itemprop` attribute are allowed in the `<body>` for [microdata](https://html.spec.whatwg.org/multipage/microdata.html#the-itemprop-attribute) markup (e.g., Schema.org structured data).

## Rationale

Expand Down Expand Up @@ -56,6 +57,16 @@ Placing these elements outside `<head>` leads to invalid HTML and undefined beha
</body>
```

```erb
<body>
<div itemscope itemtype="https://schema.org/Book">
<span itemprop="name">The Hobbit</span>
<meta itemprop="author" content="J.R.R. Tolkien">
<meta itemprop="isbn" content="978-0618260300">
</div>
</body>
```

### 🚫 Bad

```erb
Expand All @@ -76,6 +87,17 @@ Placing these elements outside `<head>` leads to invalid HTML and undefined beha
</body>
```

```erb
<body>
<!-- Regular meta tags (name, charset, http-equiv) must be in <head> -->
<meta name="description" content="Page description">
<meta charset="UTF-8">
<meta http-equiv="refresh" content="30">
</body>
```

## References

* [HTML Living Standard - The `head` element](https://html.spec.whatwg.org/multipage/semantics.html#the-head-element)
* [MDN - The `<meta>` element](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/meta#technical_summary)
* [HTML Living Standard - Microdata (`itemprop`)](https://html.spec.whatwg.org/multipage/microdata.html#the-itemprop-attribute)
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { ParserRule } from "../types"
import { BaseRuleVisitor, getTagName, isHeadOnlyTag } from "./rule-utils"
import { BaseRuleVisitor, getTagName, isHeadOnlyTag, hasAttribute } from "./rule-utils"

import type { ParseResult, HTMLElementNode } from "@herb-tools/core"
import type { UnboundLintOffense, LintContext, FullRuleConfig } from "../types"
Expand All @@ -23,13 +23,18 @@ class HeadOnlyElementsVisitor extends BaseRuleVisitor {
if (!this.insideBody) return
if (!isHeadOnlyTag(tagName)) return
if (tagName === "title" && this.insideSVG) return
if (tagName === "meta" && this.hasItempropAttribute(node)) return

this.addOffense(
`Element \`<${tagName}>\` must be placed inside the \`<head>\` tag.`,
node.location,
)
}

private hasItempropAttribute(node: HTMLElementNode): boolean {
return hasAttribute(node.open_tag, "itemprop")
}

private get insideHead(): boolean {
return this.elementStack.includes("head")
}
Expand Down
4 changes: 3 additions & 1 deletion javascript/packages/linter/src/rules/rule-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -378,7 +378,9 @@ export function findAttributeByName(attributes: Node[], attributeName: string):
/**
* Checks if a tag has a specific attribute
*/
export function hasAttribute(node: HTMLOpenTagNode, attributeName: string): boolean {
export function hasAttribute(node: HTMLOpenTagNode | null | undefined, attributeName: string): boolean {
if (!node) return false

return getAttribute(node, attributeName) !== null
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,89 @@ describe("html-head-only-elements", () => {
`)
})

test("passes when meta with itemprop is in body (microdata)", () => {
expectNoOffenses(dedent`
<html>
<head>
<title>My Page</title>
</head>
<body>
<div itemscope itemtype="https://schema.org/Book">
<span itemprop="name">The Hobbit</span>
<meta itemprop="author" content="J.R.R. Tolkien">
<meta itemprop="isbn" content="978-0618260300">
</div>
</body>
</html>
`)
})

test("passes when meta with itemprop is deeply nested in body", () => {
expectNoOffenses(dedent`
<html>
<head>
<title>My Page</title>
</head>
<body>
<article>
<div itemscope itemtype="https://schema.org/Product">
<h1 itemprop="name">Widget</h1>
<div class="details">
<meta itemprop="sku" content="12345">
<span itemprop="price" content="29.99">$29.99</span>
</div>
</div>
</article>
</body>
</html>
`)
})

test("fails when meta with name attribute is in body", () => {
expectError("Element `<meta>` must be placed inside the `<head>` tag.")

assertOffenses(dedent`
<html>
<head>
</head>
<body>
<meta name="description" content="Page description">
<h1>Welcome</h1>
</body>
</html>
`)
})

test("fails when meta with http-equiv attribute is in body", () => {
expectError("Element `<meta>` must be placed inside the `<head>` tag.")

assertOffenses(dedent`
<html>
<head>
</head>
<body>
<meta http-equiv="refresh" content="30">
<h1>Welcome</h1>
</body>
</html>
`)
})

test("fails when meta with charset attribute is in body", () => {
expectError("Element `<meta>` must be placed inside the `<head>` tag.")

assertOffenses(dedent`
<html>
<head>
</head>
<body>
<meta charset="UTF-8">
<h1>Welcome</h1>
</body>
</html>
`)
})

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

Expand Down
Loading