Skip to content

Commit 035583c

Browse files
committed
feat(biome_js_analyzer): port noHtmlLintForPages from Next.js
1 parent 957cd8e commit 035583c

File tree

15 files changed

+602
-1
lines changed

15 files changed

+602
-1
lines changed

.changeset/open-dancers-teach.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
"@biomejs/biome": patch
3+
---
4+
5+
Added the nursery rule [`noHtmlLinkForPages`](https://biomejs.dev/linter/rules/no-html-link-for-pages/) to the Next.js domain.
6+
This rule prevents usage of `<a>` elements to navigate to internal Next.js pages.
7+
8+
The following code is invalid:
9+
10+
```jsx
11+
export const Page = () => {
12+
return (
13+
<div>
14+
<a href='/about'>About</a>
15+
</div>
16+
);
17+
}
18+
```

crates/biome_cli/src/execute/migrate/eslint_any_rule_to_biome.rs

Lines changed: 12 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/biome_configuration/src/analyzer/linter/rules.rs

Lines changed: 4 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/biome_configuration/src/generated/domain_selector.rs

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/biome_diagnostics_categories/src/categories.rs

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/biome_js_analyze/src/lint/nursery.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ pub mod no_equals_to_null;
1313
pub mod no_excessive_lines_per_file;
1414
pub mod no_floating_promises;
1515
pub mod no_for_in;
16+
pub mod no_html_link_for_pages;
1617
pub mod no_import_cycles;
1718
pub mod no_increment_decrement;
1819
pub mod no_jsx_literals;
@@ -59,4 +60,4 @@ pub mod use_spread;
5960
pub mod use_vue_consistent_define_props_declaration;
6061
pub mod use_vue_define_macros_order;
6162
pub mod use_vue_multi_word_component_names;
62-
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 ,] } }
63+
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 ,] } }
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
use biome_analyze::{
2+
Ast, Rule, RuleDiagnostic, RuleDomain, RuleSource, context::RuleContext, declare_lint_rule,
3+
};
4+
use biome_console::markup;
5+
use biome_diagnostics::Severity;
6+
use biome_js_syntax::jsx_ext::AnyJsxElement;
7+
use biome_rowan::{AstNode, AstNodeList, TextRange};
8+
use biome_rule_options::no_html_link_for_pages::NoHtmlLinkForPagesOptions;
9+
10+
declare_lint_rule! {
11+
/// Prevent usage of `<a>` elements to navigate to internal Next.js pages.
12+
///
13+
/// Using `<a>` elements instead of `next/link` for internal navigation can cause unnecessary full-page reloads.
14+
///
15+
/// ## Examples
16+
///
17+
/// ### Invalid
18+
///
19+
/// ```jsx,expect_diagnostic
20+
/// export const Page = () => {
21+
/// return (
22+
/// <div>
23+
/// <a href='/about'>About</a>
24+
/// </div>
25+
/// );
26+
/// }
27+
/// ```
28+
///
29+
/// ### Valid
30+
///
31+
/// ```jsx
32+
/// import Link from "next/link";
33+
///
34+
/// export const Page = () => {
35+
/// return (
36+
/// <div>
37+
/// <Link href="/about">About</Link>
38+
/// </div>
39+
/// );
40+
/// }
41+
/// ```
42+
///
43+
pub NoHtmlLinkForPages {
44+
version: "next",
45+
name: "noHtmlLinkForPages",
46+
language: "jsx",
47+
sources: &[RuleSource::EslintNext("no-html-link-for-pages").same()],
48+
recommended: false,
49+
severity: Severity::Warning,
50+
domains: &[RuleDomain::Next],
51+
}
52+
}
53+
54+
impl Rule for NoHtmlLinkForPages {
55+
type Query = Ast<AnyJsxElement>;
56+
type State = TextRange;
57+
type Signals = Option<Self::State>;
58+
type Options = NoHtmlLinkForPagesOptions;
59+
60+
fn run(ctx: &RuleContext<Self>) -> Self::Signals {
61+
let jsx_element = ctx.query();
62+
63+
let element_name = jsx_element.name().ok()?.name_value_token().ok()?;
64+
if element_name.text_trimmed() != "a" {
65+
return None;
66+
}
67+
68+
if jsx_element.attributes().is_empty() {
69+
return None;
70+
}
71+
72+
// Skip when download attribute is present
73+
if let Some(_) = jsx_element.find_attribute_by_name("download") {
74+
return None;
75+
}
76+
77+
// Should not enforce when target="_blank" present
78+
if let Some(target) = jsx_element.find_attribute_by_name("target")
79+
&& let Some(target_value) = target.as_static_value()
80+
&& target_value.text().trim() == "_blank"
81+
{
82+
return None;
83+
}
84+
85+
let href_attribute = jsx_element.find_attribute_by_name("href")?;
86+
let href_value = href_attribute.as_static_value()?;
87+
let href_value = href_value.text();
88+
if href_value.is_empty() {
89+
return None;
90+
}
91+
92+
if is_internal_link(href_value) {
93+
return Some(jsx_element.range());
94+
}
95+
96+
None
97+
}
98+
99+
fn diagnostic(ctx: &RuleContext<Self>, state: &Self::State) -> Option<RuleDiagnostic> {
100+
let jsx_element = ctx.query();
101+
let href_attribute = jsx_element.find_attribute_by_name("href")?;
102+
let href_value = href_attribute.as_static_value()?;
103+
let href_value = href_value.text();
104+
105+
Some(
106+
RuleDiagnostic::new(
107+
rule_category!(),
108+
state,
109+
markup! {
110+
""<Emphasis>"<a>"</Emphasis>" element has an internal link to "<Emphasis>{href_value}</Emphasis>"."
111+
},
112+
)
113+
.note(markup! {
114+
""<Emphasis>"<a>"</Emphasis>" elements for internal navigation can cause unnecessary full-page reloads. Use "<Emphasis>"next/link"</Emphasis>" component instead."
115+
})
116+
.note(markup! {
117+
"See the "<Hyperlink href="https://nextjs.org/docs/messages/no-html-link-for-pages">"Next.js docs"</Hyperlink>" for more details."
118+
}),
119+
)
120+
}
121+
}
122+
123+
fn is_internal_link(href: &str) -> bool {
124+
let href = href.trim();
125+
if href.is_empty() {
126+
return false;
127+
}
128+
129+
if href.starts_with("http://") || href.starts_with("https://") || href.starts_with("//") {
130+
return false;
131+
}
132+
133+
// Skip other protocols
134+
if href.starts_with("mailto:")
135+
|| href.starts_with("tel:")
136+
|| href.starts_with("ftp:")
137+
|| href.starts_with("file:")
138+
{
139+
return false;
140+
}
141+
142+
// Skip if it appears to be a public file (e.g. .pdf)
143+
// Internal links in Next.js do not contain file extensions basically
144+
if let Some(last_segment) = href.split("/").last()
145+
&& last_segment.contains(".")
146+
{
147+
return false;
148+
}
149+
150+
href.starts_with('/') || href.starts_with("./") || href.starts_with("../")
151+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
/* should generate diagnostics */
2+
3+
export const Page = () => {
4+
return (
5+
<a href='/'>Homepage</a>
6+
);
7+
}
8+
9+
export const Page = () => {
10+
return (
11+
<a href='/list/foo/bar'>Homepage</a>
12+
);
13+
}
14+
15+
export const Page = () => {
16+
return (
17+
<a href='/list/foo?q=bar'>Homepage</a>
18+
);
19+
}
20+
21+
export const Page = () => {
22+
return (
23+
<a href='/photo/1/#section'>Photo</a>
24+
);
25+
}
26+
27+
export const Page = () => {
28+
return (
29+
<div>
30+
<a href='./photo'>Photo</a>
31+
</div>
32+
);
33+
}
34+
35+
export const Page = () => {
36+
return (
37+
<div>
38+
<a href='../photo'>Photo</a>
39+
</div>
40+
);
41+
}

0 commit comments

Comments
 (0)