Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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 <variable>ms on 1 file with 158 rules using 1 threads.
Finished in <variable>ms on 1 file with 159 rules using 1 threads.
----------
CLI result: LintSucceeded
----------
2 changes: 2 additions & 0 deletions crates/oxc_linter/src/rules.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down
235 changes: 235 additions & 0 deletions crates/oxc_linter/src/rules/nextjs/no_html_link_for_pages.rs
Original file line number Diff line number Diff line change
@@ -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 `<a>` elements to navigate between Next.js pages.")
.with_help("Use `<Link />` from `next/link` instead for internal navigation. See https://nextjs.org/docs/messages/no-html-link-for-pages")
.with_label(span.label("Replace with `<Link>` from `next/link`"))
}

#[derive(Debug, Default, Clone)]
pub struct NoHtmlLinkForPages;

declare_oxc_lint!(
/// ### What it does
///
/// Prevents the usage of `<a>` elements to navigate between Next.js pages.
///
/// ### Why is this bad?
///
/// Using `<a>` 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 `<Link />` 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 (
/// <div>
/// <a href="/about">About Us</a>
/// <a href="/contact">Contact</a>
/// </div>
/// );
/// }
/// ```
///
/// Examples of **correct** code for this rule:
/// ```jsx
/// import Link from 'next/link';
///
/// function HomePage() {
/// return (
/// <div>
/// <Link href="/about">About Us</Link>
/// <Link href="/contact">Contact</Link>
/// </div>
/// );
/// }
/// ```
///
/// External links are allowed:
/// ```jsx
/// function HomePage() {
/// return (
/// <div>
/// <a href="https://example.com">External Link</a>
/// <a href="mailto:contact@example.com">Email</a>
/// <a href="tel:+1234567890">Phone</a>
/// </div>
/// );
/// }
/// ```
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 <a> 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"<a href='https://example.com'>External Link</a>",
r"<a href='http://example.com'>External Link</a>",
r"<a href='mailto:contact@example.com'>Email</a>",
r"<a href='tel:+1234567890'>Phone</a>",
r"<a href='ftp://example.com'>FTP</a>",
r"<a href='file://path/to/file'>File</a>",
// Hash links are allowed
r"<a href='#section'>Jump to section</a>",
r"<a href='#'>Empty hash</a>",
// Protocol-relative URLs are allowed
r"<a href='//example.com'>Protocol-relative</a>",
// Links without href
r"<a>No href</a>",
// Other elements
r"<div href='/about'>Not an anchor</div>",
// Next.js Link component (correct usage)
r"<Link href='/about'>About</Link>",
];

let fail = vec![
// Internal page links
r"<a href='/about'>About</a>",
r"<a href='/contact/us'>Contact Us</a>",
r"<a href='about'>About</a>",
r"<a href='../contact'>Contact</a>",
r"<a href='./about'>About</a>",
// Dynamic hrefs (expressions)
r"<a href={dynamicLink}>Dynamic</a>",
r"<a href={`/user/${userId}`}>User Profile</a>",
];

Tester::new(NoHtmlLinkForPages::NAME, NoHtmlLinkForPages::PLUGIN, pass, fail)
.test_and_snapshot();
}
Original file line number Diff line number Diff line change
@@ -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 `<a>` elements to navigate between Next.js pages.
╭─[no_html_link_for_pages.tsx:1:1]
1 │ <a href='/about'>About</a>
· ────────┬────────
· ╰── Replace with `<Link>` from `next/link`
╰────
help: Use `<Link />` 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 `<a>` elements to navigate between Next.js pages.
╭─[no_html_link_for_pages.tsx:1:1]
1 │ <a href='/contact/us'>Contact Us</a>
· ───────────┬──────────
· ╰── Replace with `<Link>` from `next/link`
╰────
help: Use `<Link />` 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 `<a>` elements to navigate between Next.js pages.
╭─[no_html_link_for_pages.tsx:1:1]
1 │ <a href='about'>About</a>
· ────────┬───────
· ╰── Replace with `<Link>` from `next/link`
╰────
help: Use `<Link />` 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 `<a>` elements to navigate between Next.js pages.
╭─[no_html_link_for_pages.tsx:1:1]
1 │ <a href='../contact'>Contact</a>
· ──────────┬──────────
· ╰── Replace with `<Link>` from `next/link`
╰────
help: Use `<Link />` 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 `<a>` elements to navigate between Next.js pages.
╭─[no_html_link_for_pages.tsx:1:1]
1 │ <a href='./about'>About</a>
· ─────────┬────────
· ╰── Replace with `<Link>` from `next/link`
╰────
help: Use `<Link />` 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 `<a>` elements to navigate between Next.js pages.
╭─[no_html_link_for_pages.tsx:1:1]
1 │ <a href={dynamicLink}>Dynamic</a>
· ───────────┬──────────
· ╰── Replace with `<Link>` from `next/link`
╰────
help: Use `<Link />` 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 `<a>` elements to navigate between Next.js pages.
╭─[no_html_link_for_pages.tsx:1:1]
1 │ <a href={`/user/${userId}`}>User Profile</a>
· ──────────────┬─────────────
· ╰── Replace with `<Link>` from `next/link`
╰────
help: Use `<Link />` from `next/link` instead for internal navigation. See https://nextjs.org/docs/messages/no-html-link-for-pages
Loading