Skip to content

Commit 291c9f2

Browse files
feat(biome_js_analyze): port useInlineScriptId from Next.js (#8624)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
1 parent 96d09f4 commit 291c9f2

29 files changed

Lines changed: 576 additions & 1 deletion

File tree

.changeset/evil-houses-hide.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
"@biomejs/biome": patch
3+
---
4+
5+
Added the nursery rule [`useInlineScriptId`](https://biomejs.dev/linter/rules/use-inline-script-id/) to the Next.js domain.
6+
This rule enforces `id` attribute on `next/script` components with inline content or `dangerouslySetInnerHTML`.
7+
8+
The following code is invalid:
9+
10+
```jsx
11+
import Script from 'next/script';
12+
13+
export default function Page() {
14+
return (
15+
<Script>{`console.log('Hello');`}</Script> // must have `id` attribute
16+
);
17+
}
18+
```

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

Lines changed: 12 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/domain_selector.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.

crates/biome_js_analyze/src/lint/nursery.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ pub mod use_error_cause;
5252
pub mod use_exhaustive_switch_cases;
5353
pub mod use_explicit_type;
5454
pub mod use_find;
55+
pub mod use_inline_script_id;
5556
pub mod use_max_params;
5657
pub mod use_qwik_method_usage;
5758
pub mod use_qwik_valid_lexical_scope;
@@ -61,4 +62,4 @@ pub mod use_spread;
6162
pub mod use_vue_consistent_define_props_declaration;
6263
pub mod use_vue_define_macros_order;
6364
pub mod use_vue_multi_word_component_names;
64-
declare_lint_group! { pub Nursery { name : "nursery" , rules : [self :: no_ambiguous_anchor_text :: NoAmbiguousAnchorText , self :: no_before_interactive_script_outside_document :: NoBeforeInteractiveScriptOutsideDocument , self :: no_continue :: NoContinue , self :: no_deprecated_imports :: NoDeprecatedImports , self :: no_duplicate_enum_values :: NoDuplicateEnumValues , self :: no_duplicated_spread_props :: NoDuplicatedSpreadProps , self :: no_empty_source :: NoEmptySource , self :: no_equals_to_null :: NoEqualsToNull , self :: no_excessive_lines_per_file :: NoExcessiveLinesPerFile , self :: no_floating_promises :: NoFloatingPromises , self :: no_for_in :: NoForIn , self :: no_import_cycles :: NoImportCycles , self :: no_increment_decrement :: NoIncrementDecrement , self :: no_jsx_literals :: NoJsxLiterals , self :: no_jsx_props_bind :: NoJsxPropsBind , self :: no_leaked_render :: NoLeakedRender , self :: no_misused_promises :: NoMisusedPromises , self :: no_multi_assign :: NoMultiAssign , self :: no_multi_str :: NoMultiStr , self :: no_next_async_client_component :: NoNextAsyncClientComponent , self :: no_parameters_only_used_in_recursion :: NoParametersOnlyUsedInRecursion , self :: no_proto :: NoProto , self :: no_react_forward_ref :: NoReactForwardRef , self :: no_return_assign :: NoReturnAssign , self :: no_script_url :: NoScriptUrl , self :: no_shadow :: NoShadow , self :: no_sync_scripts :: NoSyncScripts , self :: no_ternary :: NoTernary , self :: no_undeclared_env_vars :: NoUndeclaredEnvVars , self :: no_unknown_attribute :: NoUnknownAttribute , self :: no_unnecessary_conditions :: NoUnnecessaryConditions , self :: no_unresolved_imports :: NoUnresolvedImports , self :: no_unused_expressions :: NoUnusedExpressions , self :: no_useless_catch_binding :: NoUselessCatchBinding , self :: no_useless_undefined :: NoUselessUndefined , self :: no_vue_data_object_declaration :: NoVueDataObjectDeclaration , self :: no_vue_duplicate_keys :: NoVueDuplicateKeys , self :: no_vue_options_api :: NoVueOptionsApi , self :: no_vue_reserved_keys :: NoVueReservedKeys , self :: no_vue_reserved_props :: NoVueReservedProps , self :: no_vue_setup_props_reactivity_loss :: NoVueSetupPropsReactivityLoss , self :: use_array_sort_compare :: UseArraySortCompare , self :: use_await_thenable :: UseAwaitThenable , self :: use_consistent_arrow_return :: UseConsistentArrowReturn , self :: use_destructuring :: UseDestructuring , self :: use_error_cause :: UseErrorCause , self :: use_exhaustive_switch_cases :: UseExhaustiveSwitchCases , self :: use_explicit_type :: UseExplicitType , self :: use_find :: UseFind , self :: use_max_params :: UseMaxParams , self :: use_qwik_method_usage :: UseQwikMethodUsage , self :: use_qwik_valid_lexical_scope :: UseQwikValidLexicalScope , self :: use_regexp_exec :: UseRegexpExec , self :: use_sorted_classes :: UseSortedClasses , self :: use_spread :: UseSpread , self :: use_vue_consistent_define_props_declaration :: UseVueConsistentDefinePropsDeclaration , self :: use_vue_define_macros_order :: UseVueDefineMacrosOrder , self :: use_vue_multi_word_component_names :: UseVueMultiWordComponentNames ,] } }
65+
declare_lint_group! { pub Nursery { name : "nursery" , rules : [self :: no_ambiguous_anchor_text :: NoAmbiguousAnchorText , self :: no_before_interactive_script_outside_document :: NoBeforeInteractiveScriptOutsideDocument , self :: no_continue :: NoContinue , self :: no_deprecated_imports :: NoDeprecatedImports , self :: no_duplicate_enum_values :: NoDuplicateEnumValues , self :: no_duplicated_spread_props :: NoDuplicatedSpreadProps , self :: no_empty_source :: NoEmptySource , self :: no_equals_to_null :: NoEqualsToNull , self :: no_excessive_lines_per_file :: NoExcessiveLinesPerFile , self :: no_floating_promises :: NoFloatingPromises , self :: no_for_in :: NoForIn , self :: no_import_cycles :: NoImportCycles , self :: no_increment_decrement :: NoIncrementDecrement , self :: no_jsx_literals :: NoJsxLiterals , self :: no_jsx_props_bind :: NoJsxPropsBind , self :: no_leaked_render :: NoLeakedRender , self :: no_misused_promises :: NoMisusedPromises , self :: no_multi_assign :: NoMultiAssign , self :: no_multi_str :: NoMultiStr , self :: no_next_async_client_component :: NoNextAsyncClientComponent , self :: no_parameters_only_used_in_recursion :: NoParametersOnlyUsedInRecursion , self :: no_proto :: NoProto , self :: no_react_forward_ref :: NoReactForwardRef , self :: no_return_assign :: NoReturnAssign , self :: no_script_url :: NoScriptUrl , self :: no_shadow :: NoShadow , self :: no_sync_scripts :: NoSyncScripts , self :: no_ternary :: NoTernary , self :: no_undeclared_env_vars :: NoUndeclaredEnvVars , self :: no_unknown_attribute :: NoUnknownAttribute , self :: no_unnecessary_conditions :: NoUnnecessaryConditions , self :: no_unresolved_imports :: NoUnresolvedImports , self :: no_unused_expressions :: NoUnusedExpressions , self :: no_useless_catch_binding :: NoUselessCatchBinding , self :: no_useless_undefined :: NoUselessUndefined , self :: no_vue_data_object_declaration :: NoVueDataObjectDeclaration , self :: no_vue_duplicate_keys :: NoVueDuplicateKeys , self :: no_vue_options_api :: NoVueOptionsApi , self :: no_vue_reserved_keys :: NoVueReservedKeys , self :: no_vue_reserved_props :: NoVueReservedProps , self :: no_vue_setup_props_reactivity_loss :: NoVueSetupPropsReactivityLoss , self :: use_array_sort_compare :: UseArraySortCompare , self :: use_await_thenable :: UseAwaitThenable , self :: use_consistent_arrow_return :: UseConsistentArrowReturn , self :: use_destructuring :: UseDestructuring , self :: use_error_cause :: UseErrorCause , self :: use_exhaustive_switch_cases :: UseExhaustiveSwitchCases , self :: use_explicit_type :: UseExplicitType , self :: use_find :: UseFind , self :: use_inline_script_id :: UseInlineScriptId , self :: use_max_params :: UseMaxParams , self :: use_qwik_method_usage :: UseQwikMethodUsage , self :: use_qwik_valid_lexical_scope :: UseQwikValidLexicalScope , self :: use_regexp_exec :: UseRegexpExec , self :: use_sorted_classes :: UseSortedClasses , self :: use_spread :: UseSpread , self :: use_vue_consistent_define_props_declaration :: UseVueConsistentDefinePropsDeclaration , self :: use_vue_define_macros_order :: UseVueDefineMacrosOrder , self :: use_vue_multi_word_component_names :: UseVueMultiWordComponentNames ,] } }
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
use biome_analyze::{
2+
Rule, RuleDiagnostic, RuleDomain, RuleSource, context::RuleContext, declare_lint_rule,
3+
};
4+
use biome_console::markup;
5+
use biome_diagnostics::Severity;
6+
use biome_js_syntax::{
7+
AnyJsExpression, AnyJsxAttribute, JsObjectExpression, JsVariableDeclarator, JsxElement,
8+
jsx_ext::AnyJsxElement,
9+
};
10+
use biome_rowan::{AstNode, AstNodeList, TextRange, TokenText};
11+
use biome_rule_options::use_inline_script_id::UseInlineScriptIdOptions;
12+
use rustc_hash::FxHashSet;
13+
14+
use crate::{
15+
nextjs::{NextUtility, is_next_import},
16+
services::semantic::Semantic,
17+
};
18+
19+
declare_lint_rule! {
20+
/// Enforce `id` attribute on `next/script` components with inline content or `dangerouslySetInnerHTML`.
21+
///
22+
/// Using inline scripts or `dangerouslySetInnerHTML` in `next/script` components requires an `id` attribute to ensure that Next.js can track and optimize them correctly.
23+
///
24+
/// ## Examples
25+
///
26+
/// ### Invalid
27+
///
28+
/// ```jsx,expect_diagnostic
29+
/// import Script from 'next/script'
30+
///
31+
/// export default function Page() {
32+
/// return (
33+
/// <Script>{`console.log('Hello world!');`}</Script>
34+
/// )
35+
/// }
36+
/// ```
37+
///
38+
/// ```jsx,expect_diagnostic
39+
/// import Script from 'next/script'
40+
///
41+
/// export default function Page() {
42+
/// return (
43+
/// <Script dangerouslySetInnerHTML={{ __html: `console.log('Hello world!');` }} />
44+
/// )
45+
/// }
46+
/// ```
47+
///
48+
/// ### Valid
49+
/// ```jsx
50+
/// import Script from 'next/script'
51+
///
52+
/// export default function Page() {
53+
/// return (
54+
/// <Script id="my-script">{`console.log('Hello world!');`}</Script>
55+
/// )
56+
/// }
57+
/// ```
58+
///
59+
/// ```jsx
60+
/// import Script from 'next/script'
61+
///
62+
/// export default function Page() {
63+
/// return (
64+
/// <Script id="my-script" dangerouslySetInnerHTML={{ __html: `console.log('Hello world!');` }} />
65+
/// )
66+
/// }
67+
/// ```
68+
///
69+
pub UseInlineScriptId {
70+
version: "next",
71+
name: "useInlineScriptId",
72+
language: "jsx",
73+
sources: &[RuleSource::EslintNext("inline-script-id").same()],
74+
recommended: true,
75+
severity: Severity::Error,
76+
domains: &[RuleDomain::Next],
77+
}
78+
}
79+
80+
impl Rule for UseInlineScriptId {
81+
type Query = Semantic<AnyJsxElement>;
82+
type State = TextRange;
83+
type Signals = Option<Self::State>;
84+
type Options = UseInlineScriptIdOptions;
85+
86+
fn run(ctx: &RuleContext<Self>) -> Self::Signals {
87+
let jsx_element = ctx.query();
88+
89+
let semantic_model = ctx.model();
90+
let reference = jsx_element.name().ok()?;
91+
let reference = reference.as_jsx_reference_identifier()?;
92+
let binding = semantic_model.binding(reference)?;
93+
if !is_next_import(&binding, NextUtility::Script) {
94+
return None;
95+
}
96+
97+
let mut attribute_names = FxHashSet::default();
98+
for attribute in jsx_element.attributes() {
99+
match attribute {
100+
AnyJsxAttribute::JsxAttribute(a) => {
101+
if let Ok(name_value) = a.name_value_token() {
102+
let name = name_value.token_text();
103+
attribute_names.insert(name);
104+
}
105+
}
106+
AnyJsxAttribute::JsxSpreadAttribute(spread) => {
107+
if let Ok(argument) = spread.argument() {
108+
match argument {
109+
AnyJsExpression::JsObjectExpression(obj_expr) => {
110+
collect_property_names(&obj_expr, &mut attribute_names)?;
111+
}
112+
AnyJsExpression::JsIdentifierExpression(ident_expr) => {
113+
if let Ok(reference) = ident_expr.name()
114+
&& let Some(binding) = semantic_model.binding(&reference)
115+
&& let Some(declarator) = binding
116+
.syntax()
117+
.ancestors()
118+
.find_map(JsVariableDeclarator::cast)
119+
&& let Some(initializer) = declarator.initializer()
120+
&& let Ok(expression) = initializer.expression()
121+
&& let AnyJsExpression::JsObjectExpression(obj_expr) =
122+
expression
123+
{
124+
collect_property_names(&obj_expr, &mut attribute_names)?;
125+
}
126+
}
127+
_ => {}
128+
}
129+
}
130+
}
131+
_ => {}
132+
}
133+
}
134+
135+
let has_children = jsx_element
136+
.parent::<JsxElement>()
137+
.is_some_and(|parent| !parent.children().is_empty());
138+
if (has_children || attribute_names.contains("dangerouslySetInnerHTML"))
139+
&& !attribute_names.contains("id")
140+
{
141+
return Some(jsx_element.range());
142+
}
143+
144+
None
145+
}
146+
147+
fn diagnostic(_ctx: &RuleContext<Self>, state: &Self::State) -> Option<RuleDiagnostic> {
148+
Some(
149+
RuleDiagnostic::new(
150+
rule_category!(),
151+
state,
152+
markup! {
153+
""<Emphasis>"next/script"</Emphasis>" components have inline content or `dangerouslySetInnerHTML` without "<Emphasis>"id"</Emphasis>" attribute."
154+
},
155+
)
156+
.note(markup!(
157+
"Next.js requires "<Emphasis>"id"</Emphasis>" attribute to track and optimize inline scripts. Without it, performance issues may occur."
158+
))
159+
.note(markup! {
160+
"See the "<Hyperlink href="https://nextjs.org/docs/messages/inline-script-id">"Next.js docs"</Hyperlink>" for more details."
161+
})
162+
)
163+
}
164+
}
165+
166+
fn collect_property_names(
167+
obj_expr: &JsObjectExpression,
168+
set: &mut FxHashSet<TokenText>,
169+
) -> Option<()> {
170+
for member in obj_expr.members() {
171+
let member = member.ok()?;
172+
if let Some(property_member) = member.as_js_property_object_member()
173+
&& let Some(name) = property_member.name().ok().and_then(|n| n.name())
174+
{
175+
set.insert(name);
176+
} else if let Some(shorthand) = member.as_js_shorthand_property_object_member()
177+
&& let Some(name) = shorthand.name().ok().and_then(|n| n.name().ok())
178+
{
179+
set.insert(name);
180+
}
181+
}
182+
Some(())
183+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
/* should generate diagnostics */
2+
import Script from 'next/script'
3+
4+
export default function Page() {
5+
return (
6+
<Script>{`console.log('Hello world!');`}</Script>
7+
)
8+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
---
2+
source: crates/biome_js_analyze/tests/spec_tests.rs
3+
expression: invalid-01.jsx
4+
---
5+
# Input
6+
```jsx
7+
/* should generate diagnostics */
8+
import Script from 'next/script'
9+
10+
export default function Page() {
11+
return (
12+
<Script>{`console.log('Hello world!');`}</Script>
13+
)
14+
}
15+
16+
```
17+
18+
# Diagnostics
19+
```
20+
invalid-01.jsx:6:5 lint/nursery/useInlineScriptId ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
21+
22+
× next/script components have inline content or `dangerouslySetInnerHTML` without id attribute.
23+
24+
4 │ export default function Page() {
25+
5return (
26+
> 6 │ <Script>{`console.log('Hello world!');`}</Script>
27+
^^^^^^^^
28+
7 │ )
29+
8}
30+
31+
i Next.js requires id attribute to track and optimize inline scripts. Without it, performance issues may occur.
32+
33+
i See the Next.js docs for more details.
34+
35+
i This rule belongs to the nursery group, which means it is not yet stable and may change in the future. Visit https://biomejs.dev/linter/#nursery for more information.
36+
37+
38+
```
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
/* should generate diagnostics */
2+
import Script from 'next/script'
3+
4+
export default function Page() {
5+
return (
6+
<Script dangerouslySetInnerHTML={{ __html: `console.log('Hello world!');` }} />
7+
)
8+
}

0 commit comments

Comments
 (0)