diff --git a/packages/docusaurus-plugin-google-gtag/src/__tests__/options.test.ts b/packages/docusaurus-plugin-google-gtag/src/__tests__/options.test.ts new file mode 100644 index 000000000000..a33ce7aff382 --- /dev/null +++ b/packages/docusaurus-plugin-google-gtag/src/__tests__/options.test.ts @@ -0,0 +1,156 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import {normalizePluginOptions} from '@docusaurus/utils-validation'; +import { + validateOptions, + type PluginOptions, + type Options, + DEFAULT_OPTIONS, +} from '../options'; +import type {Validate} from '@docusaurus/types'; + +function testValidateOptions(options: Options) { + return validateOptions({ + validate: normalizePluginOptions as Validate, + options, + }); +} + +function validationResult(options: Options) { + return { + id: 'default', + ...DEFAULT_OPTIONS, + ...options, + trackingID: + typeof options.trackingID === 'string' + ? [options.trackingID] + : options.trackingID, + }; +} + +const MinimalConfig: Options = { + trackingID: 'G-XYZ12345', +}; + +describe('validateOptions', () => { + it('throws for undefined options', () => { + expect( + // @ts-expect-error: TS should error + () => testValidateOptions(undefined), + ).toThrowErrorMatchingInlineSnapshot(`""trackingID" is required"`); + }); + + it('throws for null options', () => { + expect( + // @ts-expect-error: TS should error + () => testValidateOptions(null), + ).toThrowErrorMatchingInlineSnapshot(`""value" must be of type object"`); + }); + + it('throws for empty object options', () => { + expect( + // @ts-expect-error: TS should error + () => testValidateOptions({}), + ).toThrowErrorMatchingInlineSnapshot(`""trackingID" is required"`); + }); + + it('throws for number options', () => { + expect( + // @ts-expect-error: TS should error + () => testValidateOptions(42), + ).toThrowErrorMatchingInlineSnapshot(`""value" must be of type object"`); + }); + + it('throws for null trackingID', () => { + expect( + // @ts-expect-error: TS should error + () => testValidateOptions({trackingID: null}), + ).toThrowErrorMatchingInlineSnapshot( + `""trackingID" does not match any of the allowed types"`, + ); + }); + it('throws for number trackingID', () => { + expect( + // @ts-expect-error: TS should error + () => testValidateOptions({trackingID: 42}), + ).toThrowErrorMatchingInlineSnapshot( + `""trackingID" does not match any of the allowed types"`, + ); + }); + it('throws for empty trackingID', () => { + expect(() => + testValidateOptions({trackingID: ''}), + ).toThrowErrorMatchingInlineSnapshot( + `""trackingID" does not match any of the allowed types"`, + ); + }); + + it('accepts minimal config', () => { + expect(testValidateOptions(MinimalConfig)).toEqual( + validationResult(MinimalConfig), + ); + }); + + it('accepts anonymizeIP', () => { + const config: Options = { + ...MinimalConfig, + anonymizeIP: true, + }; + expect(testValidateOptions(config)).toEqual(validationResult(config)); + }); + + it('accepts single trackingID', () => { + const config: Options = { + trackingID: 'G-ABCDEF123', + }; + expect(testValidateOptions(config)).toEqual(validationResult(config)); + }); + + it('accepts multiple trackingIDs', () => { + const config: Options = { + trackingID: ['G-ABCDEF123', 'UA-XYZ456789'], + }; + expect(testValidateOptions(config)).toEqual(validationResult(config)); + }); + + it('throws for empty trackingID arrays', () => { + const config: Options = { + // @ts-expect-error: TS should error + trackingID: [], + }; + expect(() => + testValidateOptions(config), + ).toThrowErrorMatchingInlineSnapshot( + `""trackingID" does not match any of the allowed types"`, + ); + }); + + it('throws for sparse trackingID arrays', () => { + const config: Options = { + // @ts-expect-error: TS should error + trackingID: ['G-ABCDEF123', null, 'UA-XYZ456789'], + }; + expect(() => + testValidateOptions(config), + ).toThrowErrorMatchingInlineSnapshot( + `""trackingID" does not match any of the allowed types"`, + ); + }); + + it('throws for bad trackingID arrays', () => { + const config: Options = { + // @ts-expect-error: TS should error + trackingID: ['G-ABCDEF123', 42], + }; + expect(() => + testValidateOptions(config), + ).toThrowErrorMatchingInlineSnapshot( + `""trackingID" does not match any of the allowed types"`, + ); + }); +}); diff --git a/packages/docusaurus-plugin-google-gtag/src/index.ts b/packages/docusaurus-plugin-google-gtag/src/index.ts index 2e278094e105..a3ea84ddc419 100644 --- a/packages/docusaurus-plugin-google-gtag/src/index.ts +++ b/packages/docusaurus-plugin-google-gtag/src/index.ts @@ -5,23 +5,38 @@ * LICENSE file in the root directory of this source tree. */ -import {Joi} from '@docusaurus/utils-validation'; -import type { - LoadContext, - Plugin, - OptionValidationContext, - ThemeConfig, - ThemeConfigValidationContext, -} from '@docusaurus/types'; +import type {LoadContext, Plugin} from '@docusaurus/types'; import type {PluginOptions, Options} from './options'; +function createConfigSnippet({ + trackingID, + anonymizeIP, +}: { + trackingID: string; + anonymizeIP: boolean; +}): string { + return `gtag('config', '${trackingID}', { ${ + anonymizeIP ? "'anonymize_ip': true" : '' + } });`; +} + +function createConfigSnippets({ + trackingID: trackingIDArray, + anonymizeIP, +}: PluginOptions): string { + return trackingIDArray + .map((trackingID) => createConfigSnippet({trackingID, anonymizeIP})) + .join('\n'); +} + export default function pluginGoogleGtag( context: LoadContext, options: PluginOptions, ): Plugin { - const {anonymizeIP, trackingID} = options; const isProd = process.env.NODE_ENV === 'production'; + const firstTrackingId = options.trackingID[0]; + return { name: 'docusaurus-plugin-google-gtag', @@ -60,7 +75,11 @@ export default function pluginGoogleGtag( tagName: 'script', attributes: { async: true, - src: `https://www.googletagmanager.com/gtag/js?id=${trackingID}`, + // We only include the first tracking id here because google says + // we shouldn't install multiple tags/scripts on the same page + // Instead we should load one script and use n * gtag("config",id) + // See https://developers.google.com/tag-platform/gtagjs/install#add-products + src: `https://www.googletagmanager.com/gtag/js?id=${firstTrackingId}`, }, }, { @@ -69,9 +88,8 @@ export default function pluginGoogleGtag( window.dataLayer = window.dataLayer || []; function gtag(){dataLayer.push(arguments);} gtag('js', new Date()); - gtag('config', '${trackingID}', { ${ - anonymizeIP ? "'anonymize_ip': true" : '' - } });`, + ${createConfigSnippets(options)}; + `, }, ], }; @@ -79,27 +97,6 @@ export default function pluginGoogleGtag( }; } -const pluginOptionsSchema = Joi.object({ - trackingID: Joi.string().required(), - anonymizeIP: Joi.boolean().default(false), -}); - -export function validateOptions({ - validate, - options, -}: OptionValidationContext): PluginOptions { - return validate(pluginOptionsSchema, options); -} - -export function validateThemeConfig({ - themeConfig, -}: ThemeConfigValidationContext): ThemeConfig { - if ('gtag' in themeConfig) { - throw new Error( - 'The "gtag" field in themeConfig should now be specified as option for plugin-google-gtag. More information at https://github.com/facebook/docusaurus/pull/5832.', - ); - } - return themeConfig; -} +export {validateThemeConfig, validateOptions} from './options'; export type {PluginOptions, Options}; diff --git a/packages/docusaurus-plugin-google-gtag/src/options.ts b/packages/docusaurus-plugin-google-gtag/src/options.ts index c23d43a7b424..b2f6fc1bc6d8 100644 --- a/packages/docusaurus-plugin-google-gtag/src/options.ts +++ b/packages/docusaurus-plugin-google-gtag/src/options.ts @@ -4,10 +4,58 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. */ +import {Joi} from '@docusaurus/utils-validation'; +import type { + OptionValidationContext, + ThemeConfig, + ThemeConfigValidationContext, +} from '@docusaurus/types'; export type PluginOptions = { - trackingID: string; + trackingID: [string, ...string[]]; + // TODO deprecate anonymizeIP after June 2023 + // "In Google Analytics 4, IP masking is not necessary + // since IP addresses are not logged or stored." + // https://support.google.com/analytics/answer/2763052?hl=en anonymizeIP: boolean; }; -export type Options = Partial; +export type Options = { + trackingID: string | [string, ...string[]]; + anonymizeIP?: boolean; +}; + +export const DEFAULT_OPTIONS: Partial = { + anonymizeIP: false, +}; + +const pluginOptionsSchema = Joi.object({ + // We normalize trackingID as a string[] + trackingID: Joi.alternatives() + .try( + Joi.alternatives().conditional(Joi.string().required(), { + then: Joi.custom((val: boolean) => [val]), + }), + Joi.array().items(Joi.string().required()), + ) + .required(), + anonymizeIP: Joi.boolean().default(DEFAULT_OPTIONS.anonymizeIP), +}); + +export function validateOptions({ + validate, + options, +}: OptionValidationContext): PluginOptions { + return validate(pluginOptionsSchema, options); +} + +export function validateThemeConfig({ + themeConfig, +}: ThemeConfigValidationContext): ThemeConfig { + if ('gtag' in themeConfig) { + throw new Error( + 'The "gtag" field in themeConfig should now be specified as option for plugin-google-gtag. More information at https://github.com/facebook/docusaurus/pull/5832.', + ); + } + return themeConfig; +} diff --git a/packages/docusaurus-plugin-google-gtag/tsconfig.client.json b/packages/docusaurus-plugin-google-gtag/tsconfig.client.json index 985b9a458099..4bf4b50bbf08 100644 --- a/packages/docusaurus-plugin-google-gtag/tsconfig.client.json +++ b/packages/docusaurus-plugin-google-gtag/tsconfig.client.json @@ -10,6 +10,6 @@ "rootDir": "src", "outDir": "lib" }, - "include": ["src/gtag.ts", "src/options.ts", "src/*.d.ts"], + "include": ["src/gtag.ts", "src/*.d.ts"], "exclude": ["**/__tests__/**"] } diff --git a/website/docs/api/plugins/plugin-google-gtag.mdx b/website/docs/api/plugins/plugin-google-gtag.mdx index fca251edd3bb..dc1eeaf7b6fc 100644 --- a/website/docs/api/plugins/plugin-google-gtag.mdx +++ b/website/docs/api/plugins/plugin-google-gtag.mdx @@ -45,7 +45,7 @@ Accepted fields: | Name | Type | Default | Description | | --- | --- | --- | --- | -| `trackingID` | `string` | **Required** | The tracking ID of your gtag service. | +| `trackingID` | string \| string[] | **Required** | The tracking ID of your gtag service. It is possible to provide multiple ids. | | `anonymizeIP` | `boolean` | `false` | Whether the IP should be anonymized when sending requests. | ```mdx-code-block diff --git a/website/docusaurus.config.js b/website/docusaurus.config.js index 870cbff8b812..16b5e1dec194 100644 --- a/website/docusaurus.config.js +++ b/website/docusaurus.config.js @@ -357,7 +357,7 @@ const config = { }, gtag: !(isDeployPreview || isBranchDeploy) ? { - trackingID: 'UA-141789564-1', + trackingID: ['G-E5CR2Q1NRE', 'UA-141789564-1'], } : undefined, sitemap: {