diff --git a/crates/oxc_linter/src/rules.rs b/crates/oxc_linter/src/rules.rs index 0d8af66c0..1d8af85a0 100644 --- a/crates/oxc_linter/src/rules.rs +++ b/crates/oxc_linter/src/rules.rs @@ -353,6 +353,7 @@ mod nextjs { pub mod no_head_element; pub mod no_head_import_in_document; pub mod no_img_element; + pub mod no_page_custom_font; pub mod no_script_component_in_head; pub mod no_styled_jsx_in_document; pub mod no_sync_scripts; @@ -702,6 +703,7 @@ 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_page_custom_font, nextjs::no_styled_jsx_in_document, jsdoc::check_access, jsdoc::check_property_names, diff --git a/crates/oxc_linter/src/rules/nextjs/no_page_custom_font.rs b/crates/oxc_linter/src/rules/nextjs/no_page_custom_font.rs new file mode 100644 index 000000000..139260103 --- /dev/null +++ b/crates/oxc_linter/src/rules/nextjs/no_page_custom_font.rs @@ -0,0 +1,318 @@ +use oxc_ast::{ + ast::{Class, Function, JSXAttributeItem, JSXElementName}, + 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, AstNode}; + +#[derive(Debug, Error, Diagnostic)] +enum NoPageCustomFontDiagnostic { + #[error("eslint-plugin-next(no-page-custom-font): Custom fonts not added in `pages/_document.js` will only load for a single page. This is discouraged.")] + #[diagnostic( + severity(warning), + help("See: https://nextjs.org/docs/messages/no-page-custom-font") + )] + NotAddedInDocument(#[label] Span), + #[error("eslint-plugin-next(no-page-custom-font): Using `` outside of `` will disable automatic font optimization. This is discouraged.")] + #[diagnostic( + severity(warning), + help("See: 'https://nextjs.org/docs/messages/no-page-custom-font") + )] + LinkOutsideOfHead(#[label] Span), +} + +#[derive(Debug, Default, Clone)] +pub struct NoPageCustomFont; + +declare_oxc_lint!( + /// ### What it does + /// Prevent page-only custom fonts. + /// + /// ### Why is this bad? + /// * The custom font you're adding was added to a page - this only adds the font to the specific page and not the entire application. + /// * The custom font you're adding was added to a separate component within pages/_document.js - this disables automatic font optimization. + /// + /// ### Example + /// ```javascript + /// ``` + NoPageCustomFont, + correctness, +); + +impl Rule for NoPageCustomFont { + fn run<'a>(&self, node: &AstNode<'a>, ctx: &LintContext<'a>) { + let AstKind::JSXOpeningElement(element) = node.kind() else { return }; + if matches!(&element.name, JSXElementName::Identifier(ident) if ident.name != "link") { + return; + } + let is_custom_font = element.attributes.iter().any( + |attr| matches!(&attr, JSXAttributeItem::Attribute(attr) if attr.is_identifier("href") && attr.value.is_some()), + ); + + if !is_custom_font { + return; + } + + let mut is_inside_export_default = false; + for parent_node in ctx.nodes().iter_parents(node.id()) { + // export default function/class + let kind = parent_node.kind(); + if matches!(kind, AstKind::ExportDefaultDeclaration(_)) { + is_inside_export_default = true; + break; + } + + // function variable() {}; export default variable; + let id = match kind { + AstKind::ArrowFunctionExpression(_) => None, + AstKind::Function(Function { id, .. }) | AstKind::Class(Class { id, .. }) => { + id.clone() + } + _ => continue, + }; + + let name = id.map_or_else( + || { + let parent_parent_kind = ctx.nodes().parent_kind(parent_node.id())?; + + let AstKind::VariableDeclarator(declarator) = parent_parent_kind else { + return None; + }; + declarator.id.get_identifier().map(ToString::to_string) + }, + |id| Some(id.name.to_string()), + ); + let Some(name) = name else { + continue; + }; + if let Some(symbol_id) = ctx.scopes().get_root_binding(&name) { + if ctx.symbols().get_flag(symbol_id).is_export() { + let is_export_default = + ctx.symbols().get_resolved_references(symbol_id).any(|reference| { + reference.is_read() + && matches!( + ctx.nodes().parent_kind(reference.node_id()), + Some(AstKind::ExportDefaultDeclaration(_)) + ) + }); + + if is_export_default { + is_inside_export_default = true; + break; + } + } + } + } + + let in_document = ctx.file_path().file_name().map_or(false, |file_name| { + file_name.to_str().map_or(false, |file_name| file_name.starts_with("_document.")) + }); + let span = ctx.nodes().parent_kind(node.id()).unwrap().span(); + let diagnostic = if in_document { + if is_inside_export_default { + return; + } + NoPageCustomFontDiagnostic::LinkOutsideOfHead(span) + } else { + NoPageCustomFontDiagnostic::NotAddedInDocument(span) + }; + ctx.diagnostic(diagnostic); + } +} + +#[test] +fn test() { + use crate::tester::Tester; + use std::path::PathBuf; + + let filename = Some(PathBuf::from("pages/_document.jsx")); + let pass = vec![ + ( + r#"import Document, { Html, Head } from "next/document"; + class MyDocument extends Document { + render() { + return ( + + + + + + ); + } + } + export default MyDocument;"#, + None, + None, + filename.clone(), + ), + ( + r#"import NextDocument, { Html, Head } from "next/document"; + class Document extends NextDocument { + render() { + return ( + + + + + + ); + } + } + export default Document; + "#, + None, + None, + filename.clone(), + ), + ( + r#"export default function CustomDocument() { + return ( + + + + + + ) + }"#, + None, + None, + filename.clone(), + ), + ( + r#"function CustomDocument() { + return ( + + + + + + ) + } + + export default CustomDocument; + "#, + None, + None, + filename.clone(), + ), + ( + r#" + import Document, { Html, Head } from "next/document"; + class MyDocument { + render() { + return ( + + + + + + ); + } + } + + export default MyDocument;"#, + None, + None, + filename.clone(), + ), + ( + r#"export default function() { + return ( + + + + + + ) + }"#, + None, + None, + filename.clone(), + ), + ]; + + let fail = vec![ + ( + r#" + import Head from 'next/head' + export default function IndexPage() { + return ( +
+ + + +

Hello world!

+
+ ) + } + "#, + None, + None, + Some(PathBuf::from("pages/index.tsx")), + ), + ( + r#" + import Head from 'next/head' + + + function Links() { + return ( + <> + + + + ) + } + + export default function IndexPage() { + return ( +
+ + + +

Hello world!

+
+ ) + } + "#, + None, + None, + filename, + ), + ]; + + Tester::new(NoPageCustomFont::NAME, pass, fail).test_and_snapshot(); +} diff --git a/crates/oxc_linter/src/snapshots/no_page_custom_font.snap b/crates/oxc_linter/src/snapshots/no_page_custom_font.snap new file mode 100644 index 000000000..ef13fa87b --- /dev/null +++ b/crates/oxc_linter/src/snapshots/no_page_custom_font.snap @@ -0,0 +1,36 @@ +--- +source: crates/oxc_linter/src/tester.rs +expression: no_page_custom_font +--- + ⚠ eslint-plugin-next(no-page-custom-font): Custom fonts not added in `pages/_document.js` will only load for a single page. This is discouraged. + ╭─[no_page_custom_font.tsx:7:18] + 6 │ + 7 │ ╭─▶ + 11 │ + ╰──── + help: See: https://nextjs.org/docs/messages/no-page-custom-font + + ⚠ eslint-plugin-next(no-page-custom-font): Using `` outside of `` will disable automatic font optimization. This is discouraged. + ╭─[no_page_custom_font.tsx:8:16] + 7 │ <> + 8 │ ╭─▶ + 12 │ ` outside of `` will disable automatic font optimization. This is discouraged. + ╭─[no_page_custom_font.tsx:12:16] + 11 │ /> + 12 │ ╭─▶ + 16 │ + ╰──── + help: See: 'https://nextjs.org/docs/messages/no-page-custom-font