diff --git a/apps/oxlint/src/snapshots/fixtures__issue_11644_-c .oxlintrc.json@oxlint.snap b/apps/oxlint/src/snapshots/fixtures__issue_11644_-c .oxlintrc.json@oxlint.snap index 102513100974e..36fd86f75eba9 100644 --- a/apps/oxlint/src/snapshots/fixtures__issue_11644_-c .oxlintrc.json@oxlint.snap +++ b/apps/oxlint/src/snapshots/fixtures__issue_11644_-c .oxlintrc.json@oxlint.snap @@ -1,12 +1,13 @@ --- source: apps/oxlint/src/tester.rs +assertion_line: 96 --- ########## arguments: -c .oxlintrc.json working directory: fixtures/issue_11644 ---------- Found 0 warnings and 0 errors. -Finished in ms on 1 file with 158 rules using 1 threads. +Finished in ms on 1 file with 159 rules using 1 threads. ---------- CLI result: LintSucceeded ---------- diff --git a/crates/oxc_linter/src/rules.rs b/crates/oxc_linter/src/rules.rs index cba5fc18009a1..25a4e0998ed23 100644 --- a/crates/oxc_linter/src/rules.rs +++ b/crates/oxc_linter/src/rules.rs @@ -511,6 +511,7 @@ mod nextjs { pub mod no_duplicate_head; pub mod no_head_element; pub mod no_head_import_in_document; + pub mod no_html_link_for_pages; pub mod no_img_element; pub mod no_page_custom_font; pub mod no_script_component_in_head; @@ -866,6 +867,7 @@ oxc_macros::declare_all_lint_rules! { nextjs::no_title_in_document_head, nextjs::no_typos, nextjs::no_unwanted_polyfillio, + nextjs::no_html_link_for_pages, node::no_exports_assign, node::no_new_require, oxc::approx_constant, diff --git a/crates/oxc_linter/src/rules/nextjs/no_html_link_for_pages.rs b/crates/oxc_linter/src/rules/nextjs/no_html_link_for_pages.rs new file mode 100644 index 0000000000000..e75154f3141eb --- /dev/null +++ b/crates/oxc_linter/src/rules/nextjs/no_html_link_for_pages.rs @@ -0,0 +1,235 @@ +use oxc_ast::{ + AstKind, + ast::{JSXAttributeItem, JSXAttributeName, JSXElementName}, +}; +use oxc_diagnostics::OxcDiagnostic; +use oxc_macros::declare_oxc_lint; +use oxc_span::Span; + +use crate::{AstNode, context::LintContext, rule::Rule}; + +fn no_html_link_for_pages_diagnostic(span: Span) -> OxcDiagnostic { + OxcDiagnostic::warn("Do not use `` elements to navigate between Next.js pages.") + .with_help("Use `` from `next/link` instead for internal navigation. See https://nextjs.org/docs/messages/no-html-link-for-pages") + .with_label(span.label("Replace with `` from `next/link`")) +} + +#[derive(Debug, Default, Clone)] +pub struct NoHtmlLinkForPages; + +declare_oxc_lint!( + /// ### What it does + /// + /// Prevents the usage of `` elements to navigate between Next.js pages. + /// + /// ### Why is this bad? + /// + /// Using `` elements for internal navigation in Next.js applications can cause: + /// - Full page reloads instead of client-side navigation + /// - Loss of application state + /// - Slower navigation performance + /// - Broken prefetching capabilities + /// + /// Next.js provides the `` component from `next/link` for client-side navigation + /// between pages, which provides better performance and user experience. + /// + /// ### Examples + /// + /// Examples of **incorrect** code for this rule: + /// ```jsx + /// function HomePage() { + /// return ( + ///
+ /// About Us + /// Contact + ///
+ /// ); + /// } + /// ``` + /// + /// Examples of **correct** code for this rule: + /// ```jsx + /// import Link from 'next/link'; + /// + /// function HomePage() { + /// return ( + ///
+ /// About Us + /// Contact + ///
+ /// ); + /// } + /// ``` + /// + /// External links are allowed: + /// ```jsx + /// function HomePage() { + /// return ( + ///
+ /// External Link + /// Email + /// Phone + ///
+ /// ); + /// } + /// ``` + NoHtmlLinkForPages, + nextjs, + correctness +); + +impl Rule for NoHtmlLinkForPages { + fn run<'a>(&self, node: &AstNode<'a>, ctx: &LintContext<'a>) { + let AstKind::JSXOpeningElement(jsx_opening_element) = node.kind() else { + return; + }; + + let JSXElementName::Identifier(jsx_element_name) = &jsx_opening_element.name else { + return; + }; + + // Only check elements + if jsx_element_name.name != "a" { + return; + } + + // Find the href attribute + let href_attr = jsx_opening_element.attributes.iter().find_map(|attr| { + if let JSXAttributeItem::Attribute(attr) = attr { + if let JSXAttributeName::Identifier(name) = &attr.name { + if name.name == "href" { + return Some(attr); + } + } + } + None + }); + + let Some(href_attr) = href_attr else { + return; + }; + + // Check if href value indicates an internal link + let is_internal_link = href_attr.value.as_ref().is_some_and(|value| { + match value { + // String literal href + oxc_ast::ast::JSXAttributeValue::StringLiteral(str_lit) => { + let href_value = str_lit.value.as_str(); + is_internal_page_link(href_value) + } + // Expression href - we'll be conservative and flag it as potentially internal + oxc_ast::ast::JSXAttributeValue::ExpressionContainer(_) => true, + _ => false, + } + }); + + if is_internal_link { + ctx.diagnostic(no_html_link_for_pages_diagnostic(jsx_opening_element.span)); + } + } +} + +/// Determines if an href value represents an internal page link +fn is_internal_page_link(href: &str) -> bool { + // Skip external links + if href.starts_with("http://") || href.starts_with("https://") { + return false; + } + + // Skip protocol-relative URLs + if href.starts_with("//") { + return false; + } + + // Skip other protocols + if href.starts_with("mailto:") + || href.starts_with("tel:") + || href.starts_with("ftp:") + || href.starts_with("file:") + { + return false; + } + + // Skip hash links (same page) + if href.starts_with('#') { + return false; + } + + // Skip empty href + if href.is_empty() { + return false; + } + + // Internal links typically start with / or are relative paths + href.starts_with('/') + || (!href.split('/').next().unwrap_or("").contains(':') && !href.starts_with("//")) +} + +#[test] +fn test_is_internal_page_link() { + // Internal links + assert!(is_internal_page_link("/about")); + assert!(is_internal_page_link("/contact/us")); + assert!(is_internal_page_link("about")); + assert!(is_internal_page_link("../contact")); + assert!(is_internal_page_link("./about")); + + // External links + assert!(!is_internal_page_link("https://example.com")); + assert!(!is_internal_page_link("http://example.com")); + assert!(!is_internal_page_link("mailto:test@example.com")); + assert!(!is_internal_page_link("tel:+1234567890")); + assert!(!is_internal_page_link("ftp://example.com")); + assert!(!is_internal_page_link("file://path/to/file")); + + // Hash links (same page) + assert!(!is_internal_page_link("#section")); + assert!(!is_internal_page_link("#")); + + // Empty href + assert!(!is_internal_page_link("")); + + // Protocol-relative URLs + assert!(!is_internal_page_link("//example.com")); +} + +#[test] +fn test() { + use crate::tester::Tester; + + let pass = vec![ + // External links are allowed + r"External Link", + r"External Link", + r"Email", + r"Phone", + r"FTP", + r"File", + // Hash links are allowed + r"Jump to section", + r"Empty hash", + // Protocol-relative URLs are allowed + r"Protocol-relative", + // Links without href + r"No href", + // Other elements + r"
Not an anchor
", + // Next.js Link component (correct usage) + r"About", + ]; + + let fail = vec![ + // Internal page links + r"About", + r"Contact Us", + r"About", + r"Contact", + r"About", + // Dynamic hrefs (expressions) + r"Dynamic", + r"User Profile", + ]; + + Tester::new(NoHtmlLinkForPages::NAME, NoHtmlLinkForPages::PLUGIN, pass, fail) + .test_and_snapshot(); +} diff --git a/crates/oxc_linter/src/snapshots/nextjs_no_html_link_for_pages.snap b/crates/oxc_linter/src/snapshots/nextjs_no_html_link_for_pages.snap new file mode 100644 index 0000000000000..d44176e17b0d7 --- /dev/null +++ b/crates/oxc_linter/src/snapshots/nextjs_no_html_link_for_pages.snap @@ -0,0 +1,59 @@ +--- +source: crates/oxc_linter/src/tester.rs +assertion_line: 411 +--- + ⚠ eslint-plugin-next(no-html-link-for-pages): Do not use `` elements to navigate between Next.js pages. + ╭─[no_html_link_for_pages.tsx:1:1] + 1 │ About + · ────────┬──────── + · ╰── Replace with `` from `next/link` + ╰──── + help: Use `` from `next/link` instead for internal navigation. See https://nextjs.org/docs/messages/no-html-link-for-pages + + ⚠ eslint-plugin-next(no-html-link-for-pages): Do not use `` elements to navigate between Next.js pages. + ╭─[no_html_link_for_pages.tsx:1:1] + 1 │ Contact Us + · ───────────┬────────── + · ╰── Replace with `` from `next/link` + ╰──── + help: Use `` from `next/link` instead for internal navigation. See https://nextjs.org/docs/messages/no-html-link-for-pages + + ⚠ eslint-plugin-next(no-html-link-for-pages): Do not use `` elements to navigate between Next.js pages. + ╭─[no_html_link_for_pages.tsx:1:1] + 1 │ About + · ────────┬─────── + · ╰── Replace with `` from `next/link` + ╰──── + help: Use `` from `next/link` instead for internal navigation. See https://nextjs.org/docs/messages/no-html-link-for-pages + + ⚠ eslint-plugin-next(no-html-link-for-pages): Do not use `` elements to navigate between Next.js pages. + ╭─[no_html_link_for_pages.tsx:1:1] + 1 │ Contact + · ──────────┬────────── + · ╰── Replace with `` from `next/link` + ╰──── + help: Use `` from `next/link` instead for internal navigation. See https://nextjs.org/docs/messages/no-html-link-for-pages + + ⚠ eslint-plugin-next(no-html-link-for-pages): Do not use `` elements to navigate between Next.js pages. + ╭─[no_html_link_for_pages.tsx:1:1] + 1 │ About + · ─────────┬──────── + · ╰── Replace with `` from `next/link` + ╰──── + help: Use `` from `next/link` instead for internal navigation. See https://nextjs.org/docs/messages/no-html-link-for-pages + + ⚠ eslint-plugin-next(no-html-link-for-pages): Do not use `` elements to navigate between Next.js pages. + ╭─[no_html_link_for_pages.tsx:1:1] + 1 │ Dynamic + · ───────────┬────────── + · ╰── Replace with `` from `next/link` + ╰──── + help: Use `` from `next/link` instead for internal navigation. See https://nextjs.org/docs/messages/no-html-link-for-pages + + ⚠ eslint-plugin-next(no-html-link-for-pages): Do not use `` elements to navigate between Next.js pages. + ╭─[no_html_link_for_pages.tsx:1:1] + 1 │ User Profile + · ──────────────┬───────────── + · ╰── Replace with `` from `next/link` + ╰──── + help: Use `` from `next/link` instead for internal navigation. See https://nextjs.org/docs/messages/no-html-link-for-pages