Compare commits
45 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2faba91ddb | |||
| fe8e2ed23b | |||
| 95369ff2c9 | |||
| 829525f824 | |||
| 9ad04d325b | |||
| f218c30825 | |||
| fdee7c615c | |||
| b34754b363 | |||
| be74af40f9 | |||
| baebe28a3e | |||
| 3b9f395a7d | |||
| 95367203bf | |||
| 6fb1463a32 | |||
| b727453509 | |||
| 446fd90bea | |||
| 90c2b7f9bf | |||
| c431dafe08 | |||
| 3786c7b6d1 | |||
| 0416ff7ec2 | |||
| 26cbef5a42 | |||
| e98f29fddb | |||
| 93e6dd6db7 | |||
| 7879fabbad | |||
| a861dc1736 | |||
| f75c5ca1aa | |||
| e8e00e4e04 | |||
| 15adc77d23 | |||
| c73a4b6a96 | |||
| d96840b2f5 | |||
| e4886af90a | |||
| c69e99db3f | |||
| 55113d654d | |||
| 50d2170844 | |||
| ee53bb0a5e | |||
| 5216d75209 | |||
| d0623fe173 | |||
| e0d0618325 | |||
| 563bd4e108 | |||
| 107803bd8f | |||
| 06fd7e4eac | |||
| eba8788a76 | |||
| 8ace11f3cb | |||
| 328acbbfe7 | |||
| f99c1aa7b5 | |||
| 5217279070 |
@@ -7,6 +7,7 @@ on:
|
|||||||
default: wxt
|
default: wxt
|
||||||
type: choice
|
type: choice
|
||||||
options:
|
options:
|
||||||
|
- analytics
|
||||||
- auto-icons
|
- auto-icons
|
||||||
- i18n
|
- i18n
|
||||||
- module-react
|
- module-react
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ on:
|
|||||||
default: wxt
|
default: wxt
|
||||||
type: choice
|
type: choice
|
||||||
options:
|
options:
|
||||||
|
- analytics
|
||||||
- auto-icons
|
- auto-icons
|
||||||
- i18n
|
- i18n
|
||||||
- module-react
|
- module-react
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 132 KiB After Width: | Height: | Size: 131 KiB |
@@ -0,0 +1,174 @@
|
|||||||
|
# WXT Analytics
|
||||||
|
|
||||||
|
Report analytics events from your web extension extension.
|
||||||
|
|
||||||
|
## Supported Analytics Providers
|
||||||
|
|
||||||
|
- [Google Analytics 4 (Measurement Protocol)](#google-analytics-4-measurement-protocol)
|
||||||
|
- [Umami](#umami)
|
||||||
|
|
||||||
|
## Install With WXT
|
||||||
|
|
||||||
|
1. Install the NPM package:
|
||||||
|
```bash
|
||||||
|
pnpm i @wxt-dev/analytics
|
||||||
|
```
|
||||||
|
2. In your `wxt.config.ts`, add the WXT module:
|
||||||
|
```ts
|
||||||
|
export default defineConfig({
|
||||||
|
modules: ['@wxt-dev/analytics/module'],
|
||||||
|
});
|
||||||
|
```
|
||||||
|
3. In your `<srcDir>/app.config.ts`, add a provider:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// <srcDir>/app.config.ts
|
||||||
|
import { umami } from '@wxt-dev/analytics/providers/umami';
|
||||||
|
|
||||||
|
export default defineAppConfig({
|
||||||
|
analytics: {
|
||||||
|
debug: true,
|
||||||
|
providers: [
|
||||||
|
// ...
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Then use the `#analytics` module to report events:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { analytics } from '#analytics';
|
||||||
|
|
||||||
|
await analytics.track('some-event');
|
||||||
|
await analytics.page();
|
||||||
|
await analytics.identify('some-user-id');
|
||||||
|
analytics.autoTrack(document.body);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Install Without WXT
|
||||||
|
|
||||||
|
1. Install the NPM package:
|
||||||
|
```bash
|
||||||
|
pnpm i @wxt-dev/analytics
|
||||||
|
```
|
||||||
|
2. Create an `analytics` instance:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// utils/analytics.ts
|
||||||
|
import { createAnalytics } from '@wxt-dev/analytics';
|
||||||
|
|
||||||
|
export const analytics = createAnalytics({
|
||||||
|
providers: [
|
||||||
|
// ...
|
||||||
|
],
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Import your analytics module in the background to initialize the message listener:
|
||||||
|
```ts
|
||||||
|
// background.ts
|
||||||
|
import './utils/analytics';
|
||||||
|
```
|
||||||
|
4. Then use your `analytics` instance to report events:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { analytics } from './utils/analytics';
|
||||||
|
|
||||||
|
await analytics.track('some-event');
|
||||||
|
await analytics.page();
|
||||||
|
await analytics.identify('some-user-id');
|
||||||
|
analytics.autoTrack(document.body);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Providers
|
||||||
|
|
||||||
|
### Google Analytics 4 (Measurement Protocol)
|
||||||
|
|
||||||
|
The [Measurement Protocol](https://developers.google.com/analytics/devguides/collection/protocol/ga4) is an alternative to GTag for reporting events to Google Analytics for MV3 extensions.
|
||||||
|
|
||||||
|
> [Why use the Measurement Protocol instead of GTag?](https://developer.chrome.com/docs/extensions/how-to/integrate/google-analytics-4#measurement-protocol)
|
||||||
|
|
||||||
|
Follow [Google's documentation](https://developer.chrome.com/docs/extensions/how-to/integrate/google-analytics-4#setup-credentials) to obtain your credentials and put them in your `.env` file:
|
||||||
|
|
||||||
|
```dotenv
|
||||||
|
WXT_GA_API_SECRET='...'
|
||||||
|
```
|
||||||
|
|
||||||
|
Then add the `googleAnalytics4` provider to your `<srcDir>/app.config.ts` file:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { googleAnalytics4 } from '@wxt-dev/analytics/providers/google-analytics-4';
|
||||||
|
|
||||||
|
export default defineAppConfig({
|
||||||
|
analytics: {
|
||||||
|
providers: [
|
||||||
|
googleAnalytics4({
|
||||||
|
apiSecret: import.meta.env.WXT_GA_API_SECRET,
|
||||||
|
measurementId: '...',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Umami
|
||||||
|
|
||||||
|
[Umami](https://umami.is/) is a privacy-first, open source analytics platform.
|
||||||
|
|
||||||
|
In Umami's dashboard, create a new website. The website's name and domain can be anything. Obviously, an extension doesn't have a domain, so make one up if you don't have one.
|
||||||
|
|
||||||
|
After the website has been created, save the website ID and domain to your `.env` file:
|
||||||
|
|
||||||
|
```dotenv
|
||||||
|
WXT_UMAMI_WEBSITE_ID='...'
|
||||||
|
WXT_UMAMI_DOMAIN='...'
|
||||||
|
```
|
||||||
|
|
||||||
|
Then add the `umami` provider to your `<srcDir>/app.config.ts` file:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { umami } from '@wxt-dev/analytics/providers/umami';
|
||||||
|
|
||||||
|
export default defineAppConfig({
|
||||||
|
analytics: {
|
||||||
|
providers: [
|
||||||
|
umami({
|
||||||
|
apiUrl: 'https://<your-umami-instance>/api',
|
||||||
|
websiteId: import.meta.env.WXT_UMAMI_WEBSITE_ID,
|
||||||
|
domain: import.meta.env.WXT_UMAMI_DOMAIN,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Custom Provider
|
||||||
|
|
||||||
|
If your analytics platform is not supported, you can provide an implementation of the `AnalyticsProvider` type in your `app.config.ts` instead:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { defineAnalyticsProvider } from '@wxt-dev/analytics/client';
|
||||||
|
|
||||||
|
interface CustomAnalyticsOptions {
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
|
||||||
|
const customAnalytics = defineAnalyticsProvider<CustomAnalyticsOptions>(
|
||||||
|
(analytics, analyticsConfig, providerOptions) => {
|
||||||
|
// ...
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default defineAppConfig({
|
||||||
|
analytics: {
|
||||||
|
providers: [
|
||||||
|
customAnalytics({
|
||||||
|
// ...
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Example `AnalyticsProvider` implementations can be found at [`./modules/analytics/providers`](https://github.com/wxt-dev/wxt/tree/main/packages/analytics/modules/analytics/providers).
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { defineAppConfig } from 'wxt/sandbox';
|
||||||
|
import { googleAnalytics4 } from './modules/analytics/providers/google-analytics-4';
|
||||||
|
import { umami } from './modules/analytics/providers/umami';
|
||||||
|
|
||||||
|
export default defineAppConfig({
|
||||||
|
analytics: {
|
||||||
|
debug: true,
|
||||||
|
providers: [
|
||||||
|
googleAnalytics4({
|
||||||
|
apiSecret: '...',
|
||||||
|
measurementId: '...',
|
||||||
|
}),
|
||||||
|
umami({
|
||||||
|
apiUrl: 'https://umami.aklinker1.io/api',
|
||||||
|
domain: 'analytics.wxt.dev',
|
||||||
|
websiteId: '8f1c2aa4-fad3-406e-a5b2-33e8d4501716',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { defineBuildConfig } from 'unbuild';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
|
||||||
|
// Build module and plugins
|
||||||
|
export default defineBuildConfig({
|
||||||
|
rootDir: 'modules/analytics',
|
||||||
|
outDir: resolve(__dirname, 'dist'),
|
||||||
|
entries: [
|
||||||
|
{ input: 'index.ts', name: 'module' },
|
||||||
|
{ input: 'client.ts', name: 'index' },
|
||||||
|
'background-plugin.ts',
|
||||||
|
'types.ts',
|
||||||
|
'providers/google-analytics-4.ts',
|
||||||
|
'providers/umami.ts',
|
||||||
|
],
|
||||||
|
externals: ['#analytics'],
|
||||||
|
replace: {
|
||||||
|
'import.meta.env.NPM': 'true',
|
||||||
|
},
|
||||||
|
declaration: true,
|
||||||
|
});
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Popup</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<label>
|
||||||
|
<input id="enabledCheckbox" type="checkbox" />
|
||||||
|
 Analytics enabled
|
||||||
|
</label>
|
||||||
|
<button id="button1">Button 1</button>
|
||||||
|
<button class="cool-button">Button 2</button>
|
||||||
|
<script type="module" src="./main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { analytics } from '#analytics';
|
||||||
|
|
||||||
|
declare const enabledCheckbox: HTMLInputElement;
|
||||||
|
|
||||||
|
analytics.autoTrack(document);
|
||||||
|
|
||||||
|
enabledCheckbox.oninput = () => {
|
||||||
|
void analytics.setEnabled(enabledCheckbox.checked);
|
||||||
|
};
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import '#analytics';
|
||||||
|
|
||||||
|
export default () => {};
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
import { UAParser } from 'ua-parser-js';
|
||||||
|
import type {
|
||||||
|
Analytics,
|
||||||
|
AnalyticsConfig,
|
||||||
|
AnalyticsPageViewEvent,
|
||||||
|
AnalyticsStorageItem,
|
||||||
|
AnalyticsTrackEvent,
|
||||||
|
BaseAnalyticsEvent,
|
||||||
|
AnalyticsEventMetadata,
|
||||||
|
AnalyticsProvider,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
const ANALYTICS_PORT = '@wxt-dev/analytics';
|
||||||
|
|
||||||
|
export function createAnalytics(config?: AnalyticsConfig): Analytics {
|
||||||
|
if (typeof chrome === 'undefined' || !chrome?.runtime?.id)
|
||||||
|
throw Error(
|
||||||
|
'Cannot use WXT analytics in contexts without access to the browser.runtime APIs',
|
||||||
|
);
|
||||||
|
if (config == null) {
|
||||||
|
console.warn(
|
||||||
|
"[@wxt-dev/analytics] Config not provided to createAnalytics. If you're using WXT, add the 'analytics' property to '<srcDir>/app.config.ts'.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: This only works for standard WXT extensions, add a more generic
|
||||||
|
// background script detector that works with non-WXT projects.
|
||||||
|
if (location.pathname === '/background.js')
|
||||||
|
return createBackgroundAnalytics(config);
|
||||||
|
|
||||||
|
return createFrontendAnalytics();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an analytics client in the background responsible for uploading events to the server to avoid CORS errors.
|
||||||
|
*/
|
||||||
|
function createBackgroundAnalytics(
|
||||||
|
config: AnalyticsConfig | undefined,
|
||||||
|
): Analytics {
|
||||||
|
// User properties storage
|
||||||
|
const userIdStorage =
|
||||||
|
config?.userId ?? defineStorageItem<string>('wxt-analytics:user-id');
|
||||||
|
const userPropertiesStorage =
|
||||||
|
config?.userProperties ??
|
||||||
|
defineStorageItem<Record<string, string>>(
|
||||||
|
'wxt-analytics:user-properties',
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
const enabled =
|
||||||
|
config?.enabled ??
|
||||||
|
defineStorageItem<boolean>('local:wxt-analytics:enabled', false);
|
||||||
|
|
||||||
|
// Cached values
|
||||||
|
const platformInfo = chrome.runtime.getPlatformInfo();
|
||||||
|
const userAgent = UAParser();
|
||||||
|
let userId = Promise.resolve(userIdStorage.getValue()).then(
|
||||||
|
(id) => id ?? globalThis.crypto.randomUUID(),
|
||||||
|
);
|
||||||
|
let userProperties = userPropertiesStorage.getValue();
|
||||||
|
const manifest = chrome.runtime.getManifest();
|
||||||
|
|
||||||
|
const getBackgroundMeta = () => ({
|
||||||
|
timestamp: Date.now(),
|
||||||
|
// Don't track sessions for the background, it can be running
|
||||||
|
// indefinitely, and will inflate session duration stats.
|
||||||
|
sessionId: undefined,
|
||||||
|
language: navigator.language,
|
||||||
|
referrer: undefined,
|
||||||
|
screen: undefined,
|
||||||
|
url: location.href,
|
||||||
|
title: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const getBaseEvent = async (
|
||||||
|
meta: AnalyticsEventMetadata,
|
||||||
|
): Promise<BaseAnalyticsEvent> => {
|
||||||
|
const platform = await platformInfo;
|
||||||
|
return {
|
||||||
|
meta,
|
||||||
|
user: {
|
||||||
|
id: await userId,
|
||||||
|
properties: {
|
||||||
|
version: config?.version ?? manifest.version_name ?? manifest.version,
|
||||||
|
wxtMode: import.meta.env.MODE,
|
||||||
|
wxtBrowser: import.meta.env.BROWSER,
|
||||||
|
arch: platform.arch,
|
||||||
|
os: platform.os,
|
||||||
|
browser: userAgent.browser.name,
|
||||||
|
browserVersion: userAgent.browser.version,
|
||||||
|
...(await userProperties),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const analytics = {
|
||||||
|
identify: async (
|
||||||
|
newUserId: string,
|
||||||
|
newUserProperties: Record<string, string> = {},
|
||||||
|
meta: AnalyticsEventMetadata = getBackgroundMeta(),
|
||||||
|
) => {
|
||||||
|
// Update in-memory cache for all providers
|
||||||
|
userId = Promise.resolve(newUserId);
|
||||||
|
userProperties = Promise.resolve(newUserProperties);
|
||||||
|
// Persist user info to storage
|
||||||
|
await Promise.all([
|
||||||
|
userIdStorage.setValue?.(newUserId),
|
||||||
|
userPropertiesStorage.setValue?.(newUserProperties),
|
||||||
|
]);
|
||||||
|
// Notify providers
|
||||||
|
const event = await getBaseEvent(meta);
|
||||||
|
if (config?.debug) console.debug('[@wxt-dev/analytics] identify', event);
|
||||||
|
if (await enabled.getValue()) {
|
||||||
|
await Promise.allSettled(
|
||||||
|
providers.map((provider) => provider.identify(event)),
|
||||||
|
);
|
||||||
|
} else if (config?.debug) {
|
||||||
|
console.debug(
|
||||||
|
'[@wxt-dev/analytics] Analytics disabled, identify() not uploaded',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
page: async (
|
||||||
|
location: string,
|
||||||
|
meta: AnalyticsEventMetadata = getBackgroundMeta(),
|
||||||
|
) => {
|
||||||
|
const baseEvent = await getBaseEvent(meta);
|
||||||
|
const event: AnalyticsPageViewEvent = {
|
||||||
|
...baseEvent,
|
||||||
|
page: {
|
||||||
|
url: meta?.url ?? globalThis.location?.href,
|
||||||
|
location,
|
||||||
|
title: meta?.title ?? globalThis.document?.title,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if (config?.debug) console.debug('[@wxt-dev/analytics] page', event);
|
||||||
|
if (await enabled.getValue()) {
|
||||||
|
await Promise.allSettled(
|
||||||
|
providers.map((provider) => provider.page(event)),
|
||||||
|
);
|
||||||
|
} else if (config?.debug) {
|
||||||
|
console.debug(
|
||||||
|
'[@wxt-dev/analytics] Analytics disabled, page() not uploaded',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
track: async (
|
||||||
|
eventName: string,
|
||||||
|
eventProperties?: Record<string, string>,
|
||||||
|
meta: AnalyticsEventMetadata = getBackgroundMeta(),
|
||||||
|
) => {
|
||||||
|
const baseEvent = await getBaseEvent(meta);
|
||||||
|
const event: AnalyticsTrackEvent = {
|
||||||
|
...baseEvent,
|
||||||
|
event: { name: eventName, properties: eventProperties },
|
||||||
|
};
|
||||||
|
if (config?.debug) console.debug('[@wxt-dev/analytics] track', event);
|
||||||
|
if (await enabled.getValue()) {
|
||||||
|
await Promise.allSettled(
|
||||||
|
providers.map((provider) => provider.track(event)),
|
||||||
|
);
|
||||||
|
} else if (config?.debug) {
|
||||||
|
console.debug(
|
||||||
|
'[@wxt-dev/analytics] Analytics disabled, track() not uploaded',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
setEnabled: async (newEnabled) => {
|
||||||
|
await enabled.setValue?.(newEnabled);
|
||||||
|
},
|
||||||
|
autoTrack: () => {
|
||||||
|
// Noop, background doesn't have a UI
|
||||||
|
return () => {};
|
||||||
|
},
|
||||||
|
} satisfies Analytics;
|
||||||
|
|
||||||
|
const providers =
|
||||||
|
config?.providers?.map((provider) => provider(analytics, config)) ?? [];
|
||||||
|
|
||||||
|
// Listen for messages from the rest of the extension
|
||||||
|
chrome.runtime.onConnect.addListener((port) => {
|
||||||
|
if (port.name === ANALYTICS_PORT) {
|
||||||
|
port.onMessage.addListener(({ fn, args }) => {
|
||||||
|
// @ts-expect-error: Untyped fn key
|
||||||
|
void analytics[fn]?.(...args);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return analytics;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an analytics client for non-background contexts.
|
||||||
|
*/
|
||||||
|
function createFrontendAnalytics(): Analytics {
|
||||||
|
const port = chrome.runtime.connect({ name: ANALYTICS_PORT });
|
||||||
|
const sessionId = Date.now();
|
||||||
|
const getFrontendMetadata = (): AnalyticsEventMetadata => ({
|
||||||
|
sessionId,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
language: navigator.language,
|
||||||
|
referrer: globalThis.document?.referrer || undefined,
|
||||||
|
screen: globalThis.window
|
||||||
|
? `${globalThis.window.screen.width}x${globalThis.window.screen.height}`
|
||||||
|
: undefined,
|
||||||
|
url: location.href,
|
||||||
|
title: document.title || undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const methodForwarder =
|
||||||
|
(fn: string) =>
|
||||||
|
(...args: any[]) => {
|
||||||
|
port.postMessage({ fn, args: [...args, getFrontendMetadata()] });
|
||||||
|
};
|
||||||
|
|
||||||
|
const analytics: Analytics = {
|
||||||
|
identify: methodForwarder('identify'),
|
||||||
|
page: methodForwarder('page'),
|
||||||
|
track: methodForwarder('track'),
|
||||||
|
setEnabled: methodForwarder('setEnabled'),
|
||||||
|
autoTrack: (root) => {
|
||||||
|
const onClick = (event: Event) => {
|
||||||
|
const element = event.target as any;
|
||||||
|
if (
|
||||||
|
!element ||
|
||||||
|
(!INTERACTIVE_TAGS.has(element.tagName) &&
|
||||||
|
!INTERACTIVE_ROLES.has(element.getAttribute('role')))
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
|
||||||
|
void analytics.track('click', {
|
||||||
|
tagName: element.tagName?.toLowerCase(),
|
||||||
|
id: element.id || undefined,
|
||||||
|
className: element.className || undefined,
|
||||||
|
textContent: element.textContent?.substring(0, 50) || undefined, // Limit text content length
|
||||||
|
href: element.href,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
root.addEventListener('click', onClick, { capture: true, passive: true });
|
||||||
|
return () => {
|
||||||
|
root.removeEventListener('click', onClick);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return analytics;
|
||||||
|
}
|
||||||
|
|
||||||
|
function defineStorageItem<T>(
|
||||||
|
key: string,
|
||||||
|
defaultValue?: NonNullable<T>,
|
||||||
|
): AnalyticsStorageItem<T> {
|
||||||
|
return {
|
||||||
|
getValue: async () =>
|
||||||
|
(await chrome.storage.local.get(key))[key] ?? defaultValue,
|
||||||
|
setValue: (newValue) => chrome.storage.local.set({ [key]: newValue }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const INTERACTIVE_TAGS = new Set([
|
||||||
|
'A',
|
||||||
|
'BUTTON',
|
||||||
|
'INPUT',
|
||||||
|
'SELECT',
|
||||||
|
'TEXTAREA',
|
||||||
|
]);
|
||||||
|
const INTERACTIVE_ROLES = new Set([
|
||||||
|
'button',
|
||||||
|
'link',
|
||||||
|
'checkbox',
|
||||||
|
'menuitem',
|
||||||
|
'tab',
|
||||||
|
'radio',
|
||||||
|
]);
|
||||||
|
|
||||||
|
export function defineAnalyticsProvider<T = never>(
|
||||||
|
definition: (
|
||||||
|
/** The analytics object. */
|
||||||
|
analytics: Analytics,
|
||||||
|
/** Config passed into the analytics module from `app.config.ts`. */
|
||||||
|
config: AnalyticsConfig,
|
||||||
|
/** Provider options */
|
||||||
|
options: T,
|
||||||
|
) => ReturnType<AnalyticsProvider>,
|
||||||
|
): (options: T) => AnalyticsProvider {
|
||||||
|
return (options) => (analytics, config) =>
|
||||||
|
definition(analytics, config, options);
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import 'wxt';
|
||||||
|
import 'wxt/sandbox';
|
||||||
|
import { addAlias, addWxtPlugin, defineWxtModule } from 'wxt/modules';
|
||||||
|
import { relative, resolve } from 'node:path';
|
||||||
|
import type { AnalyticsConfig } from './types';
|
||||||
|
|
||||||
|
declare module 'wxt/sandbox' {
|
||||||
|
export interface WxtAppConfig {
|
||||||
|
analytics: AnalyticsConfig;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineWxtModule({
|
||||||
|
name: 'analytics',
|
||||||
|
imports: [{ name: 'analytics', from: '#analytics' }],
|
||||||
|
setup(wxt) {
|
||||||
|
// Paths
|
||||||
|
const wxtAnalyticsFolder = resolve(wxt.config.wxtDir, 'analytics');
|
||||||
|
const wxtAnalyticsIndex = resolve(wxtAnalyticsFolder, 'index.ts');
|
||||||
|
const clientModuleId = import.meta.env.NPM
|
||||||
|
? '@wxt-dev/analytics'
|
||||||
|
: resolve(wxt.config.modulesDir, 'analytics/client');
|
||||||
|
const pluginModuleId = import.meta.env.NPM
|
||||||
|
? '@wxt-dev/analytics/background-plugin'
|
||||||
|
: resolve(wxt.config.modulesDir, 'analytics/background-plugin');
|
||||||
|
|
||||||
|
// Add required permissions
|
||||||
|
wxt.hook('build:manifestGenerated', (_, manifest) => {
|
||||||
|
manifest.permissions ??= [];
|
||||||
|
if (!manifest.permissions.includes('storage')) {
|
||||||
|
manifest.permissions.push('storage');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Generate #analytics module
|
||||||
|
const wxtAnalyticsCode = [
|
||||||
|
`import { createAnalytics } from '${
|
||||||
|
import.meta.env.NPM
|
||||||
|
? clientModuleId
|
||||||
|
: relative(wxtAnalyticsFolder, clientModuleId)
|
||||||
|
}';`,
|
||||||
|
`import { useAppConfig } from 'wxt/client';`,
|
||||||
|
``,
|
||||||
|
`export const analytics = createAnalytics(useAppConfig().analytics);`,
|
||||||
|
``,
|
||||||
|
].join('\n');
|
||||||
|
addAlias(wxt, '#analytics', wxtAnalyticsIndex);
|
||||||
|
wxt.hook('prepare:types', async (_, entries) => {
|
||||||
|
entries.push({
|
||||||
|
path: wxtAnalyticsIndex,
|
||||||
|
text: wxtAnalyticsCode,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ensure there is a background entrypoint
|
||||||
|
wxt.hook('entrypoints:resolved', (_, entrypoints) => {
|
||||||
|
const hasBackground = entrypoints.find(
|
||||||
|
(entry) => entry.type === 'background',
|
||||||
|
);
|
||||||
|
if (!hasBackground) {
|
||||||
|
entrypoints.push({
|
||||||
|
type: 'background',
|
||||||
|
inputPath: 'virtual:user-background',
|
||||||
|
name: 'background',
|
||||||
|
options: {},
|
||||||
|
outputDir: wxt.config.outDir,
|
||||||
|
skipped: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ensure analytics is initialized in every context, mainly the background.
|
||||||
|
// TODO: Once there's a way to filter which entrypoints a plugin is applied to, only apply this to the background
|
||||||
|
addWxtPlugin(wxt, pluginModuleId);
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { defineAnalyticsProvider } from '../client';
|
||||||
|
import type { BaseAnalyticsEvent } from '../types';
|
||||||
|
|
||||||
|
const DEFAULT_ENGAGEMENT_TIME_IN_MSEC = 100;
|
||||||
|
|
||||||
|
export interface GoogleAnalytics4ProviderOptions {
|
||||||
|
apiSecret: string;
|
||||||
|
measurementId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const googleAnalytics4 =
|
||||||
|
defineAnalyticsProvider<GoogleAnalytics4ProviderOptions>(
|
||||||
|
(_, config, options) => {
|
||||||
|
const send = async (
|
||||||
|
data: BaseAnalyticsEvent,
|
||||||
|
eventName: string,
|
||||||
|
eventProperties: Record<string, string | undefined> | undefined,
|
||||||
|
): Promise<void> => {
|
||||||
|
const url = new URL(
|
||||||
|
config?.debug ? '/debug/mp/collect' : '/mp/collect',
|
||||||
|
'https://www.google-analytics.com',
|
||||||
|
);
|
||||||
|
if (options.apiSecret)
|
||||||
|
url.searchParams.set('api_secret', options.apiSecret);
|
||||||
|
if (options.measurementId)
|
||||||
|
url.searchParams.set('measurement_id', options.measurementId);
|
||||||
|
|
||||||
|
const userProperties = {
|
||||||
|
language: data.meta.language,
|
||||||
|
screen: data.meta.screen,
|
||||||
|
...data.user.properties,
|
||||||
|
};
|
||||||
|
const mappedUserProperties = Object.fromEntries(
|
||||||
|
Object.entries(userProperties).map(([name, value]) => [
|
||||||
|
name,
|
||||||
|
value == null ? undefined : { value },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
await fetch(url.href, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
client_id: data.user.id,
|
||||||
|
consent: {
|
||||||
|
ad_user_data: 'DENIED',
|
||||||
|
ad_personalization: 'DENIED',
|
||||||
|
},
|
||||||
|
user_properties: mappedUserProperties,
|
||||||
|
events: [
|
||||||
|
{
|
||||||
|
name: eventName,
|
||||||
|
params: {
|
||||||
|
session_id: data.meta.sessionId,
|
||||||
|
engagement_time_msec: DEFAULT_ENGAGEMENT_TIME_IN_MSEC,
|
||||||
|
...eventProperties,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
identify: () => Promise.resolve(), // No-op, user data uploaded in page/track
|
||||||
|
page: (event) =>
|
||||||
|
send(event, 'page_view', {
|
||||||
|
page_title: event.page.title,
|
||||||
|
page_location: event.page.location,
|
||||||
|
}),
|
||||||
|
track: (event) => send(event, event.event.name, event.event.properties),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { defineAnalyticsProvider } from '../client';
|
||||||
|
|
||||||
|
export interface UmamiProviderOptions {
|
||||||
|
apiUrl: string;
|
||||||
|
websiteId: string;
|
||||||
|
domain: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const umami = defineAnalyticsProvider<UmamiProviderOptions>(
|
||||||
|
(_, config, options) => {
|
||||||
|
const send = (payload: UmamiPayload) => {
|
||||||
|
if (config.debug) {
|
||||||
|
console.debug('[@wxt-dev/analytics] Sending event to Umami:', payload);
|
||||||
|
}
|
||||||
|
return fetch(`${options.apiUrl}/send`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ type: 'event', payload }),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
identify: () => Promise.resolve(), // No-op, user data uploaded in page/track
|
||||||
|
page: async (event) => {
|
||||||
|
await send({
|
||||||
|
name: 'page_view',
|
||||||
|
website: options.websiteId,
|
||||||
|
url: event.page.url,
|
||||||
|
hostname: options.domain,
|
||||||
|
language: event.meta.language ?? '',
|
||||||
|
referrer: event.meta.referrer ?? '',
|
||||||
|
screen: event.meta.screen ?? '',
|
||||||
|
title: event.page.title ?? '<blank>',
|
||||||
|
data: event.user.properties,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
track: async (event) => {
|
||||||
|
await send({
|
||||||
|
name: event.event.name,
|
||||||
|
website: options.websiteId,
|
||||||
|
url: event.meta.url ?? '/',
|
||||||
|
title: '<blank>',
|
||||||
|
hostname: options.domain,
|
||||||
|
language: event.meta.language ?? '',
|
||||||
|
referrer: event.meta.referrer ?? '',
|
||||||
|
screen: event.meta.screen ?? '',
|
||||||
|
data: {
|
||||||
|
...event.event.properties,
|
||||||
|
...event.user.properties,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/** @see https://umami.is/docs/api/sending-stats#post-/api/send */
|
||||||
|
interface UmamiPayload {
|
||||||
|
hostname?: string;
|
||||||
|
language?: string;
|
||||||
|
referrer?: string;
|
||||||
|
screen?: string;
|
||||||
|
title?: string;
|
||||||
|
url?: string;
|
||||||
|
website: string;
|
||||||
|
name: string;
|
||||||
|
data?: Record<string, string | undefined>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
export interface Analytics {
|
||||||
|
/** Report a page change */
|
||||||
|
page: (url: string) => void;
|
||||||
|
/** Report a custom event */
|
||||||
|
track: (eventName: string, eventProperties?: Record<string, string>) => void;
|
||||||
|
/** Save information about the user */
|
||||||
|
identify: (userId: string, userProperties?: Record<string, string>) => void;
|
||||||
|
/** Automatically setup and track user interactions, returning a function to remove any listeners that were setup. */
|
||||||
|
autoTrack: (root: Document | ShadowRoot | Element) => () => void;
|
||||||
|
/** Calls `config.enabled.setValue` */
|
||||||
|
setEnabled: (enabled: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnalyticsConfig {
|
||||||
|
/**
|
||||||
|
* Array of providers to send analytics to.
|
||||||
|
*/
|
||||||
|
providers: AnalyticsProvider[];
|
||||||
|
/**
|
||||||
|
* Enable debug logs and other provider-specific debugging features.
|
||||||
|
*/
|
||||||
|
debug?: boolean;
|
||||||
|
/**
|
||||||
|
* Extension version, defaults to `browser.runtime.getManifest().version`.
|
||||||
|
*/
|
||||||
|
version?: string;
|
||||||
|
/**
|
||||||
|
* Configure how the enabled flag is persisted. Defaults to using `""` in local extension storage.
|
||||||
|
*/
|
||||||
|
enabled?: AnalyticsStorageItem<boolean>;
|
||||||
|
/**
|
||||||
|
* Configure how the user Id is persisted
|
||||||
|
*/
|
||||||
|
userId?: AnalyticsStorageItem<string>;
|
||||||
|
/**
|
||||||
|
* Configure how user properties are persisted
|
||||||
|
*/
|
||||||
|
userProperties?: AnalyticsStorageItem<Record<string, string>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnalyticsStorageItem<T> {
|
||||||
|
getValue: () => T | Promise<T>;
|
||||||
|
setValue?: (newValue: T) => void | Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AnalyticsProvider = (
|
||||||
|
analytics: Analytics,
|
||||||
|
config: AnalyticsConfig,
|
||||||
|
) => {
|
||||||
|
/** Upload a page view event */
|
||||||
|
page: (event: AnalyticsPageViewEvent) => Promise<void>;
|
||||||
|
/** Upload a custom event */
|
||||||
|
track: (event: AnalyticsTrackEvent) => Promise<void>;
|
||||||
|
/** Upload information about the user */
|
||||||
|
identify: (event: BaseAnalyticsEvent) => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface BaseAnalyticsEvent {
|
||||||
|
meta: AnalyticsEventMetadata;
|
||||||
|
user: {
|
||||||
|
id: string;
|
||||||
|
properties: Record<string, string | undefined>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnalyticsEventMetadata {
|
||||||
|
/** Identifier of the session the event was fired from */
|
||||||
|
sessionId: number | undefined;
|
||||||
|
/** `Date.now()` of when the event was reported */
|
||||||
|
timestamp: number;
|
||||||
|
/** `"1920x1080"` */
|
||||||
|
screen: string | undefined;
|
||||||
|
/** `document.referrer` */
|
||||||
|
referrer: string | undefined;
|
||||||
|
/** `navigator.language` */
|
||||||
|
language: string | undefined;
|
||||||
|
/** `location.href` */
|
||||||
|
url: string | undefined;
|
||||||
|
/** `document.title` */
|
||||||
|
title: string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnalyticsPageInfo {
|
||||||
|
url: string;
|
||||||
|
title: string | undefined;
|
||||||
|
location: string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnalyticsPageViewEvent extends BaseAnalyticsEvent {
|
||||||
|
page: AnalyticsPageInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnalyticsTrackEvent extends BaseAnalyticsEvent {
|
||||||
|
event: {
|
||||||
|
name: string;
|
||||||
|
properties?: Record<string, string>;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
{
|
||||||
|
"name": "@wxt-dev/analytics",
|
||||||
|
"version": "0.4.0",
|
||||||
|
"description": "Add analytics to your web extension",
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "git+https://github.com/wxt-dev/wxt.git",
|
||||||
|
"directory": "packages/analytics"
|
||||||
|
},
|
||||||
|
"license": "MIT",
|
||||||
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.d.mts",
|
||||||
|
"default": "./dist/index.mjs"
|
||||||
|
},
|
||||||
|
"./module": {
|
||||||
|
"types": "./dist/module.d.mts",
|
||||||
|
"default": "./dist/module.mjs"
|
||||||
|
},
|
||||||
|
"./background-plugin": {
|
||||||
|
"types": "./dist/background-plugin.d.mts",
|
||||||
|
"default": "./dist/background-plugin.mjs"
|
||||||
|
},
|
||||||
|
"./types": {
|
||||||
|
"types": "./dist/types.d.mts"
|
||||||
|
},
|
||||||
|
"./providers/google-analytics-4": {
|
||||||
|
"types": "./dist/providers/google-analytics-4.d.mts",
|
||||||
|
"default": "./dist/providers/google-analytics-4.mjs"
|
||||||
|
},
|
||||||
|
"./providers/umami": {
|
||||||
|
"types": "./dist/providers/umami.d.mts",
|
||||||
|
"default": "./dist/providers/umami.mjs"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"module": "./dist/index.mjs",
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"files": [
|
||||||
|
"dist"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"dev": "buildc --deps-only -- wxt",
|
||||||
|
"dev:build": "buildc --deps-only -- wxt build",
|
||||||
|
"check": "pnpm build && check",
|
||||||
|
"build": "buildc -- unbuild",
|
||||||
|
"prepack": "pnpm -s build",
|
||||||
|
"prepare": "buildc --deps-only -- wxt prepare"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"wxt": ">=0.19.23"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@aklinker1/check": "^1.3.1",
|
||||||
|
"@types/chrome": "^0.0.268",
|
||||||
|
"@types/ua-parser-js": "^0.7.39",
|
||||||
|
"prettier": "^3.3.2",
|
||||||
|
"publint": "^0.2.8",
|
||||||
|
"typescript": "^5.5.2",
|
||||||
|
"unbuild": "^2.0.0",
|
||||||
|
"wxt": "workspace:*"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"ua-parser-js": "^1.0.38"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"extends": ["../../tsconfig.base.json", "./.wxt/tsconfig.json"],
|
||||||
|
"compilerOptions": {
|
||||||
|
"paths": {
|
||||||
|
"#analytics": ["./.wxt/analytics/index.ts"]
|
||||||
|
},
|
||||||
|
"types": ["chrome"]
|
||||||
|
},
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { defineConfig } from 'wxt';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
// Unimport doesn't look for imports in node_modules, so when developing a
|
||||||
|
// WXT module, we need to disable this to simplify the build process
|
||||||
|
imports: false,
|
||||||
|
|
||||||
|
manifest: {
|
||||||
|
name: 'Analytics Demo',
|
||||||
|
},
|
||||||
|
});
|
||||||
Generated
+42
@@ -83,6 +83,37 @@ importers:
|
|||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:packages/wxt
|
version: link:packages/wxt
|
||||||
|
|
||||||
|
packages/analytics:
|
||||||
|
dependencies:
|
||||||
|
ua-parser-js:
|
||||||
|
specifier: ^1.0.38
|
||||||
|
version: 1.0.40
|
||||||
|
devDependencies:
|
||||||
|
'@aklinker1/check':
|
||||||
|
specifier: ^1.3.1
|
||||||
|
version: 1.4.5(typescript@5.6.3)
|
||||||
|
'@types/chrome':
|
||||||
|
specifier: ^0.0.268
|
||||||
|
version: 0.0.268
|
||||||
|
'@types/ua-parser-js':
|
||||||
|
specifier: ^0.7.39
|
||||||
|
version: 0.7.39
|
||||||
|
prettier:
|
||||||
|
specifier: ^3.3.2
|
||||||
|
version: 3.3.3
|
||||||
|
publint:
|
||||||
|
specifier: ^0.2.8
|
||||||
|
version: 0.2.12
|
||||||
|
typescript:
|
||||||
|
specifier: ^5.5.2
|
||||||
|
version: 5.6.3
|
||||||
|
unbuild:
|
||||||
|
specifier: ^2.0.0
|
||||||
|
version: 2.0.0(sass@1.80.7)(typescript@5.6.3)
|
||||||
|
wxt:
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../wxt
|
||||||
|
|
||||||
packages/auto-icons:
|
packages/auto-icons:
|
||||||
dependencies:
|
dependencies:
|
||||||
defu:
|
defu:
|
||||||
@@ -1999,6 +2030,9 @@ packages:
|
|||||||
'@types/resolve@1.20.2':
|
'@types/resolve@1.20.2':
|
||||||
resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==}
|
resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==}
|
||||||
|
|
||||||
|
'@types/ua-parser-js@0.7.39':
|
||||||
|
resolution: {integrity: sha512-P/oDfpofrdtF5xw433SPALpdSchtJmY7nsJItf8h3KXqOslkbySh8zq4dSWXH2oTjRvJ5PczVEoCZPow6GicLg==}
|
||||||
|
|
||||||
'@types/unist@3.0.2':
|
'@types/unist@3.0.2':
|
||||||
resolution: {integrity: sha512-dqId9J8K/vGi5Zr7oo212BGii5m3q5Hxlkwy3WpYuKPklmBEvsbMYYyLxAQpSffdLl/gdW0XUpKWFvYmyoWCoQ==}
|
resolution: {integrity: sha512-dqId9J8K/vGi5Zr7oo212BGii5m3q5Hxlkwy3WpYuKPklmBEvsbMYYyLxAQpSffdLl/gdW0XUpKWFvYmyoWCoQ==}
|
||||||
|
|
||||||
@@ -4771,6 +4805,10 @@ packages:
|
|||||||
engines: {node: '>=14.17'}
|
engines: {node: '>=14.17'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
ua-parser-js@1.0.40:
|
||||||
|
resolution: {integrity: sha512-z6PJ8Lml+v3ichVojCiB8toQJBuwR42ySM4ezjXIqXK3M0HczmKQ3LF4rhU55PfD99KEEXQG6yb7iOMyvYuHew==}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
ufo@1.5.3:
|
ufo@1.5.3:
|
||||||
resolution: {integrity: sha512-Y7HYmWaFwPUmkoQCUIAYpKqkOf+SbVj/2fJJZ4RJMCfZp0rTGwRbzQD+HghfnhKOjL9E01okqz+ncJskGYfBNw==}
|
resolution: {integrity: sha512-Y7HYmWaFwPUmkoQCUIAYpKqkOf+SbVj/2fJJZ4RJMCfZp0rTGwRbzQD+HghfnhKOjL9E01okqz+ncJskGYfBNw==}
|
||||||
|
|
||||||
@@ -6442,6 +6480,8 @@ snapshots:
|
|||||||
|
|
||||||
'@types/resolve@1.20.2': {}
|
'@types/resolve@1.20.2': {}
|
||||||
|
|
||||||
|
'@types/ua-parser-js@0.7.39': {}
|
||||||
|
|
||||||
'@types/unist@3.0.2': {}
|
'@types/unist@3.0.2': {}
|
||||||
|
|
||||||
'@types/web-bluetooth@0.0.20': {}
|
'@types/web-bluetooth@0.0.20': {}
|
||||||
@@ -9449,6 +9489,8 @@ snapshots:
|
|||||||
|
|
||||||
typescript@5.6.3: {}
|
typescript@5.6.3: {}
|
||||||
|
|
||||||
|
ua-parser-js@1.0.40: {}
|
||||||
|
|
||||||
ufo@1.5.3: {}
|
ufo@1.5.3: {}
|
||||||
|
|
||||||
ufo@1.5.4: {}
|
ufo@1.5.4: {}
|
||||||
|
|||||||
Reference in New Issue
Block a user