mirror of
https://github.com/facebook/docusaurus.git
synced 2025-04-30 02:37:59 +02:00
feat(gtag-plugin): gtag should support multiple tracking ids, notably for the UA => GA4 transition (#8620)
This commit is contained in:
parent
5b05c0e73e
commit
32384b761c
6 changed files with 241 additions and 40 deletions
|
@ -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, PluginOptions>,
|
||||||
|
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"`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
|
@ -5,23 +5,38 @@
|
||||||
* LICENSE file in the root directory of this source tree.
|
* LICENSE file in the root directory of this source tree.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {Joi} from '@docusaurus/utils-validation';
|
import type {LoadContext, Plugin} from '@docusaurus/types';
|
||||||
import type {
|
|
||||||
LoadContext,
|
|
||||||
Plugin,
|
|
||||||
OptionValidationContext,
|
|
||||||
ThemeConfig,
|
|
||||||
ThemeConfigValidationContext,
|
|
||||||
} from '@docusaurus/types';
|
|
||||||
import type {PluginOptions, Options} from './options';
|
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(
|
export default function pluginGoogleGtag(
|
||||||
context: LoadContext,
|
context: LoadContext,
|
||||||
options: PluginOptions,
|
options: PluginOptions,
|
||||||
): Plugin {
|
): Plugin {
|
||||||
const {anonymizeIP, trackingID} = options;
|
|
||||||
const isProd = process.env.NODE_ENV === 'production';
|
const isProd = process.env.NODE_ENV === 'production';
|
||||||
|
|
||||||
|
const firstTrackingId = options.trackingID[0];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
name: 'docusaurus-plugin-google-gtag',
|
name: 'docusaurus-plugin-google-gtag',
|
||||||
|
|
||||||
|
@ -60,7 +75,11 @@ export default function pluginGoogleGtag(
|
||||||
tagName: 'script',
|
tagName: 'script',
|
||||||
attributes: {
|
attributes: {
|
||||||
async: true,
|
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 || [];
|
window.dataLayer = window.dataLayer || [];
|
||||||
function gtag(){dataLayer.push(arguments);}
|
function gtag(){dataLayer.push(arguments);}
|
||||||
gtag('js', new Date());
|
gtag('js', new Date());
|
||||||
gtag('config', '${trackingID}', { ${
|
${createConfigSnippets(options)};
|
||||||
anonymizeIP ? "'anonymize_ip': true" : ''
|
`,
|
||||||
} });`,
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
@ -79,27 +97,6 @@ export default function pluginGoogleGtag(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const pluginOptionsSchema = Joi.object<PluginOptions>({
|
export {validateThemeConfig, validateOptions} from './options';
|
||||||
trackingID: Joi.string().required(),
|
|
||||||
anonymizeIP: Joi.boolean().default(false),
|
|
||||||
});
|
|
||||||
|
|
||||||
export function validateOptions({
|
|
||||||
validate,
|
|
||||||
options,
|
|
||||||
}: OptionValidationContext<Options, PluginOptions>): PluginOptions {
|
|
||||||
return validate(pluginOptionsSchema, options);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function validateThemeConfig({
|
|
||||||
themeConfig,
|
|
||||||
}: ThemeConfigValidationContext<ThemeConfig>): 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 type {PluginOptions, Options};
|
export type {PluginOptions, Options};
|
||||||
|
|
|
@ -4,10 +4,58 @@
|
||||||
* This source code is licensed under the MIT license found in the
|
* This source code is licensed under the MIT license found in the
|
||||||
* LICENSE file in the root directory of this source tree.
|
* 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 = {
|
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;
|
anonymizeIP: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Options = Partial<PluginOptions>;
|
export type Options = {
|
||||||
|
trackingID: string | [string, ...string[]];
|
||||||
|
anonymizeIP?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DEFAULT_OPTIONS: Partial<PluginOptions> = {
|
||||||
|
anonymizeIP: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const pluginOptionsSchema = Joi.object<PluginOptions>({
|
||||||
|
// 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<Options, PluginOptions>): PluginOptions {
|
||||||
|
return validate(pluginOptionsSchema, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateThemeConfig({
|
||||||
|
themeConfig,
|
||||||
|
}: ThemeConfigValidationContext<ThemeConfig>): 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;
|
||||||
|
}
|
||||||
|
|
|
@ -10,6 +10,6 @@
|
||||||
"rootDir": "src",
|
"rootDir": "src",
|
||||||
"outDir": "lib"
|
"outDir": "lib"
|
||||||
},
|
},
|
||||||
"include": ["src/gtag.ts", "src/options.ts", "src/*.d.ts"],
|
"include": ["src/gtag.ts", "src/*.d.ts"],
|
||||||
"exclude": ["**/__tests__/**"]
|
"exclude": ["**/__tests__/**"]
|
||||||
}
|
}
|
||||||
|
|
|
@ -45,7 +45,7 @@ Accepted fields:
|
||||||
|
|
||||||
| Name | Type | Default | Description |
|
| Name | Type | Default | Description |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| `trackingID` | `string` | **Required** | The tracking ID of your gtag service. |
|
| `trackingID` | <code>string \| string[]</code> | **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. |
|
| `anonymizeIP` | `boolean` | `false` | Whether the IP should be anonymized when sending requests. |
|
||||||
|
|
||||||
```mdx-code-block
|
```mdx-code-block
|
||||||
|
|
|
@ -357,7 +357,7 @@ const config = {
|
||||||
},
|
},
|
||||||
gtag: !(isDeployPreview || isBranchDeploy)
|
gtag: !(isDeployPreview || isBranchDeploy)
|
||||||
? {
|
? {
|
||||||
trackingID: 'UA-141789564-1',
|
trackingID: ['G-E5CR2Q1NRE', 'UA-141789564-1'],
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
sitemap: {
|
sitemap: {
|
||||||
|
|
Loading…
Add table
Reference in a new issue