Skip to content
Open
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
18 changes: 18 additions & 0 deletions .changeset/evil-houses-hide.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
"@biomejs/biome": patch
---

Added the nursery rule [`useInlineScriptId`](https://biomejs.dev/linter/rules/use-inline-script-id/) to the Next.js domain.
This rule enforces `id` attribute on `next/script` components with inline content or `dangerouslySetInnerHTML`.

The following code is invalid:

```jsx
import Script from 'next/script';

export default function Page() {
return (
<Script>{`console.log('Hello');`}</Script> // must have `id` attribute
);
}
```
12 changes: 12 additions & 0 deletions crates/biome_cli/src/execute/migrate/eslint_any_rule_to_biome.rs

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions crates/biome_configuration/src/analyzer/linter/rules.rs

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/biome_diagnostics_categories/src/categories.rs

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion crates/biome_js_analyze/src/lint/nursery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ pub mod use_error_cause;
pub mod use_exhaustive_switch_cases;
pub mod use_explicit_type;
pub mod use_find;
pub mod use_inline_script_id;
pub mod use_max_params;
pub mod use_qwik_method_usage;
pub mod use_qwik_valid_lexical_scope;
Expand All @@ -59,4 +60,4 @@ pub mod use_spread;
pub mod use_vue_consistent_define_props_declaration;
pub mod use_vue_define_macros_order;
pub mod use_vue_multi_word_component_names;
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_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_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 ,] } }
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_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_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 ,] } }
183 changes: 183 additions & 0 deletions crates/biome_js_analyze/src/lint/nursery/use_inline_script_id.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
use biome_analyze::{
Rule, RuleDiagnostic, RuleDomain, RuleSource, context::RuleContext, declare_lint_rule,
};
use biome_console::markup;
use biome_diagnostics::Severity;
use biome_js_syntax::{
AnyJsExpression, AnyJsxAttribute, JsObjectExpression, JsVariableDeclarator, JsxElement,
jsx_ext::AnyJsxElement,
};
use biome_rowan::{AstNode, AstNodeList, TextRange, TokenText};
use biome_rule_options::use_inline_script_id::UseInlineScriptIdOptions;
use rustc_hash::FxHashSet;

use crate::{
nextjs::{NextUtility, is_next_import},
services::semantic::Semantic,
};

declare_lint_rule! {
/// Enforce `id` attribute on `next/script` components with inline content or `dangerouslySetInnerHTML`.
///
/// Using inline scripts or `dangerouslySetInnerHTML` in `next/script` components requires an `id` attribute to ensure that Next.js can track and optimize them correctly.
///
/// ## Examples
///
/// ### Invalid
///
/// ```jsx,expect_diagnostic
/// import Script from 'next/script'
///
/// export default function Page() {
/// return (
/// <Script>{`console.log('Hello world!');`}</Script>
/// )
/// }
/// ```
///
/// ```jsx,expect_diagnostic
/// import Script from 'next/script'
///
/// export default function Page() {
/// return (
/// <Script dangerouslySetInnerHTML={{ __html: `console.log('Hello world!');` }} />
/// )
/// }
/// ```
///
/// ### Valid
/// ```jsx
/// import Script from 'next/script'
///
/// export default function Page() {
/// return (
/// <Script id="my-script">{`console.log('Hello world!');`}</Script>
/// )
/// }
/// ```
///
/// ```jsx
/// import Script from 'next/script'
///
/// export default function Page() {
/// return (
/// <Script id="my-script" dangerouslySetInnerHTML={{ __html: `console.log('Hello world!');` }} />
/// )
/// }
/// ```
///
pub UseInlineScriptId {
version: "next",
name: "useInlineScriptId",
language: "jsx",
sources: &[RuleSource::EslintNext("inline-script-id").same()],
recommended: true,
severity: Severity::Error,
domains: &[RuleDomain::Next],
}
}

impl Rule for UseInlineScriptId {
type Query = Semantic<AnyJsxElement>;
type State = TextRange;
type Signals = Option<Self::State>;
type Options = UseInlineScriptIdOptions;

fn run(ctx: &RuleContext<Self>) -> Self::Signals {
let jsx_element = ctx.query();

let semantic_model = ctx.model();
let reference = jsx_element.name().ok()?;
let reference = reference.as_jsx_reference_identifier()?;
let binding = semantic_model.binding(reference)?;
if !is_next_import(&binding, NextUtility::Script) {
return None;
}

let mut attribute_names = FxHashSet::default();
for attribute in jsx_element.attributes() {
match attribute {
AnyJsxAttribute::JsxAttribute(a) => {
if let Ok(name_value) = a.name_value_token() {
let name = name_value.token_text();
attribute_names.insert(name);
}
}
AnyJsxAttribute::JsxSpreadAttribute(spread) => {
if let Ok(argument) = spread.argument() {
match argument {
AnyJsExpression::JsObjectExpression(obj_expr) => {
collect_property_names(&obj_expr, &mut attribute_names)?;
}
AnyJsExpression::JsIdentifierExpression(ident_expr) => {
if let Ok(reference) = ident_expr.name()
&& let Some(binding) = semantic_model.binding(&reference)
&& let Some(declarator) = binding
.syntax()
.ancestors()
.find_map(JsVariableDeclarator::cast)
&& let Some(initializer) = declarator.initializer()
&& let Ok(expression) = initializer.expression()
&& let AnyJsExpression::JsObjectExpression(obj_expr) =
expression
{
collect_property_names(&obj_expr, &mut attribute_names)?;
}
}
_ => {}
}
}
}
_ => {}
}
}

let has_children = jsx_element
.parent::<JsxElement>()
.is_some_and(|parent| !parent.children().is_empty());
if (has_children || attribute_names.contains("dangerouslySetInnerHTML"))
&& !attribute_names.contains("id")
{
return Some(jsx_element.range());
}

None
}

fn diagnostic(_ctx: &RuleContext<Self>, state: &Self::State) -> Option<RuleDiagnostic> {
Some(
RuleDiagnostic::new(
rule_category!(),
state,
markup! {
""<Emphasis>"next/script"</Emphasis>" components have inline content or `dangerouslySetInnerHTML` without "<Emphasis>"id"</Emphasis>" attribute."
},
)
.note(markup!(
"Next.js requires "<Emphasis>"id"</Emphasis>" attribute to track and optimize inline scripts. Without it, performance issues may occur."
))
.note(markup! {
"See the "<Hyperlink href="https://nextjs.org/docs/messages/inline-script-id">"Next.js docs"</Hyperlink>" for more details."
})
)
}
}

fn collect_property_names(
obj_expr: &JsObjectExpression,
set: &mut FxHashSet<TokenText>,
) -> Option<()> {
for member in obj_expr.members() {
let member = member.ok()?;
if let Some(property_member) = member.as_js_property_object_member()
&& let Some(name) = property_member.name().ok().and_then(|n| n.name())
{
set.insert(name);
} else if let Some(shorthand) = member.as_js_shorthand_property_object_member()
&& let Some(name) = shorthand.name().ok().and_then(|n| n.name().ok())
{
set.insert(name);
}
}
Some(())
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/* should generate diagnostics */
import Script from 'next/script'

export default function Page() {
return (
<Script>{`console.log('Hello world!');`}</Script>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
---
source: crates/biome_js_analyze/tests/spec_tests.rs
expression: invalid-01.jsx
---
# Input
```jsx
/* should generate diagnostics */
import Script from 'next/script'

export default function Page() {
return (
<Script>{`console.log('Hello world!');`}</Script>
)
}

```

# Diagnostics
```
invalid-01.jsx:6:5 lint/nursery/useInlineScriptId ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

× next/script components have inline content or `dangerouslySetInnerHTML` without id attribute.

4 │ export default function Page() {
5 │ return (
> 6 │ <Script>{`console.log('Hello world!');`}</Script>
│ ^^^^^^^^
7 │ )
8 │ }

i Next.js requires id attribute to track and optimize inline scripts. Without it, performance issues may occur.

i See the Next.js docs for more details.

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.


```
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/* should generate diagnostics */
import Script from 'next/script'

export default function Page() {
return (
<Script dangerouslySetInnerHTML={{ __html: `console.log('Hello world!');` }} />
)
}
Loading