├── renovate.json
├── example
├── plugins
│ └── gtm.js
├── pages
│ └── index.vue
└── nuxt.config.js
├── .eslintignore
├── .gitignore
├── .eslintrc.js
├── babel.config.js
├── .editorconfig
├── lib
├── plugin.utils.js
├── defaults.js
├── compatibility.js
├── plugin.mock.js
├── plugin.js
└── module.js
├── jest.config.js
├── .github
└── workflows
│ └── ci.yml
├── LICENSE
├── package.json
├── test
└── module.test.js
├── README.md
└── CHANGELOG.md
/renovate.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": [
3 | "@nuxtjs"
4 | ]
5 | }
6 |
--------------------------------------------------------------------------------
/example/plugins/gtm.js:
--------------------------------------------------------------------------------
1 | export default function ({ $gtm }) {
2 | $gtm.init('GTM-56H68LH')
3 | }
4 |
--------------------------------------------------------------------------------
/.eslintignore:
--------------------------------------------------------------------------------
1 | # Common
2 | node_modules
3 | dist
4 | .nuxt
5 | coverage
6 |
7 | # Plugin
8 | lib/plugin*.js
9 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | *.iml
3 | .idea
4 | *.log*
5 | .nuxt
6 | .vscode
7 | .DS_Store
8 | coverage
9 | dist
10 |
--------------------------------------------------------------------------------
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | root: true,
3 | parserOptions: {
4 | parser: 'babel-eslint',
5 | sourceType: 'module'
6 | },
7 | extends: [
8 | '@nuxtjs'
9 | ]
10 | }
11 |
--------------------------------------------------------------------------------
/babel.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | presets: [
3 | [
4 | '@babel/preset-env', {
5 | targets: {
6 | esmodules: true
7 | }
8 | }
9 | ]
10 | ]
11 | }
12 |
--------------------------------------------------------------------------------
/example/pages/index.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 | Works!
4 |
5 |
6 |
7 |
14 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | # editorconfig.org
2 | root = true
3 |
4 | [*]
5 | indent_size = 2
6 | indent_style = space
7 | end_of_line = lf
8 | charset = utf-8
9 | trim_trailing_whitespace = true
10 | insert_final_newline = true
11 |
12 | [*.md]
13 | trim_trailing_whitespace = false
14 |
--------------------------------------------------------------------------------
/lib/plugin.utils.js:
--------------------------------------------------------------------------------
1 | const logSyle = 'background: #2E495E;border-radius: 0.5em;color: white;font-weight: bold;padding: 2px 0.5em;'
2 |
3 | export function log(...args) {
4 | // eslint-disable-next-line no-console
5 | <% if (options.debug) { %>console.log('%cGTM', logSyle, ...args)<% } %>
6 | }
7 |
--------------------------------------------------------------------------------
/jest.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | testEnvironment: 'node',
3 | collectCoverage: true,
4 | collectCoverageFrom: [
5 | 'lib/**/*.js',
6 | '!lib/plugin.js'
7 | ],
8 | moduleNameMapper: {
9 | '^~/(.*)$': '/lib/$1',
10 | '^~~$': '',
11 | '^@@$': '',
12 | '^@/(.*)$': '/lib/$1'
13 | },
14 | transform: {
15 | '^.+\\.js$': 'babel-jest'
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/lib/defaults.js:
--------------------------------------------------------------------------------
1 | const defaults = {
2 | enabled: undefined,
3 | debug: false,
4 |
5 | id: undefined,
6 | layer: 'dataLayer',
7 | variables: {},
8 |
9 | pageTracking: false,
10 | pageViewEventName: 'nuxtRoute',
11 |
12 | autoInit: true,
13 | respectDoNotTrack: true,
14 |
15 | scriptId: 'gtm-script',
16 | scriptDefer: false,
17 | scriptURL: 'https://www.googletagmanager.com/gtm.js',
18 | crossOrigin: false,
19 |
20 | noscript: true,
21 | noscriptId: 'gtm-noscript',
22 | noscriptURL: 'https://www.googletagmanager.com/ns.html'
23 | }
24 |
25 | module.exports = defaults
26 |
--------------------------------------------------------------------------------
/lib/compatibility.js:
--------------------------------------------------------------------------------
1 | const chalk = require('chalk')
2 | const semver = require('semver')
3 |
4 | function requireNuxtVersion (nuxt, version) {
5 | const pkgName = require('../package.json').name
6 | const currentVersion = semver.coerce(nuxt.constructor.version)
7 | const requiredVersion = semver.coerce(version)
8 |
9 | if (semver.lt(currentVersion, requiredVersion)) {
10 | throw new Error(`\n
11 | ${chalk.cyan(pkgName)} is not compatible with your current Nuxt version : ${chalk.yellow('v' + currentVersion)}\n
12 | Required: ${chalk.green('v' + requiredVersion)} or ${chalk.cyan('higher')}
13 | `)
14 | }
15 | }
16 |
17 | module.exports = {
18 | requireNuxtVersion
19 | }
20 |
--------------------------------------------------------------------------------
/example/nuxt.config.js:
--------------------------------------------------------------------------------
1 | const { resolve } = require('path')
2 |
3 | module.exports = {
4 | rootDir: resolve(__dirname, '..'),
5 | buildDir: resolve(__dirname, '.nuxt'),
6 | head: {
7 | title: '@nuxtjs/gtm-module'
8 | },
9 | srcDir: __dirname,
10 | render: {
11 | resourceHints: false
12 | },
13 | modules: [
14 | { handler: require('../') }
15 | ],
16 | plugins: [
17 | '~/plugins/gtm'
18 | ],
19 | gtm: {
20 | id: process.env.GTM_ID || 'GTM-KLQB72K',
21 | scriptDefer: true,
22 | pageTracking: true,
23 | // layer: 'test',
24 | variables: {
25 | test: '1'
26 | }
27 | },
28 | publicRuntimeConfig: {
29 | gtm: {
30 | id: 'GTM-KLQB72K&runtime'
31 | }
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: test
2 |
3 | on:
4 | push:
5 | branches:
6 | - master
7 | pull_request:
8 | branches:
9 | - master
10 |
11 |
12 | jobs:
13 | ci:
14 | runs-on: ${{ matrix.os }}
15 |
16 | strategy:
17 | matrix:
18 | os: [ubuntu-latest]
19 | node: [12]
20 |
21 | steps:
22 | - uses: actions/setup-node@v1
23 | with:
24 | node-version: ${{ matrix.node }}
25 |
26 | - name: checkout
27 | uses: actions/checkout@master
28 |
29 | - name: cache node_modules
30 | uses: actions/cache@v2
31 | with:
32 | path: node_modules
33 | key: ${{ matrix.os }}-node-v${{ matrix.node }}-deps-${{ hashFiles(format('{0}{1}', github.workspace, '/yarn.lock')) }}
34 |
35 | - name: Install dependencies
36 | if: steps.cache.outputs.cache-hit != 'true'
37 | run: yarn
38 |
39 | - name: Lint
40 | run: yarn lint
41 |
42 | - name: Test
43 | run: yarn jest
44 |
45 | # - name: Coverage
46 | # uses: codecov/codecov-action@v1
47 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) Nuxt.js Community
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@nuxtjs/gtm",
3 | "version": "2.4.0",
4 | "description": "Google Tag Manager Module for Nuxt.js",
5 | "repository": "nuxt-community/gtm-module",
6 | "license": "MIT",
7 | "main": "lib/module.js",
8 | "files": [
9 | "lib"
10 | ],
11 | "scripts": {
12 | "dev": "nuxt example",
13 | "generate": "nuxt generate example",
14 | "lint": "eslint --ext .js,.vue example lib test",
15 | "release": "yarn test && standard-version && git push --follow-tags && npm publish",
16 | "test": "yarn lint && jest"
17 | },
18 | "dependencies": {
19 | "chalk": "^4.1.0",
20 | "semver": "^7.3.2"
21 | },
22 | "devDependencies": {
23 | "@babel/core": "latest",
24 | "@babel/preset-env": "latest",
25 | "@nuxtjs/eslint-config": "latest",
26 | "@nuxtjs/module-test-utils": "latest",
27 | "@types/jest": "latest",
28 | "babel-eslint": "latest",
29 | "babel-jest": "latest",
30 | "codecov": "latest",
31 | "eslint": "latest",
32 | "jest": "latest",
33 | "nuxt-edge": "latest",
34 | "standard-version": "latest"
35 | },
36 | "publishConfig": {
37 | "access": "public"
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/lib/plugin.mock.js:
--------------------------------------------------------------------------------
1 | // This is a mock version because gtm module is disabled
2 | // You can explicitly enable module using `gtm.enabled: true` in nuxt.config
3 | import { log } from './gtm.utils'
4 |
5 | const _layer = '<%= options.layer %>'
6 | const _id = '<%= options.id %>'
7 |
8 | function startPageTracking (ctx) {
9 | ctx.app.router.afterEach((to) => {
10 | setTimeout(() => {
11 | ctx.$gtm.push(to.gtm || {
12 | routeName: to.name,
13 | pageType: 'PageView',
14 | pageUrl: '<%= options.routerBase %>' + to.fullPath,
15 | pageTitle: (typeof document !== 'undefined' && document.title) || '',
16 | event: '<%= options.pageViewEventName %>'
17 | })
18 | }, 250)
19 | })
20 | }
21 |
22 | export default function (ctx, inject) {
23 | log('Using mocked API. Real GTM events will not be reported.')
24 | const gtm = {
25 | init: (id) => {
26 | log('init', id)
27 | },
28 | push: (event) => {
29 | log('push', process.client ? event : JSON.stringify(event))
30 | if (typeof event.eventCallback === 'function') {
31 | event.eventCallback()
32 | }
33 | }
34 | }
35 |
36 | ctx.$gtm = gtm
37 | inject('gtm', gtm)
38 | <% if (options.pageTracking) { %>if (process.client) { startPageTracking(ctx); }<% } %>
39 | }
40 |
--------------------------------------------------------------------------------
/lib/plugin.js:
--------------------------------------------------------------------------------
1 | import { log } from './gtm.utils'
2 |
3 | const _layer = '<%= options.layer %>'
4 | const _id = '<%= options.id %>'
5 |
6 | function gtmClient(ctx, initialized) {
7 | return {
8 | init(id = _id) {
9 | if (initialized[id] || !window._gtm_inject) {
10 | return
11 | }
12 | window._gtm_inject(id)
13 | initialized[id] = true
14 | log('init', id)
15 | },
16 | push(obj) {
17 | if (!window[_layer]) {
18 | window[_layer] = []
19 | }
20 | window[_layer].push(obj)
21 | log('push', obj)
22 | }
23 | }
24 | }
25 |
26 | function gtmServer(ctx, initialized) {
27 | const events = []
28 | const inits = []
29 |
30 | ctx.beforeNuxtRender(() => {
31 | if (!inits.length && !events.length) {
32 | return
33 | }
34 |
35 | const gtmScript = ctx.app.head.script.find(s => s.hid == '<%= options.scriptId %>')
36 | gtmScript.innerHTML = `window['${_layer}']=${JSON.stringify(events)};${gtmScript.innerHTML}`
37 |
38 | if (inits.length) {
39 | gtmScript.innerHTML += `;${JSON.stringify(inits)}.forEach(function(i){window._gtm_inject(i)})`
40 | }
41 | <% if (options.noscript) { %>
42 | const gtmIframe = ctx.app.head.noscript.find(s => s.hid == '<%= options.noscriptId %>')
43 | const renderIframe = id => `<%= options.renderIframe('${id}') %>`
44 | if (inits.length) {
45 | gtmIframe.innerHTML += inits.map(renderIframe)
46 | }
47 | <% } %>
48 | })
49 |
50 | return {
51 | init(id = _id) {
52 | if (initialized[id]) {
53 | return
54 | }
55 | inits.push(id)
56 | initialized[id] = true
57 | log('init', id)
58 | },
59 | push(obj) {
60 | events.push(obj)
61 | log('push', JSON.stringify(obj))
62 | }
63 | }
64 | }
65 |
66 | function startPageTracking(ctx) {
67 | ctx.app.router.afterEach((to) => {
68 | setTimeout(() => {
69 | ctx.$gtm.push(to.gtm || {
70 | routeName: to.name,
71 | pageType: 'PageView',
72 | pageUrl: '<%= options.routerBase %>' + to.fullPath,
73 | pageTitle: (typeof document !== 'undefined' && document.title) || '',
74 | event: '<%= options.pageViewEventName %>'
75 | })
76 | }, 250)
77 | })
78 | }
79 |
80 | export default function (ctx, inject) {
81 | const runtimeConfig = (ctx.$config && ctx.$config.gtm) || {}
82 | const autoInit = <%= options.autoInit %>
83 | const id = '<%= options.id %>'
84 | const runtimeId = runtimeConfig.id
85 | const initialized = autoInit && id ? {[id]: true} : {}
86 | const $gtm = process.client ? gtmClient(ctx, initialized) : gtmServer(ctx, initialized)
87 | if (autoInit && runtimeId && runtimeId !== id) {
88 | $gtm.init(runtimeId)
89 | }
90 | ctx.$gtm = $gtm
91 | inject('gtm', ctx.$gtm)
92 | <% if (options.pageTracking) { %>if (process.client) { startPageTracking(ctx); }<% } %>
93 | }
94 |
--------------------------------------------------------------------------------
/test/module.test.js:
--------------------------------------------------------------------------------
1 | const path = require('path')
2 | const { setup, loadConfig, get, url } = require('@nuxtjs/module-test-utils')
3 | const defaultSettings = require(path.join(__dirname, '../', 'lib', 'defaults.js'))
4 |
5 | function expectPageTrackingEvent (eventsArray, expectedEvent) {
6 | return new Promise((resolve) => {
7 | // Need to wait at least 250ms as that's how long plugin delays before triggering event.
8 | setTimeout(() => {
9 | expect(eventsArray).toStrictEqual(
10 | expect.arrayContaining([
11 | expect.objectContaining(expectedEvent)
12 | ])
13 | )
14 | expect(eventsArray.filter(event => event.event === 'nuxtRoute').length).toBe(1)
15 | resolve()
16 | }, 300)
17 | })
18 | }
19 |
20 | const modes = ['universal', 'spa']
21 |
22 | for (const mode of modes) {
23 | describe(`Module (${mode} mode)`, () => {
24 | let nuxt
25 |
26 | const nuxtConfig = loadConfig(__dirname, '../../example')
27 | nuxtConfig.mode = mode
28 |
29 | const gtmId = nuxtConfig.gtm.id
30 | const runtimeId = nuxtConfig.publicRuntimeConfig.gtm.id
31 | const scriptId = nuxtConfig.gtm.scriptId || defaultSettings.scriptId
32 | const noscriptId = nuxtConfig.gtm.noscriptId || defaultSettings.noscriptId
33 |
34 | beforeAll(async () => {
35 | ({ nuxt } = (await setup(nuxtConfig)))
36 | }, 60000)
37 |
38 | afterAll(async () => {
39 | await nuxt.close()
40 | })
41 |
42 | test('Render', async () => {
43 | const html = await get('/')
44 | const expected = { universal: 'Works!', spa: 'Loading...' }[mode]
45 | expect(html).toContain(expected)
46 | })
47 |
48 | test('Has GTM script', async () => {
49 | const html = await get('/')
50 | expect(html).toContain(`data-hid="${scriptId}"`)
51 | })
52 |
53 | test('Has GTM noscript', async () => {
54 | const html = await get('/')
55 | expect(html).toContain(`data-hid="${noscriptId}"`)
56 | })
57 |
58 | // test with real GTM id
59 | test('GTM should be defined ($nuxt.$gtm)', async () => {
60 | const window = await nuxt.renderAndGetWindow(url('/'))
61 | expect(window.$nuxt.$gtm).toBeDefined()
62 | })
63 |
64 | test('Should include runtimeConfig', async () => {
65 | const window = await nuxt.renderAndGetWindow(url('/'))
66 |
67 | const headGtmScriptsExternal = window.document.querySelectorAll(`head script[src^="https://www.googletagmanager.com/gtm.js?id=${runtimeId}"]`)
68 |
69 | expect(headGtmScriptsExternal.length).toBe(1)
70 | })
71 |
72 | test('Verifying duplicate GTM script', async () => {
73 | const window = await nuxt.renderAndGetWindow(url('/'))
74 |
75 | const headGtmScriptsExternal = window.document.querySelectorAll(`head script[src^="https://www.googletagmanager.com/gtm.js?id=${gtmId}"]`)
76 | const headGtmScriptsHid = window.document.querySelectorAll(`head script[data-hid="${scriptId}"]`)
77 | const totalAmoutOfGtmScriptsAtHead = headGtmScriptsExternal.length + headGtmScriptsHid.length
78 |
79 | expect(totalAmoutOfGtmScriptsAtHead).toBeLessThan(4)
80 | })
81 |
82 | test('Should include pushed event', async () => {
83 | const window = await nuxt.renderAndGetWindow(url('/'))
84 | expect(window.dataLayer).toStrictEqual(
85 | expect.arrayContaining([
86 | expect.objectContaining({ event: 'ssr' })
87 | ])
88 | )
89 | expect(window.dataLayer.filter(event => event.event === 'ssr').length).toBe(1)
90 | })
91 |
92 | test('Should include page tracking event', async () => {
93 | const window = await nuxt.renderAndGetWindow(url('/'))
94 |
95 | await expectPageTrackingEvent(window.dataLayer, {
96 | event: 'nuxtRoute',
97 | pageTitle: '@nuxtjs/gtm-module',
98 | pageType: 'PageView',
99 | pageUrl: '/',
100 | routeName: 'index'
101 | })
102 | })
103 | })
104 | }
105 |
106 | for (const mode of modes) {
107 | describe(`Page tracking with router base (${mode} mode)`, () => {
108 | let nuxt
109 |
110 | const override = {
111 | gtm: {
112 | layer: 'testDataLayer',
113 | pageTracking: true
114 | }
115 | }
116 |
117 | const nuxtConfig = loadConfig(__dirname, '../../example', override, { merge: true })
118 | if (!nuxtConfig.router) {
119 | nuxtConfig.router = {}
120 | }
121 | nuxtConfig.router.base = '/base/'
122 |
123 | beforeAll(async () => {
124 | ({ nuxt } = (await setup(nuxtConfig)))
125 | }, 60000)
126 |
127 | afterAll(async () => {
128 | await nuxt.close()
129 | })
130 |
131 | test('Event should contain page URL with base', async () => {
132 | const window = await nuxt.renderAndGetWindow(url('/base/'))
133 |
134 | await expectPageTrackingEvent(window.testDataLayer, {
135 | event: 'nuxtRoute',
136 | pageTitle: '@nuxtjs/gtm-module',
137 | pageType: 'PageView',
138 | pageUrl: '/base/',
139 | routeName: 'index'
140 | })
141 | })
142 | })
143 | }
144 |
--------------------------------------------------------------------------------
/lib/module.js:
--------------------------------------------------------------------------------
1 | const path = require('path')
2 | const defaults = require('./defaults')
3 | const { requireNuxtVersion } = require('./compatibility')
4 |
5 | // doNotTrack polyfill
6 | // https://gist.github.com/pi0/a76fd97c4ea259c89f728a4a8ebca741
7 | const dnt = "(function(w,n,d,m,e,p){w[d]=(w[d]==1||n[d]=='yes'||n[d]==1||n[m]==1||(w[e]&&w[e][p]&&w[e][p]()))?1:0})(window,navigator,'doNotTrack','msDoNotTrack','external','msTrackingProtectionEnabled')"
8 |
9 | module.exports = async function gtmModule (_options) {
10 | requireNuxtVersion(this.nuxt, '2.12.0')
11 |
12 | const options = {
13 | ...defaults,
14 | ..._options,
15 | ...this.options.gtm
16 | }
17 |
18 | // By default enable only for non development
19 | if (options.enabled === undefined) {
20 | options.enabled = !this.options.dev
21 | }
22 |
23 | if (options.dev !== undefined) {
24 | // eslint-disable-next-line no-console
25 | console.warn('[gtm] `dev` option is deprecated! Please use `enabled`')
26 | if (options.dev === true && this.options.dev) {
27 | options.enabled = true
28 | }
29 | delete options.dev
30 | }
31 |
32 | this.addTemplate({
33 | src: path.resolve(__dirname, 'plugin.utils.js'),
34 | fileName: 'gtm.utils.js',
35 | options
36 | })
37 |
38 | if (!options.enabled) {
39 | // Register mock plugin
40 | this.addPlugin({
41 | src: path.resolve(__dirname, 'plugin.mock.js'),
42 | fileName: 'gtm.js',
43 | options
44 | })
45 | return
46 | }
47 |
48 | // Async id evaluation
49 | if (typeof (options.id) === 'function') {
50 | options.id = await options.id()
51 | }
52 |
53 | // Build query
54 | const query = {
55 | // Default is dataLayer for google
56 | l: options.layer !== 'dataLayer' ? options.layer : null,
57 | ...options.variables
58 | }
59 | const queryString = Object.keys(query)
60 | .filter(key => query[key] !== null && query[key] !== undefined)
61 | .map(key => `${encodeURIComponent(key)}=${encodeURIComponent(query[key])}`)
62 | .join('&')
63 |
64 | // Compile scripts
65 | const injectScript = `var f=d.getElementsByTagName(s)[0],j=d.createElement(s);${options.crossOrigin ? 'j.crossOrigin=\'' + options.crossOrigin + '\';' : ''}j.${options.scriptDefer ? 'defer' : 'async'}=true;j.src='${options.scriptURL + '?id=\'+i' + (queryString ? (`+'&${queryString}` + '\'') : '')};f.parentNode.insertBefore(j,f)` // deps: d,s,i
66 |
67 | const doNotTrackScript = options.respectDoNotTrack ? 'if(w.doNotTrack||w[x][i])return;' : ''
68 |
69 | const initLayer = "w[l]=w[l]||[];w[l].push({'gtm.start':new Date().getTime(),event:'gtm.js'})" // deps: w,l
70 | let script = `w[x]={};w._gtm_inject=function(i){${doNotTrackScript}w[x][i]=1;${initLayer};${injectScript};}`
71 |
72 | if (options.autoInit && options.id) {
73 | script += `;w[y]('${options.id}')`
74 | }
75 |
76 | // Add doNotTrack polyfill and wrap to IIFE
77 | script = `${dnt};(function(w,d,s,l,x,y){${script}})(window,document,'script','${options.layer}','_gtm_ids','_gtm_inject')`
78 |
79 | // Guard against double IIFE executation in SPA mode (#3)
80 | script = `if(!window._gtm_init){window._gtm_init=1;${script}}`
81 |
82 | // Add google tag manager