From 0f15099f6fabf09bbc57baffa94fbb63e818fa58 Mon Sep 17 00:00:00 2001 From: Cameron Date: Mon, 8 Jan 2024 04:04:32 +0000 Subject: [PATCH] feat(linter) eslint-plugin-next next-script-for-ga (#1934) --- crates/oxc_linter/src/rules.rs | 2 + .../src/rules/nextjs/next_script_for_ga.rs | 314 ++++++++++++++++++ .../src/snapshots/next_script_for_ga.snap | 50 +++ 3 files changed, 366 insertions(+) create mode 100644 crates/oxc_linter/src/rules/nextjs/next_script_for_ga.rs create mode 100644 crates/oxc_linter/src/snapshots/next_script_for_ga.snap diff --git a/crates/oxc_linter/src/rules.rs b/crates/oxc_linter/src/rules.rs index 7474c17a5..411501e9e 100644 --- a/crates/oxc_linter/src/rules.rs +++ b/crates/oxc_linter/src/rules.rs @@ -272,6 +272,7 @@ mod nextjs { pub mod google_font_display; pub mod google_font_preconnect; pub mod inline_script_id; + pub mod next_script_for_ga; } oxc_macros::declare_all_lint_rules! { @@ -513,4 +514,5 @@ oxc_macros::declare_all_lint_rules! { nextjs::google_font_display, nextjs::google_font_preconnect, nextjs::inline_script_id, + nextjs::next_script_for_ga, } diff --git a/crates/oxc_linter/src/rules/nextjs/next_script_for_ga.rs b/crates/oxc_linter/src/rules/nextjs/next_script_for_ga.rs new file mode 100644 index 000000000..3314f05f3 --- /dev/null +++ b/crates/oxc_linter/src/rules/nextjs/next_script_for_ga.rs @@ -0,0 +1,314 @@ +use oxc_ast::{ + ast::{ + Expression, JSXAttributeItem, JSXAttributeValue, JSXElementName, JSXExpression, + JSXOpeningElement, ObjectProperty, ObjectPropertyKind, PropertyKey, + }, + AstKind, +}; +use oxc_diagnostics::{ + miette::{self, Diagnostic}, + thiserror::Error, +}; +use oxc_macros::declare_oxc_lint; +use oxc_span::Span; + +use crate::{ + context::LintContext, + rule::Rule, + utils::{get_string_literal_prop_value, has_jsx_prop_lowercase}, + AstNode, +}; + +#[derive(Debug, Error, Diagnostic)] +#[error("eslint-plugin-next(next-script-for-ga): Prefer `next/script` component when using the inline script for Google Analytics.")] +#[diagnostic(severity(warning), help("See https://nextjs.org/docs/messages/next-script-for-ga"))] +struct NextScriptForGaDiagnostic(#[label] pub Span); + +#[derive(Debug, Default, Clone)] +pub struct NextScriptForGa; + +declare_oxc_lint!( + /// ### What it does + /// + /// + /// ### Why is this bad? + /// + /// + /// ### Example + /// ```javascript + /// ``` + NextScriptForGa, + correctness +); + +impl Rule for NextScriptForGa { + fn run<'a>(&self, node: &AstNode<'a>, ctx: &LintContext<'a>) { + let AstKind::JSXOpeningElement(jsx_opening_element) = node.kind() else { return }; + + let JSXElementName::Identifier(jsx_opening_element_name) = &jsx_opening_element.name else { + return; + }; + + if jsx_opening_element_name.name.as_str() != "script" { + return; + } + + // Check if the Alternative async tag is being used to add GA. + // https://developers.google.com/analytics/devguides/collection/analyticsjs#alternative_async_tag + // https://developers.google.com/analytics/devguides/collection/gtagjs + if let Some(src_prop) = has_jsx_prop_lowercase(jsx_opening_element, "src") { + if let Some(src_prop_value) = get_string_literal_prop_value(src_prop) { + if SUPPORTED_SRCS.iter().any(|s| src_prop_value.contains(s)) { + ctx.diagnostic(NextScriptForGaDiagnostic(jsx_opening_element_name.span)); + return; + } + } + } + + // Check if inline script is being used to add GA. + // https://developers.google.com/analytics/devguides/collection/analyticsjs#the_google_analytics_tag + // https://developers.google.com/tag-manager/quickstart + if let Some(danger_value) = get_dangerously_set_inner_html_prop_value(jsx_opening_element) { + let Expression::TemplateLiteral(template_literal) = &danger_value.value else { return }; + let template_literal = template_literal.quasis[0].value.raw.as_str(); + if SUPPORTED_HTML_CONTENT_URLS.iter().any(|s| template_literal.contains(s)) { + ctx.diagnostic(NextScriptForGaDiagnostic(jsx_opening_element_name.span)); + } + } + } +} + +const SUPPORTED_SRCS: [&str; 2] = + ["www.google-analytics.com/analytics.js", "www.googletagmanager.com/gtag/js"]; + +const SUPPORTED_HTML_CONTENT_URLS: [&str; 2] = + ["www.google-analytics.com/analytics.js", "www.googletagmanager.com/gtm.js"]; + +fn get_dangerously_set_inner_html_prop_value<'a>( + jsx_opening_element: &'a JSXOpeningElement<'a>, +) -> Option<&'a ObjectProperty<'a>> { + let Some(JSXAttributeItem::Attribute(dangerously_set_inner_html_prop)) = + has_jsx_prop_lowercase(jsx_opening_element, "dangerouslysetinnerhtml") + else { + return None; + }; + let Some(JSXAttributeValue::ExpressionContainer(object_expr)) = + &dangerously_set_inner_html_prop.value + else { + return None; + }; + let JSXExpression::Expression(Expression::ObjectExpression(object_expr)) = + &object_expr.expression + else { + return None; + }; + + if let Some(html_prop) = object_expr.properties.iter().find_map(|prop| { + if let ObjectPropertyKind::ObjectProperty(html_prop) = prop { + if let PropertyKey::Identifier(html_prop_ident) = &html_prop.key { + if html_prop_ident.name == "__html" { + Some(html_prop) + } else { + None + } + } else { + None + } + } else { + None + } + }) { + return Some(html_prop); + } + + None +} + +#[test] +fn test() { + use crate::tester::Tester; + + let pass = vec![ + r#"import Script from 'next/script' + + export class Blah extends Head { + render() { + return ( +
+

Hello title

+ +
+ ); + } + }"#, + r#"import Script from 'next/script' + + export class Blah extends Head { + render() { + return ( +
+

Hello title

+ +
+ ); + } + }"#, + r#"import Script from 'next/script' + + export class Blah extends Head { + render() { + return ( +
+

Hello title

+ +
+ ); + } + }"#, + r"export class Blah extends Head { + render() { + return ( +
+

Hello title

+ +
+ ); + } + }", + r" + export class Blah extends Head { + createGoogleAnalyticsMarkup() { + return { + __html: ` + window.dataLayer = window.dataLayer || []; + function gtag(){dataLayer.push(arguments);} + gtag('js', new Date()); + gtag('config', 'UA-148481588-2');`, + }; + } + + render() { + return ( +
+

Hello title

+ +
+ ); + } + }", + ]; + + Tester::new_without_config(NextScriptForGa::NAME, pass, fail).test_and_snapshot(); +} diff --git a/crates/oxc_linter/src/snapshots/next_script_for_ga.snap b/crates/oxc_linter/src/snapshots/next_script_for_ga.snap new file mode 100644 index 000000000..9c0283d06 --- /dev/null +++ b/crates/oxc_linter/src/snapshots/next_script_for_ga.snap @@ -0,0 +1,50 @@ +--- +source: crates/oxc_linter/src/tester.rs +expression: next_script_for_ga +--- + ⚠ eslint-plugin-next(next-script-for-ga): Prefer `next/script` component when using the inline script for Google Analytics. + ╭─[next_script_for_ga.tsx:6:1] + 6 │

Hello title

+ 7 │ + · ────── + 15 │ + ╰──── + help: See https://nextjs.org/docs/messages/next-script-for-ga + + ⚠ eslint-plugin-next(next-script-for-ga): Prefer `next/script` component when using the inline script for Google Analytics. + ╭─[next_script_for_ga.tsx:17:1] + 17 │ + · ────── + 19 │ + ╰──── + help: See https://nextjs.org/docs/messages/next-script-for-ga + +