feat(linter) eslint-plugin-next inline-script-id (#1933)

This commit is contained in:
Cameron 2024-01-08 03:59:47 +00:00 committed by GitHub
parent 3d00d31c1e
commit 0475bcbd92
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
3 changed files with 293 additions and 0 deletions

View file

@ -271,6 +271,7 @@ mod oxc {
mod nextjs {
pub mod google_font_display;
pub mod google_font_preconnect;
pub mod inline_script_id;
}
oxc_macros::declare_all_lint_rules! {
@ -511,4 +512,5 @@ oxc_macros::declare_all_lint_rules! {
oxc::only_used_in_recursion,
nextjs::google_font_display,
nextjs::google_font_preconnect,
nextjs::inline_script_id,
}

View file

@ -0,0 +1,250 @@
use oxc_ast::{
ast::{
Expression, ImportDeclarationSpecifier, JSXAttributeItem, JSXAttributeName,
ModuleDeclaration, ObjectPropertyKind, PropertyKey,
},
AstKind,
};
use oxc_diagnostics::{
miette::{self, Diagnostic},
thiserror::Error,
};
use oxc_macros::declare_oxc_lint;
use oxc_span::{GetSpan, Span};
use rustc_hash::FxHashSet;
use crate::{context::LintContext, rule::Rule, AstNode};
#[derive(Debug, Error, Diagnostic)]
#[error("eslint-plugin-next(inline-script-id): `next/script` components with inline content must specify an `id` attribute.")]
#[diagnostic(severity(warning), help("See https://nextjs.org/docs/messages/inline-script-id"))]
struct InlineScriptIdDiagnostic(#[label] pub Span);
#[derive(Debug, Default, Clone)]
pub struct InlineScriptId;
declare_oxc_lint!(
/// ### What it does
///
///
/// ### Why is this bad?
///
///
/// ### Example
/// ```javascript
/// ```
InlineScriptId,
correctness
);
impl Rule for InlineScriptId {
fn run<'a>(&self, node: &AstNode<'a>, ctx: &LintContext<'a>) {
let AstKind::ModuleDeclaration(ModuleDeclaration::ImportDeclaration(import_decl)) =
node.kind()
else {
return;
};
if import_decl.source.value.as_str() != "next/script" {
return;
}
let Some(import_specifiers) = &import_decl.specifiers else { return };
// find default import
let Some(default_import) = import_specifiers.iter().find_map(|import_specifier| {
let ImportDeclarationSpecifier::ImportDefaultSpecifier(import_default_specifier) =
import_specifier
else {
return None;
};
Some(import_default_specifier)
}) else {
return;
};
'references_loop: for reference in
ctx.semantic().symbol_references(default_import.local.symbol_id.get().unwrap())
{
let node = ctx.nodes().get_node(reference.node_id());
let AstKind::JSXElementName(_) = node.kind() else { continue };
let parent_node = ctx.nodes().parent_node(node.id()).unwrap();
let AstKind::JSXOpeningElement(jsx_opening_element) = parent_node.kind() else {
continue;
};
let Some(AstKind::JSXElement(jsx_element)) = ctx.nodes().parent_kind(parent_node.id())
else {
continue;
};
let mut prop_names_hash_set = FxHashSet::default();
for prop in &jsx_opening_element.attributes {
match prop {
JSXAttributeItem::Attribute(attr) => {
if let JSXAttributeName::Identifier(ident) = &attr.name {
prop_names_hash_set.insert(ident.name.clone());
}
}
JSXAttributeItem::SpreadAttribute(spread_attr) => {
if let Expression::ObjectExpression(obj_expr) =
spread_attr.argument.without_parenthesized()
{
for prop in &obj_expr.properties {
if let ObjectPropertyKind::ObjectProperty(obj_prop) = prop {
if let PropertyKey::Identifier(ident) = &obj_prop.key {
prop_names_hash_set.insert(ident.name.clone());
}
}
}
} else {
continue 'references_loop;
}
}
}
}
if prop_names_hash_set.contains("id") {
continue;
}
if jsx_element.children.len() > 0
|| prop_names_hash_set.contains("dangerouslySetInnerHTML")
{
ctx.diagnostic(InlineScriptIdDiagnostic(jsx_opening_element.name.span()));
}
}
}
}
#[test]
fn test() {
use crate::tester::Tester;
let pass = vec![
r#"import Script from 'next/script';
export default function TestPage() {
return (
<Script id="test-script">
{`console.log('Hello world');`}
</Script>
)
}"#,
r#"import Script from 'next/script';
export default function TestPage() {
return (
<Script
id="test-script"
dangerouslySetInnerHTML={{
__html: `console.log('Hello world');`
}}
/>
)
}"#,
r#"import Script from 'next/script';
export default function TestPage() {
return (
<Script src="https://example.com" />
)
}"#,
r#"import MyScript from 'next/script';
export default function TestPage() {
return (
<MyScript id="test-script">
{`console.log('Hello world');`}
</MyScript>
)
}"#,
r#"import MyScript from 'next/script';
export default function TestPage() {
return (
<MyScript
id="test-script"
dangerouslySetInnerHTML={{
__html: `console.log('Hello world');`
}}
/>
)
}"#,
r#"import Script from 'next/script';
export default function TestPage() {
return (
<Script {...{ strategy: "lazyOnload" }} id={"test-script"}>
{`console.log('Hello world');`}
</Script>
)
}"#,
r#"import Script from 'next/script';
export default function TestPage() {
return (
<Script {...{ strategy: "lazyOnload", id: "test-script" }}>
{`console.log('Hello world');`}
</Script>
)
}"#,
r#"import Script from 'next/script';
const spread = { strategy: "lazyOnload" }
export default function TestPage() {
return (
<Script {...spread} id={"test-script"}>
{`console.log('Hello world');`}
</Script>
)
}"#,
];
let fail = vec![
r"import Script from 'next/script';
export default function TestPage() {
return (
<Script>
{`console.log('Hello world');`}
</Script>
)
}",
r"import Script from 'next/script';
export default function TestPage() {
return (
<Script
dangerouslySetInnerHTML={{
__html: `console.log('Hello world');`
}}
/>
)
}",
r"import MyScript from 'next/script';
export default function TestPage() {
return (
<MyScript>
{`console.log('Hello world');`}
</MyScript>
)
}",
r"import MyScript from 'next/script';
export default function TestPage() {
return (
<MyScript
dangerouslySetInnerHTML={{
__html: `console.log('Hello world');`
}}
/>
)
}",
];
Tester::new_without_config(InlineScriptId::NAME, pass, fail).test_and_snapshot();
}

View file

@ -0,0 +1,41 @@
---
source: crates/oxc_linter/src/tester.rs
expression: inline_script_id
---
⚠ eslint-plugin-next(inline-script-id): `next/script` components with inline content must specify an `id` attribute.
╭─[inline_script_id.tsx:4:1]
4 │ return (
5 │ <Script>
· ──────
6 │ {`console.log('Hello world');`}
╰────
help: See https://nextjs.org/docs/messages/inline-script-id
⚠ eslint-plugin-next(inline-script-id): `next/script` components with inline content must specify an `id` attribute.
╭─[inline_script_id.tsx:4:1]
4 │ return (
5 │ <Script
· ──────
6 │ dangerouslySetInnerHTML={{
╰────
help: See https://nextjs.org/docs/messages/inline-script-id
⚠ eslint-plugin-next(inline-script-id): `next/script` components with inline content must specify an `id` attribute.
╭─[inline_script_id.tsx:4:1]
4 │ return (
5 │ <MyScript>
· ────────
6 │ {`console.log('Hello world');`}
╰────
help: See https://nextjs.org/docs/messages/inline-script-id
⚠ eslint-plugin-next(inline-script-id): `next/script` components with inline content must specify an `id` attribute.
╭─[inline_script_id.tsx:4:1]
4 │ return (
5 │ <MyScript
· ────────
6 │ dangerouslySetInnerHTML={{
╰────
help: See https://nextjs.org/docs/messages/inline-script-id