mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
feat: add GDPR-compliant cookie consent banner (#8022)
Co-authored-by: Roo Code <roomote@roocode.com> Co-authored-by: Bruno Bergher <me@brunobergher.com> Co-authored-by: Matt Rubens <mrubens@users.noreply.github.com>
This commit is contained in:
parent
6d8de53c8f
commit
2819120216
10 changed files with 347 additions and 49 deletions
|
|
@ -28,11 +28,13 @@
|
|||
"next-themes": "^0.4.6",
|
||||
"posthog-js": "^1.248.1",
|
||||
"react": "^18.3.1",
|
||||
"react-cookie-consent": "^9.0.0",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-icons": "^5.5.0",
|
||||
"recharts": "^2.15.3",
|
||||
"tailwind-merge": "^3.3.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"tldts": "^6.1.86",
|
||||
"zod": "^3.25.61"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import React from "react"
|
||||
import type { Metadata } from "next"
|
||||
import { Inter } from "next/font/google"
|
||||
import Script from "next/script"
|
||||
import { SEO } from "@/lib/seo"
|
||||
import { CookieConsentWrapper } from "@/components/CookieConsentWrapper"
|
||||
|
||||
import { Providers } from "@/components/providers"
|
||||
|
||||
|
|
@ -93,22 +93,13 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
|||
/>
|
||||
</head>
|
||||
<body className={inter.className}>
|
||||
{/* Google tag (gtag.js) */}
|
||||
<Script src="https://www.googletagmanager.com/gtag/js?id=AW-17391954825" strategy="afterInteractive" />
|
||||
<Script id="google-analytics" strategy="afterInteractive">
|
||||
{`
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
function gtag(){dataLayer.push(arguments);}
|
||||
gtag('js', new Date());
|
||||
gtag('config', 'AW-17391954825');
|
||||
`}
|
||||
</Script>
|
||||
<div itemScope itemType="https://schema.org/WebSite">
|
||||
<link itemProp="url" href={SEO.url} />
|
||||
<meta itemProp="name" content={SEO.name} />
|
||||
</div>
|
||||
<Providers>
|
||||
<Shell>{children}</Shell>
|
||||
<CookieConsentWrapper />
|
||||
</Providers>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
111
apps/web-roo-code/src/components/CookieConsentWrapper.tsx
Normal file
111
apps/web-roo-code/src/components/CookieConsentWrapper.tsx
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
"use client"
|
||||
|
||||
import React, { useState, useEffect } from "react"
|
||||
import ReactCookieConsent from "react-cookie-consent"
|
||||
import { Cookie } from "lucide-react"
|
||||
import { getDomain } from "tldts"
|
||||
import { CONSENT_COOKIE_NAME } from "@roo-code/types"
|
||||
import { dispatchConsentEvent } from "@/lib/analytics/consent-manager"
|
||||
|
||||
/**
|
||||
* GDPR-compliant cookie consent banner component
|
||||
* Handles both the UI and consent event dispatching
|
||||
*/
|
||||
export function CookieConsentWrapper() {
|
||||
const [cookieDomain, setCookieDomain] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
// Get the appropriate domain using tldts
|
||||
if (typeof window !== "undefined") {
|
||||
const domain = getDomain(window.location.hostname)
|
||||
setCookieDomain(domain)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleAccept = () => {
|
||||
dispatchConsentEvent(true)
|
||||
}
|
||||
|
||||
const handleDecline = () => {
|
||||
dispatchConsentEvent(false)
|
||||
}
|
||||
|
||||
const extraCookieOptions = cookieDomain
|
||||
? {
|
||||
domain: cookieDomain,
|
||||
}
|
||||
: {}
|
||||
|
||||
const containerClasses = `
|
||||
fixed bottom-2 left-2 right-2 z-[999]
|
||||
bg-black/95 dark:bg-white/95
|
||||
text-white dark:text-black
|
||||
border-t-neutral-800 dark:border-t-gray-200
|
||||
backdrop-blur-xl
|
||||
border-t
|
||||
font-semibold
|
||||
rounded-t-lg
|
||||
px-4 py-4 md:px-8 md:py-4
|
||||
flex flex-wrap items-center justify-between gap-4
|
||||
text-sm font-sans
|
||||
`.trim()
|
||||
|
||||
const buttonWrapperClasses = `
|
||||
flex
|
||||
flex-row-reverse
|
||||
items-center
|
||||
gap-2
|
||||
`.trim()
|
||||
|
||||
const acceptButtonClasses = `
|
||||
bg-white text-black border-neutral-800
|
||||
dark:bg-black dark:text-white dark:border-gray-200
|
||||
hover:opacity-50
|
||||
transition-opacity
|
||||
rounded-md
|
||||
px-4 py-2 mr-2
|
||||
text-sm font-bold
|
||||
cursor-pointer
|
||||
focus:outline-none focus:ring-2 focus:ring-offset-2
|
||||
`.trim()
|
||||
|
||||
const declineButtonClasses = `
|
||||
dark:bg-white dark:text-black dark:border-gray-200
|
||||
bg-black text-white border-neutral-800
|
||||
hover:opacity-50
|
||||
border border-border
|
||||
transition-opacity
|
||||
rounded-md
|
||||
px-4 py-2
|
||||
text-sm font-bold
|
||||
cursor-pointer
|
||||
focus:outline-none focus:ring-2 focus:ring-offset-2
|
||||
`.trim()
|
||||
|
||||
return (
|
||||
<div role="banner" aria-label="Cookie consent banner" aria-live="polite">
|
||||
<ReactCookieConsent
|
||||
location="bottom"
|
||||
buttonText="Accept"
|
||||
declineButtonText="Decline"
|
||||
cookieName={CONSENT_COOKIE_NAME}
|
||||
expires={365}
|
||||
enableDeclineButton={true}
|
||||
onAccept={handleAccept}
|
||||
onDecline={handleDecline}
|
||||
containerClasses={containerClasses}
|
||||
buttonClasses={acceptButtonClasses}
|
||||
buttonWrapperClasses={buttonWrapperClasses}
|
||||
declineButtonClasses={declineButtonClasses}
|
||||
extraCookieOptions={extraCookieOptions}
|
||||
disableStyles={true}
|
||||
ariaAcceptLabel={`Accept`}
|
||||
ariaDeclineLabel={`Decline`}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Cookie className="size-5 hidden md:block" />
|
||||
<span>Like most of the internet, we use cookies. Are you OK with that?</span>
|
||||
</div>
|
||||
</ReactCookieConsent>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import Script from "next/script"
|
||||
import { hasConsent, onConsentChange } from "@/lib/analytics/consent-manager"
|
||||
|
||||
// Google Tag Manager ID
|
||||
const GTM_ID = "AW-17391954825"
|
||||
|
||||
/**
|
||||
* Google Analytics Provider
|
||||
* Only loads Google Tag Manager after user gives consent
|
||||
*/
|
||||
export function GoogleAnalyticsProvider({ children }: { children: React.ReactNode }) {
|
||||
const [shouldLoad, setShouldLoad] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
// Check initial consent status
|
||||
if (hasConsent()) {
|
||||
setShouldLoad(true)
|
||||
initializeGoogleAnalytics()
|
||||
}
|
||||
|
||||
// Listen for consent changes
|
||||
const unsubscribe = onConsentChange((consented) => {
|
||||
if (consented && !shouldLoad) {
|
||||
setShouldLoad(true)
|
||||
initializeGoogleAnalytics()
|
||||
}
|
||||
})
|
||||
|
||||
return unsubscribe
|
||||
}, [shouldLoad])
|
||||
|
||||
const initializeGoogleAnalytics = () => {
|
||||
// Initialize the dataLayer and gtag function
|
||||
if (typeof window !== "undefined") {
|
||||
window.dataLayer = window.dataLayer || []
|
||||
window.gtag = function (...args: GtagArgs) {
|
||||
window.dataLayer.push(args)
|
||||
}
|
||||
window.gtag("js", new Date())
|
||||
window.gtag("config", GTM_ID)
|
||||
}
|
||||
}
|
||||
|
||||
// Only render Google Analytics scripts if consent is given
|
||||
if (!shouldLoad) {
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Google tag (gtag.js) - Only loads after consent */}
|
||||
<Script
|
||||
src={`https://www.googletagmanager.com/gtag/js?id=${GTM_ID}`}
|
||||
strategy="afterInteractive"
|
||||
onLoad={() => {
|
||||
console.log("Google Analytics loaded with consent")
|
||||
}}
|
||||
/>
|
||||
<Script id="google-analytics-init" strategy="afterInteractive">
|
||||
{`
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
function gtag(){dataLayer.push(arguments);}
|
||||
gtag('js', new Date());
|
||||
gtag('config', '${GTM_ID}');
|
||||
`}
|
||||
</Script>
|
||||
{children}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// Type definitions for Google Analytics
|
||||
type GtagArgs = ["js", Date] | ["config", string, GtagConfig?] | ["event", string, GtagEventParameters?]
|
||||
|
||||
interface GtagConfig {
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
interface GtagEventParameters {
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
// Declare global types for TypeScript
|
||||
declare global {
|
||||
interface Window {
|
||||
dataLayer: GtagArgs[]
|
||||
gtag: (...args: GtagArgs) => void
|
||||
}
|
||||
}
|
||||
|
|
@ -3,16 +3,15 @@
|
|||
import { usePathname, useSearchParams } from "next/navigation"
|
||||
import posthog from "posthog-js"
|
||||
import { PostHogProvider as OriginalPostHogProvider } from "posthog-js/react"
|
||||
import { useEffect, Suspense } from "react"
|
||||
import { useEffect, Suspense, useState } from "react"
|
||||
import { hasConsent, onConsentChange } from "@/lib/analytics/consent-manager"
|
||||
|
||||
// Create a separate component for analytics tracking that uses useSearchParams
|
||||
function PageViewTracker() {
|
||||
const pathname = usePathname()
|
||||
const searchParams = useSearchParams()
|
||||
|
||||
// Track page views
|
||||
useEffect(() => {
|
||||
// Only track page views if PostHog is properly initialized
|
||||
if (pathname && process.env.NEXT_PUBLIC_POSTHOG_KEY) {
|
||||
let url = window.location.origin + pathname
|
||||
if (searchParams && searchParams.toString()) {
|
||||
|
|
@ -29,8 +28,10 @@ function PageViewTracker() {
|
|||
}
|
||||
|
||||
export function PostHogProvider({ children }: { children: React.ReactNode }) {
|
||||
const [isInitialized, setIsInitialized] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
// Initialize PostHog only on the client side
|
||||
// Initialize PostHog only on the client side AND when consent is given
|
||||
if (typeof window !== "undefined") {
|
||||
const posthogKey = process.env.NEXT_PUBLIC_POSTHOG_KEY
|
||||
const posthogHost = process.env.NEXT_PUBLIC_POSTHOG_HOST
|
||||
|
|
@ -51,27 +52,48 @@ export function PostHogProvider({ children }: { children: React.ReactNode }) {
|
|||
)
|
||||
}
|
||||
|
||||
posthog.init(posthogKey, {
|
||||
api_host: posthogHost || "https://us.i.posthog.com",
|
||||
capture_pageview: false, // We'll handle this manually
|
||||
loaded: (posthogInstance) => {
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
// Log to console in development
|
||||
posthogInstance.debug()
|
||||
}
|
||||
},
|
||||
respect_dnt: true, // Respect Do Not Track
|
||||
const initializePosthog = () => {
|
||||
if (!isInitialized) {
|
||||
posthog.init(posthogKey, {
|
||||
api_host: posthogHost || "https://us.i.posthog.com",
|
||||
capture_pageview: false,
|
||||
loaded: (posthogInstance) => {
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
posthogInstance.debug()
|
||||
}
|
||||
},
|
||||
respect_dnt: true, // Respect Do Not Track
|
||||
})
|
||||
setIsInitialized(true)
|
||||
}
|
||||
}
|
||||
|
||||
// Check initial consent status
|
||||
if (hasConsent()) {
|
||||
initializePosthog()
|
||||
}
|
||||
|
||||
// Listen for consent changes
|
||||
const unsubscribe = onConsentChange((consented) => {
|
||||
if (consented && !isInitialized) {
|
||||
initializePosthog()
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
unsubscribe()
|
||||
}
|
||||
}
|
||||
}, [isInitialized])
|
||||
|
||||
// No explicit cleanup needed for posthog-js v1.231.0
|
||||
}, [])
|
||||
|
||||
// Only provide PostHog context if it's initialized
|
||||
return (
|
||||
<OriginalPostHogProvider client={posthog}>
|
||||
<Suspense fallback={null}>
|
||||
<PageViewTracker />
|
||||
</Suspense>
|
||||
{isInitialized && (
|
||||
<Suspense fallback={null}>
|
||||
<PageViewTracker />
|
||||
</Suspense>
|
||||
)}
|
||||
{children}
|
||||
</OriginalPostHogProvider>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -4,17 +4,20 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
|
|||
import { ThemeProvider } from "next-themes"
|
||||
|
||||
import { PostHogProvider } from "./posthog-provider"
|
||||
import { GoogleAnalyticsProvider } from "./google-analytics-provider"
|
||||
|
||||
const queryClient = new QueryClient()
|
||||
|
||||
export const Providers = ({ children }: { children: React.ReactNode }) => {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<PostHogProvider>
|
||||
<ThemeProvider attribute="class" defaultTheme="dark" enableSystem={false}>
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
</PostHogProvider>
|
||||
<GoogleAnalyticsProvider>
|
||||
<PostHogProvider>
|
||||
<ThemeProvider attribute="class" defaultTheme="dark" enableSystem={false}>
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
</PostHogProvider>
|
||||
</GoogleAnalyticsProvider>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
47
apps/web-roo-code/src/lib/analytics/consent-manager.ts
Normal file
47
apps/web-roo-code/src/lib/analytics/consent-manager.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
/**
|
||||
* Simple consent event system
|
||||
* Dispatches events when cookie consent changes
|
||||
*/
|
||||
|
||||
import { getCookieConsentValue } from "react-cookie-consent"
|
||||
import { CONSENT_COOKIE_NAME } from "@roo-code/types"
|
||||
|
||||
export const CONSENT_EVENT = "cookieConsentChanged"
|
||||
|
||||
/**
|
||||
* Check if user has given consent for analytics cookies
|
||||
* Uses react-cookie-consent's built-in function
|
||||
*/
|
||||
export function hasConsent(): boolean {
|
||||
if (typeof window === "undefined") return false
|
||||
return getCookieConsentValue(CONSENT_COOKIE_NAME) === "true"
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch a consent change event
|
||||
*/
|
||||
export function dispatchConsentEvent(consented: boolean): void {
|
||||
if (typeof window !== "undefined") {
|
||||
const event = new CustomEvent(CONSENT_EVENT, {
|
||||
detail: { consented },
|
||||
})
|
||||
window.dispatchEvent(event)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Listen for consent changes
|
||||
*/
|
||||
export function onConsentChange(callback: (consented: boolean) => void): () => void {
|
||||
if (typeof window === "undefined") {
|
||||
return () => {}
|
||||
}
|
||||
|
||||
const handler = (event: Event) => {
|
||||
const customEvent = event as CustomEvent<{ consented: boolean }>
|
||||
callback(customEvent.detail.consented)
|
||||
}
|
||||
|
||||
window.addEventListener(CONSENT_EVENT, handler)
|
||||
return () => window.removeEventListener(CONSENT_EVENT, handler)
|
||||
}
|
||||
22
packages/types/src/cookie-consent.ts
Normal file
22
packages/types/src/cookie-consent.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
/**
|
||||
* Cookie consent constants and types
|
||||
* Shared across all Roo Code repositories
|
||||
*/
|
||||
|
||||
/**
|
||||
* The name of the cookie that stores user's consent preference
|
||||
* Used by react-cookie-consent library
|
||||
*/
|
||||
export const CONSENT_COOKIE_NAME = "roo-code-cookie-consent"
|
||||
|
||||
/**
|
||||
* Possible values for the consent cookie
|
||||
*/
|
||||
export type ConsentCookieValue = "true" | "false"
|
||||
|
||||
/**
|
||||
* Cookie consent event names for communication between components
|
||||
*/
|
||||
export const COOKIE_CONSENT_EVENTS = {
|
||||
CHANGED: "cookieConsentChanged",
|
||||
} as const
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
export * from "./api.js"
|
||||
export * from "./cloud.js"
|
||||
export * from "./codebase-index.js"
|
||||
export * from "./cookie-consent.js"
|
||||
export * from "./events.js"
|
||||
export * from "./experiment.js"
|
||||
export * from "./followup.js"
|
||||
|
|
|
|||
33
pnpm-lock.yaml
generated
33
pnpm-lock.yaml
generated
|
|
@ -302,6 +302,9 @@ importers:
|
|||
react:
|
||||
specifier: ^18.3.1
|
||||
version: 18.3.1
|
||||
react-cookie-consent:
|
||||
specifier: ^9.0.0
|
||||
version: 9.0.0(react@18.3.1)
|
||||
react-dom:
|
||||
specifier: ^18.3.1
|
||||
version: 18.3.1(react@18.3.1)
|
||||
|
|
@ -317,6 +320,9 @@ importers:
|
|||
tailwindcss-animate:
|
||||
specifier: ^1.0.7
|
||||
version: 1.0.7(tailwindcss@3.4.17)
|
||||
tldts:
|
||||
specifier: ^6.1.86
|
||||
version: 6.1.86
|
||||
zod:
|
||||
specifier: ^3.25.61
|
||||
version: 3.25.61
|
||||
|
|
@ -8171,10 +8177,6 @@ packages:
|
|||
resolution: {integrity: sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
|
||||
postcss@8.5.3:
|
||||
resolution: {integrity: sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
|
||||
postcss@8.5.4:
|
||||
resolution: {integrity: sha512-QSa9EBe+uwlGTFmHsPKokv3B/oEMQZxfqW0QqNCyhpa6mB1afzulwn8hihglqAb2pOw+BJgNlmXQ8la2VeHB7w==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
|
|
@ -8341,6 +8343,12 @@ packages:
|
|||
resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==}
|
||||
hasBin: true
|
||||
|
||||
react-cookie-consent@9.0.0:
|
||||
resolution: {integrity: sha512-Blyj+m+Zz7SFHYqT18p16EANgnSg2sIyU6Yp3vk83AnOnSW7qnehPkUe4+8+qxztJrNmCH5GP+VHsWzAKVOoZA==}
|
||||
engines: {node: '>=10'}
|
||||
peerDependencies:
|
||||
react: '>=16'
|
||||
|
||||
react-dom@18.3.1:
|
||||
resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==}
|
||||
peerDependencies:
|
||||
|
|
@ -13269,7 +13277,7 @@ snapshots:
|
|||
'@alloc/quick-lru': 5.2.0
|
||||
'@tailwindcss/node': 4.1.8
|
||||
'@tailwindcss/oxide': 4.1.8
|
||||
postcss: 8.5.3
|
||||
postcss: 8.5.4
|
||||
tailwindcss: 4.1.8
|
||||
|
||||
'@tailwindcss/typography@0.5.16(tailwindcss@3.4.17)':
|
||||
|
|
@ -13834,7 +13842,7 @@ snapshots:
|
|||
sirv: 3.0.1
|
||||
tinyglobby: 0.2.14
|
||||
tinyrainbow: 2.0.0
|
||||
vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.2.1)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@26.1.0)(lightningcss@1.30.1)(tsx@4.19.4)(yaml@2.8.0)
|
||||
vitest: 3.2.4(@types/debug@4.1.12)(@types/node@20.17.57)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@26.1.0)(lightningcss@1.30.1)(tsx@4.19.4)(yaml@2.8.0)
|
||||
|
||||
'@vitest/utils@3.2.4':
|
||||
dependencies:
|
||||
|
|
@ -18353,12 +18361,6 @@ snapshots:
|
|||
picocolors: 1.1.1
|
||||
source-map-js: 1.2.1
|
||||
|
||||
postcss@8.5.3:
|
||||
dependencies:
|
||||
nanoid: 3.3.11
|
||||
picocolors: 1.1.1
|
||||
source-map-js: 1.2.1
|
||||
|
||||
postcss@8.5.4:
|
||||
dependencies:
|
||||
nanoid: 3.3.11
|
||||
|
|
@ -18562,6 +18564,11 @@ snapshots:
|
|||
strip-json-comments: 2.0.1
|
||||
optional: true
|
||||
|
||||
react-cookie-consent@9.0.0(react@18.3.1):
|
||||
dependencies:
|
||||
js-cookie: 2.2.1
|
||||
react: 18.3.1
|
||||
|
||||
react-dom@18.3.1(react@18.3.1):
|
||||
dependencies:
|
||||
loose-envify: 1.4.0
|
||||
|
|
@ -19792,7 +19799,7 @@ snapshots:
|
|||
source-map: 0.8.0-beta.0
|
||||
sucrase: 3.35.0
|
||||
tinyexec: 0.3.2
|
||||
tinyglobby: 0.2.13
|
||||
tinyglobby: 0.2.14
|
||||
tree-kill: 1.2.2
|
||||
optionalDependencies:
|
||||
postcss: 8.5.4
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue