From 82fe5afe960b40beee8bd99bf1fcbd8d3a13bc4c Mon Sep 17 00:00:00 2001 From: Koji NAKAMURA Date: Sun, 28 Dec 2025 17:09:06 +0900 Subject: [PATCH 1/7] Linter: html-content-model-conformance --- .../rules/html-content-model-conformance.ts | 801 ++++++++++++++++++ .../html-content-model-conformance.test.ts | 196 +++++ 2 files changed, 997 insertions(+) create mode 100644 javascript/packages/linter/src/rules/html-content-model-conformance.ts create mode 100644 javascript/packages/linter/test/rules/html-content-model-conformance.test.ts diff --git a/javascript/packages/linter/src/rules/html-content-model-conformance.ts b/javascript/packages/linter/src/rules/html-content-model-conformance.ts new file mode 100644 index 000000000..c88ba6302 --- /dev/null +++ b/javascript/packages/linter/src/rules/html-content-model-conformance.ts @@ -0,0 +1,801 @@ +import { BaseRuleVisitor } from "./rule-utils.js" + +import { ParserRule } from "../types.js" +import type { + UnboundLintOffense, + LintContext, + FullRuleConfig, +} from "../types.js" +import type { + HTMLOpenTagNode, + HTMLElementNode, + ParseResult, +} from "@herb-tools/core" + +type ContentCategory = "flow" | "phrasing" +type ContentModel = + | ContentCategory + | "transparent" + | "nothing" + | "text" + | Array +type Spec = { + categories: Array + contentModel: ContentModel + link: string +} +type Specs = { [key: string]: Spec } + +class ContentModelConformanceVisitor extends BaseRuleVisitor { + private elementStack: string[] = [] + + private isValidHTMLOpenTag(node: HTMLElementNode): boolean { + return !!(node.open_tag && node.open_tag.type === "AST_HTML_OPEN_TAG_NODE") + } + + private resolveTransparentContentModel(): ContentModel | null { + for (const tagName of this.elementStack.reverse()) { + const contentModel = specs[tagName]?.contentModel + if (contentModel !== "transparent") { + return contentModel + } + } + return null + } + + private addOffenseMessage( + tagName: string, + parentTagName: string, + openTag: HTMLOpenTagNode, + ): void { + this.addOffense( + `Element \`<${tagName}>\` cannot be placed inside element \`<${parentTagName}>\`.`, + openTag.tag_name!.location, + ) + } + + private checkConformance(tagName: string, openTag: HTMLOpenTagNode) { + const spec = specs[tagName] + + const parentTagName = this.elementStack.at(-1) + if (spec && parentTagName && specs[parentTagName]) { + const check = (spec: Spec, parentContentModel: ContentModel) => { + switch (parentContentModel) { + case "flow": + case "phrasing": + if (!spec.categories.includes(parentContentModel)) { + this.addOffenseMessage(tagName, parentTagName, openTag) + } + break + case "transparent": + const transparentContentModel = this.resolveTransparentContentModel() + if (transparentContentModel) { + check(spec, transparentContentModel) + } + break + case "nothing": + // If a void element has elements, but unreachable because of parse error. + break + case "text": + // unreachable + break + default: + if (Array.isArray(parentContentModel)) { + if (!parentContentModel.includes(tagName)) { + this.addOffenseMessage(tagName, parentTagName, openTag) + } + } else { + const _exhaustiveCheck: never = parentContentModel + throw new Error(`$unreachable: ${_exhaustiveCheck}`) + } + } + } + check(spec, specs[parentTagName].contentModel) + } + } + + visitHTMLElementNode(node: HTMLElementNode): void { + if (!this.isValidHTMLOpenTag(node)) { + super.visitHTMLElementNode(node) + + return + } + + const openTag = node.open_tag as HTMLOpenTagNode + const tagName = openTag.tag_name?.value.toLowerCase() + + if (!tagName) { + super.visitHTMLElementNode(node) + + return + } + + this.checkConformance(tagName, openTag) + + this.elementStack.push(tagName) + super.visitHTMLElementNode(node) + this.elementStack.pop() + } +} + +export class HTMLContentModelConformanceRule extends ParserRule { + name = "html-content-model-conformance" + + get defaultConfig(): FullRuleConfig { + return { + enabled: false, + severity: "error", + } + } + + check( + result: ParseResult, + context?: Partial, + ): UnboundLintOffense[] { + const visitor = new ContentModelConformanceVisitor(this.name, context) + visitor.visit(result.value) + return visitor.offenses + } +} + +// 3.2.5.2.9: https://html.spec.whatwg.org/multipage/dom.html#script-supporting-elements-2 +const scriptSupportingElements = ["script", "template"] + +// 3.2.5.2.10: https://html.spec.whatwg.org/multipage/dom.html#select-element-inner-content-elements +const selectElementInnerContentElements = ["option", "optgroup", "hr"] + .concat(scriptSupportingElements) + .concat(["noscript", "div"]) + +// 3.2.5.2.11: https://html.spec.whatwg.org/multipage/dom.html#optgroup-element-inner-content-elements-2 +const optgroupElementInnerContentElements = ["option"] + .concat(scriptSupportingElements) + .concat(["noscript", "div"]) + +const specs = { + body: { + categories: [], + contentModel: "flow", + link: "https://html.spec.whatwg.org/multipage/sections.html#the-body-element", + }, + article: { + categories: ["flow"], + contentModel: "flow", + link: "https://html.spec.whatwg.org/multipage/sections.html#the-article-element", + }, + section: { + categories: ["flow"], + contentModel: "flow", + link: "https://html.spec.whatwg.org/multipage/sections.html#the-section-element", + }, + nav: { + categories: ["flow"], + contentModel: "flow", + link: "https://html.spec.whatwg.org/multipage/sections.html#the-nav-element", + }, + aside: { + categories: ["flow"], + contentModel: "flow", + link: "https://html.spec.whatwg.org/multipage/sections.html#the-aside-element", + }, + h1: { + categories: ["flow"], + contentModel: "phrasing", + link: "https://html.spec.whatwg.org/multipage/sections.html#the-h1,-h2,-h3,-h4,-h5,-and-h6-elements", + }, + h2: { + categories: ["flow"], + contentModel: "phrasing", + link: "https://html.spec.whatwg.org/multipage/sections.html#the-h1,-h2,-h3,-h4,-h5,-and-h6-elements", + }, + h3: { + categories: ["flow"], + contentModel: "phrasing", + link: "https://html.spec.whatwg.org/multipage/sections.html#the-h1,-h2,-h3,-h4,-h5,-and-h6-elements", + }, + h4: { + categories: ["flow"], + contentModel: "phrasing", + link: "https://html.spec.whatwg.org/multipage/sections.html#the-h1,-h2,-h3,-h4,-h5,-and-h6-elements", + }, + h5: { + categories: ["flow"], + contentModel: "phrasing", + link: "https://html.spec.whatwg.org/multipage/sections.html#the-h1,-h2,-h3,-h4,-h5,-and-h6-elements", + }, + h6: { + categories: ["flow"], + contentModel: "phrasing", + link: "https://html.spec.whatwg.org/multipage/sections.html#the-h1,-h2,-h3,-h4,-h5,-and-h6-elements", + }, + hgroup: { + categories: ["flow"], + // Zero or more p elements, followed by one h1, h2, h3, h4, h5, or h6 element, followed by zero or more p elements, optionally intermixed with script-supporting elements. + contentModel: ["p", "h1", "h2", "h3", "h4", "h5", "h6"].concat( + scriptSupportingElements, + ), + link: "https://html.spec.whatwg.org/multipage/sections.html#the-hgroup-element", + }, + header: { + categories: ["flow"], + // Flow content, but with no header or footer element descendants. + contentModel: "flow", + link: "https://html.spec.whatwg.org/multipage/sections.html#the-header-element", + }, + footer: { + categories: ["flow"], + // Flow content, but with no header or footer element descendants. + contentModel: "flow", + link: "https://html.spec.whatwg.org/multipage/sections.html#the-footer-element", + }, + address: { + categories: ["flow"], + // Flow content, but with no heading content descendants, no sectioning content descendants, and no header, footer, or address element descendants. + contentModel: "flow", + link: "https://html.spec.whatwg.org/multipage/sections.html#the-address-element", + }, + p: { + categories: ["flow"], + contentModel: "phrasing", + link: "https://html.spec.whatwg.org/multipage/grouping-content.html#the-p-element", + }, + hr: { + categories: ["flow"], + contentModel: "nothing", + link: "https://html.spec.whatwg.org/multipage/grouping-content.html#the-hr-element", + }, + pre: { + categories: ["flow"], + contentModel: "phrasing", + link: "https://html.spec.whatwg.org/multipage/grouping-content.html#the-pre-element", + }, + blockquote: { + categories: ["flow"], + contentModel: "flow", + link: "https://html.spec.whatwg.org/multipage/grouping-content.html#the-blockquote-element", + }, + ol: { + categories: ["flow"], + // Zero or more li and script-supporting elements. + contentModel: ["li"].concat(scriptSupportingElements), + link: "https://html.spec.whatwg.org/multipage/grouping-content.html#the-ol-element", + }, + ul: { + categories: ["flow"], + // Zero or more li and script-supporting elements. + contentModel: ["li"].concat(scriptSupportingElements), + link: "https://html.spec.whatwg.org/multipage/grouping-content.html#the-ul-element", + }, + menu: { + categories: ["flow"], + // Zero or more li and script-supporting elements. + contentModel: ["li"].concat(scriptSupportingElements), + link: "https://html.spec.whatwg.org/multipage/grouping-content.html#the-menu-element", + }, + li: { + categories: [], + contentModel: "flow", + link: "https://html.spec.whatwg.org/multipage/grouping-content.html#the-li-element", + }, + dl: { + categories: ["flow"], + // Either: Zero or more groups each consisting of one or more dt elements followed by one or more dd elements, optionally intermixed with script-supporting elements. + // Or: One or more div elements, optionally intermixed with script-supporting elements. + contentModel: ["dt", "dd"].concat(scriptSupportingElements).concat(["div"]), + link: "https://html.spec.whatwg.org/multipage/grouping-content.html#the-dl-element", + }, + dt: { + categories: [], + // Flow content, but with no header, footer, sectioning content, or heading content descendants. + contentModel: "flow", + link: "https://html.spec.whatwg.org/multipage/grouping-content.html#the-dt-element", + }, + dd: { + categories: [], + contentModel: "flow", + link: "https://html.spec.whatwg.org/multipage/grouping-content.html#the-dd-element", + }, + figure: { + categories: ["flow"], + // Either: one figcaption element followed by flow content. + // Or: flow contenut followed by one figcaption element. + // Or: flow content. + get contentModel() { + return flowElements.concat(["figcaption"]) + }, + link: "https://html.spec.whatwg.org/multipage/grouping-content.html#the-figure-element", + }, + figcaption: { + categories: [], + contentModel: "flow", + link: "https://html.spec.whatwg.org/multipage/grouping-content.html#the-figcaption-element", + }, + main: { + categories: ["flow"], + contentModel: "flow", + link: "https://html.spec.whatwg.org/multipage/grouping-content.html#the-main-element", + }, + search: { + categories: ["flow"], + contentModel: "flow", + link: "https://html.spec.whatwg.org/multipage/grouping-content.html#the-search-element", + }, + div: { + categories: ["flow"], + // If the element is a child of a dl element: One or more dt elements followed by one or more dd elements, optionally intermixed with script-supporting elements. + // Otherwise, if the element is a descendant of an option element: Zero or more option element inner content elements. + // Otherwise, if the element is a descendant of an optgroup element: Zero or more optgroup element inner content elements. + // Otherwise, if the element is a descendant of a select element: Zero or more select element inner content elements. + // Otherwise: flow content. + get contentModel() { + return ["dt", "dd"] + .concat(scriptSupportingElements) + .concat(optionElementInnerContentElements) + .concat(optgroupElementInnerContentElements) + .concat(selectElementInnerContentElements) + .concat(flowElements) + }, + link: "https://html.spec.whatwg.org/multipage/grouping-content.html#the-div-element", + }, + a: { + categories: ["flow", "phrasing"], + // Transparent, but there must be no interactive content descendant, a element descendant, or descendant with the tabindex attribute specified. + contentModel: "transparent", + link: "https://html.spec.whatwg.org/multipage/text-level-semantics.html#the-a-element", + }, + em: { + categories: ["flow", "phrasing"], + contentModel: "phrasing", + link: "https://html.spec.whatwg.org/multipage/text-level-semantics.html#the-em-element", + }, + strong: { + categories: ["flow", "phrasing"], + contentModel: "phrasing", + link: "https://html.spec.whatwg.org/multipage/text-level-semantics.html#the-strong-element", + }, + small: { + categories: ["flow", "phrasing"], + contentModel: "phrasing", + link: "https://html.spec.whatwg.org/multipage/text-level-semantics.html#the-small-element", + }, + s: { + categories: ["flow", "phrasing"], + contentModel: "phrasing", + link: "https://html.spec.whatwg.org/multipage/text-level-semantics.html#the-s-element", + }, + cite: { + categories: ["flow", "phrasing"], + contentModel: "phrasing", + link: "https://html.spec.whatwg.org/multipage/text-level-semantics.html#the-cite-element", + }, + q: { + categories: ["flow", "phrasing"], + contentModel: "phrasing", + link: "https://html.spec.whatwg.org/multipage/text-level-semantics.html#the-q-element", + }, + dfn: { + categories: ["flow", "phrasing"], + // Phrasing content, but there must be no dfn element descendants. + contentModel: "phrasing", + link: "https://html.spec.whatwg.org/multipage/text-level-semantics.html#the-dfn-element", + }, + abbr: { + categories: ["flow", "phrasing"], + contentModel: "phrasing", + link: "https://html.spec.whatwg.org/multipage/text-level-semantics.html#the-abbr-element", + }, + ruby: { + categories: ["flow", "phrasing"], + // 1. One or the other of the following: + // - Phrasing content, but with no ruby elements and with no ruby element descendants + // - A single ruby element that itself has no ruby element descendants + // 2. One or the other of the following: + // - One or more rt elements + // - An rp element followed by one or more rt elements, each of which is itself followed by an rp element + get contentModel() { + return phrasingElements.concat(["rt", "rp"]) + }, + link: "https://html.spec.whatwg.org/multipage/text-level-semantics.html#the-ruby-element", + }, + rt: { + categories: [], + contentModel: "phrasing", + link: "https://html.spec.whatwg.org/multipage/text-level-semantics.html#the-rt-element", + }, + rp: { + categories: [], + contentModel: "text", + link: "https://html.spec.whatwg.org/multipage/text-level-semantics.html#the-rp-element", + }, + data: { + categories: ["flow", "phrasing"], + contentModel: "phrasing", + link: "https://html.spec.whatwg.org/multipage/text-level-semantics.html#the-data-element", + }, + time: { + categories: ["flow", "phrasing"], + // If the element has a datetime attribute: Phrasing content. Otherwise: Text, but must match requirements described in prose below. + contentModel: "phrasing", + link: "https://html.spec.whatwg.org/multipage/text-level-semantics.html#the-time-element", + }, + code: { + categories: ["flow", "phrasing"], + contentModel: "phrasing", + link: "https://html.spec.whatwg.org/multipage/text-level-semantics.html#the-code-element", + }, + var: { + categories: ["flow", "phrasing"], + contentModel: "phrasing", + link: "https://html.spec.whatwg.org/multipage/text-level-semantics.html#the-var-element", + }, + samp: { + categories: ["flow", "phrasing"], + contentModel: "phrasing", + link: "https://html.spec.whatwg.org/multipage/text-level-semantics.html#the-samp-element", + }, + kbd: { + categories: ["flow", "phrasing"], + contentModel: "phrasing", + link: "https://html.spec.whatwg.org/multipage/text-level-semantics.html#the-kbd-element", + }, + sub: { + categories: ["flow", "phrasing"], + contentModel: "phrasing", + link: "https://html.spec.whatwg.org/multipage/text-level-semantics.html#the-sub-and-sup-elements", + }, + sup: { + categories: ["flow", "phrasing"], + contentModel: "phrasing", + link: "https://html.spec.whatwg.org/multipage/text-level-semantics.html#the-sub-and-sup-elements", + }, + i: { + categories: ["flow", "phrasing"], + contentModel: "phrasing", + link: "https://html.spec.whatwg.org/multipage/text-level-semantics.html#the-i-element", + }, + b: { + categories: ["flow", "phrasing"], + contentModel: "phrasing", + link: "https://html.spec.whatwg.org/multipage/text-level-semantics.html#the-b-element", + }, + u: { + categories: ["flow", "phrasing"], + contentModel: "phrasing", + link: "https://html.spec.whatwg.org/multipage/text-level-semantics.html#the-u-element", + }, + mark: { + categories: ["flow", "phrasing"], + contentModel: "phrasing", + link: "https://html.spec.whatwg.org/multipage/text-level-semantics.html#the-mark-element", + }, + bdi: { + categories: ["flow", "phrasing"], + contentModel: "phrasing", + link: "https://html.spec.whatwg.org/multipage/text-level-semantics.html#the-bdi-element", + }, + bdo: { + categories: ["flow", "phrasing"], + contentModel: "phrasing", + link: "https://html.spec.whatwg.org/multipage/text-level-semantics.html#the-bdo-element", + }, + span: { + categories: ["flow", "phrasing"], + // If the element is a descendant of an option element: Zero or more option element inner content elements, except div elements. + // Otherwise: Phrasing content. + get contentModel() { + return optionElementInnerContentElements + .filter((e) => e !== "div") + .concat(phrasingElements) + }, + link: "https://html.spec.whatwg.org/multipage/text-level-semantics.html#the-span-element", + }, + br: { + categories: ["flow", "phrasing"], + contentModel: "nothing", + link: "https://html.spec.whatwg.org/multipage/text-level-semantics.html#the-br-element", + }, + wbr: { + categories: ["flow", "phrasing"], + contentModel: "nothing", + link: "https://html.spec.whatwg.org/multipage/text-level-semantics.html#the-wbr-element", + }, + ins: { + categories: ["flow", "phrasing"], + contentModel: "transparent", + link: "https://html.spec.whatwg.org/multipage/edits.html#the-ins-element", + }, + del: { + categories: ["flow", "phrasing"], + contentModel: "transparent", + link: "https://html.spec.whatwg.org/multipage/edits.html#the-del-element", + }, + picture: { + categories: ["flow", "phrasing"], + // "Zero or more source elements, followed by one img element, optionally intermixed with script-supporting elements. + contentModel: ["source", "img"].concat(scriptSupportingElements), + link: "https://html.spec.whatwg.org/multipage/embedded-content.html#the-picture-element", + }, + source: { + categories: [], + contentModel: "nothing", + link: "https://html.spec.whatwg.org/multipage/embedded-content.html#the-source-element", + }, + img: { + categories: ["flow", "phrasing"], + contentModel: "nothing", + link: "https://html.spec.whatwg.org/multipage/embedded-content.html#the-img-element", + }, + iframe: { + categories: ["flow", "phrasing"], + contentModel: "nothing", + link: "https://html.spec.whatwg.org/multipage/iframe-embed-object.html#the-iframe-element", + }, + embed: { + categories: ["flow", "phrasing"], + contentModel: "nothing", + link: "https://html.spec.whatwg.org/multipage/iframe-embed-object.html#the-embed-element", + }, + object: { + categories: ["flow", "phrasing"], + contentModel: "transparent", + link: "https://html.spec.whatwg.org/multipage/iframe-embed-object.html#the-object-element", + }, + video: { + categories: ["flow", "phrasing"], + // If the element has a src attribute: zero or more track elements, then transparent, but with no media element descendants. + // If the element does not have a src attribute: zero or more source elements, then zero or more track elements, then transparent, but with no media element descendants. + contentModel: ["track", "source"], + link: "https://html.spec.whatwg.org/multipage/media.html#the-video-element", + }, + audio: { + categories: ["flow", "phrasing"], + // If the element has a src attribute: zero or more track elements, then transparent, but with no media element descendants. + // If the element does not have a src attribute: zero or more source elements, then zero or more track elements, then transparent, but with no media element descendants. + contentModel: ["track", "source"], + link: "https://html.spec.whatwg.org/multipage/media.html#the-audio-element", + }, + track: { + categories: [], + contentModel: "nothing", + link: "https://html.spec.whatwg.org/multipage/media.html#the-track-element", + }, + map: { + categories: ["flow", "phrasing"], + contentModel: "transparent", + link: "https://html.spec.whatwg.org/multipage/image-maps.html#the-map-element", + }, + area: { + categories: ["flow", "phrasing"], + contentModel: "nothing", + link: "https://html.spec.whatwg.org/multipage/image-maps.html#the-area-element", + }, + table: { + categories: ["flow"], + // In this order: optionally a caption element, followed by zero or more colgroup elements, followed optionally by a thead element, + // followed by either zero or more tbody elements or one or more tr elements, followed optionally by a tfoot element, + // optionally intermixed with one or more script-supporting elements. + contentModel: [ + "caption", + "colgroup", + "thead", + "tbody", + "tr", + "tfoot", + ].concat(scriptSupportingElements), + link: "https://html.spec.whatwg.org/multipage/tables.html#the-table-element", + }, + caption: { + categories: [], + // Flow content, but with no descendant table elements. + contentModel: "flow", + link: "https://html.spec.whatwg.org/multipage/tables.html#the-caption-element", + }, + colgroup: { + categories: [], + // If the span attribute is present: Nothing. If the span attribute is absent: Zero or more col and template elements. + contentModel: ["col", "template"], + link: "https://html.spec.whatwg.org/multipage/tables.html#the-colgroup-element", + }, + col: { + categories: [], + contentModel: "nothing", + link: "https://html.spec.whatwg.org/multipage/tables.html#the-col-element", + }, + tbody: { + categories: [], + // Zero or more tr and script-supporting elements. + contentModel: ["tr"].concat(scriptSupportingElements), + link: "https://html.spec.whatwg.org/multipage/tables.html#the-tbody-element", + }, + thead: { + categories: [], + // Zero or more tr and script-supporting elements. + contentModel: ["tr"].concat(scriptSupportingElements), + link: "https://html.spec.whatwg.org/multipage/tables.html#the-thead-element", + }, + tfoot: { + categories: [], + // Zero or more tr and script-supporting elements. + contentModel: ["tr"].concat(scriptSupportingElements), + link: "https://html.spec.whatwg.org/multipage/tables.html#the-tfoot-element", + }, + tr: { + categories: [], + // Zero or more td, th, and script-supporting elements. + contentModel: ["td", "th"].concat(scriptSupportingElements), + link: "https://html.spec.whatwg.org/multipage/tables.html#the-tr-element", + }, + td: { + categories: [], + contentModel: "flow", + link: "https://html.spec.whatwg.org/multipage/tables.html#the-td-element", + }, + th: { + categories: [], + // Flow content, but with no header, footer, sectioning content, or heading content descendants. + contentModel: "flow", + link: "https://html.spec.whatwg.org/multipage/tables.html#the-th-element", + }, + form: { + categories: ["flow"], + // Flow content, but with no form element descendants. + contentModel: "flow", + link: "https://html.spec.whatwg.org/multipage/forms.html#the-form-element", + }, + label: { + categories: ["flow", "phrasing"], + // Phrasing content, but with no descendant labelable elements unless it is the element's labeled control, and no descendant label elements. + contentModel: "phrasing", + link: "https://html.spec.whatwg.org/multipage/forms.html#the-label-element", + }, + input: { + categories: ["flow", "phrasing"], + contentModel: "nothing", + link: "https://html.spec.whatwg.org/multipage/input.html#the-input-element", + }, + button: { + categories: ["flow", "phrasing"], + // Phrasing content, but there must be no interactive content descendant and no descendant with the tabindex attribute specified. + // If the element is the first child of a select element, then it may also have zero or one descendant selectedcontent element." ], + contentModel: "phrasing", + link: "https://html.spec.whatwg.org/multipage/form-elements.html#the-button-element", + }, + select: { + categories: ["flow", "phrasing"], + // Zero or one button elements if the select is a drop-down box, followed by zero or more select element inner content elements. + contentModel: ["button"].concat(selectElementInnerContentElements), + link: "https://html.spec.whatwg.org/multipage/form-elements.html#the-select-element", + }, + datalist: { + categories: ["flow", "phrasing"], + // Either: phrasing content. + // Or: Zero or more option and script-supporting elements. + get contentModel() { + return phrasingElements.concat(scriptSupportingElements) + }, + link: "https://html.spec.whatwg.org/multipage/form-elements.html#the-datalist-element", + }, + optgroup: { + categories: [], + // Zero or one legend element followed by zero or more optgroup element inner content elements. + contentModel: ["legend"].concat(optgroupElementInnerContentElements), + link: "https://html.spec.whatwg.org/multipage/form-elements.html#the-optgroup-element", + }, + option: { + categories: [], + // If the element has a label attribute and a value attribute: Nothing. + // If the element has a label attribute but no value attribute: Text. + // If the element has no label attribute and is not a descendant of a datalist element: Zero or more option element inner content elements. + // If the element has no label attribute and is a descendant of a datalist element: Text. + get contentModel() { + return optionElementInnerContentElements + }, + link: "https://html.spec.whatwg.org/multipage/form-elements.html#the-option-element", + }, + textarea: { + categories: ["flow", "phrasing"], + contentModel: "text", + link: "https://html.spec.whatwg.org/multipage/form-elements.html#the-textarea-element", + }, + output: { + categories: ["flow", "phrasing"], + contentModel: "phrasing", + link: "https://html.spec.whatwg.org/multipage/form-elements.html#the-output-element", + }, + progress: { + categories: ["flow", "phrasing"], + // Phrasing content, but there must be no progress element descendants." ], + contentModel: "phrasing", + link: "https://html.spec.whatwg.org/multipage/form-elements.html#the-progress-element", + }, + meter: { + categories: ["flow", "phrasing"], + // (node) => [ "Phrasing content, but there must be no meter element descendants." ], + contentModel: "phrasing", + link: "https://html.spec.whatwg.org/multipage/form-elements.html#the-meter-element", + }, + fieldset: { + categories: ["flow"], + // (node) => [ "Optionally, a legend element, followed by flow content." ], + get contentModel() { + return ["legend"].concat(flowElements) + }, + link: "https://html.spec.whatwg.org/multipage/form-elements.html#the-fieldset-element", + }, + legend: { + categories: [], + // If the element is a child of an optgroup element: Phrasing content, but there must be no interactive content and no descendant with the tabindex attribute. + // Otherwise: Phrasing content, optionally intermixed with heading content. + contentModel: "phrasing", + link: "https://html.spec.whatwg.org/multipage/form-elements.html#the-legend-element", + }, + selectedcontent: { + categories: ["phrasing"], + contentModel: "nothing", + link: "https://html.spec.whatwg.org/multipage/form-elements.html#the-selectedcontent-element", + }, + details: { + categories: ["flow"], + // (node) => [ "One summary element followed by flow content." ], + get contentModel() { + return ["summary"].concat(flowElements) + }, + link: "https://html.spec.whatwg.org/multipage/interactive-elements.html#the-details-element", + }, + summary: { + categories: [], + // Phrasing content, optionally intermixed with heading content. + contentModel: "phrasing", + link: "https://html.spec.whatwg.org/multipage/interactive-elements.html#the-summary-element", + }, + dialog: { + categories: ["flow"], + contentModel: "flow", + link: "https://html.spec.whatwg.org/multipage/interactive-elements.html#the-dialog-element", + }, + script: { + categories: ["flow", "phrasing"], + // If there is no src attribute, depends on the value of the type attribute, but must match script content restrictions. + // If there is a src attribute, the element must be either empty or contain only script documentation that also matches script content restrictions. + contentModel: "text", + link: "https://html.spec.whatwg.org/multipage/scripting.html#the-script-element", + }, + /* noscript: { + categories: ["flow", "phrasing"], + // When scripting is disabled, in a head element: in any order, zero or more link elements, zero or more style elements, and zero or more meta elements. + // When scripting is disabled, not in a head element: transparent, but there must be no noscript element descendants. + // Otherwise: text that conforms to the requirements given in the prose. + contentModel: , + link: "https://html.spec.whatwg.org/multipage/scripting.html#the-noscript-element" + }, */ + /* template: { + categories: ["flow", "phrasing"], + // Nothing (for clarification, see example). + contentModel: "nothing", + link: "https://html.spec.whatwg.org/multipage/scripting.html#the-template-element" + }, */ + slot: { + categories: ["flow", "phrasing"], + contentModel: "transparent", + link: "https://html.spec.whatwg.org/multipage/scripting.html#the-slot-element", + }, + canvas: { + categories: ["flow", "phrasing"], + // Transparent, but with no interactive content descendants except for a elements, img elements with usemap attributes, + // button elements, input elements whose type attribute are in the Checkbox or Radio Button states, input elements that are buttons, + // and select elements with a multiple attribute or a display size greater than 1. + contentModel: "transparent", + link: "https://html.spec.whatwg.org/multipage/canvas.html#the-canvas-element", + }, +} as Specs + +const flowElements = Object.entries(specs) + .map(([tag, spec]) => spec.categories.includes("flow") && tag) + .filter(Boolean) as Array +const phrasingElements = Object.entries(specs) + .map(([tag, spec]) => spec.categories.includes("phrasing") && tag) + .filter(Boolean) as Array + +// 3.2.5.2.12: https://html.spec.whatwg.org/multipage/dom.html#option-element-inner-content-elements-2 +const optionElementInnerContentElements = ["div"] + .concat(phrasingElements) + .filter((t) => !["datalist", "object"].includes(t)) diff --git a/javascript/packages/linter/test/rules/html-content-model-conformance.test.ts b/javascript/packages/linter/test/rules/html-content-model-conformance.test.ts new file mode 100644 index 000000000..4a007555b --- /dev/null +++ b/javascript/packages/linter/test/rules/html-content-model-conformance.test.ts @@ -0,0 +1,196 @@ +import { describe, test } from "vitest" +import { HTMLContentModelConformanceRule } from "../../src/rules/html-content-model-conformance.js" +import { createLinterTest } from "../helpers/linter-test-helper.js" + +const { expectNoOffenses, expectError, assertOffenses } = createLinterTest( + HTMLContentModelConformanceRule, +) + +describe("html-content-model-conformance", () => { + test("passes for phrasing elements containing other phrasing elements", () => { + expectNoOffenses(`Hello World`) + }) + + test("passes for flow elements containing flow elements", () => { + expectNoOffenses(`

Paragraph inside div (valid)

`) + }) + + test("fails for div inside span", () => { + expectError("Element `
` cannot be placed inside element ``.") + assertOffenses(`
Invalid flow content inside span
`) + }) + + test("fails for paragraph inside span", () => { + expectError("Element `

` cannot be placed inside element ``.") + assertOffenses(`

Paragraph inside span (invalid)

`) + }) + + test("fails for multiple flow elements inside phrasing", () => { + expectError("Element `
` cannot be placed inside element ``.") + expectError("Element `

` cannot be placed inside element ``.") + assertOffenses(`

First

Second

`) + }) + + test("fails for flow inside anchor tag with span parent", () => { + expectError("Element `
` cannot be placed inside element ``.") + assertOffenses(`
Link with div
`) + }) + + test("passes for flow inside anchor tag with div parent", () => { + expectNoOffenses(``) + }) + + test("passes for flow inside anchor tag with no parent", () => { + expectNoOffenses(`
Link with div
`) + }) + + test("fails for heading inside strong", () => { + expectError("Element `

` cannot be placed inside element ``.") + assertOffenses(`

Heading in strong

`) + }) + + test("fails for section inside em", () => { + expectError("Element `
` cannot be placed inside element ``.") + assertOffenses(`
Section in em
`) + }) + + test("passes for nested phrasing elements", () => { + expectNoOffenses( + `Valid nesting`, + ) + }) + + test("fails for deeply nested flow in inline", () => { + expectError("Element `
` cannot be placed inside element ``.") + assertOffenses( + `
Deeply nested div
`, + ) + }) + + test("passes for phrasing elements with text and phrasing children", () => { + expectNoOffenses(`Text before code text after`) + }) + + test("passes for list inside flow element", () => { + expectNoOffenses(`
  • Item
`) + }) + + test("fails for list inside phrasing element", () => { + expectError("Element `
    ` cannot be placed inside element ``.") + assertOffenses(`
    • Item
    `) + }) + + test("handles ERB templates correctly", () => { + expectNoOffenses(`<%= render partial: "some/partial" %>`) + }) + + test("fails for form inside button", () => { + expectError("Element `
    ` cannot be placed inside element `
    `) + }) + + test("fails for table inside label", () => { + expectError("Element `` cannot be placed inside element `
    Cell
    `) + }) + + test("passes for custom elements inside phrasing elements", () => { + expectNoOffenses(`Custom content`) + }) + + test("passes for flow elements inside custom elements", () => { + expectNoOffenses( + `
    Flow inside custom
    `, + ) + }) + + test("passes for custom elements with various naming patterns inside phrasing", () => { + expectNoOffenses(` + + Click me + + Content + + `) + }) + + test("still fails for standard flow elements after custom elements", () => { + expectError("Element `
    ` cannot be placed inside element ``.") + assertOffenses( + `Custom
    Flow div
    `, + ) + }) + + test("passes for nested custom elements inside phrasing", () => { + expectNoOffenses( + `
    Content
    `, + ) + }) + + test("passes for single-word custom elements inside phrasing", () => { + expectNoOffenses( + `
    Flow inside unknown element
    `, + ) + }) + + test("passes for unknown elements inside phrasing but allows content inside unknown elements", () => { + expectNoOffenses( + `
    Flow content
    `, + ) + }) + + test("passes for custom elements at top level", () => { + expectNoOffenses( + `
    Flow inside custom
    `, + ) + }) + + test("fails for phrasing element containg erb node containing flow node", () => { + expectError("Element `
    ` cannot be placed inside element ``.") + assertOffenses(`<% if true %>
    Not allowed
    <% end %>
    `) + }) + + test("passes for dt and dd elements inside div with dl parent", () => { + expectNoOffenses(`
    dt
    dd
    `) + }) + + test.skip("fails for p element inside div with dl parent", () => { + expectError("Element `

    ` cannot be placed inside element `

    `.") + assertOffenses(`

    invalid

    `) + }) + + test("passes for span element inside div with option ancestor", () => { + expectNoOffenses( + ``, + ) + }) + + test.skip("fails for p element inside div with option ancestor", () => { + expectError("Element `

    ` cannot be placed inside element `

    `.") + assertOffenses(``) + }) + + test("passes for div element inside div with optgroup ancestor", () => { + expectNoOffenses( + ``, + ) + }) + + test.skip("fails for span element inside div with optgroup ancestor", () => { + expectError("Element `` cannot be placed inside element `
    `.") + assertOffenses( + ``, + ) + }) + + test("passes for div element inside div with select ancestor", () => { + expectNoOffenses( + ``, + ) + }) + + test.skip("fails for span element inside div with select ancestor", () => { + expectError("Element `` cannot be placed inside element `
    `.") + assertOffenses(``) + }) +}) From 144ad171e0d4a97e492101bf4008df46acaed246 Mon Sep 17 00:00:00 2001 From: Koji NAKAMURA Date: Wed, 31 Dec 2025 00:00:15 +0900 Subject: [PATCH 2/7] Linter: html-content-model-conformance introduces computedContentModel --- .../rules/html-content-model-conformance.ts | 65 ++++++++++++------- .../html-content-model-conformance.test.ts | 8 +-- 2 files changed, 47 insertions(+), 26 deletions(-) diff --git a/javascript/packages/linter/src/rules/html-content-model-conformance.ts b/javascript/packages/linter/src/rules/html-content-model-conformance.ts index c88ba6302..9b1a9124b 100644 --- a/javascript/packages/linter/src/rules/html-content-model-conformance.ts +++ b/javascript/packages/linter/src/rules/html-content-model-conformance.ts @@ -22,6 +22,7 @@ type ContentModel = type Spec = { categories: Array contentModel: ContentModel + computedContentModel?: (elementStack: string[]) => ContentModel link: string } type Specs = { [key: string]: Spec } @@ -34,11 +35,16 @@ class ContentModelConformanceVisitor extends BaseRuleVisitor { } private resolveTransparentContentModel(): ContentModel | null { - for (const tagName of this.elementStack.reverse()) { - const contentModel = specs[tagName]?.contentModel - if (contentModel !== "transparent") { + let index = -1 + for (const tagName of this.elementStack.slice(0, -1).reverse()) { + const spec = specs[tagName] + const contentModel = spec?.computedContentModel + ? spec.computedContentModel(this.elementStack.slice(0, index)) + : spec?.contentModel + if (spec && contentModel !== "transparent") { return contentModel } + index-- } return null } @@ -90,7 +96,12 @@ class ContentModelConformanceVisitor extends BaseRuleVisitor { } } } - check(spec, specs[parentTagName].contentModel) + + const parentSpec = specs[parentTagName] + const parentContentModel = parentSpec.computedContentModel + ? parentSpec.computedContentModel(this.elementStack.slice(0, -1)) + : parentSpec.contentModel + check(spec, parentContentModel) } } @@ -321,18 +332,26 @@ const specs = { }, div: { categories: ["flow"], - // If the element is a child of a dl element: One or more dt elements followed by one or more dd elements, optionally intermixed with script-supporting elements. - // Otherwise, if the element is a descendant of an option element: Zero or more option element inner content elements. - // Otherwise, if the element is a descendant of an optgroup element: Zero or more optgroup element inner content elements. - // Otherwise, if the element is a descendant of a select element: Zero or more select element inner content elements. - // Otherwise: flow content. - get contentModel() { - return ["dt", "dd"] - .concat(scriptSupportingElements) - .concat(optionElementInnerContentElements) - .concat(optgroupElementInnerContentElements) - .concat(selectElementInnerContentElements) - .concat(flowElements) + contentModel: "flow", + computedContentModel(elementStack) { + // If the element is a child of a dl element: One or more dt elements followed by one or more dd elements, optionally intermixed with script-supporting elements. + if (elementStack.at(-1) === "dl") { + return ["dt", "dd"].concat(scriptSupportingElements) + } + // Otherwise, if the element is a descendant of an option element: Zero or more option element inner content elements. + if (elementStack.includes("option")) { + return optionElementInnerContentElements + } + // Otherwise, if the element is a descendant of an optgroup element: Zero or more optgroup element inner content elements. + if (elementStack.includes("optgroup")) { + return optgroupElementInnerContentElements + } + // Otherwise, if the element is a descendant of a select element: Zero or more select element inner content elements. + if (elementStack.includes("select")) { + return selectElementInnerContentElements + } + // Otherwise: flow content. + return this.contentModel }, link: "https://html.spec.whatwg.org/multipage/grouping-content.html#the-div-element", }, @@ -479,12 +498,14 @@ const specs = { }, span: { categories: ["flow", "phrasing"], - // If the element is a descendant of an option element: Zero or more option element inner content elements, except div elements. - // Otherwise: Phrasing content. - get contentModel() { - return optionElementInnerContentElements - .filter((e) => e !== "div") - .concat(phrasingElements) + contentModel: "phrasing", + computedContentModel(elementStack) { + // If the element is a descendant of an option element: Zero or more option element inner content elements, except div elements. + if (elementStack.includes("option")) { + return optionElementInnerContentElements.filter((e) => e !== "div") + } + // Otherwise: Phrasing content. + return this.contentModel }, link: "https://html.spec.whatwg.org/multipage/text-level-semantics.html#the-span-element", }, diff --git a/javascript/packages/linter/test/rules/html-content-model-conformance.test.ts b/javascript/packages/linter/test/rules/html-content-model-conformance.test.ts index 4a007555b..c71170838 100644 --- a/javascript/packages/linter/test/rules/html-content-model-conformance.test.ts +++ b/javascript/packages/linter/test/rules/html-content-model-conformance.test.ts @@ -154,7 +154,7 @@ describe("html-content-model-conformance", () => { expectNoOffenses(`
    dt
    dd
    `) }) - test.skip("fails for p element inside div with dl parent", () => { + test("fails for p element inside div with dl parent", () => { expectError("Element `

    ` cannot be placed inside element `

    `.") assertOffenses(`

    invalid

    `) }) @@ -165,7 +165,7 @@ describe("html-content-model-conformance", () => { ) }) - test.skip("fails for p element inside div with option ancestor", () => { + test("fails for p element inside div with option ancestor", () => { expectError("Element `

    ` cannot be placed inside element `

    `.") assertOffenses(``) }) @@ -176,7 +176,7 @@ describe("html-content-model-conformance", () => { ) }) - test.skip("fails for span element inside div with optgroup ancestor", () => { + test("fails for span element inside div with optgroup ancestor", () => { expectError("Element `` cannot be placed inside element `
    `.") assertOffenses( ``, @@ -189,7 +189,7 @@ describe("html-content-model-conformance", () => { ) }) - test.skip("fails for span element inside div with select ancestor", () => { + test("fails for span element inside div with select ancestor", () => { expectError("Element `` cannot be placed inside element `
    `.") assertOffenses(``) }) From 558876e233f6f105050c0981e1ee3a0ac4a87ae1 Mon Sep 17 00:00:00 2001 From: Koji NAKAMURA Date: Wed, 31 Dec 2025 00:24:36 +0900 Subject: [PATCH 3/7] Linter: html-content-model-conformance fixes legend spec and summary spec --- .../rules/html-content-model-conformance.ts | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/javascript/packages/linter/src/rules/html-content-model-conformance.ts b/javascript/packages/linter/src/rules/html-content-model-conformance.ts index 9b1a9124b..1fbb7575c 100644 --- a/javascript/packages/linter/src/rules/html-content-model-conformance.ts +++ b/javascript/packages/linter/src/rules/html-content-model-conformance.ts @@ -74,7 +74,8 @@ class ContentModelConformanceVisitor extends BaseRuleVisitor { } break case "transparent": - const transparentContentModel = this.resolveTransparentContentModel() + const transparentContentModel = + this.resolveTransparentContentModel() if (transparentContentModel) { check(spec, transparentContentModel) } @@ -149,6 +150,9 @@ export class HTMLContentModelConformanceRule extends ParserRule { } } +// 3.2.5.2.4: https://html.spec.whatwg.org/multipage/dom.html#heading-content-2 +const headingContent = ["h1", "h2", "h3", "h4", "h5", "h6", "hgroup"] + // 3.2.5.2.9: https://html.spec.whatwg.org/multipage/dom.html#script-supporting-elements-2 const scriptSupportingElements = ["script", "template"] @@ -691,7 +695,9 @@ const specs = { // Either: phrasing content. // Or: Zero or more option and script-supporting elements. get contentModel() { - return phrasingElements.concat(scriptSupportingElements) + return phrasingElements + .concat(["option"]) + .concat(scriptSupportingElements) }, link: "https://html.spec.whatwg.org/multipage/form-elements.html#the-datalist-element", }, @@ -746,7 +752,9 @@ const specs = { categories: [], // If the element is a child of an optgroup element: Phrasing content, but there must be no interactive content and no descendant with the tabindex attribute. // Otherwise: Phrasing content, optionally intermixed with heading content. - contentModel: "phrasing", + get contentModel() { + return phrasingElements.concat(headingContent) + }, link: "https://html.spec.whatwg.org/multipage/form-elements.html#the-legend-element", }, selectedcontent: { @@ -765,7 +773,9 @@ const specs = { summary: { categories: [], // Phrasing content, optionally intermixed with heading content. - contentModel: "phrasing", + get contentModel() { + return phrasingElements.concat(headingContent) + }, link: "https://html.spec.whatwg.org/multipage/interactive-elements.html#the-summary-element", }, dialog: { From 87e5dade3c21492519279dea390c0fa7c2a9b3b9 Mon Sep 17 00:00:00 2001 From: Koji NAKAMURA Date: Wed, 31 Dec 2025 11:00:50 +0900 Subject: [PATCH 4/7] Linter: Add tests to verify #186 is fixed --- .../test/rules/html-content-model-conformance.test.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/javascript/packages/linter/test/rules/html-content-model-conformance.test.ts b/javascript/packages/linter/test/rules/html-content-model-conformance.test.ts index c71170838..2c9e3fac9 100644 --- a/javascript/packages/linter/test/rules/html-content-model-conformance.test.ts +++ b/javascript/packages/linter/test/rules/html-content-model-conformance.test.ts @@ -193,4 +193,15 @@ describe("html-content-model-conformance", () => { expectError("Element `` cannot be placed inside element `
    `.") assertOffenses(``) }) + + test("passes for span element inside button (#186)", () => { + expectNoOffenses( + ``, + ) + }) + + test("fails for h1 element inside button (#186)", () => { + expectError("Element `

    ` cannot be placed inside element ``) + }) }) From b93d354352afc66dde85cbd99b57679bba3097d2 Mon Sep 17 00:00:00 2001 From: Koji NAKAMURA Date: Wed, 31 Dec 2025 11:10:16 +0900 Subject: [PATCH 5/7] Linter: Add tests to verify #260 is fixed --- .../test/rules/html-content-model-conformance.test.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/javascript/packages/linter/test/rules/html-content-model-conformance.test.ts b/javascript/packages/linter/test/rules/html-content-model-conformance.test.ts index 2c9e3fac9..43839a0df 100644 --- a/javascript/packages/linter/test/rules/html-content-model-conformance.test.ts +++ b/javascript/packages/linter/test/rules/html-content-model-conformance.test.ts @@ -204,4 +204,10 @@ describe("html-content-model-conformance", () => { expectError("Element `

    ` cannot be placed inside element ``) }) + + test("passes for custom element inside button (#260)", () => { + expectNoOffenses( + ``, + ) + }) }) From 3e29aec4a3f0ccc5a26c2e4ac9e4d8062c9573ac Mon Sep 17 00:00:00 2001 From: Koji NAKAMURA Date: Wed, 31 Dec 2025 11:12:40 +0900 Subject: [PATCH 6/7] Linter: Add tests to verify #291 is fixed --- .../rules/html-content-model-conformance.test.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/javascript/packages/linter/test/rules/html-content-model-conformance.test.ts b/javascript/packages/linter/test/rules/html-content-model-conformance.test.ts index 43839a0df..383133b87 100644 --- a/javascript/packages/linter/test/rules/html-content-model-conformance.test.ts +++ b/javascript/packages/linter/test/rules/html-content-model-conformance.test.ts @@ -210,4 +210,17 @@ describe("html-content-model-conformance", () => { ``, ) }) + + test("passes for svg element inside button (#291)", () => { + expectNoOffenses(` +
    + +
    + `) + }) }) From ca3758b2afadd06b78bc82c29c1d9b3d382d7cf9 Mon Sep 17 00:00:00 2001 From: Koji NAKAMURA Date: Thu, 1 Jan 2026 15:59:39 +0900 Subject: [PATCH 7/7] Linter: html-content-model-conformance handles ERB block with tag helper --- .../rules/html-content-model-conformance.ts | 64 +++++++++++++++++-- .../html-content-model-conformance.test.ts | 24 +++++++ 2 files changed, 82 insertions(+), 6 deletions(-) diff --git a/javascript/packages/linter/src/rules/html-content-model-conformance.ts b/javascript/packages/linter/src/rules/html-content-model-conformance.ts index 1fbb7575c..2681ffb89 100644 --- a/javascript/packages/linter/src/rules/html-content-model-conformance.ts +++ b/javascript/packages/linter/src/rules/html-content-model-conformance.ts @@ -10,6 +10,9 @@ import type { HTMLOpenTagNode, HTMLElementNode, ParseResult, + ERBBlockNode, + ERBContentNode, + Location, } from "@herb-tools/core" type ContentCategory = "flow" | "phrasing" @@ -49,18 +52,51 @@ class ContentModelConformanceVisitor extends BaseRuleVisitor { return null } + // TODO: Rewrite using Prism Nodes once available + private extractTagName(content?: string): string | null { + if (!content) { + return null + } + + const cleaned_code = content.trim() + const match = cleaned_code.match(/\btag\.(\w+)(?:\s|\()?.*(?:do|\{)?\s*$/) + if (match) { + return match[1] + } + return null + } + + private handleERBNode(tagOpening: string | undefined, content: string | undefined, location: Location | undefined, visitChildren: () => void): void { + if (tagOpening !== "<%=") { + visitChildren() + return + } + + const tagName = this.extractTagName(content) + + if (tagName) { + this.checkConformance(tagName, location!) + + this.elementStack.push(tagName) + visitChildren() + this.elementStack.pop() + } else { + visitChildren() + } + } + private addOffenseMessage( tagName: string, parentTagName: string, - openTag: HTMLOpenTagNode, + location: Location, ): void { this.addOffense( `Element \`<${tagName}>\` cannot be placed inside element \`<${parentTagName}>\`.`, - openTag.tag_name!.location, + location, ) } - private checkConformance(tagName: string, openTag: HTMLOpenTagNode) { + private checkConformance(tagName: string, location: Location) { const spec = specs[tagName] const parentTagName = this.elementStack.at(-1) @@ -70,7 +106,7 @@ class ContentModelConformanceVisitor extends BaseRuleVisitor { case "flow": case "phrasing": if (!spec.categories.includes(parentContentModel)) { - this.addOffenseMessage(tagName, parentTagName, openTag) + this.addOffenseMessage(tagName, parentTagName, location) } break case "transparent": @@ -89,7 +125,7 @@ class ContentModelConformanceVisitor extends BaseRuleVisitor { default: if (Array.isArray(parentContentModel)) { if (!parentContentModel.includes(tagName)) { - this.addOffenseMessage(tagName, parentTagName, openTag) + this.addOffenseMessage(tagName, parentTagName, location) } } else { const _exhaustiveCheck: never = parentContentModel @@ -122,12 +158,28 @@ class ContentModelConformanceVisitor extends BaseRuleVisitor { return } - this.checkConformance(tagName, openTag) + this.checkConformance(tagName, openTag.tag_name!.location) this.elementStack.push(tagName) super.visitHTMLElementNode(node) this.elementStack.pop() } + + visitERBContentNode(node: ERBContentNode): void { + const tagOpening = node.tag_opening?.value + const content = node.content?.value + const location = node.content?.location + + this.handleERBNode(tagOpening, content, location, () => {}) + } + + visitERBBlockNode(node: ERBBlockNode): void { + const tagOpening = node.tag_opening?.value + const content = node.content?.value + const location = node.content?.location + + this.handleERBNode(tagOpening, content, location, () => super.visitERBBlockNode(node)) + } } export class HTMLContentModelConformanceRule extends ParserRule { diff --git a/javascript/packages/linter/test/rules/html-content-model-conformance.test.ts b/javascript/packages/linter/test/rules/html-content-model-conformance.test.ts index 383133b87..408c13ffa 100644 --- a/javascript/packages/linter/test/rules/html-content-model-conformance.test.ts +++ b/javascript/packages/linter/test/rules/html-content-model-conformance.test.ts @@ -223,4 +223,28 @@ describe("html-content-model-conformance", () => {

    `) }) + + test("passes for div element inside tag.div block", () => { + expectNoOffenses(`<%= tag.div do %>
    inside tag.p
    <% end %>`) + }) + + test("fails for div element inside tag.p block", () => { + expectError("Element `
    ` cannot be placed inside element `

    `.") + assertOffenses(`<%= tag.p do %>

    inside tag.p
    <% end %>`) + }) + + test("fails for div element inside nesting tag blocks and elements", () => { + expectError("Element `
    ` cannot be placed inside element `

    `.") + expectError("Element `