diff --git a/.env b/.env index ab689d5ee..89d71a6fc 100644 --- a/.env +++ b/.env @@ -9,10 +9,14 @@ REACT_APP_PROFILE_GRAPHQL= REACT_APP_OIDC_SCOPE="openid profile https://api.hel.fi/auth/helsinkiprofile" REACT_APP_OIDC_RESPONSE_TYPE="code" REACT_APP_SENTRY_DSN= -REACT_APP_MATOMO_ENABLED=false REACT_APP_ENVIRONMENT= +REACT_APP_MATOMO_SRC_URL= +REACT_APP_MATOMO_URL_BASE="" +REACT_APP_MATOMO_SITE_ID="" +REACT_APP_MATOMO_ENABLED=false REACT_APP_VERSION=$npm_package_version TRANSLATION_LANGUAGES=en,fi,sv TRANSLATIONS_SHEET_ID=1Ky-E1nJ_pRUYMoORobahOJ_IWucfL7kirBBLiA6r8zs TRANSLATION_PROJECT_NAME=open-city-profile GENERATE_SOURCEMAP=false + diff --git a/.env.test b/.env.test index b22cbd3d2..cd1c176cf 100644 --- a/.env.test +++ b/.env.test @@ -1,3 +1,6 @@ REACT_APP_PROFILE_GRAPHQL="https://helsinkiprofile.test.kuva.hel.ninja/graphql/" REACT_APP_HELSINKI_ACCOUNT_AMR="helsinki_tunnus" REACT_APP_KEYCLOAK_AUTHORITY="https://tunnistus.test.hel.ninja/auth/realms/helsinki-tunnistus/" +REACT_APP_MATOMO_URL_BASE="test" +REACT_APP_MATOMO_SITE_ID="test123" +REACT_APP_MATOMO_ENABLED=false diff --git a/package.json b/package.json index 6f6f62300..b4d3ecd41 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,6 @@ "private": true, "dependencies": { "@apollo/client": "^3.9.6", - "@datapunt/matomo-tracker-react": "^0.5.1", "@react-aria/visually-hidden": "^3.2.1", "@sentry/react": "^7.103.0", "@sinonjs/fake-timers": "^8.1.0", diff --git a/src/App.tsx b/src/App.tsx index ff8a225de..a58b2cc22 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,12 +1,10 @@ import React from 'react'; import { Switch, Route } from 'react-router'; import { ApolloProvider } from '@apollo/client'; -import { MatomoProvider } from '@datapunt/matomo-tracker-react'; import countries from 'i18n-iso-countries'; import fi from 'i18n-iso-countries/langs/fi.json'; import en from 'i18n-iso-countries/langs/en.json'; import sv from 'i18n-iso-countries/langs/sv.json'; -import { MatomoInstance } from '@datapunt/matomo-tracker-react/lib/types'; import graphqlClient from './graphql/client'; import Login from './auth/components/login/Login'; @@ -26,7 +24,8 @@ import { useHistoryListener } from './profile/hooks/useHistoryListener'; import WithAuthCheck from './profile/components/withAuthCheck/WithAuthCheck'; import CookieConsentPage from './cookieConsents/CookieConsentPage'; import LoginSSO from './auth/components/loginsso/LoginSSO'; -import { useTrackingInstance } from './common/helpers/tracking/matomoTracking'; +import MatomoTracker from './common/matomo/MatomoTracker'; +import { MatomoProvider } from './common/matomo/matomo-context'; countries.registerLocale(fi); countries.registerLocale(en); @@ -34,11 +33,21 @@ countries.registerLocale(sv); function App(): React.ReactElement { useHistoryListener(); - const instance = useTrackingInstance(); + + const matomoTracker = new MatomoTracker({ + urlBase: window._env_.REACT_APP_MATOMO_URL_BASE, + siteId: window._env_.REACT_APP_MATOMO_SITE_ID, + srcUrl: window._env_.REACT_APP_MATOMO_SRC_URL, + enabled: window._env_.REACT_APP_MATOMO_ENABLED === 'true', + configurations: { + setDoNotTrack: true, + }, + }); + return ( - + diff --git a/src/BrowserApp.tsx b/src/BrowserApp.tsx index d9a2cb79d..819d99db8 100644 --- a/src/BrowserApp.tsx +++ b/src/BrowserApp.tsx @@ -5,10 +5,8 @@ import { I18nextProvider } from 'react-i18next'; import App from './App'; import i18n from './i18n/i18nInit'; import CookieConsentModal from './cookieConsents/CookieConsentModal'; -import { disableTrackingCookiesUntilConsentGiven } from './common/helpers/tracking/matomoTracking'; function BrowserApp(): React.ReactElement { - disableTrackingCookiesUntilConsentGiven(); return ( diff --git a/src/__tests__/BrowserApp.test.tsx b/src/__tests__/BrowserApp.test.tsx index 5bb3d7f89..a893b51e0 100644 --- a/src/__tests__/BrowserApp.test.tsx +++ b/src/__tests__/BrowserApp.test.tsx @@ -3,7 +3,6 @@ import { render } from '@testing-library/react'; import { Mock } from 'vitest'; import BrowserApp from '../BrowserApp'; -import { getMockCallArgs } from '../common/test/mockHelper'; describe('BrowserApp', () => { const pushTracker = vi.fn(); @@ -21,16 +20,7 @@ describe('BrowserApp', () => { })); }); - it('renders without crashing and commands tracker to wait for consent', () => { + it('renders without crashing', () => { render(); - - const calls = getMockCallArgs(pushTracker) as string[]; - - expect(calls).toEqual([ - ['requireCookieConsent'], - ['requireConsent'], - ['forgetCookieConsentGiven'], - ['forgetConsentGiven'], - ]); }); }); diff --git a/src/auth/components/login/Login.tsx b/src/auth/components/login/Login.tsx index f37f405b5..07525845f 100644 --- a/src/auth/components/login/Login.tsx +++ b/src/auth/components/login/Login.tsx @@ -1,6 +1,5 @@ import React from 'react'; import { useTranslation } from 'react-i18next'; -import { useMatomo } from '@datapunt/matomo-tracker-react'; import classNames from 'classnames'; import { Button } from 'hds-react'; @@ -10,6 +9,7 @@ import PageLayout from '../../../common/pageLayout/PageLayout'; import commonContentStyles from '../../../common/cssHelpers/content.module.css'; import authService from '../../authService'; import FocusableH1 from '../../../common/focusableH1/FocusableH1'; +import useMatomo from '../../../common/matomo/hooks/useMatomo'; function Login(): React.ReactElement { const { t } = useTranslation(); diff --git a/src/common/header/Header.tsx b/src/common/header/Header.tsx index 2483672d1..07d803864 100644 --- a/src/common/header/Header.tsx +++ b/src/common/header/Header.tsx @@ -9,11 +9,11 @@ import { logoSv, LanguageSelectorProps, } from 'hds-react'; -import { useMatomo } from '@datapunt/matomo-tracker-react'; import { MAIN_CONTENT_ID } from '../constants'; import { ProfileContext } from '../../profile/context/ProfileContext'; import UserDropdown from './userDropdown/UserDropdown'; +import useMatomo from '../matomo/hooks/useMatomo'; function Header(): React.ReactElement { const { t, i18n } = useTranslation(); diff --git a/src/common/header/userDropdown/UserDropdown.tsx b/src/common/header/userDropdown/UserDropdown.tsx index 0b5c68709..cc602960e 100644 --- a/src/common/header/userDropdown/UserDropdown.tsx +++ b/src/common/header/userDropdown/UserDropdown.tsx @@ -1,10 +1,10 @@ import React, { useContext } from 'react'; import { useTranslation } from 'react-i18next'; import { Header as HDSHeader, IconUser } from 'hds-react'; -import { useMatomo } from '@datapunt/matomo-tracker-react'; import authService from '../../../auth/authService'; import { ProfileContext } from '../../../profile/context/ProfileContext'; +import useMatomo from '../../matomo/hooks/useMatomo'; type UserDataWithActions = { userName: string; diff --git a/src/common/helpers/tracking/matomoTracking.ts b/src/common/helpers/tracking/matomoTracking.ts index bd7d13f77..899cf6d5e 100644 --- a/src/common/helpers/tracking/matomoTracking.ts +++ b/src/common/helpers/tracking/matomoTracking.ts @@ -1,6 +1,3 @@ -import { createInstance } from '@datapunt/matomo-tracker-react'; -import { useMemo } from 'react'; - import { trackingCookieId } from '../../../cookieConsents/cookieContentSource'; type TrackingEvent = string[]; @@ -58,36 +55,3 @@ export function handleCookieConsentChange( disableTrackingCookies(); } } - -export function useTrackingInstance(): - | ReturnType - | undefined { - return useMemo(() => { - if (import.meta.env.REACT_APP_MATOMO_ENABLED !== 'true') { - return undefined; - } - - // matomo.js is not loaded, if window._paq.length > 0 - // so clearing it before creating the instance. - // events are pushed back below - const existingTrackingEvents = Array.isArray(window._paq) - ? [...window._paq] - : []; - - const hadExistingEvents = existingTrackingEvents.length; - if (hadExistingEvents) { - window._paq.length = 0; - } - - const matomo = createInstance({ - urlBase: 'https://analytics.hel.ninja/', - siteId: 60, - }); - - if (hadExistingEvents) { - existingTrackingEvents.forEach(pushEvent); - } - - return matomo; - }, []); -} diff --git a/src/common/matomo/MatomoTracker.ts b/src/common/matomo/MatomoTracker.ts new file mode 100644 index 000000000..9d16c096e --- /dev/null +++ b/src/common/matomo/MatomoTracker.ts @@ -0,0 +1,152 @@ +/* eslint-disable no-underscore-dangle */ +import { TRACK_TYPES } from './constants'; + +export type MatomoTrackerOptions = { + urlBase: string; + siteId: string; + srcUrl: string; + trackerUrl?: string; + enabled: boolean; + linkTracking?: boolean; + configurations?: { + [key: string]: string | string[] | boolean | undefined; + }; +}; + +export type CustomDimension = { + id: number; + value: string; +}; + +export type TrackPageViewParams = { + documentTitle?: string; + href?: string | Location; + customDimensions?: boolean | CustomDimension[]; +}; + +export interface TrackEventParams extends TrackPageViewParams { + category: string; + action: string; + name?: string; + value?: number; +} + +export type TrackParams = { + data: unknown[]; +} & TrackPageViewParams; + +class MatomoTracker { + constructor(userOptions: MatomoTrackerOptions) { + if (!userOptions.urlBase) { + throw new Error('Matomo urlBase is required'); + } + + if (!userOptions.siteId) { + throw new Error('Matomo siteId is required.'); + } + + this.initialize(userOptions); + } + + enableLinkTracking(active: boolean): void { + this.pushInstruction('enableLinkTracking', active); + } + + pushInstruction(name: string, ...args: unknown[]): this { + if (typeof window !== 'undefined') { + window._paq.push([name, ...args]); + } + + return this; + } + + trackPageView(params?: TrackPageViewParams): void { + this.track({ data: [TRACK_TYPES.TRACK_VIEW], ...params }); + } + + // Tracks events + // https://matomo.org/docs/event-tracking/#tracking-events + trackEvent({ + category, + action, + name, + value, + ...otherParams + }: TrackEventParams): void { + if (category && action) { + this.track({ + data: [TRACK_TYPES.TRACK_EVENT, category, action, name, value], + ...otherParams, + }); + } else { + throw new Error(`Error: category and action are required.`); + } + } + + track({ + data = [], + documentTitle = document.title, + href, + }: TrackParams): void { + if (data.length) { + this.pushInstruction('setCustomUrl', href ?? window.location.href); + this.pushInstruction('setDocumentTitle', documentTitle); + + this.pushInstruction(...(data as [string, ...unknown[]])); + } + } + + private initialize({ + urlBase, + siteId, + srcUrl, + trackerUrl = 'matomo.php', + enabled = true, + linkTracking = true, + configurations = {}, + }: MatomoTrackerOptions) { + if (typeof window === 'undefined') { + return; + } + + window._paq = window._paq || []; + + if (window._paq.length !== 0) { + return; + } + + if (!enabled) { + return; + } + + this.pushInstruction('setTrackerUrl', `${urlBase}${trackerUrl}`); + this.pushInstruction('setSiteId', siteId); + + Object.entries(configurations).forEach(([name, instructions]) => { + if (instructions instanceof Array) { + this.pushInstruction(name, ...instructions); + } else if (instructions === undefined) { + this.pushInstruction(name); + } else { + this.pushInstruction(name, instructions); + } + }); + + this.enableLinkTracking(linkTracking); + + const doc = document; + const scriptElement = doc.createElement('script'); + const scripts = doc.getElementsByTagName('script')[0]; + + scriptElement.type = 'text/javascript'; + scriptElement.async = true; + scriptElement.defer = true; + scriptElement.src = `${urlBase}${srcUrl}`; + + if (scripts?.parentNode) { + scripts?.parentNode.insertBefore(scriptElement, scripts); + } + } +} + +export default MatomoTracker; diff --git a/src/common/matomo/__tests__/MatomoTracker.test.ts b/src/common/matomo/__tests__/MatomoTracker.test.ts new file mode 100644 index 000000000..a0c502d61 --- /dev/null +++ b/src/common/matomo/__tests__/MatomoTracker.test.ts @@ -0,0 +1,45 @@ +/* eslint-disable no-underscore-dangle */ +import MatomoTracker, { MatomoTrackerOptions } from '../MatomoTracker'; + +describe('MatomoTracker', () => { + it('should initialise window._paq', () => { + window._paq = []; + + const intance = new MatomoTracker({ + urlBase: 'https://www.test.fi/', + siteId: 'test123', + srcUrl: 'test.js', + enabled: true, + configurations: { + foo: 'bar', + testArray: ['testArrayItem1', 'testArrayItem2'], + testNoValue: undefined, + }, + }); + + expect(intance).toBeTruthy(); + expect(window._paq).toEqual([ + ['setTrackerUrl', 'https://www.test.fi/matomo.php'], + ['setSiteId', 'test123'], + ['foo', 'bar'], + ['testArray', 'testArrayItem1', 'testArrayItem2'], + ['testNoValue'], + ['enableLinkTracking', true], + ]); + }); + + it('should throw error if urlBase missing', () => { + expect( + () => new MatomoTracker({ siteId: 'test123' } as MatomoTrackerOptions) + ).toThrowError(); + }); + + it('should throw error if siteId missing', () => { + expect( + () => + new MatomoTracker({ + urlBase: 'https://www.test.fi', + } as MatomoTrackerOptions) + ).toThrowError(); + }); +}); diff --git a/src/common/matomo/__tests__/useMatomo.test.tsx b/src/common/matomo/__tests__/useMatomo.test.tsx new file mode 100644 index 000000000..a331139be --- /dev/null +++ b/src/common/matomo/__tests__/useMatomo.test.tsx @@ -0,0 +1,98 @@ +/* eslint-disable sonarjs/no-duplicate-string */ +import React, { useEffect } from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import * as MatomoTracker from '../MatomoTracker'; +import { MatomoProvider } from '../matomo-context'; +import useMatomo from '../hooks/useMatomo'; + +describe('useMatomo', () => { + const MockedComponent = () => { + const { trackPageView, trackEvent } = useMatomo(); + + useEffect(() => { + trackPageView({ href: 'https://www.hel.fi' }); + }, [trackPageView]); + + const mockTrackEvent = () => + trackEvent({ category: 'action', action: 'Test click track event' }); + + return ( +
+ MockedComponent +
+ ); + }; + + it('should trackPageView', () => { + const trackPageViewMock = vi.fn(); + + vi.spyOn(MatomoTracker, 'default').mockImplementation( + () => + (({ + trackPageView: trackPageViewMock, + } as unknown) as MatomoTracker.default) + ); + + // eslint-disable-next-line new-cap + const instance = new MatomoTracker.default({ + urlBase: 'https://www.hel.fi', + siteId: 'test123', + srcUrl: 'test.js', + enabled: true, + }); + + const MockProvider = () => ( + + + + ); + + expect(MatomoTracker.default).toHaveBeenCalled(); + + render(); + + expect(trackPageViewMock).toHaveBeenCalledWith({ + href: 'https://www.hel.fi', + }); + }); + + it('should trackEvent', async () => { + const trackPageViewMock = vi.fn(); + const trackEventMock = vi.fn(); + + vi.spyOn(MatomoTracker, 'default').mockImplementation( + () => + (({ + trackPageView: trackPageViewMock, + trackEvent: trackEventMock, + } as unknown) as MatomoTracker.default) + ); + + // eslint-disable-next-line new-cap + const instance = new MatomoTracker.default({ + urlBase: 'https://www.hel.fi', + siteId: 'test123', + srcUrl: 'test.js', + enabled: true, + }); + + const MockProvider = () => ( + + + + ); + + render(); + + const user = userEvent.setup(); + + await user.click(await screen.findByRole('button')); + + expect(trackEventMock).toHaveBeenCalledWith({ + category: 'action', + action: 'Test click track event', + }); + }); +}); diff --git a/src/common/matomo/constants.ts b/src/common/matomo/constants.ts new file mode 100644 index 000000000..93b7ba388 --- /dev/null +++ b/src/common/matomo/constants.ts @@ -0,0 +1,5 @@ +// eslint-disable-next-line import/prefer-default-export, @typescript-eslint/naming-convention +export const TRACK_TYPES = { + TRACK_EVENT: 'trackEvent', + TRACK_VIEW: 'trackPageView', +}; diff --git a/src/common/matomo/hooks/useMatomo.ts b/src/common/matomo/hooks/useMatomo.ts new file mode 100644 index 000000000..e545cb973 --- /dev/null +++ b/src/common/matomo/hooks/useMatomo.ts @@ -0,0 +1,23 @@ +import { useCallback, useContext } from 'react'; + +import MatomoContext from '../matomo-context'; +import { TrackEventParams, TrackPageViewParams } from '../MatomoTracker'; +import { MatomoTrackerInstance } from '../types'; + +function useMatomo(): MatomoTrackerInstance { + const instance = useContext(MatomoContext); + + const trackPageView = useCallback( + (params?: TrackPageViewParams) => instance?.trackPageView(params), + [instance] + ); + + const trackEvent = useCallback( + (params: TrackEventParams) => instance?.trackEvent(params), + [instance] + ); + + return { trackPageView, trackEvent }; +} + +export default useMatomo; diff --git a/src/common/matomo/matomo-context.tsx b/src/common/matomo/matomo-context.tsx new file mode 100644 index 000000000..06a887f04 --- /dev/null +++ b/src/common/matomo/matomo-context.tsx @@ -0,0 +1,21 @@ +import React, { createContext } from 'react'; + +import { MatomoTrackerInstance } from './types'; + +export type MatomoProviderProps = { + children?: React.ReactNode; + value: MatomoTrackerInstance; +}; + +const MatomoContext = createContext(null); + +export const MatomoProvider: React.FC = ({ + children, + value, +}) => { + const Context = MatomoContext; + + return {children}; +}; + +export default MatomoContext; diff --git a/src/common/matomo/types.ts b/src/common/matomo/types.ts new file mode 100644 index 000000000..bb7ed92e6 --- /dev/null +++ b/src/common/matomo/types.ts @@ -0,0 +1,6 @@ +import MatomoTracker from './MatomoTracker'; + +export type MatomoTrackerInstance = { + trackEvent: MatomoTracker['trackEvent']; + trackPageView: MatomoTracker['trackPageView']; +}; diff --git a/src/common/pageLayout/PageLayout.tsx b/src/common/pageLayout/PageLayout.tsx index 32190ccf7..3f6e18667 100644 --- a/src/common/pageLayout/PageLayout.tsx +++ b/src/common/pageLayout/PageLayout.tsx @@ -1,6 +1,5 @@ import React, { useEffect } from 'react'; import classNames from 'classnames'; -import { useMatomo } from '@datapunt/matomo-tracker-react'; import { useTranslation } from 'react-i18next'; import { MAIN_CONTENT_ID } from '../constants'; @@ -9,6 +8,7 @@ import Footer from '../footer/Footer'; import styles from './PageLayout.module.css'; import PageMeta from '../pageMeta/PageMeta'; import { usePageLoadFocusSetter } from '../../profile/hooks/usePageLoadFocusSetter'; +import useMatomo from '../matomo/hooks/useMatomo'; type Props = React.PropsWithChildren<{ className?: string; diff --git a/src/index.tsx b/src/index.tsx index 6918b5873..29a033005 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -10,6 +10,7 @@ declare global { interface Window { // eslint-disable-next-line @typescript-eslint/no-explicit-any _env_: any; + _paq: [string, ...unknown[]][]; } } diff --git a/src/profile/components/createProfile/CreateProfile.tsx b/src/profile/components/createProfile/CreateProfile.tsx index 8c5706bbc..528472b59 100644 --- a/src/profile/components/createProfile/CreateProfile.tsx +++ b/src/profile/components/createProfile/CreateProfile.tsx @@ -3,7 +3,6 @@ import { User } from 'oidc-client-ts'; import { useTranslation } from 'react-i18next'; import { useMutation } from '@apollo/client'; import * as Sentry from '@sentry/react'; -import { useMatomo } from '@datapunt/matomo-tracker-react'; import classNames from 'classnames'; import CreateProfileForm, { @@ -20,6 +19,7 @@ import useToast from '../../../toast/useToast'; import Explanation from '../../../common/explanation/Explanation'; import commonContentStyles from '../../../common/cssHelpers/content.module.css'; import { CREATE_PROFILE } from '../../graphql/CreateMyProfileMutation'; +import useMatomo from '../../../common/matomo/hooks/useMatomo'; type Props = { tunnistamoUser: User; diff --git a/src/profile/components/deleteProfile/DeleteProfile.tsx b/src/profile/components/deleteProfile/DeleteProfile.tsx index 11a587cb1..2cf57c57d 100644 --- a/src/profile/components/deleteProfile/DeleteProfile.tsx +++ b/src/profile/components/deleteProfile/DeleteProfile.tsx @@ -4,7 +4,6 @@ import { useTranslation } from 'react-i18next'; import * as Sentry from '@sentry/react'; import { Button, Notification } from 'hds-react'; import { useHistory } from 'react-router'; -import { useMatomo } from '@datapunt/matomo-tracker-react'; import ConfirmationModal from '../modals/confirmationModal/ConfirmationModal'; import { @@ -29,6 +28,7 @@ import { getDeleteProfileResultOrError } from '../../../gdprApi/actions/deletePr import reportErrorsToSentry from '../../../common/sentry/reportErrorsToSentry'; import { SERVICE_CONNECTIONS } from '../../graphql/ServiceConnectionsQuery'; import { QueueController } from '../../../common/actionQueue/actionQueue'; +import useMatomo from '../../../common/matomo/hooks/useMatomo'; function DeleteProfile(): React.ReactElement { const [showConfirmationModal, setShowConfirmationModal] = useState(false); diff --git a/yarn.lock b/yarn.lock index a442a334b..519209ec2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1367,18 +1367,6 @@ resolved "https://registry.yarnpkg.com/@csstools/normalize.css/-/normalize.css-12.1.1.tgz#f0ad221b7280f3fc814689786fd9ee092776ef8f" integrity sha512-YAYeJ+Xqh7fUou1d1j9XHl44BmsuThiTr4iNrgCQ3J27IbhXsxXDGZ1cXv8Qvs99d4rBbLiSKy3+WZiet32PcQ== -"@datapunt/matomo-tracker-js@^0.5.1": - version "0.5.1" - resolved "https://registry.yarnpkg.com/@datapunt/matomo-tracker-js/-/matomo-tracker-js-0.5.1.tgz#92a746ffa421f91b3a59fefce707d45ca22be96b" - integrity sha512-9/MW9vt/BA5Db7tO6LqCeQKtuvBNjyq51faF3AzUmPMlYsJCnASIxcut3VqJKiribhUoey7aYbPIYuj9x4DLPA== - -"@datapunt/matomo-tracker-react@^0.5.1": - version "0.5.1" - resolved "https://registry.yarnpkg.com/@datapunt/matomo-tracker-react/-/matomo-tracker-react-0.5.1.tgz#d7a4e62b23610eab2b7513d4df41d500291aaa53" - integrity sha512-lrNYM9hFL6XK0VAdtMb7MwZrLWhaAconx4c7gOGAMvoWuoVm+ZZIYFuKtfYdYMeBf0avxWtmKRwjZEg7T8jV2A== - dependencies: - "@datapunt/matomo-tracker-js" "^0.5.1" - "@digitak/grubber@^3.1.4": version "3.1.4" resolved "https://registry.yarnpkg.com/@digitak/grubber/-/grubber-3.1.4.tgz#f29280bc8d205995b6bf4d73344f08b01f21fc65"