Skip to content

Commit e29a3f3

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

File tree

15 files changed

+605
-1
lines changed

15 files changed

+605
-1
lines changed

.changeset/open-dancers-teach.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
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='/'>Home</a>
15+
<a href='/blogs'>Blogs</a>
16+
</div>
17+
);
18+
}
19+
```

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: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
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='/'>Home</a>
24+
/// <a href='/blogs'>Blogs</a>
25+
/// </div>
26+
/// );
27+
/// }
28+
/// ```
29+
///
30+
/// ### Valid
31+
///
32+
/// ```jsx
33+
/// import Link from "next/link";
34+
///
35+
/// export const Page = () => {
36+
/// return (
37+
/// <div>
38+
/// <Link href="/">Home</Link>
39+
/// <Link href="/blogs">Blogs</Link>
40+
/// </div>
41+
/// );
42+
/// }
43+
/// ```
44+
///
45+
pub NoHtmlLinkForPages {
46+
version: "next",
47+
name: "noHtmlLinkForPages",
48+
language: "jsx",
49+
sources: &[RuleSource::EslintNext("no-html-link-for-pages").same()],
50+
recommended: false,
51+
severity: Severity::Warning,
52+
domains: &[RuleDomain::Next],
53+
}
54+
}
55+
56+
impl Rule for NoHtmlLinkForPages {
57+
type Query = Ast<AnyJsxElement>;
58+
type State = TextRange;
59+
type Signals = Option<Self::State>;
60+
type Options = NoHtmlLinkForPagesOptions;
61+
62+
fn run(ctx: &RuleContext<Self>) -> Self::Signals {
63+
let jsx_element = ctx.query();
64+
65+
let element_name = jsx_element.name().ok()?.name_value_token().ok()?;
66+
if element_name.text_trimmed() != "a" {
67+
return None;
68+
}
69+
70+
if jsx_element.attributes().is_empty() {
71+
return None;
72+
}
73+
74+
// Skip when download attribute is present
75+
if let Some(_) = jsx_element.find_attribute_by_name("download") {
76+
return None;
77+
}
78+
79+
// Should not enforce when target="_blank" present
80+
if let Some(target) = jsx_element.find_attribute_by_name("target")
81+
&& let Some(target_value) = target.as_static_value()
82+
&& target_value.text().trim() == "_blank"
83+
{
84+
return None;
85+
}
86+
87+
let href_attribute = jsx_element.find_attribute_by_name("href")?;
88+
let href_value = href_attribute.as_static_value()?;
89+
let href_value = href_value.text();
90+
if href_value.is_empty() {
91+
return None;
92+
}
93+
94+
if is_internal_link(href_value) {
95+
return Some(jsx_element.range());
96+
}
97+
98+
None
99+
}
100+
101+
fn diagnostic(ctx: &RuleContext<Self>, state: &Self::State) -> Option<RuleDiagnostic> {
102+
let jsx_element = ctx.query();
103+
let href_attribute = jsx_element.find_attribute_by_name("href")?;
104+
let href_value = href_attribute.as_static_value()?;
105+
let href_value = href_value.text();
106+
107+
Some(
108+
RuleDiagnostic::new(
109+
rule_category!(),
110+
state,
111+
markup! {
112+
""<Emphasis>"<a>"</Emphasis>" element has an internal link to "<Emphasis>{href_value}</Emphasis>"."
113+
},
114+
)
115+
.note(markup! {
116+
""<Emphasis>"<a>"</Emphasis>" elements for internal navigation can cause unnecessary full-page reloads. Use "<Emphasis>"next/link"</Emphasis>" component instead."
117+
})
118+
.note(markup! {
119+
"See the "<Hyperlink href="https://nextjs.org/docs/messages/no-html-link-for-pages">"Next.js docs"</Hyperlink>" for more details."
120+
}),
121+
)
122+
}
123+
}
124+
125+
fn is_internal_link(href: &str) -> bool {
126+
let href = href.trim();
127+
if href.is_empty() {
128+
return false;
129+
}
130+
131+
if href.starts_with("http://") || href.starts_with("https://") || href.starts_with("//") {
132+
return false;
133+
}
134+
135+
// Skip other protocols
136+
if href.starts_with("mailto:")
137+
|| href.starts_with("tel:")
138+
|| href.starts_with("ftp:")
139+
|| href.starts_with("file:")
140+
{
141+
return false;
142+
}
143+
144+
// Skip if it appears to be a public file (e.g. .pdf)
145+
// Internal links in Next.js do not contain file extensions basically
146+
if let Some(last_segment) = href.split("/").last()
147+
&& last_segment.contains(".")
148+
{
149+
return false;
150+
}
151+
152+
href.starts_with('/') || href.starts_with("./") || href.starts_with("../")
153+
}
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)