diff --git a/crates/oxc_linter/src/rules.rs b/crates/oxc_linter/src/rules.rs index d9f04c868..7474c17a5 100644 --- a/crates/oxc_linter/src/rules.rs +++ b/crates/oxc_linter/src/rules.rs @@ -271,6 +271,7 @@ mod oxc { mod nextjs { pub mod google_font_display; pub mod google_font_preconnect; + pub mod inline_script_id; } oxc_macros::declare_all_lint_rules! { @@ -511,4 +512,5 @@ oxc_macros::declare_all_lint_rules! { oxc::only_used_in_recursion, nextjs::google_font_display, nextjs::google_font_preconnect, + nextjs::inline_script_id, } diff --git a/crates/oxc_linter/src/rules/nextjs/inline_script_id.rs b/crates/oxc_linter/src/rules/nextjs/inline_script_id.rs new file mode 100644 index 000000000..6c23dfb2a --- /dev/null +++ b/crates/oxc_linter/src/rules/nextjs/inline_script_id.rs @@ -0,0 +1,250 @@ +use oxc_ast::{ + ast::{ + Expression, ImportDeclarationSpecifier, JSXAttributeItem, JSXAttributeName, + ModuleDeclaration, ObjectPropertyKind, PropertyKey, + }, + AstKind, +}; +use oxc_diagnostics::{ + miette::{self, Diagnostic}, + thiserror::Error, +}; +use oxc_macros::declare_oxc_lint; +use oxc_span::{GetSpan, Span}; +use rustc_hash::FxHashSet; + +use crate::{context::LintContext, rule::Rule, AstNode}; + +#[derive(Debug, Error, Diagnostic)] +#[error("eslint-plugin-next(inline-script-id): `next/script` components with inline content must specify an `id` attribute.")] +#[diagnostic(severity(warning), help("See https://nextjs.org/docs/messages/inline-script-id"))] +struct InlineScriptIdDiagnostic(#[label] pub Span); + +#[derive(Debug, Default, Clone)] +pub struct InlineScriptId; + +declare_oxc_lint!( + /// ### What it does + /// + /// + /// ### Why is this bad? + /// + /// + /// ### Example + /// ```javascript + /// ``` + InlineScriptId, + correctness +); + +impl Rule for InlineScriptId { + fn run<'a>(&self, node: &AstNode<'a>, ctx: &LintContext<'a>) { + let AstKind::ModuleDeclaration(ModuleDeclaration::ImportDeclaration(import_decl)) = + node.kind() + else { + return; + }; + + if import_decl.source.value.as_str() != "next/script" { + return; + } + + let Some(import_specifiers) = &import_decl.specifiers else { return }; + + // find default import + let Some(default_import) = import_specifiers.iter().find_map(|import_specifier| { + let ImportDeclarationSpecifier::ImportDefaultSpecifier(import_default_specifier) = + import_specifier + else { + return None; + }; + + Some(import_default_specifier) + }) else { + return; + }; + + 'references_loop: for reference in + ctx.semantic().symbol_references(default_import.local.symbol_id.get().unwrap()) + { + let node = ctx.nodes().get_node(reference.node_id()); + + let AstKind::JSXElementName(_) = node.kind() else { continue }; + let parent_node = ctx.nodes().parent_node(node.id()).unwrap(); + let AstKind::JSXOpeningElement(jsx_opening_element) = parent_node.kind() else { + continue; + }; + + let Some(AstKind::JSXElement(jsx_element)) = ctx.nodes().parent_kind(parent_node.id()) + else { + continue; + }; + + let mut prop_names_hash_set = FxHashSet::default(); + + for prop in &jsx_opening_element.attributes { + match prop { + JSXAttributeItem::Attribute(attr) => { + if let JSXAttributeName::Identifier(ident) = &attr.name { + prop_names_hash_set.insert(ident.name.clone()); + } + } + JSXAttributeItem::SpreadAttribute(spread_attr) => { + if let Expression::ObjectExpression(obj_expr) = + spread_attr.argument.without_parenthesized() + { + for prop in &obj_expr.properties { + if let ObjectPropertyKind::ObjectProperty(obj_prop) = prop { + if let PropertyKey::Identifier(ident) = &obj_prop.key { + prop_names_hash_set.insert(ident.name.clone()); + } + } + } + } else { + continue 'references_loop; + } + } + } + } + + if prop_names_hash_set.contains("id") { + continue; + } + + if jsx_element.children.len() > 0 + || prop_names_hash_set.contains("dangerouslySetInnerHTML") + { + ctx.diagnostic(InlineScriptIdDiagnostic(jsx_opening_element.name.span())); + } + } + } +} + +#[test] +fn test() { + use crate::tester::Tester; + + let pass = vec![ + r#"import Script from 'next/script'; + + export default function TestPage() { + return ( + + ) + }"#, + r#"import Script from 'next/script'; + + export default function TestPage() { + return ( + + ) + }"#, + r#"import Script from 'next/script'; + + export default function TestPage() { + return ( + + ) + }"#, + r#"import Script from 'next/script'; + const spread = { strategy: "lazyOnload" } + export default function TestPage() { + return ( + + ) + }"#, + ]; + + let fail = vec![ + r"import Script from 'next/script'; + + export default function TestPage() { + return ( + + ) + }", + r"import Script from 'next/script'; + + export default function TestPage() { + return ( +