diff --git a/crates/oxc_ast/src/ast/js.rs b/crates/oxc_ast/src/ast/js.rs index 70fc5c036ee6c..0edcbc819d2c6 100644 --- a/crates/oxc_ast/src/ast/js.rs +++ b/crates/oxc_ast/src/ast/js.rs @@ -295,6 +295,14 @@ impl<'a> Expression<'a> { _ => false, } } + + pub fn as_identifier(&self) -> Option<&Box<'a, IdentifierReference>> { + if let Self::Identifier(v) = self { + Some(v) + } else { + None + } + } } /// Identifier Name diff --git a/crates/oxc_ast/src/ast/jsx.rs b/crates/oxc_ast/src/ast/jsx.rs index 907570c2d6d01..28e9152b752bc 100644 --- a/crates/oxc_ast/src/ast/jsx.rs +++ b/crates/oxc_ast/src/ast/jsx.rs @@ -86,6 +86,16 @@ pub enum JSXElementName<'a> { MemberExpression(Box<'a, JSXMemberExpression<'a>>), } +impl<'a> JSXElementName<'a> { + pub fn as_identifier(&self) -> Option<&JSXIdentifier> { + if let Self::Identifier(v) = self { + Some(v) + } else { + None + } + } +} + /// JSX Namespaced Name #[derive(Debug, Hash)] #[cfg_attr(feature = "serde", derive(Serialize), serde(tag = "type"))] @@ -237,6 +247,16 @@ pub enum JSXChild<'a> { Spread(JSXSpreadChild<'a>), } +impl<'a> JSXChild<'a> { + pub fn as_element(&self) -> Option<&Box<'a, JSXElement<'a>>> { + if let Self::Element(v) = self { + Some(v) + } else { + None + } + } +} + #[derive(Debug, Hash)] #[cfg_attr(feature = "serde", derive(Serialize), serde(tag = "type"))] #[cfg_attr(all(feature = "serde", feature = "wasm"), derive(tsify::Tsify))] diff --git a/crates/oxc_linter/src/rules.rs b/crates/oxc_linter/src/rules.rs index 3874e20251b2c..894163de02148 100644 --- a/crates/oxc_linter/src/rules.rs +++ b/crates/oxc_linter/src/rules.rs @@ -307,6 +307,7 @@ mod nextjs { pub mod no_before_interactive_script_outside_document; pub mod no_css_tags; pub mod no_document_import_in_page; + pub mod no_duplicate_head; pub mod no_head_element; pub mod no_head_import_in_document; pub mod no_img_element; @@ -596,4 +597,5 @@ oxc_macros::declare_all_lint_rules! { nextjs::no_document_import_in_page, nextjs::no_unwanted_polyfillio, nextjs::no_before_interactive_script_outside_document, + nextjs::no_duplicate_head, } diff --git a/crates/oxc_linter/src/rules/nextjs/no_duplicate_head.rs b/crates/oxc_linter/src/rules/nextjs/no_duplicate_head.rs new file mode 100644 index 0000000000000..a070932a04dc6 --- /dev/null +++ b/crates/oxc_linter/src/rules/nextjs/no_duplicate_head.rs @@ -0,0 +1,220 @@ +use oxc_ast::{ + ast::{Expression, ImportDeclarationSpecifier::ImportDefaultSpecifier}, + AstKind, +}; +use oxc_diagnostics::{ + miette::{self, Diagnostic}, + thiserror::Error, +}; +use oxc_macros::declare_oxc_lint; +use oxc_span::{GetSpan, Span}; + +use crate::{context::LintContext, rule::Rule}; + +#[derive(Debug, Error, Diagnostic)] +#[error("eslint-plugin-next(no-duplicate-head): Do not include multiple instances of ``. See: https://nextjs.org/docs/messages/no-duplicate-head")] +#[diagnostic( + severity(warning), + help( + "Only use a single `` component in your custom document in `pages/_document.js`." + ) +)] +struct NoDuplicateHeadDiagnostic(#[label] pub Span); + +#[derive(Debug, Default, Clone)] +pub struct NoDuplicateHead; + +declare_oxc_lint!( + /// ### What it does + /// Prevent duplicate usage of in pages/_document.js. + /// + /// ### Why is this bad? + /// This can cause unexpected behavior in your application. + /// + /// ### Example + /// ```javascript + /// import Document, { Html, Head, Main, NextScript } from 'next/document' + /// class MyDocument extends Document { + /// static async getInitialProps(ctx) { + /// } + /// render() { + /// return ( + /// + /// + /// + ///
+ /// + /// + /// + /// ) + /// } + ///} + ///export default MyDocument + /// ``` + NoDuplicateHead, + correctness +); + +impl Rule for NoDuplicateHead { + fn run_once(&self, ctx: &LintContext) { + let mut document_import_name = None; + let nodes = ctx.semantic().nodes(); + for node in nodes.iter() { + match node.kind() { + oxc_ast::AstKind::ImportDeclaration(decl) => { + if decl.source.value == "next/document" { + let document_import = decl.specifiers.as_ref().and_then(|specifiers| { + specifiers.iter().find(|spec| matches!(spec, ImportDefaultSpecifier(_))) + }); + if let Some(ImportDefaultSpecifier(document_import_specifier)) = + document_import + { + document_import_name = + Some(document_import_specifier.local.name.clone()); + } + } + } + oxc_ast::AstKind::ReturnStatement(stmt) => { + let document_class_id = nodes.ancestors(node.id()).find(|node_id| { + matches!(nodes.get_node(*node_id).kind(), + AstKind::Class(class) + if class + .super_class + .as_ref() + .and_then(|sc| sc.as_identifier()) + .map(|id| &id.name) + == document_import_name.as_ref()) + }); + if document_class_id.map(|id| nodes.get_node(id)).is_none() { + continue; + } + + let Some(Expression::JSXElement(ref element)) = + stmt.argument.as_ref().map(oxc_ast::ast::Expression::get_inner_expression) + else { + continue; + }; + let head_components = element + .children + .iter() + .filter(|child| { + child + .as_element() + .and_then(|e| e.opening_element.name.as_identifier()) + .map(|id| id.name == "Head") + .unwrap_or_default() + }) + .collect::>(); + if head_components.len() > 1 { + for component in head_components { + ctx.diagnostic(NoDuplicateHeadDiagnostic(component.span())); + } + } + } + _ => continue, + } + } + } +} + +#[test] +fn test() { + use crate::tester::Tester; + + let pass = vec![ + r"import Document, { Html, Head, Main, NextScript } from 'next/document' + + class MyDocument extends Document { + static async getInitialProps(ctx) { + //... + } + + render() { + return ( + + + + ) + } + } + + export default MyDocument + ", + r#"import Document, { Html, Head, Main, NextScript } from 'next/document' + + class MyDocument extends Document { + render() { + return ( + + + + + + + ) + } + } + + export default MyDocument + "#, + ]; + + let fail = vec![ + r" + import Document, { Html, Main, NextScript } from 'next/document' + import Head from 'next/head' + + class MyDocument extends Document { + render() { + return ( + + + + + + ) + } + } + + export default MyDocument + ", + r" + import Document, { Html, Main, NextScript } from 'next/document' + import Head from 'next/head' + + class MyDocument extends Document { + render() { + return ( + + + + + + +
+ + + +