From da4d65252f8699c7e894735d299875f612b12082 Mon Sep 17 00:00:00 2001 From: Rahul Gaba Date: Tue, 17 Aug 2021 00:25:07 +0530 Subject: [PATCH] ESLint Plugin: Prefer next script component when using the inline script for Google Analytics. (#25147) * Add a lint rule for using next script component when using inline script for Google Analytics. * Apply suggestions from code review Co-authored-by: JJ Kasper * Update errors/next-script-for-ga.md Co-authored-by: JJ Kasper * Apply suggestions from code review Co-authored-by: JJ Kasper --- docs/basic-features/eslint.md | 1 + errors/next-script-for-ga.md | 98 ++++++++ packages/eslint-plugin-next/lib/index.js | 2 + .../lib/rules/next-script-for-ga.js | 77 +++++++ .../lib/utils/node-attributes.js | 4 +- .../next-script-for-ga.unit.test.js | 212 ++++++++++++++++++ 6 files changed, 393 insertions(+), 1 deletion(-) create mode 100644 errors/next-script-for-ga.md create mode 100644 packages/eslint-plugin-next/lib/rules/next-script-for-ga.js create mode 100644 test/eslint-plugin-next/next-script-for-ga.unit.test.js diff --git a/docs/basic-features/eslint.md b/docs/basic-features/eslint.md index a08fdc7e7882151..dc68f5faed35cfc 100644 --- a/docs/basic-features/eslint.md +++ b/docs/basic-features/eslint.md @@ -95,6 +95,7 @@ Next.js provides an ESLint plugin, [`eslint-plugin-next`](https://www.npmjs.com/ | ✔️ | [next/no-title-in-document-head](https://nextjs.org/docs/messages/no-title-in-document-head) | Disallow using <title> with Head from next/document | | ✔️ | [next/no-unwanted-polyfillio](https://nextjs.org/docs/messages/no-unwanted-polyfillio) | Prevent duplicate polyfills from Polyfill.io | | ✔️ | next/no-typos | Ensure no typos were made declaring [Next.js's data fetching function](https://nextjs.org/docs/basic-features/data-fetching) | +| ✔️ | [next/next-script-for-ga](https://nextjs.org/docs/messages/next-script-for-ga) | Use the Script component to defer loading of the script until necessary. | - ✔: Enabled in the recommended configuration diff --git a/errors/next-script-for-ga.md b/errors/next-script-for-ga.md new file mode 100644 index 000000000000000..15d400c098bd5d3 --- /dev/null +++ b/errors/next-script-for-ga.md @@ -0,0 +1,98 @@ +# Next Script for Google Analytics + +### Why This Error Occurred + +An inline script was used for Google analytics which might impact your webpage's performance. + +### Possible Ways to Fix It + +#### Using gtag.js + +If you are using the [gtag.js](https://developers.google.com/analytics/devguides/collection/gtagjs) script to add analytics, use the `next/script` component with the right loading strategy to defer loading of the script until necessary. + +```jsx +import Script from 'next/script' + +const Home = () => { + return ( +
+ + + +
+ ) +} + +export default Home +``` + +#### Using analytics.js + +If you are using the [analytics.js](https://developers.google.com/analytics/devguides/collection/analyticsjs) script to add analytics: + +```jsx +import Script from 'next/script' + +const Home = () => { + return ( +
+ +
+ ) +} + +export default Home +``` + +If you are using the [alternative async variant](https://developers.google.com/analytics/devguides/collection/analyticsjs#alternative_async_tag): + +```jsx +import Script from 'next/script' + +const Home = () => { + return ( +
+ + +
+ ) +} + +export default Home +``` + +### Useful Links + +- [Add analytics.js to Your Site](https://developers.google.com/analytics/devguides/collection/analyticsjs) +- [Efficiently load third-party JavaScript](https://web.dev/efficiently-load-third-party-javascript/) +- [next/script Documentation](https://nextjs.org/docs/basic-features/script) diff --git a/packages/eslint-plugin-next/lib/index.js b/packages/eslint-plugin-next/lib/index.js index 59e12b7f43dfdf6..ae474796056cebc 100644 --- a/packages/eslint-plugin-next/lib/index.js +++ b/packages/eslint-plugin-next/lib/index.js @@ -16,6 +16,7 @@ module.exports = { 'no-script-in-head': require('./rules/no-script-in-head'), 'no-typos': require('./rules/no-typos'), 'no-duplicate-head': require('./rules/no-duplicate-head'), + 'next-script-for-ga': require('./rules/next-script-for-ga'), }, configs: { recommended: { @@ -31,6 +32,7 @@ module.exports = { '@next/next/google-font-display': 1, '@next/next/google-font-preconnect': 1, '@next/next/link-passhref': 1, + '@next/next/next-script-for-ga': 1, '@next/next/no-document-import-in-page': 2, '@next/next/no-head-import-in-document': 2, '@next/next/no-script-in-document': 2, diff --git a/packages/eslint-plugin-next/lib/rules/next-script-for-ga.js b/packages/eslint-plugin-next/lib/rules/next-script-for-ga.js new file mode 100644 index 000000000000000..09385460dcc8f15 --- /dev/null +++ b/packages/eslint-plugin-next/lib/rules/next-script-for-ga.js @@ -0,0 +1,77 @@ +const NodeAttributes = require('../utils/node-attributes.js') + +const SUPPORTED_SRCS = [ + 'www.google-analytics.com/analytics.js', + 'www.googletagmanager.com/gtag/js', +] +const SUPPORTED_HTML_CONTENT_URLS = [ + 'www.google-analytics.com/analytics.js', + 'www.googletagmanager.com/gtm.js', +] +const ERROR_MSG = + 'Use the `next/script` component for loading third party scripts. See: https://nextjs.org/docs/messages/next-script-for-ga.' + +// Check if one of the items in the list is a substring of the passed string +const containsStr = (str, strList) => { + return strList.some((s) => str.includes(s)) +} + +module.exports = { + meta: { + docs: { + description: + 'Prefer next script component when using the inline script for Google Analytics', + recommended: true, + }, + }, + create: function (context) { + return { + JSXOpeningElement(node) { + if (node.name.name !== 'script') { + return + } + if (node.attributes.length === 0) { + return + } + const attributes = new NodeAttributes(node) + + // 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 ( + typeof attributes.value('src') === 'string' && + containsStr(attributes.value('src'), SUPPORTED_SRCS) + ) { + return context.report({ + node, + message: ERROR_MSG, + }) + } + + // 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 ( + attributes.has('dangerouslySetInnerHTML') && + attributes.value('dangerouslySetInnerHTML')[0] + ) { + const htmlContent = + attributes.value('dangerouslySetInnerHTML')[0].value.quasis && + attributes.value('dangerouslySetInnerHTML')[0].value.quasis[0].value + .raw + if ( + htmlContent && + containsStr(htmlContent, SUPPORTED_HTML_CONTENT_URLS) + ) { + context.report({ + node, + message: ERROR_MSG, + }) + } + } + }, + } + }, +} + +module.exports.schema = [] diff --git a/packages/eslint-plugin-next/lib/utils/node-attributes.js b/packages/eslint-plugin-next/lib/utils/node-attributes.js index c9eb583275c8d21..3367f31a264635a 100644 --- a/packages/eslint-plugin-next/lib/utils/node-attributes.js +++ b/packages/eslint-plugin-next/lib/utils/node-attributes.js @@ -26,7 +26,9 @@ class NodeAttributes { this.attributes[attribute.name.name].value = attribute.value.value } else if (attribute.value.expression) { this.attributes[attribute.name.name].value = - attribute.value.expression.value + typeof attribute.value.expression.value !== 'undefined' + ? attribute.value.expression.value + : attribute.value.expression.properties } } }) diff --git a/test/eslint-plugin-next/next-script-for-ga.unit.test.js b/test/eslint-plugin-next/next-script-for-ga.unit.test.js new file mode 100644 index 000000000000000..18b156239c028c0 --- /dev/null +++ b/test/eslint-plugin-next/next-script-for-ga.unit.test.js @@ -0,0 +1,212 @@ +const rule = require('@next/eslint-plugin-next/lib/rules/next-script-for-ga') + +const RuleTester = require('eslint').RuleTester + +const ERROR_MSG = + 'Use the `next/script` component for loading third party scripts. See: https://nextjs.org/docs/messages/next-script-for-ga.' + +RuleTester.setDefaultConfig({ + parserOptions: { + ecmaVersion: 2018, + sourceType: 'module', + ecmaFeatures: { + modules: true, + jsx: true, + }, + }, +}) + +var ruleTester = new RuleTester() +ruleTester.run('sync-scripts', rule, { + valid: [ + `import Script from 'next/script' + + export class Blah extends Head { + render() { + return ( +
+

Hello title

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

Hello title

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

Hello title

+ +
+ ); + } + }`, + `export class Blah extends Head { + render() { + return ( +
+

Hello title

+ +
+ ); + } + }`, + errors: [ + { + message: ERROR_MSG, + type: 'JSXOpeningElement', + }, + ], + }, + ], +})