diff --git a/.changeset/open-dancers-teach.md b/.changeset/open-dancers-teach.md new file mode 100644 index 000000000000..3186e275ed87 --- /dev/null +++ b/.changeset/open-dancers-teach.md @@ -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 `` elements to navigate to internal Next.js pages. + +The following code is invalid: + +```jsx +export const Page = () => { + return ( +
+ About +
+ ); +} +``` diff --git a/crates/biome_cli/src/execute/migrate/eslint_any_rule_to_biome.rs b/crates/biome_cli/src/execute/migrate/eslint_any_rule_to_biome.rs index fde938638f67..7a598927b982 100644 --- a/crates/biome_cli/src/execute/migrate/eslint_any_rule_to_biome.rs +++ b/crates/biome_cli/src/execute/migrate/eslint_any_rule_to_biome.rs @@ -129,6 +129,18 @@ pub(crate) fn migrate_eslint_any_rule( .get_or_insert(Default::default()); rule.set_level(rule.level().max(rule_severity.into())); } + "@next/next/no-html-link-for-pages" => { + if !options.include_nursery { + results.add(eslint_name, eslint_to_biome::RuleMigrationResult::Nursery); + return false; + } + let group = rules.nursery.get_or_insert_with(Default::default); + let rule = group + .unwrap_group_as_mut() + .no_html_link_for_pages + .get_or_insert(Default::default()); + rule.set_level(rule.level().max(rule_severity.into())); + } "@next/next/no-img-element" => { let group = rules.performance.get_or_insert_with(Default::default); let rule = group diff --git a/crates/biome_configuration/src/analyzer/linter/rules.rs b/crates/biome_configuration/src/analyzer/linter/rules.rs index 23996a2f3dc2..5e074cb0af0a 100644 --- a/crates/biome_configuration/src/analyzer/linter/rules.rs +++ b/crates/biome_configuration/src/analyzer/linter/rules.rs @@ -190,6 +190,7 @@ pub enum RuleName { NoHeadElement, NoHeadImportInDocument, NoHeaderScope, + NoHtmlLinkForPages, NoImgElement, NoImplicitAnyLet, NoImplicitBoolean, @@ -613,6 +614,7 @@ impl RuleName { Self::NoHeadElement => "noHeadElement", Self::NoHeadImportInDocument => "noHeadImportInDocument", Self::NoHeaderScope => "noHeaderScope", + Self::NoHtmlLinkForPages => "noHtmlLinkForPages", Self::NoImgElement => "noImgElement", Self::NoImplicitAnyLet => "noImplicitAnyLet", Self::NoImplicitBoolean => "noImplicitBoolean", @@ -1040,6 +1042,7 @@ impl RuleName { Self::NoHeadElement => RuleGroup::Style, Self::NoHeadImportInDocument => RuleGroup::Suspicious, Self::NoHeaderScope => RuleGroup::A11y, + Self::NoHtmlLinkForPages => RuleGroup::Nursery, Self::NoImgElement => RuleGroup::Performance, Self::NoImplicitAnyLet => RuleGroup::Suspicious, Self::NoImplicitBoolean => RuleGroup::Style, @@ -1468,6 +1471,7 @@ impl std::str::FromStr for RuleName { "noHeadElement" => Ok(Self::NoHeadElement), "noHeadImportInDocument" => Ok(Self::NoHeadImportInDocument), "noHeaderScope" => Ok(Self::NoHeaderScope), + "noHtmlLinkForPages" => Ok(Self::NoHtmlLinkForPages), "noImgElement" => Ok(Self::NoImgElement), "noImplicitAnyLet" => Ok(Self::NoImplicitAnyLet), "noImplicitBoolean" => Ok(Self::NoImplicitBoolean), diff --git a/crates/biome_configuration/src/generated/domain_selector.rs b/crates/biome_configuration/src/generated/domain_selector.rs index 03fd957d578a..d03eaf805a99 100644 --- a/crates/biome_configuration/src/generated/domain_selector.rs +++ b/crates/biome_configuration/src/generated/domain_selector.rs @@ -8,6 +8,7 @@ static NEXT_FILTERS: LazyLock>> = LazyLock::new(|| { RuleFilter::Rule("correctness", "useExhaustiveDependencies"), RuleFilter::Rule("correctness", "useHookAtTopLevel"), RuleFilter::Rule("nursery", "noBeforeInteractiveScriptOutsideDocument"), + RuleFilter::Rule("nursery", "noHtmlLinkForPages"), RuleFilter::Rule("nursery", "noNextAsyncClientComponent"), RuleFilter::Rule("nursery", "noSyncScripts"), RuleFilter::Rule("performance", "noImgElement"), diff --git a/crates/biome_diagnostics_categories/src/categories.rs b/crates/biome_diagnostics_categories/src/categories.rs index 5d5577fe2736..2fcbec9f142b 100644 --- a/crates/biome_diagnostics_categories/src/categories.rs +++ b/crates/biome_diagnostics_categories/src/categories.rs @@ -174,6 +174,7 @@ define_categories! { "lint/nursery/noExcessiveLinesPerFile": "https://biomejs.dev/linter/rules/no-excessive-lines-per-file", "lint/nursery/noFloatingPromises": "https://biomejs.dev/linter/rules/no-floating-promises", "lint/nursery/noForIn": "https://biomejs.dev/linter/rules/no-for-in", + "lint/nursery/noHtmlLinkForPages": "https://biomejs.dev/linter/rules/no-html-link-for-pages", "lint/nursery/noImplicitCoercion": "https://biomejs.dev/linter/rules/no-implicit-coercion", "lint/nursery/noImportCycles": "https://biomejs.dev/linter/rules/no-import-cycles", "lint/nursery/noIncrementDecrement": "https://biomejs.dev/linter/rules/no-increment-decrement", diff --git a/crates/biome_js_analyze/src/lint/nursery.rs b/crates/biome_js_analyze/src/lint/nursery.rs index be93ffd6eba1..3757ca775ac3 100644 --- a/crates/biome_js_analyze/src/lint/nursery.rs +++ b/crates/biome_js_analyze/src/lint/nursery.rs @@ -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; @@ -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 ,] } } diff --git a/crates/biome_js_analyze/src/lint/nursery/no_html_link_for_pages.rs b/crates/biome_js_analyze/src/lint/nursery/no_html_link_for_pages.rs new file mode 100644 index 000000000000..5cbee7415a05 --- /dev/null +++ b/crates/biome_js_analyze/src/lint/nursery/no_html_link_for_pages.rs @@ -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 `` elements to navigate to internal Next.js pages. + /// + /// Using `` elements instead of `next/link` for internal navigation can cause unnecessary full-page reloads. + /// + /// ## Examples + /// + /// ### Invalid + /// + /// ```jsx,expect_diagnostic + /// export const Page = () => { + /// return ( + ///
+ /// About + ///
+ /// ); + /// } + /// ``` + /// + /// ### Valid + /// + /// ```jsx + /// import Link from "next/link"; + /// + /// export const Page = () => { + /// return ( + ///
+ /// About + ///
+ /// ); + /// } + /// ``` + /// + 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; + type State = TextRange; + type Signals = Option; + type Options = NoHtmlLinkForPagesOptions; + + fn run(ctx: &RuleContext) -> 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, state: &Self::State) -> Option { + 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! { + """"" element has an internal link to "{href_value}"." + }, + ) + .note(markup! { + """"" elements for internal navigation can cause unnecessary full-page reloads. Use ""next/link"" component instead." + }) + .note(markup! { + "See the ""Next.js docs"" 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("../") +} diff --git a/crates/biome_js_analyze/tests/specs/nursery/noHtmlLinkForPages/invalid.jsx b/crates/biome_js_analyze/tests/specs/nursery/noHtmlLinkForPages/invalid.jsx new file mode 100644 index 000000000000..30530846f9cb --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/nursery/noHtmlLinkForPages/invalid.jsx @@ -0,0 +1,34 @@ +/* should generate diagnostics */ + +export const Page = () => { + return ( + Homepage + ); +} + +export const Page = () => { + return ( + Homepage + ); +} + +export const Page = () => { + return ( + Homepage + ); +} + +export const Page = () => { + return ( + Photo + ); +} + +export const Page = () => { + return ( + <> + Photo + Photo + + ); +} diff --git a/crates/biome_js_analyze/tests/specs/nursery/noHtmlLinkForPages/invalid.jsx.snap b/crates/biome_js_analyze/tests/specs/nursery/noHtmlLinkForPages/invalid.jsx.snap new file mode 100644 index 000000000000..49f36571db6c --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/nursery/noHtmlLinkForPages/invalid.jsx.snap @@ -0,0 +1,169 @@ +--- +source: crates/biome_js_analyze/tests/spec_tests.rs +expression: invalid.jsx +--- +# Input +```jsx +/* should generate diagnostics */ + +export const Page = () => { + return ( + Homepage + ); +} + +export const Page = () => { + return ( + Homepage + ); +} + +export const Page = () => { + return ( + Homepage + ); +} + +export const Page = () => { + return ( + Photo + ); +} + +export const Page = () => { + return ( + <> + Photo + Photo + + ); +} + +``` + +# Diagnostics +``` +invalid.jsx:5:5 lint/nursery/noHtmlLinkForPages ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! element has an internal link to /. + + 3 │ export const Page = () => { + 4 │ return ( + > 5 │ Homepage + │ ^^^^^^^^^^^^ + 6 │ ); + 7 │ } + + i elements for internal navigation can cause unnecessary full-page reloads. Use next/link component instead. + + i See the Next.js docs for more details. + + i This rule belongs to the nursery group, which means it is not yet stable and may change in the future. Visit https://biomejs.dev/linter/#nursery for more information. + + +``` + +``` +invalid.jsx:11:5 lint/nursery/noHtmlLinkForPages ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! element has an internal link to /list/foo/bar. + + 9 │ export const Page = () => { + 10 │ return ( + > 11 │ Homepage + │ ^^^^^^^^^^^^^^^^^^^^^^^^ + 12 │ ); + 13 │ } + + i elements for internal navigation can cause unnecessary full-page reloads. Use next/link component instead. + + i See the Next.js docs for more details. + + i This rule belongs to the nursery group, which means it is not yet stable and may change in the future. Visit https://biomejs.dev/linter/#nursery for more information. + + +``` + +``` +invalid.jsx:17:5 lint/nursery/noHtmlLinkForPages ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! element has an internal link to /list/foo?q=bar. + + 15 │ export const Page = () => { + 16 │ return ( + > 17 │ Homepage + │ ^^^^^^^^^^^^^^^^^^^^^^^^^^ + 18 │ ); + 19 │ } + + i elements for internal navigation can cause unnecessary full-page reloads. Use next/link component instead. + + i See the Next.js docs for more details. + + i This rule belongs to the nursery group, which means it is not yet stable and may change in the future. Visit https://biomejs.dev/linter/#nursery for more information. + + +``` + +``` +invalid.jsx:23:5 lint/nursery/noHtmlLinkForPages ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! element has an internal link to /photo/1/#section. + + 21 │ export const Page = () => { + 22 │ return ( + > 23 │ Photo + │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + 24 │ ); + 25 │ } + + i elements for internal navigation can cause unnecessary full-page reloads. Use next/link component instead. + + i See the Next.js docs for more details. + + i This rule belongs to the nursery group, which means it is not yet stable and may change in the future. Visit https://biomejs.dev/linter/#nursery for more information. + + +``` + +``` +invalid.jsx:30:7 lint/nursery/noHtmlLinkForPages ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! element has an internal link to ./photo. + + 28 │ return ( + 29 │ <> + > 30 │ Photo + │ ^^^^^^^^^^^^^^^^^^ + 31 │ Photo + 32 │ + + i elements for internal navigation can cause unnecessary full-page reloads. Use next/link component instead. + + i See the Next.js docs for more details. + + i This rule belongs to the nursery group, which means it is not yet stable and may change in the future. Visit https://biomejs.dev/linter/#nursery for more information. + + +``` + +``` +invalid.jsx:31:7 lint/nursery/noHtmlLinkForPages ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! element has an internal link to ../photo. + + 29 │ <> + 30 │ Photo + > 31 │ Photo + │ ^^^^^^^^^^^^^^^^^^^ + 32 │ + 33 │ ); + + i elements for internal navigation can cause unnecessary full-page reloads. Use next/link component instead. + + i See the Next.js docs for more details. + + i This rule belongs to the nursery group, which means it is not yet stable and may change in the future. Visit https://biomejs.dev/linter/#nursery for more information. + + +``` diff --git a/crates/biome_js_analyze/tests/specs/nursery/noHtmlLinkForPages/valid.jsx b/crates/biome_js_analyze/tests/specs/nursery/noHtmlLinkForPages/valid.jsx new file mode 100644 index 000000000000..ea513484bec2 --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/nursery/noHtmlLinkForPages/valid.jsx @@ -0,0 +1,70 @@ +/* should not generate diagnostics */ + +import Link from 'next/link'; + +export const Page = () => { + return ( + Homepage + ); +} + +export const Page = () => { + return ( + Homepage + ); +}; + +export const Page = () => { + return ( + Homepage + ); +}; + +export const Page = () => { + return ( + <> + Homepage + Homepage + Homepage + + ); +} + +export const Page = () => { + return ( + <> + Download + View PDF + + ); +} + +export const Page = () => { + return ( + New Tab + ); +} + +export const Page = () => { + return ( + Photo + ); +} + +export const Page = () => { + return ( + <> + Photo + Photo + + ); +} + +export const Page = () => { + return ( + <> + Email + Phone + + ); +} \ No newline at end of file diff --git a/crates/biome_js_analyze/tests/specs/nursery/noHtmlLinkForPages/valid.jsx.snap b/crates/biome_js_analyze/tests/specs/nursery/noHtmlLinkForPages/valid.jsx.snap new file mode 100644 index 000000000000..6204dcdaac3b --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/nursery/noHtmlLinkForPages/valid.jsx.snap @@ -0,0 +1,77 @@ +--- +source: crates/biome_js_analyze/tests/spec_tests.rs +expression: valid.jsx +--- +# Input +```jsx +/* should not generate diagnostics */ + +import Link from 'next/link'; + +export const Page = () => { + return ( + Homepage + ); +} + +export const Page = () => { + return ( + Homepage + ); +}; + +export const Page = () => { + return ( + Homepage + ); +}; + +export const Page = () => { + return ( + <> + Homepage + Homepage + Homepage + + ); +} + +export const Page = () => { + return ( + <> + Download + View PDF + + ); +} + +export const Page = () => { + return ( + New Tab + ); +} + +export const Page = () => { + return ( + Photo + ); +} + +export const Page = () => { + return ( + <> + Photo + Photo + + ); +} + +export const Page = () => { + return ( + <> + Email + Phone + + ); +} +``` diff --git a/crates/biome_rule_options/src/lib.rs b/crates/biome_rule_options/src/lib.rs index cd6d81c4ba22..722e06352ce1 100644 --- a/crates/biome_rule_options/src/lib.rs +++ b/crates/biome_rule_options/src/lib.rs @@ -103,6 +103,7 @@ pub mod no_global_object_calls; pub mod no_head_element; pub mod no_head_import_in_document; pub mod no_header_scope; +pub mod no_html_link_for_pages; pub mod no_img_element; pub mod no_implicit_any_let; pub mod no_implicit_boolean; diff --git a/crates/biome_rule_options/src/no_html_link_for_pages.rs b/crates/biome_rule_options/src/no_html_link_for_pages.rs new file mode 100644 index 000000000000..e09d0d1148f7 --- /dev/null +++ b/crates/biome_rule_options/src/no_html_link_for_pages.rs @@ -0,0 +1,6 @@ +use biome_deserialize_macros::{Deserializable, Merge}; +use serde::{Deserialize, Serialize}; +#[derive(Default, Clone, Debug, Deserialize, Deserializable, Merge, Eq, PartialEq, Serialize)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase", deny_unknown_fields, default)] +pub struct NoHtmlLinkForPagesOptions {} diff --git a/packages/@biomejs/backend-jsonrpc/src/workspace.ts b/packages/@biomejs/backend-jsonrpc/src/workspace.ts index 6dbd8ad8ac1a..bf21d0bf4ea3 100644 --- a/packages/@biomejs/backend-jsonrpc/src/workspace.ts +++ b/packages/@biomejs/backend-jsonrpc/src/workspace.ts @@ -1914,6 +1914,11 @@ See */ noForIn?: NoForInConfiguration; /** + * Prevent usage of \ elements to navigate to internal Next.js pages. +See + */ + noHtmlLinkForPages?: NoHtmlLinkForPagesConfiguration; + /** * Prevent import cycles. See */ @@ -3739,6 +3744,9 @@ export type NoFloatingPromisesConfiguration = export type NoForInConfiguration = | RulePlainConfiguration | RuleWithNoForInOptions; +export type NoHtmlLinkForPagesConfiguration = + | RulePlainConfiguration + | RuleWithNoHtmlLinkForPagesOptions; export type NoImportCyclesConfiguration = | RulePlainConfiguration | RuleWithNoImportCyclesOptions; @@ -5231,6 +5239,10 @@ export interface RuleWithNoForInOptions { level: RulePlainConfiguration; options?: NoForInOptions; } +export interface RuleWithNoHtmlLinkForPagesOptions { + level: RulePlainConfiguration; + options?: NoHtmlLinkForPagesOptions; +} export interface RuleWithNoImportCyclesOptions { level: RulePlainConfiguration; options?: NoImportCyclesOptions; @@ -6667,6 +6679,7 @@ export interface NoExcessiveLinesPerFileOptions { } export type NoFloatingPromisesOptions = {}; export type NoForInOptions = {}; +export type NoHtmlLinkForPagesOptions = {}; export interface NoImportCyclesOptions { /** * Ignores type-only imports when finding an import cycle. A type-only import (`import type`) @@ -7592,6 +7605,7 @@ export type Category = | "lint/nursery/noExcessiveLinesPerFile" | "lint/nursery/noFloatingPromises" | "lint/nursery/noForIn" + | "lint/nursery/noHtmlLinkForPages" | "lint/nursery/noImplicitCoercion" | "lint/nursery/noImportCycles" | "lint/nursery/noIncrementDecrement" diff --git a/packages/@biomejs/biome/configuration_schema.json b/packages/@biomejs/biome/configuration_schema.json index a8c4e8dd0b49..050246a3c388 100644 --- a/packages/@biomejs/biome/configuration_schema.json +++ b/packages/@biomejs/biome/configuration_schema.json @@ -3497,6 +3497,16 @@ ] }, "NoHeaderScopeOptions": { "type": "object", "additionalProperties": false }, + "NoHtmlLinkForPagesConfiguration": { + "oneOf": [ + { "$ref": "#/$defs/RulePlainConfiguration" }, + { "$ref": "#/$defs/RuleWithNoHtmlLinkForPagesOptions" } + ] + }, + "NoHtmlLinkForPagesOptions": { + "type": "object", + "additionalProperties": false + }, "NoImgElementConfiguration": { "oneOf": [ { "$ref": "#/$defs/RulePlainConfiguration" }, @@ -5296,6 +5306,13 @@ { "type": "null" } ] }, + "noHtmlLinkForPages": { + "description": "Prevent usage of \\ elements to navigate to internal Next.js pages.\nSee ", + "anyOf": [ + { "$ref": "#/$defs/NoHtmlLinkForPagesConfiguration" }, + { "type": "null" } + ] + }, "noImportCycles": { "description": "Prevent import cycles.\nSee ", "anyOf": [ @@ -7275,6 +7292,15 @@ "additionalProperties": false, "required": ["level"] }, + "RuleWithNoHtmlLinkForPagesOptions": { + "type": "object", + "properties": { + "level": { "$ref": "#/$defs/RulePlainConfiguration" }, + "options": { "$ref": "#/$defs/NoHtmlLinkForPagesOptions" } + }, + "additionalProperties": false, + "required": ["level"] + }, "RuleWithNoImgElementOptions": { "type": "object", "properties": {