From 3628fb562597d4de5d8691a7ae361056a8a555ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gabriel=20D=C3=ADaz=20Aguilera?= Date: Thu, 10 Jul 2025 14:01:52 +0000 Subject: [PATCH 1/6] feat(nextjs): add rule no_html_link_for_pages logic for nextjs eslint plugin full compatibility and append it to rules.rs file --- crates/oxc_linter/src/rules.rs | 2 + .../rules/nextjs/no_html_link_for_pages.rs | 234 ++++++++++++++++++ 2 files changed, 236 insertions(+) create mode 100644 crates/oxc_linter/src/rules/nextjs/no_html_link_for_pages.rs 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..e0c02aac5e5bb --- /dev/null +++ b/crates/oxc_linter/src/rules/nextjs/no_html_link_for_pages.rs @@ -0,0 +1,234 @@ +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().map_or(false, |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.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(); +} From 173759898d5132e9a912e71d1b8417aed861e0f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gabriel=20D=C3=ADaz=20Aguilera?= Date: Thu, 10 Jul 2025 14:17:28 +0000 Subject: [PATCH 2/6] Move snap --- .../nextjs_no_html_link_for_pages.snap | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 crates/oxc_linter/src/snapshots/nextjs_no_html_link_for_pages.snap 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 From f3340e90c5aa19a6d19f7905dffd94d37713ae25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gabriel=20D=C3=ADaz=20Aguilera?= Date: Fri, 11 Jul 2025 11:14:09 +0000 Subject: [PATCH 3/6] fix(snapshots): update rule count in snapshot for issue 11644 --- .../fixtures__issue_11644_-c .oxlintrc.json@oxlint.snap | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 ---------- From 9c52531787cab10964ed981bdc91c976badf1c33 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gabriel=20D=C3=ADaz=20Aguilera?= Date: Fri, 11 Jul 2025 13:24:16 +0000 Subject: [PATCH 4/6] fix(nextjs): use is_some_and for href attribute check in no_html_link_for_pages rule to solve clippy linting conflict --- crates/oxc_linter/src/rules/nextjs/no_html_link_for_pages.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index e0c02aac5e5bb..40bea195c24b7 100644 --- 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 @@ -110,7 +110,7 @@ impl Rule for NoHtmlLinkForPages { }; // Check if href value indicates an internal link - let is_internal_link = href_attr.value.as_ref().map_or(false, |value| { + 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) => { From 98bea5126f2b7bdbe7541c925a7fdb6726d027e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gabriel=20D=C3=ADaz=20Aguilera?= Date: Fri, 11 Jul 2025 15:37:09 +0200 Subject: [PATCH 5/6] Update crates/oxc_linter/src/rules/nextjs/no_html_link_for_pages.rs Co-authored-by: graphite-app[bot] <96075541+graphite-app[bot]@users.noreply.github.com> --- crates/oxc_linter/src/rules/nextjs/no_html_link_for_pages.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 40bea195c24b7..c033e393e3c05 100644 --- 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 @@ -161,7 +161,7 @@ fn is_internal_page_link(href: &str) -> bool { } // Internal links typically start with / or are relative paths - href.starts_with('/') || (!href.contains(':') && !href.starts_with("//")) +href.starts_with('/') || (!href.split('/').next().unwrap_or("").contains(':') && !href.starts_with("//")) } #[test] From de99c7bd3b7fa46c537e2541f1ae114a185a301e Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Fri, 11 Jul 2025 15:33:56 +0000 Subject: [PATCH 6/6] [autofix.ci] apply automated fixes --- crates/oxc_linter/src/rules/nextjs/no_html_link_for_pages.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 index c033e393e3c05..e75154f3141eb 100644 --- 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 @@ -161,7 +161,8 @@ fn is_internal_page_link(href: &str) -> bool { } // Internal links typically start with / or are relative paths -href.starts_with('/') || (!href.split('/').next().unwrap_or("").contains(':') && !href.starts_with("//")) + href.starts_with('/') + || (!href.split('/').next().unwrap_or("").contains(':') && !href.starts_with("//")) } #[test]