Skip to content

Commit 717db8c

Browse files
authored
feat(lint): add noMisplacedListElements (#11917)
1 parent eaf45e2 commit 717db8c

46 files changed

Lines changed: 2715 additions & 2 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.changeset/old-tools-leave.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@biomejs/biome": patch
3+
---
4+
5+
Added the nursery rule [`noMisplacedListElements`](https://biomejs.dev/linter/rules/no-misplaced-list-elements/) for HTML and JSX, which requires `<li>` elements with an HTML element parent to be children of `<ul>`, `<ol>`, or `<menu>`. For example, `<div><li>Item</li></div>` is invalid.

‎crates/biome_cli/src/execute/migrate/eslint_any_rule_to_biome.rs‎

Lines changed: 16 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎crates/biome_configuration/src/analyzer/linter/rules.rs‎

Lines changed: 4 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎crates/biome_configuration/src/generated/linter_options_check.rs‎

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎crates/biome_diagnostics_categories/src/categories.rs‎

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 258 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
1+
use biome_analyze::{
2+
Ast, Rule, RuleDiagnostic, RuleSource, context::RuleContext, declare_lint_rule,
3+
};
4+
use biome_console::markup;
5+
use biome_diagnostics::Severity;
6+
use biome_html_syntax::{
7+
HtmlElement, HtmlSyntaxKind, HtmlSyntaxNode, T, element_ext::AnyHtmlTagElement,
8+
};
9+
use biome_languages::HtmlFileSource;
10+
use biome_parser::{TokenSet, token_set};
11+
use biome_rowan::{AstNode, TextRange};
12+
use biome_rule_options::no_misplaced_list_elements::NoMisplacedListElementsOptions;
13+
14+
declare_lint_rule! {
15+
/// Require `<li>` elements with an HTML element parent to be children of `<ul>`, `<ol>`, or `<menu>`.
16+
///
17+
/// List items need a list container to define their relationship to the other items.
18+
/// Placing a list item outside a list container produces invalid HTML.
19+
///
20+
/// The parent is the element the item is rendered into. List items that aren't inside
21+
/// any element in the file are ignored, because the file may be rendered inside a list
22+
/// somewhere else, for example as a component. Items directly inside a <template> element are also
23+
/// ignored, because the template's content is inserted by a script, not rendered in place.
24+
/// Items whose parent is a component are also ignored,
25+
/// because the element a component renders is unknown.
26+
///
27+
/// In Svelte, control blocks such as `{#if}` and `{#each}`, as well as `<svelte:boundary>`,
28+
/// `<svelte:fragment>`, and `<slot>`, are skipped when finding the parent, while items
29+
/// inside snippets, `<svelte:element>`, and other Svelte special elements are ignored.
30+
///
31+
/// In Vue, `<template>` elements with a `v-if`, `v-else-if`, `v-else`, or `v-for`
32+
/// directive, `<transition>`, `<keep-alive>`, `<suspense>`, and `<slot>` are looked
33+
/// through to find the parent. Items directly inside the component's top-level
34+
/// `<template>`, and items inside `<component>`, `<transition-group>`, or `<teleport>`,
35+
/// are ignored.
36+
///
37+
/// In Astro, fragments and `<slot>` are looked through to find the parent. Markup inside
38+
/// a `{...}` expression is checked separately from the markup around it, so an item in
39+
/// an expression is only compared with elements in the same expression. For example,
40+
/// `<div>{items.map((item) => <li>{item}</li>)}</div>` isn't reported, because the
41+
/// `<div>` is outside the expression.
42+
///
43+
/// ## Examples
44+
///
45+
/// ### Invalid
46+
///
47+
/// ```html,expect_diagnostic
48+
/// <div><li>Item</li></div>
49+
/// ```
50+
///
51+
/// ```html,expect_diagnostic
52+
/// <ul><div><li>Item</li></div></ul>
53+
/// ```
54+
///
55+
/// ```svelte,expect_diagnostic
56+
/// <div>
57+
/// {#each items as item}
58+
/// <li>{item}</li>
59+
/// {/each}
60+
/// </div>
61+
/// ```
62+
///
63+
/// ```astro,expect_diagnostic
64+
/// <div>
65+
/// <>
66+
/// <li>Item</li>
67+
/// </>
68+
/// </div>
69+
/// ```
70+
///
71+
/// ### Valid
72+
///
73+
/// ```html
74+
/// <ul><li>Item</li></ul>
75+
/// <ol><li>Item</li></ol>
76+
/// <menu><li>Item</li></menu>
77+
/// ```
78+
///
79+
/// ```vue
80+
/// <template>
81+
/// <li>Item rendered in a list elsewhere</li>
82+
/// </template>
83+
/// ```
84+
///
85+
/// ```svelte
86+
/// {#snippet item()}
87+
/// <li>Item</li>
88+
/// {/snippet}
89+
/// ```
90+
///
91+
/// ```astro
92+
/// <ul>
93+
/// <>
94+
/// <li>Item</li>
95+
/// </>
96+
/// </ul>
97+
/// ```
98+
///
99+
pub NoMisplacedListElements {
100+
version: "next",
101+
name: "noMisplacedListElements",
102+
language: "html",
103+
recommended: true,
104+
severity: Severity::Error,
105+
sources: &[RuleSource::HtmlEslint("require-li-container").inspired()],
106+
}
107+
}
108+
109+
const LIST_CONTAINERS: TokenSet<HtmlSyntaxKind> = token_set![T![ul], T![ol], T![menu]];
110+
const SVELTE_CONTROL_BLOCK_KINDS: TokenSet<HtmlSyntaxKind> = token_set![
111+
HtmlSyntaxKind::SVELTE_IF_BLOCK,
112+
HtmlSyntaxKind::SVELTE_IF_OPENING_BLOCK,
113+
HtmlSyntaxKind::SVELTE_ELSE_IF_CLAUSE_LIST,
114+
HtmlSyntaxKind::SVELTE_ELSE_IF_CLAUSE,
115+
HtmlSyntaxKind::SVELTE_ELSE_CLAUSE,
116+
HtmlSyntaxKind::SVELTE_EACH_BLOCK,
117+
HtmlSyntaxKind::SVELTE_AWAIT_BLOCK,
118+
HtmlSyntaxKind::SVELTE_AWAIT_OPENING_BLOCK,
119+
HtmlSyntaxKind::SVELTE_AWAIT_THEN_BLOCK,
120+
HtmlSyntaxKind::SVELTE_AWAIT_CATCH_BLOCK,
121+
HtmlSyntaxKind::SVELTE_AWAIT_CLAUSES_LIST,
122+
HtmlSyntaxKind::SVELTE_KEY_BLOCK,
123+
];
124+
125+
impl Rule for NoMisplacedListElements {
126+
type Query = Ast<AnyHtmlTagElement>;
127+
type State = TextRange;
128+
type Signals = Option<Self::State>;
129+
type Options = NoMisplacedListElementsOptions;
130+
131+
fn run(ctx: &RuleContext<Self>) -> Self::Signals {
132+
let node = ctx.query();
133+
if node.tag_name_kind() != Some(T![li]) {
134+
return None;
135+
}
136+
137+
let element = match node {
138+
AnyHtmlTagElement::HtmlOpeningElement(opening) => opening.syntax().parent()?,
139+
AnyHtmlTagElement::HtmlSelfClosingElement(element) => element.syntax().clone(),
140+
};
141+
let parent = rendered_parent(&element, ctx.source_type::<HtmlFileSource>())?;
142+
let parent_kind = parent.tag_name_kind()?;
143+
if LIST_CONTAINERS.contains(parent_kind) {
144+
return None;
145+
}
146+
147+
Some(node.syntax().text_trimmed_range())
148+
}
149+
150+
fn diagnostic(_ctx: &RuleContext<Self>, range: &Self::State) -> Option<RuleDiagnostic> {
151+
Some(
152+
RuleDiagnostic::new(
153+
rule_category!(),
154+
*range,
155+
markup! {
156+
"This "<Emphasis>"<li>"</Emphasis>" element is outside a list container."
157+
},
158+
)
159+
.note("List items need a list container to form a valid HTML list.")
160+
.note(markup! {
161+
"Make this element a direct child of "<Emphasis>"<ul>"</Emphasis>", "<Emphasis>"<ol>"</Emphasis>", or "<Emphasis>"<menu>"</Emphasis>"."
162+
}),
163+
)
164+
}
165+
}
166+
167+
/// Returns the opening tag of the element that `element` is rendered into.
168+
///
169+
/// Returns `None` when the element has no parent element, or when the parent does not
170+
/// determine where its children are rendered. See [`ParentRole`].
171+
fn rendered_parent(
172+
element: &HtmlSyntaxNode,
173+
source_type: &HtmlFileSource,
174+
) -> Option<AnyHtmlTagElement> {
175+
for ancestor in element.ancestors().skip(1) {
176+
let kind = ancestor.kind();
177+
if kind == HtmlSyntaxKind::HTML_ELEMENT_LIST
178+
|| kind == HtmlSyntaxKind::ASTRO_FRAGMENT
179+
|| (source_type.is_svelte() && SVELTE_CONTROL_BLOCK_KINDS.contains(kind))
180+
{
181+
continue;
182+
}
183+
184+
let parent = HtmlElement::cast(ancestor)?;
185+
let opening = AnyHtmlTagElement::from(parent.opening_element().ok()?);
186+
match ParentRole::of(&opening, source_type) {
187+
ParentRole::Element => return Some(opening),
188+
ParentRole::Transparent => {}
189+
ParentRole::Unknown => return None,
190+
}
191+
}
192+
None
193+
}
194+
195+
/// How a parent element relates to the element its children are rendered into.
196+
enum ParentRole {
197+
/// The parent is the element its children are rendered into.
198+
Element,
199+
/// The parent renders its children in its own place, so they are rendered into the
200+
/// nearest enclosing element.
201+
Transparent,
202+
/// The element the children are rendered into can't be determined.
203+
Unknown,
204+
}
205+
206+
impl ParentRole {
207+
fn of(opening: &AnyHtmlTagElement, source_type: &HtmlFileSource) -> Self {
208+
if source_type.is_svelte() && opening.is_svelte_special_element() {
209+
return match opening.tag_name().as_ref().map(|name| name.text()) {
210+
Some("svelte:boundary" | "svelte:fragment") => Self::Transparent,
211+
_ => Self::Unknown,
212+
};
213+
}
214+
215+
if source_type.is_vue() {
216+
match opening.tag_name().as_ref().map(|name| name.text()) {
217+
Some("transition" | "keep-alive" | "suspense") => return Self::Transparent,
218+
Some("component" | "transition-group" | "teleport") => return Self::Unknown,
219+
_ => {}
220+
}
221+
}
222+
223+
match opening.tag_name_kind() {
224+
// Vue renders the content of a `<template>` with a control flow directive in
225+
// place of the template.
226+
Some(T![template])
227+
if source_type.is_vue() && has_vue_control_flow_directive(opening) =>
228+
{
229+
Self::Transparent
230+
}
231+
// The content of a native `<template>` is inserted by a script, which decides
232+
// where it is rendered.
233+
Some(T![template]) => Self::Unknown,
234+
Some(T![slot])
235+
if source_type.is_vue() || source_type.is_svelte() || source_type.is_astro() =>
236+
{
237+
Self::Transparent
238+
}
239+
Some(_) => Self::Element,
240+
None => Self::Unknown,
241+
}
242+
}
243+
}
244+
245+
fn has_vue_control_flow_directive(opening: &AnyHtmlTagElement) -> bool {
246+
opening.attributes().into_iter().any(|attribute| {
247+
attribute
248+
.as_any_vue_directive()
249+
.and_then(|directive| directive.as_vue_directive())
250+
.and_then(|directive| directive.name_token().ok())
251+
.is_some_and(|name| {
252+
matches!(
253+
name.text_trimmed(),
254+
"v-if" | "v-else-if" | "v-else" | "v-for"
255+
)
256+
})
257+
})
258+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
<!-- should generate diagnostics -->
2+
<div><li /></div>
3+
<ul><><div><li>Indirect child</li></div></></ul>
4+
<div><><li>Nested fragment item</li><li /></></div>
5+
<div><slot><li>Slot fallback item</li></slot></div>

0 commit comments

Comments
 (0)