Skip to content
Closed
Show file tree
Hide file tree
Changes from 4 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
18 changes: 18 additions & 0 deletions .changeset/open-dancers-teach.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
"@biomejs/biome": patch
---

Added the nursery rule [`noHtmlLinkForPages`](https://biomejs.dev/linter/rules/no-html-link-for-pages/) to the Next.js domain.
This rule prevents usage of `<a>` elements to navigate to internal Next.js pages.

The following code is invalid:

```jsx
export const Page = () => {
return (
<div>
<a href='/about'>About</a>
</div>
);
}
```
12 changes: 12 additions & 0 deletions crates/biome_cli/src/execute/migrate/eslint_any_rule_to_biome.rs

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions crates/biome_configuration/src/analyzer/linter/rules.rs

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/biome_diagnostics_categories/src/categories.rs

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion crates/biome_js_analyze/src/lint/nursery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ pub mod no_equals_to_null;
pub mod no_excessive_lines_per_file;
pub mod no_floating_promises;
pub mod no_for_in;
pub mod no_html_link_for_pages;
pub mod no_import_cycles;
pub mod no_increment_decrement;
pub mod no_jsx_literals;
Expand Down Expand Up @@ -59,4 +60,4 @@ pub mod use_spread;
pub mod use_vue_consistent_define_props_declaration;
pub mod use_vue_define_macros_order;
pub mod use_vue_multi_word_component_names;
declare_lint_group! { pub Nursery { name : "nursery" , rules : [self :: no_ambiguous_anchor_text :: NoAmbiguousAnchorText , self :: no_before_interactive_script_outside_document :: NoBeforeInteractiveScriptOutsideDocument , self :: no_continue :: NoContinue , self :: no_deprecated_imports :: NoDeprecatedImports , self :: no_duplicated_spread_props :: NoDuplicatedSpreadProps , self :: no_empty_source :: NoEmptySource , self :: no_equals_to_null :: NoEqualsToNull , self :: no_excessive_lines_per_file :: NoExcessiveLinesPerFile , self :: no_floating_promises :: NoFloatingPromises , self :: no_for_in :: NoForIn , self :: no_import_cycles :: NoImportCycles , self :: no_increment_decrement :: NoIncrementDecrement , self :: no_jsx_literals :: NoJsxLiterals , self :: no_jsx_props_bind :: NoJsxPropsBind , self :: no_leaked_render :: NoLeakedRender , self :: no_misused_promises :: NoMisusedPromises , self :: no_multi_assign :: NoMultiAssign , self :: no_multi_str :: NoMultiStr , self :: no_next_async_client_component :: NoNextAsyncClientComponent , self :: no_parameters_only_used_in_recursion :: NoParametersOnlyUsedInRecursion , self :: no_proto :: NoProto , self :: no_react_forward_ref :: NoReactForwardRef , self :: no_return_assign :: NoReturnAssign , self :: no_script_url :: NoScriptUrl , self :: no_shadow :: NoShadow , self :: no_sync_scripts :: NoSyncScripts , self :: no_ternary :: NoTernary , self :: no_undeclared_env_vars :: NoUndeclaredEnvVars , self :: no_unknown_attribute :: NoUnknownAttribute , self :: no_unnecessary_conditions :: NoUnnecessaryConditions , self :: no_unresolved_imports :: NoUnresolvedImports , self :: no_unused_expressions :: NoUnusedExpressions , self :: no_useless_catch_binding :: NoUselessCatchBinding , self :: no_useless_undefined :: NoUselessUndefined , self :: no_vue_data_object_declaration :: NoVueDataObjectDeclaration , self :: no_vue_duplicate_keys :: NoVueDuplicateKeys , self :: no_vue_reserved_keys :: NoVueReservedKeys , self :: no_vue_reserved_props :: NoVueReservedProps , self :: no_vue_setup_props_reactivity_loss :: NoVueSetupPropsReactivityLoss , self :: use_array_sort_compare :: UseArraySortCompare , self :: use_await_thenable :: UseAwaitThenable , self :: use_consistent_arrow_return :: UseConsistentArrowReturn , self :: use_destructuring :: UseDestructuring , self :: use_error_cause :: UseErrorCause , self :: use_exhaustive_switch_cases :: UseExhaustiveSwitchCases , self :: use_explicit_type :: UseExplicitType , self :: use_find :: UseFind , self :: use_max_params :: UseMaxParams , self :: use_qwik_method_usage :: UseQwikMethodUsage , self :: use_qwik_valid_lexical_scope :: UseQwikValidLexicalScope , self :: use_regexp_exec :: UseRegexpExec , self :: use_sorted_classes :: UseSortedClasses , self :: use_spread :: UseSpread , self :: use_vue_consistent_define_props_declaration :: UseVueConsistentDefinePropsDeclaration , self :: use_vue_define_macros_order :: UseVueDefineMacrosOrder , self :: use_vue_multi_word_component_names :: UseVueMultiWordComponentNames ,] } }
declare_lint_group! { pub Nursery { name : "nursery" , rules : [self :: no_ambiguous_anchor_text :: NoAmbiguousAnchorText , self :: no_before_interactive_script_outside_document :: NoBeforeInteractiveScriptOutsideDocument , self :: no_continue :: NoContinue , self :: no_deprecated_imports :: NoDeprecatedImports , self :: no_duplicated_spread_props :: NoDuplicatedSpreadProps , self :: no_empty_source :: NoEmptySource , self :: no_equals_to_null :: NoEqualsToNull , self :: no_excessive_lines_per_file :: NoExcessiveLinesPerFile , self :: no_floating_promises :: NoFloatingPromises , self :: no_for_in :: NoForIn , self :: no_html_link_for_pages :: NoHtmlLinkForPages , self :: no_import_cycles :: NoImportCycles , self :: no_increment_decrement :: NoIncrementDecrement , self :: no_jsx_literals :: NoJsxLiterals , self :: no_jsx_props_bind :: NoJsxPropsBind , self :: no_leaked_render :: NoLeakedRender , self :: no_misused_promises :: NoMisusedPromises , self :: no_multi_assign :: NoMultiAssign , self :: no_multi_str :: NoMultiStr , self :: no_next_async_client_component :: NoNextAsyncClientComponent , self :: no_parameters_only_used_in_recursion :: NoParametersOnlyUsedInRecursion , self :: no_proto :: NoProto , self :: no_react_forward_ref :: NoReactForwardRef , self :: no_return_assign :: NoReturnAssign , self :: no_script_url :: NoScriptUrl , self :: no_shadow :: NoShadow , self :: no_sync_scripts :: NoSyncScripts , self :: no_ternary :: NoTernary , self :: no_undeclared_env_vars :: NoUndeclaredEnvVars , self :: no_unknown_attribute :: NoUnknownAttribute , self :: no_unnecessary_conditions :: NoUnnecessaryConditions , self :: no_unresolved_imports :: NoUnresolvedImports , self :: no_unused_expressions :: NoUnusedExpressions , self :: no_useless_catch_binding :: NoUselessCatchBinding , self :: no_useless_undefined :: NoUselessUndefined , self :: no_vue_data_object_declaration :: NoVueDataObjectDeclaration , self :: no_vue_duplicate_keys :: NoVueDuplicateKeys , self :: no_vue_reserved_keys :: NoVueReservedKeys , self :: no_vue_reserved_props :: NoVueReservedProps , self :: no_vue_setup_props_reactivity_loss :: NoVueSetupPropsReactivityLoss , self :: use_array_sort_compare :: UseArraySortCompare , self :: use_await_thenable :: UseAwaitThenable , self :: use_consistent_arrow_return :: UseConsistentArrowReturn , self :: use_destructuring :: UseDestructuring , self :: use_error_cause :: UseErrorCause , self :: use_exhaustive_switch_cases :: UseExhaustiveSwitchCases , self :: use_explicit_type :: UseExplicitType , self :: use_find :: UseFind , self :: use_max_params :: UseMaxParams , self :: use_qwik_method_usage :: UseQwikMethodUsage , self :: use_qwik_valid_lexical_scope :: UseQwikValidLexicalScope , self :: use_regexp_exec :: UseRegexpExec , self :: use_sorted_classes :: UseSortedClasses , self :: use_spread :: UseSpread , self :: use_vue_consistent_define_props_declaration :: UseVueConsistentDefinePropsDeclaration , self :: use_vue_define_macros_order :: UseVueDefineMacrosOrder , self :: use_vue_multi_word_component_names :: UseVueMultiWordComponentNames ,] } }
168 changes: 168 additions & 0 deletions crates/biome_js_analyze/src/lint/nursery/no_html_link_for_pages.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
use biome_analyze::{
Ast, Rule, RuleDiagnostic, RuleDomain, RuleSource, context::RuleContext, declare_lint_rule,
};
use biome_console::markup;
use biome_diagnostics::Severity;
use biome_js_syntax::jsx_ext::AnyJsxElement;
use biome_rowan::{AstNode, AstNodeList, TextRange};
use biome_rule_options::no_html_link_for_pages::NoHtmlLinkForPagesOptions;

declare_lint_rule! {
/// Prevent usage of `<a>` elements to navigate to internal Next.js pages.
///
/// Using `<a>` elements instead of `next/link` for internal navigation can cause unnecessary full-page reloads.
///
/// ## Examples
///
/// ### Invalid
///
/// ```jsx,expect_diagnostic
/// export const Page = () => {
/// return (
/// <div>
/// <a href='/about'>About</a>
/// </div>
/// );
/// }
/// ```
///
/// ### Valid
///
/// ```jsx
/// import Link from "next/link";
///
/// export const Page = () => {
/// return (
/// <div>
/// <Link href="/about">About</Link>
/// </div>
/// );
/// }
/// ```
///
pub NoHtmlLinkForPages {
version: "next",
name: "noHtmlLinkForPages",
language: "jsx",
sources: &[RuleSource::EslintNext("no-html-link-for-pages").same()],
recommended: false,
severity: Severity::Warning,
domains: &[RuleDomain::Next],
}
}

impl Rule for NoHtmlLinkForPages {
type Query = Ast<AnyJsxElement>;
type State = TextRange;
type Signals = Option<Self::State>;
type Options = NoHtmlLinkForPagesOptions;

fn run(ctx: &RuleContext<Self>) -> Self::Signals {
let jsx_element = ctx.query();

let element_name = jsx_element.name().ok()?.name_value_token().ok()?;
if element_name.text_trimmed() != "a" {
return None;
}

if jsx_element.attributes().is_empty() {
return None;
}

// Skip when download attribute is present
if jsx_element.find_attribute_by_name("download").is_some() {
return None;
}

// Should not enforce when target="_blank" present
if let Some(target) = jsx_element.find_attribute_by_name("target")
&& let Some(target_value) = target.as_static_value()
&& target_value.text().trim() == "_blank"
{
return None;
}

let href_attribute = jsx_element.find_attribute_by_name("href")?;
let href_value = href_attribute.as_static_value()?;
let href_value = href_value.text();
if href_value.is_empty() {
return None;
}

if is_internal_link(&normalize_href(href_value)) {
return Some(jsx_element.range());
}

None
}

fn diagnostic(ctx: &RuleContext<Self>, state: &Self::State) -> Option<RuleDiagnostic> {
let jsx_element = ctx.query();
let href_attribute = jsx_element.find_attribute_by_name("href")?;
let href_value = href_attribute.as_static_value()?;
let href_value = href_value.text();

Some(
RuleDiagnostic::new(
rule_category!(),
state,
markup! {
""<Emphasis>"<a>"</Emphasis>" element has an internal link to "<Emphasis>{href_value}</Emphasis>"."
},
)
.note(markup! {
""<Emphasis>"<a>"</Emphasis>" elements for internal navigation can cause unnecessary full-page reloads. Use "<Emphasis>"next/link"</Emphasis>" component instead."
})
.note(markup! {
"See the "<Hyperlink href="https://nextjs.org/docs/messages/no-html-link-for-pages">"Next.js docs"</Hyperlink>" for more details."
}),
)
}
}

fn normalize_href(href: &str) -> String {
let query_start = href.find('?');
let href = &href[..query_start.unwrap_or(href.len())];

let hash_start = href.find('#');
let href = &href[..hash_start.unwrap_or(href.len())];

href.to_string()
}

const COMMON_EXTENSIONS: [&str; 18] = [
".pdf", ".txt", ".md", ".json", ".xml", ".csv", ".rss", ".atom", ".zip", ".tar", ".gz", ".png",
".jpg", ".jpeg", ".webp", ".svg", ".mp4", ".mp3",
];

fn is_internal_link(href: &str) -> bool {
let href = href.trim();
if href.is_empty() {
return false;
}

if href.starts_with("http://") || href.starts_with("https://") || 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 if it appears to be a public file (e.g. .pdf)
// Internal links in Next.js do not contain file extensions basically
if let Some(last_segment) = href.split('/').next_back()
&& COMMON_EXTENSIONS
.iter()
.any(|ext| last_segment.ends_with(ext))
{
return false;
}

href.starts_with('/') || href.starts_with("./") || href.starts_with("../")
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/* should generate diagnostics */

export const Page = () => {
return (
<a href='/'>Homepage</a>
);
}

export const Page = () => {
return (
<a href='/list/foo/bar'>Homepage</a>
);
}

export const Page = () => {
return (
<a href='/list/foo?q=bar'>Homepage</a>
);
}

export const Page = () => {
return (
<a href='/photo/1/#section'>Photo</a>
);
}

export const Page = () => {
return (
<div>
<a href='./photo'>Photo</a>
</div>
);
}

export const Page = () => {
return (
<div>
<a href='../photo'>Photo</a>
</div>
);
}
Loading
Loading