Skip to content

Commit

Permalink
feat(linter): eslint-plugin-next/no-html-link-for-pages
Browse files Browse the repository at this point in the history
  • Loading branch information
Dunqing committed May 8, 2024
1 parent 219e1ab commit c73d547
Show file tree
Hide file tree
Showing 12 changed files with 229 additions and 3 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export default () => {}
1 change: 1 addition & 0 deletions crates/oxc_linter/fixtures/next/custom-pages/index.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export default () => {}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export default () => {}
1 change: 1 addition & 0 deletions crates/oxc_linter/fixtures/next/with-app-dir/app/index.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export default () => {}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export default () => {}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export default () => {}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export default () => {}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export default () => {}
12 changes: 11 additions & 1 deletion crates/oxc_linter/src/config/settings/next.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
use std::env;

use serde::Deserialize;

/// <https://nextjs.org/docs/pages/building-your-application/configuring/eslint#eslint-plugin>
Expand All @@ -10,10 +12,18 @@ pub struct NextPluginSettings {

impl NextPluginSettings {
pub fn get_root_dirs(&self) -> Vec<String> {
match &self.root_dir {
let mut root_dirs = match &self.root_dir {
OneOrMany::One(val) => vec![val.clone()],
OneOrMany::Many(vec) => vec.clone(),
};

if root_dirs.is_empty() {
if let Ok(current_dir) = env::current_dir() {
root_dirs.push(current_dir.to_string_lossy().to_string());
}
}

root_dirs
}
}

Expand Down
2 changes: 2 additions & 0 deletions crates/oxc_linter/src/rules.rs
Original file line number Diff line number Diff line change
Expand Up @@ -352,6 +352,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 @@ -705,6 +706,7 @@ oxc_macros::declare_all_lint_rules! {
nextjs::no_before_interactive_script_outside_document,
nextjs::no_page_custom_font,
nextjs::no_styled_jsx_in_document,
nextjs::no_html_link_for_pages,
jsdoc::check_access,
jsdoc::check_property_names,
jsdoc::check_tag_names,
Expand Down
199 changes: 199 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,199 @@
use std::path::PathBuf;

use itertools::Itertools;
use oxc_ast::{
ast::{JSXAttributeItem, JSXAttributeName, JSXAttributeValue, JSXElementName},
AstKind,
};
use oxc_diagnostics::{
miette::{self, Diagnostic},
thiserror::{self, Error},
};
use oxc_macros::declare_oxc_lint;
use oxc_span::Span;

use crate::{context::LintContext, rule::Rule, AstNode};

#[derive(Debug, Error, Diagnostic)]
#[error("eslint-plugin-next(no-html-link-for-pages):")]
#[diagnostic(severity(warning), help(""))]
struct NoHtmlLinkForPagesDiagnostic(#[label] pub Span);

#[derive(Debug, Default, Clone)]
pub struct NoHtmlLinkForPages(Box<NoHtmlLinkForPagesConfig>);

#[derive(Debug, Default, Clone)]
pub struct NoHtmlLinkForPagesConfig {
pages_dirs: Option<Vec<String>>,
}

impl std::ops::Deref for NoHtmlLinkForPages {
type Target = NoHtmlLinkForPagesConfig;

fn deref(&self) -> &Self::Target {
&self.0
}
}

declare_oxc_lint!(
/// ### What it does
///
///
/// ### Why is this bad?
///
///
/// ### Example
/// ```javascript
/// ```
NoHtmlLinkForPages,
nursery, // TODO: change category to `correctness`, `suspicious`, `pedantic`, `perf`, `restriction`, or `style`
// See <https://oxc-project.github.io/docs/contribute/linter.html#rule-category> for details
);

impl Rule for NoHtmlLinkForPages {
fn from_configuration(value: serde_json::Value) -> Self {
let pages_dirs = value.as_array().map(|dirs| {
dirs.iter().filter_map(|item| item.as_str().map(ToString::to_string)).collect_vec()
});
Self(Box::new(NoHtmlLinkForPagesConfig { pages_dirs }))
}
fn run_once(&self, ctx: &LintContext) {
let pages_dirs = self.pages_dirs.as_ref().map_or_else(
|| {
ctx.settings()
.next
.get_root_dirs()
.iter()
.flat_map(|item| {
vec![
PathBuf::from(item).join("pages"),
PathBuf::from(item).join("src/pages"),
]
})
.collect_vec()
},
|dirs| dirs.iter().map(PathBuf::from).collect_vec(),
);

let found_pages_dirs = pages_dirs.iter().filter(|dir| dir.exists()).collect_vec();

for node in ctx.nodes().iter() {
let kind = node.kind();
let AstKind::JSXOpeningElement(element) = kind else { continue };
if matches!(&element.name, JSXElementName::Identifier(ident) if ident.name != "a") {
continue;
}
let should_ignore = element.attributes.iter().any(|attr| {
let JSXAttributeItem::Attribute(attr) = attr else {
return false;
};

let JSXAttributeName::Identifier(ident) = &attr.name else {
return false;
};

match ident.name.as_str() {
"target" => {
attr.value.as_ref().map_or(false, |value| {
matches!(&value, JSXAttributeValue::StringLiteral(value) if value.value == "_blank")
})
},
"download" => true,
"href" => {
attr.value.as_ref().map_or(false, |value| {
if let JSXAttributeValue::StringLiteral(literal) = value {
// Outgoing links are ignored
literal.value.starts_with("http://") || literal.value.starts_with("https://") || literal.value.starts_with("//")
} else {
true
}
})
},
_ => false
}
});
if should_ignore {
continue;
}

let Some(href) = element.attributes.iter().find_map(|item| match item {
JSXAttributeItem::Attribute(attribute) => {
if attribute.is_identifier("href") {
attribute.value.as_ref().and_then(|value| {
if let JSXAttributeValue::StringLiteral(literal) = value {
Some(literal)
} else {
None
}
})
} else {
None
}
}
JSXAttributeItem::SpreadAttribute(_) => None,
}) else {
continue;
};
}
}
fn run<'a>(&self, _node: &AstNode<'a>, _ctx: &LintContext<'a>) {}
}

#[test]
fn test() {
use crate::tester::Tester;
use serde_json::json;
use std::env;
use std::path::PathBuf;

let cwd = env::current_dir().unwrap().join("fixtures/next");
let with_custom_pages_directory = cwd.join("with-custom-pages-dir");
let custom_page_dir = with_custom_pages_directory.join("custom-pages");
let filename = Some(PathBuf::from("foo.jsx"));

let valid_code = r"
import Link from 'next/link';
export class Blah extends Head {
render() {
return (
<div>
<Link href='/'>
<a>Homepage</a>
</Link>
<h1>Hello title</h1>
</div>
);
}
}
";

let invalid_static_code = r"
import Link from 'next/link';
export class Blah extends Head {
render() {
return (
<div>
<a href='/'>Homepage</a>
<h1>Hello title</h1>
</div>
);
}
}
";

let pass = vec![(
valid_code,
Some(json!([custom_page_dir.to_string_lossy().to_string()])),
None,
filename.clone(),
)];

let fail = vec![(invalid_static_code, None, None, filename.clone())];

Tester::new(NoHtmlLinkForPages::NAME, pass, fail)
.with_nextjs_plugin(true)
.change_rule_path("with-custom-pages-dir")
.test_and_snapshot();
}
11 changes: 9 additions & 2 deletions crates/oxc_linter/src/tester.rs
Original file line number Diff line number Diff line change
Expand Up @@ -90,8 +90,7 @@ impl Tester {
let rule_path = PathBuf::from(rule_name.replace('-', "_")).with_extension("tsx");
let expect_pass = expect_pass.into_iter().map(Into::into).collect::<Vec<_>>();
let expect_fail = expect_fail.into_iter().map(Into::into).collect::<Vec<_>>();
let current_working_directory =
env::current_dir().unwrap().join("fixtures/import").into_boxed_path();
let current_working_directory = env::current_dir().unwrap().into_boxed_path();
Self {
rule_name,
rule_path,
Expand All @@ -116,6 +115,10 @@ impl Tester {

pub fn with_import_plugin(mut self, yes: bool) -> Self {
self.import_plugin = yes;
if yes {
self.current_working_directory =
self.current_working_directory.join("fixtures/import").into_boxed_path();
}
self
}

Expand All @@ -131,6 +134,10 @@ impl Tester {

pub fn with_nextjs_plugin(mut self, yes: bool) -> Self {
self.nextjs_plugin = yes;
if yes {
self.current_working_directory =
self.current_working_directory.join("fixtures/next").into_boxed_path();
}
self
}

Expand Down

0 comments on commit c73d547

Please sign in to comment.