diff --git a/javascript/packages/linter/docs/rules/html-head-only-elements.md b/javascript/packages/linter/docs/rules/html-head-only-elements.md index 8a12b47f8..54f9339c8 100644 --- a/javascript/packages/linter/docs/rules/html-head-only-elements.md +++ b/javascript/packages/linter/docs/rules/html-head-only-elements.md @@ -8,8 +8,9 @@ Enforce that certain elements only appear inside the `` section of the doc 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. +> [!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 @@ -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 @@ -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) diff --git a/javascript/packages/linter/src/rules/html-head-only-elements.ts b/javascript/packages/linter/src/rules/html-head-only-elements.ts index a78a7038e..c5c624da3 100644 --- a/javascript/packages/linter/src/rules/html-head-only-elements.ts +++ b/javascript/packages/linter/src/rules/html-head-only-elements.ts @@ -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" @@ -23,6 +23,7 @@ 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.`, @@ -30,6 +31,10 @@ class HeadOnlyElementsVisitor extends BaseRuleVisitor { ) } + private hasItempropAttribute(node: HTMLElementNode): boolean { + return hasAttribute(node.open_tag, "itemprop") + } + private get insideHead(): boolean { return this.elementStack.includes("head") } diff --git a/javascript/packages/linter/src/rules/rule-utils.ts b/javascript/packages/linter/src/rules/rule-utils.ts index 37903e8a3..3500fe1e3 100644 --- a/javascript/packages/linter/src/rules/rule-utils.ts +++ b/javascript/packages/linter/src/rules/rule-utils.ts @@ -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 } 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 index 97a3c28bd..8d1a68e45 100644 --- a/javascript/packages/linter/test/rules/html-head-only-elements.test.ts +++ b/javascript/packages/linter/test/rules/html-head-only-elements.test.ts @@ -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 + + +
+ The Hobbit + + +
+ + + `) + }) + + test("passes when meta with itemprop is deeply nested in body", () => { + expectNoOffenses(dedent` + + + My Page + + +
+
+

Widget

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

Welcome

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

Welcome

+ + + `) + }) + + test("fails when meta with charset attribute 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.")