├── .babelrc ├── .editorconfig ├── .eslintignore ├── .eslintrc.js ├── .gitignore ├── .travis.yml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── jest.config.js ├── lib ├── api-methods-list.json ├── module.js ├── plugin.js └── utils.js ├── package.json ├── scripts └── createApiMethodsList.js ├── test ├── e2e │ ├── basic.test.js │ └── meta-changed.test.js ├── fixtures │ ├── basic │ │ ├── basic.test.js │ │ ├── layouts │ │ │ └── default.vue │ │ ├── middleware │ │ │ └── matomo.js │ │ ├── nuxt.config.js │ │ ├── pages │ │ │ ├── component-fn.vue │ │ │ ├── component-prop.vue │ │ │ ├── consent.vue │ │ │ ├── headfn.vue │ │ │ ├── index.vue │ │ │ ├── injected.vue │ │ │ ├── manuallytracked.vue │ │ │ └── middleware.vue │ │ ├── static │ │ │ └── piwik.js │ │ └── store │ │ │ └── matomo.js │ └── meta-changed │ │ ├── meta-changed.test.js │ │ ├── nuxt.config.js │ │ ├── pages │ │ ├── notitle.vue │ │ ├── noupdate1.vue │ │ ├── noupdate2.vue │ │ ├── page1.vue │ │ └── page2.vue │ │ └── static │ │ └── piwik.js └── utils │ ├── browser.js │ ├── index.js │ ├── piwik.js │ └── setup.js └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [["@babel/preset-env", { 3 | "targets": { "node": "current" } 4 | }]], 5 | "plugins": ["@babel/plugin-syntax-dynamic-import"] 6 | } 7 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | coverage 2 | piwik.js 3 | plugin.js 4 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | parserOptions: { 4 | parser: 'babel-eslint', 5 | sourceType: 'module' 6 | }, 7 | extends: [ 8 | '@nuxtjs' 9 | ], 10 | rules: { 11 | 'vue/singleline-html-element-content-newline': 'off', 12 | 'no-console': ['error', { allow: ['debug', 'warn'] }] 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage 3 | .nuxt 4 | yarn-error.log 5 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "8" 4 | - "10" 5 | - "11" 6 | cache: 7 | yarn: true 8 | directories: 9 | - node_modules 10 | install: 11 | - yarn install 12 | script: 13 | - yarn lint:lib 14 | - yarn test:fixtures 15 | - yarn lint:matomo 16 | - yarn test:e2e 17 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. 4 | 5 | ### [1.2.4](https://github.com/pimlie/nuxt-matomo/compare/v1.2.3...v1.2.4) (2021-01-31) 6 | 7 | ### [1.2.3](https://github.com/pimlie/nuxt-matomo/compare/v1.2.2...v1.2.3) (2019-08-24) 8 | 9 | 10 | ### Bug Fixes 11 | 12 | * update piwik api methods list ([02afa67](https://github.com/pimlie/nuxt-matomo/commit/02afa67)) 13 | 14 | 15 | ## [1.2.2](https://github.com/pimlie/nuxt-matomo/compare/v1.2.1...v1.2.2) (2019-03-03) 16 | 17 | 18 | ### Features 19 | 20 | * add router.base on tracking page url ([df414aa](https://github.com/pimlie/nuxt-matomo/commit/df414aa)) 21 | 22 | 23 | 24 | 25 | ## [1.2.1](https://github.com/pimlie/nuxt-matomo/compare/v1.2.0...v1.2.1) (2019-01-24) 26 | 27 | 28 | ### Bug Fixes 29 | 30 | * loop api methods array in plugin, not template ([d443247](https://github.com/pimlie/nuxt-matomo/commit/d443247)) 31 | * unset correct var ([ee2d612](https://github.com/pimlie/nuxt-matomo/commit/ee2d612)) 32 | 33 | 34 | 35 | 36 | # [1.2.0](https://github.com/pimlie/nuxt-matomo/compare/v1.1.1...v1.2.0) (2019-01-23) 37 | 38 | 39 | ### Bug Fixes 40 | 41 | * lint ([427cd69](https://github.com/pimlie/nuxt-matomo/commit/427cd69)) 42 | 43 | 44 | ### Features 45 | 46 | * add api-methods-list ([8059ef5](https://github.com/pimlie/nuxt-matomo/commit/8059ef5)) 47 | * add manual api method list for proxying ([7f40705](https://github.com/pimlie/nuxt-matomo/commit/7f40705)) 48 | * better proxy workaround implementation ([1cd7643](https://github.com/pimlie/nuxt-matomo/commit/1cd7643)) 49 | * dont block page loading (by default) ([7aca169](https://github.com/pimlie/nuxt-matomo/commit/7aca169)) 50 | 51 | 52 | 53 | 54 | ## [1.1.1](https://github.com/pimlie/nuxt-matomo/compare/v1.1.0...v1.1.1) (2019-01-18) 55 | 56 | 57 | ### Bug Fixes 58 | 59 | * clear warn mocks ([f1c8549](https://github.com/pimlie/nuxt-matomo/commit/f1c8549)) 60 | * use es6 export ([02e0b70](https://github.com/pimlie/nuxt-matomo/commit/02e0b70)) 61 | 62 | 63 | 64 | 65 | # [1.1.0](https://github.com/pimlie/nuxt-matomo/compare/v1.0.0...v1.1.0) (2019-01-07) 66 | 67 | 68 | ### Bug Fixes 69 | 70 | * update travis config ([fda563c](https://github.com/pimlie/nuxt-matomo/commit/fda563c)) 71 | 72 | 73 | ### Features 74 | 75 | * add tracking on vue-meta change ([39c4fb3](https://github.com/pimlie/nuxt-matomo/commit/39c4fb3)) 76 | 77 | 78 | 79 | 80 | # [1.0.0](https://github.com/pimlie/nuxt-matomo/compare/v0.5.1...v1.0.0) (2019-01-06) 81 | 82 | 83 | ### Features 84 | 85 | * refactor module ([fe1c264](https://github.com/pimlie/nuxt-matomo/commit/fe1c264)) 86 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | nuxt-matomo 2 | 3 | Copyright (C) 2018, pimlie 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Matomo analytics for Nuxt.js 2 | Build Status 3 | [![npm](https://img.shields.io/npm/dt/nuxt-matomo.svg?style=flat-square)](https://www.npmjs.com/package/nuxt-matomo) 4 | [![npm (scoped with tag)](https://img.shields.io/npm/v/nuxt-matomo/latest.svg?style=flat-square)](https://www.npmjs.com/package/nuxt-matomo) 5 | 6 | Add Matomo analytics to your nuxt.js application. This plugin automatically sends first page and route change events to matomo 7 | 8 | ## Setup 9 | > nuxt-matomo is not enabled in `dev` mode unless you set the debug option 10 | 11 | - Install with 12 | ``` 13 | npm install --save nuxt-matomo 14 | // or 15 | yarn add nuxt-matomo 16 | ``` 17 | - Add `nuxt-matomo` to `modules` section of `nuxt.config.js` 18 | ```js 19 | modules: [ 20 | ['nuxt-matomo', { matomoUrl: '//matomo.example.com/', siteId: 1 }], 21 | ] 22 | ```` 23 | 24 | ## Usage 25 | 26 | By default `route.fullPath` and the [document title](#documenttitle) are tracked. You can add additional tracking info by adding a `route.meta.matomo` object in a middleware or by adding a matomo function or object to your page components. 27 | 28 | The matomo javascript tracker is also injected as `$matomo` in the Nuxt.js context. Use this to e.g. manually track a page view. See the [injected](./test/fixtures/basic/pages/injected.vue) and [manually tracked](./test/fixtures/basic/pages/manuallytracked.vue) pages in the test fixture for an example 29 | 30 | > :blue_book: See the official [Matomo JavaScript Tracking client docs](https://developer.matomo.org/api-reference/tracking-javascript) for a full overview of available methods 31 | 32 | #### Middleware example 33 | ```js 34 | export default function ({ route, store }) { 35 | route.meta.matomo = { 36 | documentTitle: ['setDocumentTitle', 'Some other title'], 37 | userId: ['setUserId', store.state.userId], 38 | someVar: ['setCustomVariable', 1, 'VisitorType', 'Member'] 39 | } 40 | } 41 | 42 | ``` 43 | 44 | #### Page component example 45 | ```js 46 | 52 | 53 | 81 | ``` 82 | 83 |
84 | Track manually with vue-router beforeRouterEnter guard 85 | 86 | This is overly complicated, you probably shouldnt use this 87 | 88 | ```js 89 | 94 | 95 | 116 | 117 | ``` 118 |
119 | 120 | ## Consent 121 | 122 | The plugin extends the matomo tracker with a `setConsent()` convenience method. 123 | 124 | When `setConsent()` is called, the plugin will automatically call rememberConsentGiven when the module option consentExpires has been set. To forget consent you can pass false to this method. 125 | 126 | See the [basic fixture](./test/fixtures/basic) for an example how to use this method in combination with a Vuex store. 127 | 128 | ## Module Options 129 | 130 | #### `siteId` (required) 131 | 132 | The matomo siteId 133 | 134 | #### `matomoUrl` 135 | 136 | - Default: ` ` 137 | Url to matomo installation 138 | 139 | #### `trackerUrl` 140 | 141 | - Default: `matomoUrl + 'piwik.php'` 142 | Url to piwik.php 143 | 144 | #### `scriptUrl` 145 | 146 | - Default: `matomoUrl + 'piwik.js'` 147 | Url to piwik.js 148 | 149 | #### `onMetaChange` 150 | 151 | - Default: `false` 152 | If true, page views will be tracked on the first vue-meta update after navigation occured. See caveats below for more information 153 | 154 | #### `blockLoading` 155 | 156 | - Default: `false` 157 | 158 | If true, loading of the page is blocked until `window.Piwik` becomes available. 159 | If false, a proxy implementation is used to delay tracker calls until Piwik is available. 160 | 161 | #### `addNoProxyWorkaround` 162 | 163 | - Default: `true` 164 | 165 | When `blockLoading: false` we have to wait until `window.Piwik` becomes available, if a browser supports a [`Proxy`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy) then we use this. Older browsers like IE9/10 dont support this, for these browsers a manual list of api methods to proxy is added when `addNoProxyWorkaround: true`. See the list [here](./lib/api-methods-list.json) 166 | 167 | > :warning: If you set this to `false` and still need to support IE9/10 you need to include a [ProxyPolyfill](https://github.com/GoogleChrome/proxy-polyfill) manually as [Babel](https://babeljs.io/docs/en/learn/#proxies) doesnt provide one 168 | 169 | #### `cookies` 170 | 171 | - Default: `true` 172 | If false, Matomo will not create a tracking cookie 173 | 174 | #### `consentRequired` 175 | 176 | - Default: `false` 177 | If true, Matomo will not start tracking until the user has given consent 178 | 179 | #### `consentExpires` 180 | 181 | - Default: `0` 182 | If greater than 0 and when the `tracker.setConsent` method is called then we call `rememberConsentGiven()` instead of `setConsentGiven`. See above for more information 183 | 184 | #### `doNotTrack` 185 | 186 | - Default: `false` 187 | If true, dont track users who have set Mozilla's (proposed) Do Not Track setting 188 | 189 | #### `debug` 190 | 191 | - Default: `false` 192 | If true, the plugin will log debug information to the console. 193 | 194 | > The plugin also logs debug information when Nuxt's debug option is set 195 | 196 | #### `verbose` 197 | 198 | - Default: `false` 199 | If true, the plugin will log every tracker function call to the console 200 | 201 | ## Caveats 202 | 203 | ### document.title 204 | 205 | Nuxt.js uses vue-meta to asynchronously update the `document.title`, this means by default we dont know when the `document.title` is changed. Therefore the default behaviour for this plugin is to set the `route.path` as document title. 206 | 207 | If you set the module option `onMetaChange: true`, then this plugin will track page views on the first time some meta data is updated by vue-meta (after navigation). This makes sure the `document.title` is available and updated, but if you have multiple pages without any meta data then those page views **could not be tracked** 208 | 209 | > vue-meta's changed event is only triggered when any meta data changed, make sure all your routes have a [`head`](https://nuxtjs.org/api/pages-head) option. 210 | 211 | When debug is true, this plugin will show warnings in the console when 212 | - it detects pages without a title 213 | - when no vue-meta changed event is triggered within 500ms after navigation (tracking could still occur, the timeout only shows a warning) 214 | 215 | You can also use a combination of manual tracking and a vuex store to keep track of the document.title 216 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | testEnvironment: 'node', 3 | 4 | collectCoverage: false, 5 | collectCoverageFrom: ['lib'], 6 | 7 | setupFilesAfterEnv: ['./test/utils/setup'] 8 | } 9 | -------------------------------------------------------------------------------- /lib/api-methods-list.json: -------------------------------------------------------------------------------- 1 | [ 2 | "getHook", 3 | "getQuery", 4 | "getContent", 5 | "isUsingAlwaysUseSendBeacon", 6 | "buildContentImpressionRequest", 7 | "buildContentInteractionRequest", 8 | "buildContentInteractionRequestNode", 9 | "getContentImpressionsRequestsFromNodes", 10 | "getCurrentlyVisibleContentImpressionsRequestsIfNotTrackedYet", 11 | "trackCallbackOnLoad", 12 | "trackCallbackOnReady", 13 | "buildContentImpressionsRequests", 14 | "wasContentImpressionAlreadyTracked", 15 | "appendContentInteractionToRequestIfPossible", 16 | "setupInteractionsTracking", 17 | "trackContentImpressionClickInteraction", 18 | "internalIsNodeVisible", 19 | "isNodeAuthorizedToTriggerInteraction", 20 | "getDomains", 21 | "getConfigIdPageView", 22 | "getConfigDownloadExtensions", 23 | "enableTrackOnlyVisibleContent", 24 | "clearTrackedContentImpressions", 25 | "getTrackedContentImpressions", 26 | "clearEnableTrackOnlyVisibleContent", 27 | "disableLinkTracking", 28 | "getConfigVisitorCookieTimeout", 29 | "getConfigCookieSameSite", 30 | "removeAllAsyncTrackersButFirst", 31 | "getConsentRequestsQueue", 32 | "getRequestQueue", 33 | "unsetPageIsUnloading", 34 | "getRemainingVisitorCookieTimeout", 35 | "hasConsent", 36 | "getVisitorId", 37 | "getVisitorInfo", 38 | "getAttributionInfo", 39 | "getAttributionCampaignName", 40 | "getAttributionCampaignKeyword", 41 | "getAttributionReferrerTimestamp", 42 | "getAttributionReferrerUrl", 43 | "setTrackerUrl", 44 | "getTrackerUrl", 45 | "getMatomoUrl", 46 | "getPiwikUrl", 47 | "addTracker", 48 | "getSiteId", 49 | "setSiteId", 50 | "resetUserId", 51 | "setUserId", 52 | "setVisitorId", 53 | "getUserId", 54 | "setCustomData", 55 | "getCustomData", 56 | "setCustomRequestProcessing", 57 | "appendToTrackingUrl", 58 | "getRequest", 59 | "addPlugin", 60 | "setCustomDimension", 61 | "getCustomDimension", 62 | "deleteCustomDimension", 63 | "setCustomVariable", 64 | "getCustomVariable", 65 | "deleteCustomVariable", 66 | "deleteCustomVariables", 67 | "storeCustomVariablesInCookie", 68 | "setLinkTrackingTimer", 69 | "getLinkTrackingTimer", 70 | "setDownloadExtensions", 71 | "addDownloadExtensions", 72 | "removeDownloadExtensions", 73 | "setDomains", 74 | "enableCrossDomainLinking", 75 | "disableCrossDomainLinking", 76 | "isCrossDomainLinkingEnabled", 77 | "setCrossDomainLinkingTimeout", 78 | "getCrossDomainLinkingUrlParameter", 79 | "setIgnoreClasses", 80 | "setRequestMethod", 81 | "setRequestContentType", 82 | "setGenerationTimeMs", 83 | "setReferrerUrl", 84 | "setCustomUrl", 85 | "getCurrentUrl", 86 | "setDocumentTitle", 87 | "setAPIUrl", 88 | "setDownloadClasses", 89 | "setLinkClasses", 90 | "setCampaignNameKey", 91 | "setCampaignKeywordKey", 92 | "discardHashTag", 93 | "setCookieNamePrefix", 94 | "setCookieDomain", 95 | "getCookieDomain", 96 | "hasCookies", 97 | "setSessionCookie", 98 | "getCookie", 99 | "setCookiePath", 100 | "getCookiePath", 101 | "setVisitorCookieTimeout", 102 | "setSessionCookieTimeout", 103 | "getSessionCookieTimeout", 104 | "setReferralCookieTimeout", 105 | "setConversionAttributionFirstReferrer", 106 | "setSecureCookie", 107 | "setCookieSameSite", 108 | "disableCookies", 109 | "areCookiesEnabled", 110 | "setCookieConsentGiven", 111 | "requireCookieConsent", 112 | "getRememberedCookieConsent", 113 | "forgetCookieConsentGiven", 114 | "rememberCookieConsentGiven", 115 | "deleteCookies", 116 | "setDoNotTrack", 117 | "alwaysUseSendBeacon", 118 | "disableAlwaysUseSendBeacon", 119 | "addListener", 120 | "enableLinkTracking", 121 | "enableJSErrorTracking", 122 | "disablePerformanceTracking", 123 | "enableHeartBeatTimer", 124 | "disableHeartBeatTimer", 125 | "killFrame", 126 | "redirectFile", 127 | "setCountPreRendered", 128 | "trackGoal", 129 | "trackLink", 130 | "getNumTrackedPageViews", 131 | "trackPageView", 132 | "trackAllContentImpressions", 133 | "trackVisibleContentImpressions", 134 | "trackContentImpression", 135 | "trackContentImpressionsWithinNode", 136 | "trackContentInteraction", 137 | "trackContentInteractionNode", 138 | "logAllContentBlocksOnPage", 139 | "trackEvent", 140 | "trackSiteSearch", 141 | "setEcommerceView", 142 | "getEcommerceItems", 143 | "addEcommerceItem", 144 | "removeEcommerceItem", 145 | "clearEcommerceCart", 146 | "trackEcommerceOrder", 147 | "trackEcommerceCartUpdate", 148 | "trackRequest", 149 | "ping", 150 | "disableQueueRequest", 151 | "setRequestQueueInterval", 152 | "queueRequest", 153 | "isConsentRequired", 154 | "getRememberedConsent", 155 | "hasRememberedConsent", 156 | "requireConsent", 157 | "setConsentGiven", 158 | "rememberConsentGiven", 159 | "forgetConsentGiven", 160 | "isUserOptedOut", 161 | "optUserOut", 162 | "forgetUserOptOut" 163 | ] -------------------------------------------------------------------------------- /lib/module.js: -------------------------------------------------------------------------------- 1 | import { resolve } from 'path' 2 | 3 | const defaults = { 4 | onMetaChange: false, 5 | debug: false, 6 | verbose: false, 7 | siteId: null, 8 | matomoUrl: null, 9 | trackerUrl: null, 10 | scriptUrl: null, 11 | cookies: true, 12 | consentRequired: false, 13 | consentExpires: 0, 14 | doNotTrack: false, 15 | blockLoading: false, 16 | addNoProxyWorkaround: true 17 | } 18 | 19 | export default function matomoModule (moduleOptions) { 20 | const options = Object.assign({}, defaults, moduleOptions) 21 | 22 | // do not enable in dev mode, unless debug is enabled or node-env is set to production 23 | if (this.options.dev && !options.debug && process.env.NODE_ENV !== 'production') { 24 | return 25 | } 26 | 27 | options.trackerUrl = options.trackerUrl || options.matomoUrl + 'piwik.php' 28 | options.scriptUrl = options.scriptUrl || options.matomoUrl + 'piwik.js' 29 | 30 | if (options.addNoProxyWorkaround) { 31 | options.apiMethodsList = options.apiMethodsList || require('./api-methods-list.json') 32 | } 33 | 34 | this.options.head.script.push({ 35 | src: options.scriptUrl, 36 | body: true, 37 | defer: true, 38 | async: true 39 | }) 40 | 41 | this.addTemplate({ 42 | src: resolve(__dirname, 'utils.js'), 43 | fileName: 'matomo/utils.js', 44 | ssr: false 45 | }) 46 | 47 | // register plugin 48 | this.addPlugin({ 49 | src: resolve(__dirname, 'plugin.js'), 50 | fileName: 'matomo/plugin.js', 51 | ssr: false, 52 | options 53 | }) 54 | } 55 | -------------------------------------------------------------------------------- /lib/plugin.js: -------------------------------------------------------------------------------- 1 | import { debug, warn, isFn, waitUntil, routeOption } from './utils'<% if(isTest) { %>// eslint-disable-line no-unused-vars<% } %> 2 | 3 | <% if (!isDev && options.debug) consola.warn('nuxt-matomo debug is enabled') %> 4 | 5 | export default <%= options.blockLoading ? 'async ' : ''%>(context, inject) => { 6 | const { app: { router, store } } = context 7 | 8 | <% if (options.blockLoading) { %> 9 | await waitUntil(() => window.Piwik) 10 | const tracker = createTracker() 11 | if (!tracker) return 12 | <% } else { %> 13 | let tracker 14 | if (window.Piwik) { 15 | tracker = createTracker() 16 | } else { 17 | // if window.Piwik is not (yet) available, add a Proxy which delays calls 18 | // to the tracker and execute them once the Piwik tracker becomes available 19 | let _tracker // The real Piwik tracker 20 | let delayedCalls = [] 21 | const proxyTrackerCall = (fnName, ...args) => { 22 | if (_tracker) { 23 | return _tracker[fnName](...args) 24 | } 25 | 26 | <% if(debug || options.debug) { %> 27 | debug(`Delaying call to tracker: ${fnName}`) 28 | <% } %> 29 | delayedCalls.push([fnName, ...args]) 30 | } 31 | 32 | if (typeof Proxy === 'function') { 33 | // Create a Proxy for any tracker property (IE11+) 34 | tracker = new Proxy({}, { 35 | get (target, key) { 36 | return (...args) => proxyTrackerCall(key, ...args) 37 | } 38 | }) 39 | <% if (options.addNoProxyWorkaround) { %> 40 | } else { 41 | tracker = {}; 42 | <%= JSON.stringify(options.apiMethodsList, null, 8).replace(/"/g, "'").replace(']', ' ]') %>.forEach((fnName) => { 43 | // IE9/10 dont support Proxies, create a proxy map for known api methods 44 | tracker[fnName] = (...args) => proxyTrackerCall(fnName, ...args) 45 | }) 46 | <% } %> 47 | } 48 | 49 | <% if(debug || options.debug) { %> 50 | // Log a warning when piwik doesnt become available within 10s (in debug mode) 51 | const hasPiwikCheck = setTimeout(() => { 52 | if (!window.Piwik) { 53 | debug(`window.Piwik was not set within timeout`) 54 | } 55 | }, 10000) 56 | <% } %> 57 | 58 | // Use a getter/setter to know when window.Piwik becomes available 59 | let _windowPiwik 60 | Object.defineProperty(window, 'Piwik', { 61 | configurable: true, 62 | enumerable: true, 63 | get () { 64 | return _windowPiwik 65 | }, 66 | set (newVal) { 67 | <% if(debug || options.debug) { %> 68 | clearTimeout(hasPiwikCheck) 69 | if (_windowPiwik) { 70 | debug(`window.Piwik is already defined`) 71 | } 72 | <% } %> 73 | 74 | _windowPiwik = newVal 75 | _tracker = createTracker(delayedCalls) 76 | delayedCalls = undefined 77 | } 78 | }) 79 | } 80 | <% } %> 81 | 82 | // inject tracker into app & context 83 | context.$matomo = tracker 84 | inject('matomo', tracker) 85 | 86 | <% if(options.onMetaChange) { %> 87 | // onMetaChange setup 88 | let trackOnMetaChange 89 | <% if(debug || options.debug) { %> 90 | let metaChangeTimeout 91 | <% } %><% } %> 92 | 93 | // define base url 94 | const baseUrl = window.location.protocol + 95 | (window.location.protocol.slice(-1) === ':' ? '' : ':') + 96 | '//' + 97 | window.location.host + 98 | router.options.base.replace(/\/+$/, '') 99 | 100 | const trackRoute = ({ to, componentOption }) => { 101 | <% if(options.onMetaChange) { %> 102 | tracker.setDocumentTitle(document.title) 103 | <% } else { %> 104 | // we might not know the to's page title in vue-router.afterEach, DOM is updated _after_ afterEach 105 | tracker.setDocumentTitle(to.path) 106 | <% } %> 107 | tracker.setCustomUrl(baseUrl + to.fullPath) 108 | 109 | // allow override page settings 110 | const settings = Object.assign( 111 | {}, 112 | context.route.meta && context.route.meta.matomo, 113 | componentOption 114 | ) 115 | 116 | for (const key in settings) { 117 | const setting = settings[key] 118 | const fn = setting.shift() 119 | if (isFn(tracker[fn])) { 120 | <% if(debug || options.debug) { %> 121 | debug(`Calling matomo.${fn} with args ${JSON.stringify(setting)}`) 122 | <% } %> 123 | tracker[fn].call(null, ...setting) 124 | <% if(debug || options.debug) { %> 125 | } else { 126 | debug(`Unknown matomo function ${fn} with args ${JSON.stringify(setting)}`) 127 | <% } %> 128 | } 129 | } 130 | 131 | <% if(debug || options.debug) { %> 132 | debug(`Tell matomo to track pageview ${to.fullPath}`, document.title) 133 | 134 | <% } %> 135 | // tell Matomo to add a page view (doesnt do anything if tracker is disabled) 136 | tracker.trackPageView() 137 | } 138 | 139 | <% if(options.onMetaChange) { %> 140 | // listen on vue-meta's changed event 141 | const changed = context.app.head.changed 142 | context.app.head.changed = (...args) => { 143 | <% if(debug || options.debug) { %> 144 | clearTimeout(metaChangeTimeout) 145 | console.log 146 | if (!args[0].title) { 147 | warn(`title was updated but empty for ${trackOnMetaChange && trackOnMetaChange.to.fullPath || 'unknown route'}`) 148 | } 149 | <% } %> 150 | 151 | if (trackOnMetaChange) { 152 | trackRoute(trackOnMetaChange) 153 | trackOnMetaChange = null 154 | } 155 | 156 | if (changed && isFn(changed)) { 157 | changed.call(null, ...args) 158 | } 159 | } 160 | <% } %> 161 | 162 | // every time the route changes (fired on initialization too) 163 | router.afterEach((to, from) => { 164 | const componentOption = routeOption('matomo', tracker, from, to, store) 165 | if (componentOption === false) { 166 | <% if(debug || options.debug) { %> 167 | debug(`Component option returned false, wont (automatically) track pageview ${to.fullPath}`) 168 | <% } %> 169 | return 170 | } 171 | 172 | <% if(options.onMetaChange) { %> 173 | if (trackOnMetaChange === undefined) { 174 | // track on initialization 175 | trackRoute({ to, componentOption }) 176 | trackOnMetaChange = null 177 | } else { 178 | trackOnMetaChange = { to, componentOption } 179 | 180 | <% if(debug || options.debug) { %> 181 | // set a timeout to track pages without a title/meta update 182 | metaChangeTimeout = setTimeout(() => { 183 | warn(`vue-meta's changed event was not triggered for ${to.fullPath}'`) 184 | }, 500) 185 | <% } %> 186 | } 187 | <% } else { %> 188 | trackRoute({ to, componentOption }) 189 | <% } %> 190 | }) 191 | } 192 | 193 | function createTracker (delayedCalls = []) { 194 | if (!window.Piwik) { 195 | <% if(debug || options.debug) { %> 196 | debug(`window.Piwik not initialized, unable to create a tracker`) 197 | <% } %> 198 | return 199 | } 200 | 201 | const tracker = window.Piwik.getTracker('<%= options.trackerUrl %>', '<%= options.siteId %>') 202 | 203 | // extend tracker 204 | tracker.setConsent = (val) => { 205 | if (val || val === undefined) { 206 | <% if(options.consentExpires > 0) { %> 207 | tracker.rememberConsentGiven(<%= options.consentExpires %>) 208 | <% } else { %> 209 | tracker.setConsentGiven() 210 | <% } %> 211 | } else { 212 | tracker.forgetConsentGiven() 213 | } 214 | } 215 | 216 | <% if(debug || options.debug) { %> 217 | debug(`Created tracker for siteId <%= options.siteId %> to <%= options.trackerUrl %>`) 218 | <% if(options.verbose) { %> 219 | // wrap all Piwik functions for verbose logging 220 | Object.keys(tracker).forEach((key) => { 221 | const fn = tracker[key] 222 | if (isFn(fn)) { 223 | tracker[key] = (...args) => { 224 | debug(`Calling tracker.${key} with args ${JSON.stringify(args)}`) 225 | return fn.call(tracker, ...args) 226 | } 227 | } 228 | }) 229 | <% } %><% } %> 230 | 231 | <% if(options.cookies === false) { %> 232 | tracker.disableCookies() 233 | <% } %> 234 | <% if(options.consentRequired !== false) { %> 235 | tracker.requireConsent() 236 | <% } %> 237 | <% if(options.doNotTrack !== false) { %> 238 | tracker.setDoNotTrack(true) 239 | <% } %> 240 | 241 | while (delayedCalls.length) { 242 | const [fnName, ...args] = delayedCalls.shift() 243 | if (isFn(tracker[fnName])) { 244 | <% if(debug || options.debug) { %> 245 | debug(`Calling delayed ${fnName} on tracker`) 246 | <% } %> 247 | tracker[fnName](...args) 248 | } 249 | } 250 | 251 | return tracker 252 | } 253 | -------------------------------------------------------------------------------- /lib/utils.js: -------------------------------------------------------------------------------- 1 | 2 | export function debug (msg) { 3 | console.debug(`[nuxt-matomo] ${msg}`) 4 | } 5 | 6 | export function warn (msg) { 7 | console.warn(`[nuxt-matomo] ${msg}`) 8 | } 9 | 10 | export function isFn (fn) { 11 | return typeof fn === 'function' 12 | } 13 | 14 | export function waitFor (time) { 15 | return new Promise(resolve => setTimeout(resolve, time || 0)) 16 | } 17 | 18 | export async function waitUntil (condition, timeout = 10000, interval = 10) { 19 | let duration = 0 20 | while (!(isFn(condition) ? condition() : condition)) { 21 | await waitFor(interval) 22 | duration += interval 23 | 24 | if (duration >= timeout) { 25 | break 26 | } 27 | } 28 | } 29 | 30 | export function routeOption (key, thisArg, from, to, ...args) { 31 | const matched = to.matched[0] 32 | const matchedComponent = matched.components.default 33 | return componentOption(matchedComponent, key, thisArg, from, to, ...args) 34 | } 35 | 36 | export function componentOption (component, key, thisArg, ...args) { 37 | if (!component || !component.options || component.options[key] === undefined) { 38 | return null 39 | } 40 | 41 | const option = component.options[key] 42 | return isFn(option) ? option.call(thisArg, ...args) : option 43 | } 44 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nuxt-matomo", 3 | "version": "1.2.4", 4 | "license": "MIT", 5 | "description": "Matomo analytics for Nuxt.js", 6 | "repository": "https://github.com/pimlie/nuxt-matomo", 7 | "homepage": "https://github.com/pimlie/nuxt-matomo", 8 | "main": "lib/module.js", 9 | "keywords": [ 10 | "matomo", 11 | "piwik", 12 | "analytics", 13 | "nuxt", 14 | "nuxt.js", 15 | "nuxtjs" 16 | ], 17 | "files": [ 18 | "lib" 19 | ], 20 | "scripts": { 21 | "build": "nuxt build test/fixtures/basic", 22 | "dev": "nuxt test/fixtures/meta-changed", 23 | "lint": "yarn lint:lib && yarn lint:matomo", 24 | "lint:lib": "eslint --ext .js,.vue .", 25 | "lint:matomo": "if [ $(node -e \"console.log(require('fs').existsSync('./test/fixtures/basic/.nuxt/matomo'));\") = \"false\" ]; then yarn test:fixtures; fi && eslint --no-ignore test/fixtures/basic/.nuxt/matomo/", 26 | "release": "yarn lint && yarn test && standard-version", 27 | "test": "yarn test:fixtures && yarn test:e2e", 28 | "test:fixtures": "jest test/fixtures", 29 | "test:e2e": "jest test/e2e", 30 | "download-matomo": "wget -O test/utils/piwik.js https://raw.githubusercontent.com/matomo-org/matomo/master/js/piwik.js", 31 | "create-matomo-api-list": "yarn download-matomo && node ./scripts/createApiMethodsList.js" 32 | }, 33 | "publishConfig": { 34 | "access": "public" 35 | }, 36 | "devDependencies": { 37 | "@babel/core": "^7.12.10", 38 | "@babel/plugin-syntax-dynamic-import": "^7.8.3", 39 | "@babel/preset-env": "^7.12.11", 40 | "@nuxtjs/eslint-config": "^5.0.0", 41 | "babel-core": "7.0.0-bridge.0", 42 | "babel-eslint": "^10.1.0", 43 | "babel-jest": "^26.6.3", 44 | "eslint": "^7.19.0", 45 | "eslint-config-standard": "^16.0.2", 46 | "eslint-plugin-html": "^6.1.1", 47 | "eslint-plugin-import": "^2.22.1", 48 | "eslint-plugin-jest": "^24.1.3", 49 | "eslint-plugin-node": "^11.1.0", 50 | "eslint-plugin-promise": "^4.2.1", 51 | "eslint-plugin-standard": "^5.0.0", 52 | "eslint-plugin-vue": "^7.5.0", 53 | "get-port": "^5.1.1", 54 | "jest": "^26.6.3", 55 | "jsdom": "^15.1.1", 56 | "nuxt": "^2.14.12", 57 | "puppeteer": "^5.5.0", 58 | "standard-version": "^9.1.0" 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /scripts/createApiMethodsList.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const fs = require('fs') 4 | const path = require('path') 5 | const { JSDOM, ResourceLoader, VirtualConsole } = require('jsdom') 6 | 7 | class MatomoResourceLoader extends ResourceLoader { 8 | fetch (url, options) { 9 | // Override the contents of this script to do something unusual. 10 | if (url.endsWith('piwik.js')) { 11 | return Promise.resolve(Buffer.from(fs.readFileSync(path.resolve(__dirname, '../test/utils/piwik.js'), 'utf8'))) 12 | } 13 | 14 | return super.fetch(url, options) 15 | } 16 | } 17 | 18 | const { window } = new JSDOM('', { 19 | pretendToBeVisual: true, 20 | runScripts: 'dangerously', 21 | resources: new MatomoResourceLoader(), 22 | virtualConsole: new VirtualConsole().sendTo(console) 23 | }) 24 | 25 | window.document.addEventListener('DOMContentLoaded', () => { 26 | const tracker = window.Piwik.getTracker('', 1) 27 | 28 | const fns = [] 29 | Object.keys(tracker).forEach((fn) => { 30 | if (typeof tracker[fn] === 'function') { 31 | fns.push(fn) 32 | } 33 | }) 34 | 35 | fs.writeFileSync(path.resolve(__dirname, '../lib/api-methods-list.json'), JSON.stringify(fns, null, 2)) 36 | }) 37 | -------------------------------------------------------------------------------- /test/e2e/basic.test.js: -------------------------------------------------------------------------------- 1 | import { URL } from 'url' 2 | import { Nuxt, getPort, waitFor, waitUntil, expectParams } from '../utils' 3 | import Browser from '../utils/browser' 4 | 5 | let port 6 | const browser = new Browser() 7 | const url = route => `http://localhost:${port}${route}` 8 | 9 | describe('matomo analytics', () => { 10 | let nuxt 11 | let page 12 | let matomoUrl = [] 13 | const createTrackerMsg = 'Created tracker for siteId 1 to ./piwik.php' 14 | 15 | beforeAll(async () => { 16 | const config = require('../fixtures/basic/nuxt.config') 17 | nuxt = new Nuxt(config) 18 | 19 | port = await getPort() 20 | await nuxt.server.listen(port, 'localhost') 21 | await browser.start({}) 22 | 23 | console.debug = jest.fn() 24 | console.warn = jest.fn() 25 | 26 | nuxt.hook('render:route', (url, result, context) => { 27 | if (url.includes('piwik.php')) { 28 | matomoUrl.push(new URL(url, `http://localhost:${port}`)) 29 | } 30 | }) 31 | }) 32 | 33 | afterAll(async () => { 34 | await nuxt.close() 35 | await browser.close() 36 | }) 37 | 38 | afterEach(() => { 39 | console.debug.mockClear() 40 | console.warn.mockClear() 41 | }) 42 | 43 | test('matomo is triggered on page load', async () => { 44 | matomoUrl = [] 45 | const pageUrl = '/' 46 | page = await browser.page(url(pageUrl)) 47 | await waitUntil(() => matomoUrl.length >= 1) 48 | 49 | expect(console.debug).toHaveBeenCalledWith(expect.stringMatching(createTrackerMsg)) 50 | expect(console.debug).toHaveBeenCalledWith(expect.stringMatching(`to track pageview ${pageUrl}`)) 51 | 52 | expect(await page.$text('h1')).toBe('index') 53 | 54 | expectParams(matomoUrl[0].searchParams, { 55 | idsite: '1', 56 | action_name: pageUrl 57 | }) 58 | }) 59 | 60 | test('cookies have been set', async () => { 61 | const cookies = await page.cookies() 62 | 63 | expect(cookies[0].name).toEqual(expect.stringMatching('_pk_ses.1.')) 64 | expect(cookies[1].name).toEqual(expect.stringMatching('_pk_id.1.')) 65 | }) 66 | 67 | test('matomo is triggered on navigation', async () => { 68 | matomoUrl = [] 69 | const pageUrl = '/middleware' 70 | await page.nuxt.navigate(pageUrl) 71 | await waitUntil(() => matomoUrl.length >= 1) 72 | 73 | expect(console.debug).not.toHaveBeenCalledWith(expect.stringMatching(createTrackerMsg)) 74 | expect(console.debug).toHaveBeenCalledWith(expect.stringMatching(`to track pageview ${pageUrl}`)) 75 | 76 | expect(await page.$text('h1')).toBe('middleware') 77 | 78 | expectParams(matomoUrl[0].searchParams, { 79 | idsite: '1', 80 | action_name: pageUrl 81 | }) 82 | }) 83 | 84 | test('route.meta from global middleware is used', () => { 85 | expectParams(matomoUrl[0].searchParams, { 86 | cvar: [ 87 | ['VisitorType', 'A'], 88 | ['OtherType', 'true'] 89 | ] 90 | }) 91 | }) 92 | 93 | test('matomo prop defined in page component is used', async () => { 94 | matomoUrl = [] 95 | const pageUrl = '/component-prop' 96 | await page.nuxt.navigate(pageUrl) 97 | await waitUntil(() => matomoUrl.length >= 1) 98 | 99 | expect(await page.$text('h1')).toBe('component prop') 100 | 101 | expectParams(matomoUrl[0].searchParams, { 102 | idsite: '1', 103 | action_name: pageUrl, 104 | cvar: [ 105 | ['VisitorType', 'B'], 106 | ['OtherType', 'true'] 107 | ] 108 | }) 109 | }) 110 | 111 | test('matomo function defined in page component is used', async () => { 112 | matomoUrl = [] 113 | const pageUrl = '/component-fn' 114 | await page.nuxt.navigate(pageUrl) 115 | await waitUntil(() => matomoUrl.length >= 1) 116 | 117 | expect(await page.$text('h1')).toBe('component fn') 118 | 119 | expectParams(matomoUrl[0].searchParams, { 120 | idsite: '1', 121 | action_name: pageUrl, 122 | cvar: [ 123 | ['VisitorType', 'C'], 124 | ['OtherType', 'true'] 125 | ] 126 | }) 127 | }) 128 | 129 | test('tracker is injected and can be used', async () => { 130 | matomoUrl = [] 131 | const pageUrl = '/injected' 132 | await page.nuxt.navigate(pageUrl) 133 | await waitUntil(() => matomoUrl.length >= 2) 134 | 135 | expect(console.debug).not.toHaveBeenCalledWith(expect.stringMatching(createTrackerMsg)) 136 | expect(console.debug).toHaveBeenCalledWith(expect.stringMatching(`to track pageview ${pageUrl}`)) 137 | 138 | expect(await page.$text('h1')).toBe('injected') 139 | 140 | expectParams(matomoUrl[0].searchParams, { 141 | idsite: '1', 142 | action_name: pageUrl, 143 | cvar: [ 144 | ['VisitorType', 'C'], 145 | ['OtherType', 'true'] 146 | ] 147 | }) 148 | 149 | expectParams(matomoUrl[1].searchParams, { 150 | idsite: '1', 151 | download: 'file' 152 | }) 153 | }) 154 | 155 | test('can disable automatic tracking to track manually', async () => { 156 | matomoUrl = [] 157 | const pageUrl = '/manuallytracked' 158 | await page.nuxt.navigate(pageUrl) 159 | await waitUntil(() => matomoUrl.length >= 1) 160 | await waitFor(100) // wait a bit more 161 | 162 | expect(console.debug).not.toHaveBeenCalledWith(expect.stringMatching(createTrackerMsg)) 163 | expect(console.debug).toHaveBeenCalledWith(expect.stringMatching(`wont \\(automatically\\) track pageview ${pageUrl}`)) 164 | 165 | expect(await page.$text('h1')).toBe('manually tracked') 166 | 167 | expect(matomoUrl.length).toBe(1) 168 | expectParams(matomoUrl[0].searchParams, { 169 | idsite: '1', 170 | action_name: 'manually tracked', 171 | cvar: [ 172 | ['VisitorType', 'C'], 173 | ['OtherType', 'true'] 174 | ] 175 | }) 176 | }) 177 | 178 | test('does not track when consent is required', async () => { 179 | matomoUrl = [] 180 | const pageUrl = '/consent' 181 | await page.nuxt.navigate(pageUrl) 182 | await waitFor(250) // wait a bit 183 | 184 | expect(console.debug).not.toHaveBeenCalledWith(expect.stringMatching(createTrackerMsg)) 185 | expect(console.debug).toHaveBeenCalledWith(expect.stringMatching(`to track pageview ${pageUrl}`)) 186 | 187 | expect(await page.$text('h1')).toBe('consent') 188 | expect(matomoUrl.length).toBe(0) 189 | }) 190 | 191 | test('still does not track when consent is required', async () => { 192 | matomoUrl = [] 193 | const pageUrl = '/' 194 | await page.nuxt.navigate(pageUrl) 195 | await waitFor(250) // wait a bit 196 | 197 | expect(console.debug).toHaveBeenCalledWith(expect.stringMatching(`to track pageview ${pageUrl}`)) 198 | 199 | expect(await page.$text('h1')).toBe('index') 200 | expect(matomoUrl.length).toBe(0) 201 | }) 202 | 203 | test('tracking is triggered once consent is given', async () => { 204 | matomoUrl = [] 205 | 206 | await page.evaluate($nuxt => $nuxt.$store.commit('matomo/consented'), page.$nuxt) 207 | const store = await page.nuxt.storeState() 208 | expect(store.matomo.consented).toBe(true) 209 | 210 | await waitUntil(() => matomoUrl.length >= 1) 211 | 212 | expect(console.debug).not.toHaveBeenCalled() 213 | 214 | expectParams(matomoUrl[0].searchParams, { 215 | idsite: '1', 216 | action_name: '/', 217 | cvar: [ 218 | ['VisitorType', 'A'], 219 | ['OtherType', 'true'] 220 | ] 221 | }) 222 | }) 223 | }) 224 | -------------------------------------------------------------------------------- /test/e2e/meta-changed.test.js: -------------------------------------------------------------------------------- 1 | import { URL } from 'url' 2 | import { Nuxt, getPort, waitUntil, expectParams } from '../utils' 3 | import Browser from '../utils/browser' 4 | 5 | let port 6 | const browser = new Browser() 7 | const url = route => `http://localhost:${port}/app${route}` 8 | 9 | describe('matomo analytics', () => { 10 | let nuxt 11 | let page 12 | let matomoUrl = [] 13 | const createTrackerMsg = 'Created tracker for siteId 2 to ./piwik.php' 14 | 15 | beforeAll(async () => { 16 | const config = require('../fixtures/meta-changed/nuxt.config') 17 | nuxt = new Nuxt(config) 18 | 19 | port = await getPort() 20 | await nuxt.server.listen(port, 'localhost') 21 | await browser.start({}) 22 | 23 | console.debug = jest.fn() 24 | console.warn = jest.fn() 25 | 26 | nuxt.hook('render:route', (url, result, context) => { 27 | if (url.includes('piwik.php')) { 28 | matomoUrl.push(new URL(url, `http://localhost:${port}`)) 29 | } 30 | }) 31 | }) 32 | 33 | afterAll(async () => { 34 | await nuxt.close() 35 | await browser.close() 36 | }) 37 | 38 | afterEach(() => { 39 | console.debug.mockClear() 40 | console.warn.mockClear() 41 | matomoUrl = [] 42 | }) 43 | 44 | test('matomo is triggered on page load', async () => { 45 | const pagePath = '/page1' 46 | const pageUrl = url(pagePath) 47 | page = await browser.page(pageUrl) 48 | await waitUntil(() => matomoUrl.length >= 1) 49 | 50 | expect(matomoUrl.length).toBe(1) 51 | 52 | expect(console.debug).toHaveBeenCalledWith(expect.stringMatching(createTrackerMsg)) 53 | expect(console.debug).toHaveBeenCalledWith(expect.stringMatching(`to track pageview ${pagePath}`)) 54 | 55 | expect(await page.$text('h1')).toBe('page1') 56 | 57 | expectParams(matomoUrl[0].searchParams, { 58 | idsite: '2', 59 | action_name: 'page1', 60 | url: `${pageUrl}` 61 | }) 62 | }) 63 | 64 | test('matomo is triggered on navigation', async () => { 65 | const pageUrl = '/page2' 66 | await page.nuxt.navigate(pageUrl) 67 | await waitUntil(() => matomoUrl.length >= 1) 68 | expect(matomoUrl.length).toBe(1) 69 | 70 | expect(console.debug).not.toHaveBeenCalledWith(expect.stringMatching(createTrackerMsg)) 71 | expect(console.debug).toHaveBeenCalledWith(expect.stringMatching(`to track pageview ${pageUrl}`)) 72 | 73 | expect(await page.$text('h1')).toBe('page2') 74 | 75 | expectParams(matomoUrl[0].searchParams, { 76 | idsite: '2', 77 | action_name: 'page2' 78 | }) 79 | }) 80 | 81 | test('warns on empty title', async () => { 82 | const pageUrl = '/notitle' 83 | await page.nuxt.navigate(pageUrl) 84 | await waitUntil(() => matomoUrl.length >= 1) 85 | expect(matomoUrl.length).toBe(1) 86 | 87 | expect(console.debug).not.toHaveBeenCalledWith(expect.stringMatching(createTrackerMsg)) 88 | expect(console.debug).toHaveBeenCalledWith(expect.stringMatching(`to track pageview ${pageUrl}`)) 89 | expect(console.warn).toHaveBeenCalledWith(expect.stringMatching(`title was updated but empty for ${pageUrl}`)) 90 | 91 | expect(await page.$text('h1')).toBe('notitle') 92 | 93 | expectParams(matomoUrl[0].searchParams, { 94 | idsite: '2', 95 | action_name: '' 96 | }) 97 | }) 98 | 99 | test('warns on meta changed timeout (in debug, test setup)', async () => { 100 | const pageUrl = '/noupdate1' 101 | await page.nuxt.navigate(pageUrl) 102 | await waitUntil(() => matomoUrl.length >= 1) 103 | expect(matomoUrl.length).toBe(1) 104 | 105 | expect(console.debug).not.toHaveBeenCalledWith(expect.stringMatching(createTrackerMsg)) 106 | expect(console.debug).toHaveBeenCalledWith(expect.stringMatching(`to track pageview ${pageUrl}`)) 107 | 108 | expect(await page.$text('h1')).toBe('noupdate1') 109 | }) 110 | 111 | test('warns on meta changed timeout (in debug, the test)', async () => { 112 | const pageUrl = '/noupdate2' 113 | await page.nuxt.navigate(pageUrl) 114 | await waitUntil(() => matomoUrl.length >= 1, 2000) 115 | expect(matomoUrl.length).toBe(0) 116 | 117 | expect(console.debug).not.toHaveBeenCalledWith(expect.stringMatching(createTrackerMsg)) 118 | expect(console.debug).not.toHaveBeenCalledWith(expect.stringMatching(`to track pageview ${pageUrl}`)) 119 | expect(console.warn).toHaveBeenCalledWith(expect.stringMatching(`changed event was not triggered for ${pageUrl}`)) 120 | 121 | expect(await page.$text('h1')).toBe('noupdate2') 122 | }) 123 | }) 124 | -------------------------------------------------------------------------------- /test/fixtures/basic/basic.test.js: -------------------------------------------------------------------------------- 1 | import { Nuxt, Builder } from '../../utils' 2 | 3 | describe('Build fixture', () => { 4 | let nuxt 5 | let builder 6 | let buildDone 7 | 8 | beforeAll(async () => { 9 | const config = require('./nuxt.config') 10 | nuxt = new Nuxt(config) 11 | 12 | buildDone = jest.fn() 13 | 14 | nuxt.hook('build:done', buildDone) 15 | builder = await new Builder(nuxt).build() 16 | }) 17 | 18 | test('correct build status', () => { 19 | expect(builder._buildStatus).toBe(2) 20 | }) 21 | 22 | test('build:done hook called', () => { 23 | expect(buildDone).toHaveBeenCalledTimes(1) 24 | }) 25 | }) 26 | -------------------------------------------------------------------------------- /test/fixtures/basic/layouts/default.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 19 | -------------------------------------------------------------------------------- /test/fixtures/basic/middleware/matomo.js: -------------------------------------------------------------------------------- 1 | export default ({ route }) => { 2 | if (route.name && !['index', 'injected'].includes(route.name)) { 3 | route.meta.matomo = { 4 | someVar1: ['setCustomVariable', 1, 'VisitorType', 'A', 'page'], 5 | someVar2: ['setCustomVariable', 2, 'OtherType', true, 'page'] 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /test/fixtures/basic/nuxt.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | rootDir: __dirname, 3 | dev: false, 4 | router: { 5 | middleware: 'matomo' 6 | }, 7 | modules: [ 8 | ['@/../../../', { 9 | debug: true, 10 | siteId: 1, 11 | matomoUrl: './' 12 | }] 13 | ], 14 | matomoLoadDelay: 5000, 15 | build: { 16 | terser: false 17 | }, 18 | hooks: { 19 | render: { 20 | before: (server, render) => { 21 | server.app.use((req, res, next) => { 22 | if (server.options.matomoLoadDelay && req.originalUrl.endsWith('piwik.js')) { 23 | setTimeout(next, server.options.matomoLoadDelay) 24 | } else { 25 | next() 26 | } 27 | }) 28 | } 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /test/fixtures/basic/pages/component-fn.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 16 | -------------------------------------------------------------------------------- /test/fixtures/basic/pages/component-prop.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 14 | -------------------------------------------------------------------------------- /test/fixtures/basic/pages/consent.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 19 | -------------------------------------------------------------------------------- /test/fixtures/basic/pages/headfn.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 23 | -------------------------------------------------------------------------------- /test/fixtures/basic/pages/index.vue: -------------------------------------------------------------------------------- 1 | 11 | -------------------------------------------------------------------------------- /test/fixtures/basic/pages/injected.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 15 | -------------------------------------------------------------------------------- /test/fixtures/basic/pages/manuallytracked.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 29 | -------------------------------------------------------------------------------- /test/fixtures/basic/pages/middleware.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 15 | -------------------------------------------------------------------------------- /test/fixtures/basic/static/piwik.js: -------------------------------------------------------------------------------- 1 | ../../../utils/piwik.js -------------------------------------------------------------------------------- /test/fixtures/basic/store/matomo.js: -------------------------------------------------------------------------------- 1 | export const state = () => ({ 2 | cookies: true, 3 | consented: false 4 | }) 5 | 6 | export const mutations = { 7 | cookies (state, noCookies) { 8 | state.cookies = !noCookies 9 | }, 10 | consented (state, noConsent) { 11 | state.consented = !noConsent 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /test/fixtures/meta-changed/meta-changed.test.js: -------------------------------------------------------------------------------- 1 | import { Nuxt, Builder } from '../../utils' 2 | 3 | describe('Build fixture', () => { 4 | let nuxt 5 | let builder 6 | let buildDone 7 | 8 | beforeAll(async () => { 9 | const config = require('./nuxt.config') 10 | nuxt = new Nuxt(config) 11 | 12 | buildDone = jest.fn() 13 | 14 | nuxt.hook('build:done', buildDone) 15 | builder = new Builder(nuxt) 16 | await builder.build() 17 | }) 18 | 19 | test('correct build status', () => { 20 | expect(builder._buildStatus).toBe(2) 21 | }) 22 | 23 | test('build:done hook called', () => { 24 | expect(buildDone).toHaveBeenCalledTimes(1) 25 | }) 26 | }) 27 | -------------------------------------------------------------------------------- /test/fixtures/meta-changed/nuxt.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | rootDir: __dirname, 3 | dev: false, 4 | router: { 5 | base: '/app/' 6 | }, 7 | build: { 8 | terser: false 9 | }, 10 | modules: [ 11 | ['@/../../../', { 12 | onMetaChange: true, 13 | debug: true, 14 | siteId: 2, 15 | matomoUrl: './' 16 | }] 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /test/fixtures/meta-changed/pages/notitle.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 14 | -------------------------------------------------------------------------------- /test/fixtures/meta-changed/pages/noupdate1.vue: -------------------------------------------------------------------------------- 1 | 6 | -------------------------------------------------------------------------------- /test/fixtures/meta-changed/pages/noupdate2.vue: -------------------------------------------------------------------------------- 1 | 6 | -------------------------------------------------------------------------------- /test/fixtures/meta-changed/pages/page1.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 15 | -------------------------------------------------------------------------------- /test/fixtures/meta-changed/pages/page2.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 17 | -------------------------------------------------------------------------------- /test/fixtures/meta-changed/static/piwik.js: -------------------------------------------------------------------------------- 1 | ../../../utils/piwik.js -------------------------------------------------------------------------------- /test/utils/browser.js: -------------------------------------------------------------------------------- 1 | import puppeteer from 'puppeteer' 2 | 3 | export default class Browser { 4 | async start (options = {}) { 5 | // https://github.com/GoogleChrome/puppeteer/blob/master/docs/api.md#puppeteerlaunchoptions 6 | this.browser = await puppeteer.launch( 7 | Object.assign( 8 | { 9 | args: ['--no-sandbox', '--disable-setuid-sandbox'], 10 | executablePath: process.env.PUPPETEER_EXECUTABLE_PATH 11 | }, 12 | options 13 | ) 14 | ) 15 | } 16 | 17 | async close () { 18 | if (!this.browser) { return } 19 | await this.browser.close() 20 | } 21 | 22 | async page (url, globalName = 'nuxt') { 23 | if (!this.browser) { throw new Error('Please call start() before page(url)') } 24 | const page = await this.browser.newPage() 25 | 26 | // pass on console messages 27 | const typeMap = { 28 | debug: 'debug', 29 | warning: 'warn' 30 | } 31 | page.on('console', (msg) => { 32 | if (typeMap[msg.type()]) { 33 | console[typeMap[msg.type()]](msg.text()) // eslint-disable-line no-console 34 | } 35 | }) 36 | 37 | await page.goto(url) 38 | page.$nuxtGlobalHandle = `window.$${globalName}` 39 | await page.waitForFunction(`!!${page.$nuxtGlobalHandle}`) 40 | page.html = () => 41 | page.evaluate(() => window.document.documentElement.outerHTML) 42 | page.$text = (selector, trim) => page.$eval(selector, (el, trim) => { 43 | return trim ? el.textContent.replace(/^\s+|\s+$/g, '') : el.textContent 44 | }, trim) 45 | page.$$text = (selector, trim) => 46 | page.$$eval(selector, (els, trim) => els.map((el) => { 47 | return trim ? el.textContent.replace(/^\s+|\s+$/g, '') : el.textContent 48 | }), trim) 49 | page.$attr = (selector, attr) => 50 | page.$eval(selector, (el, attr) => el.getAttribute(attr), attr) 51 | page.$$attr = (selector, attr) => 52 | page.$$eval( 53 | selector, 54 | (els, attr) => els.map(el => el.getAttribute(attr)), 55 | attr 56 | ) 57 | 58 | page.$nuxt = await page.evaluateHandle(page.$nuxtGlobalHandle) 59 | 60 | page.nuxt = { 61 | async navigate (path, waitEnd = true) { 62 | const hook = page.evaluate(` 63 | new Promise(resolve => 64 | ${page.$nuxtGlobalHandle}.$once('routeChanged', resolve) 65 | ).then(() => new Promise(resolve => setTimeout(resolve, 50))) 66 | `) 67 | await page.evaluate( 68 | ($nuxt, path) => $nuxt.$router.push(path), 69 | page.$nuxt, 70 | path 71 | ) 72 | if (waitEnd) { 73 | await hook 74 | } 75 | return { hook } 76 | }, 77 | routeData () { 78 | return page.evaluate(($nuxt) => { 79 | return { 80 | path: $nuxt.$route.path, 81 | query: $nuxt.$route.query 82 | } 83 | }, page.$nuxt) 84 | }, 85 | loadingData () { 86 | return page.evaluate($nuxt => $nuxt.$loading.$data, page.$nuxt) 87 | }, 88 | errorData () { 89 | return page.evaluate($nuxt => $nuxt.nuxt.err, page.$nuxt) 90 | }, 91 | storeState () { 92 | return page.evaluate($nuxt => $nuxt.$store.state, page.$nuxt) 93 | } 94 | } 95 | 96 | return page 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /test/utils/index.js: -------------------------------------------------------------------------------- 1 | export { default as getPort } from 'get-port' 2 | export { Nuxt, Builder } from 'nuxt' 3 | 4 | export { isFn, waitFor, waitUntil } from '../../lib/utils' 5 | 6 | export function expectParams (received, expectedParams) { 7 | if (!expectedParams) { 8 | expect(received).toBeFalsy() 9 | } else { 10 | expect(received).toBeTruthy() 11 | 12 | for (const key in expectedParams) { // eslint-disable-line no-unused-vars 13 | if (key === 'cvar') { 14 | expectCvars(received.get(key), expectedParams[key]) 15 | } else { 16 | expect(received.get(key)).toBe(expectedParams[key]) 17 | } 18 | } 19 | } 20 | } 21 | 22 | export function expectCvars (received, expectedCvars) { 23 | if (!expectedCvars) { 24 | expect(received).toBeFalsy() 25 | } else { 26 | expect(received).toBeTruthy() 27 | 28 | const cvars = JSON.parse(received) 29 | 30 | for (const key in expectedCvars) { // eslint-disable-line no-unused-vars 31 | const expectedCvar = expectedCvars[key] 32 | const cvar = cvars[`${parseInt(key) + 1}`] 33 | 34 | expect(cvar).toBeTruthy() 35 | expect(cvar[0]).toBe(expectedCvar[0]) 36 | expect(cvar[1]).toBe(expectedCvar[1]) 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /test/utils/setup.js: -------------------------------------------------------------------------------- 1 | jest.setTimeout(60000) 2 | --------------------------------------------------------------------------------