sdk(astro,nextjs): add astro sdk and ensure window.op always first on nextjs

This commit is contained in:
Carl-Gerhard Lindesvärd
2025-05-06 22:10:38 +02:00
parent 0189b922f2
commit 2d8f6f36f6
17 changed files with 2110 additions and 83 deletions

View File

@@ -5,4 +5,3 @@
- `clientSecret` - The client secret of your application (**only required for server-side events**)
- `filter` - A function that will be called before sending an event. If it returns false, the event will not be sent
- `disabled` - If true, the library will not send any events
- `waitForProfile` - If true, the library will wait for the profile to be set before sending events

View File

@@ -2,4 +2,132 @@
title: Astro
---
You can use [script tag](/docs/sdks/script) or [Web SDK](/docs/sdks/web) to track events in Astro.
import { Step, Steps } from 'fumadocs-ui/components/steps';
import CommonSdkConfig from '@/components/common-sdk-config.mdx';
import WebSdkConfig from '@/components/web-sdk-config.mdx';
## Installation
<Steps>
### Install dependencies
```bash
pnpm install @openpanel/astro
```
### Initialize
Add `OpenPanelComponent` to your root layout component.
```astro
---
import { OpenPanelComponent } from '@openpanel/astro';
---
<html>
<head>
<OpenPanelComponent
clientId="your-client-id"
trackScreenViews={true}
// trackAttributes={true}
// trackOutgoingLinks={true}
// If you have a user id, you can pass it here to identify the user
// profileId={'123'}
/>
</head>
<body>
<slot />
</body>
</html>
```
#### Options
<CommonSdkConfig />
<WebSdkConfig />
##### Astro options
- `profileId` - If you have a user id, you can pass it here to identify the user
- `cdnUrl` - The url to the OpenPanel SDK (default: `https://openpanel.dev/op1.js`)
- `filter` - This is a function that will be called before tracking an event. If it returns false the event will not be tracked. [Read more](#filter)
- `globalProperties` - This is an object of properties that will be sent with every event.
##### `filter`
This options needs to be a stringified function and cannot access any variables outside of the function.
```astro
<OpenPanelComponent
clientId="your-client-id"
filter={`
function filter(event) {
return event.name !== 'my_event';
}
`}
/>
```
To take advantage of typescript you can do the following. _Note `toString`_
```ts
import { type TrackHandlerPayload } from '@openpanel/astro';
const opFilter = ((event: TrackHandlerPayload) => {
return event.type === 'track' && event.payload.name === 'my_event';
}).toString();
<OpenPanelComponent
clientId="your-client-id"
filter={opFilter}
/>
```
</Steps>
## Usage
### Client-side Tracking
You can track events with the global op function or you can use data attributes.
```astro
<button onclick="window.op('track', 'clicky')">Click me</button>
<button data-track="clicky" data-prop1="prop1" data-prop2="prop2">Click me</button>
```
### Identifying Users
To identify a user, you can use either the `identify` function or the `IdentifyComponent`.
```astro
---
import { IdentifyComponent } from '@openpanel/astro';
---
<IdentifyComponent
profileId="123"
firstName="Joe"
lastName="Doe"
email="joe@doe.com"
properties={{
tier: 'premium',
}}
/>
```
### Setting Global Properties
You can set global properties that will be sent with every event using either the `setGlobalProperties` function or the `SetGlobalPropertiesComponent`.
```astro
---
import { SetGlobalPropertiesComponent } from '@openpanel/astro';
---
<SetGlobalPropertiesComponent
properties={{
app_version: '1.0.2',
environment: 'production',
}}
/>
```

View File

@@ -0,0 +1,3 @@
# OpenPanel Astro SDK
Read full documentation [here](https://openpanel.dev/docs/sdks/astro)

7
packages/sdks/astro/env.d.ts vendored Normal file
View File

@@ -0,0 +1,7 @@
declare module '*.astro' {
import type { AstroComponentFactory } from 'astro';
const component: AstroComponentFactory;
export default component;
}
/// <reference types="astro/client" />

View File

@@ -0,0 +1,46 @@
import type {
DecrementPayload,
IdentifyPayload,
IncrementPayload,
TrackProperties,
} from '@openpanel/web';
import IdentifyComponent from './src/IdentifyComponent.astro';
import OpenPanelComponent from './src/OpenPanelComponent.astro';
import SetGlobalPropertiesComponent from './src/SetGlobalPropertiesComponent.astro';
export * from '@openpanel/web';
export { OpenPanelComponent, IdentifyComponent, SetGlobalPropertiesComponent };
export function setGlobalProperties(properties: Record<string, unknown>) {
window.op?.('setGlobalProperties', properties);
}
export function track(name: string, properties?: TrackProperties) {
window.op?.('track', name, properties);
}
export function screenView(properties?: TrackProperties): void;
export function screenView(path: string, properties?: TrackProperties): void;
export function screenView(
pathOrProperties?: string | TrackProperties,
propertiesOrUndefined?: TrackProperties,
) {
window.op?.('screenView', pathOrProperties, propertiesOrUndefined);
}
export function identify(payload: IdentifyPayload) {
window.op?.('identify', payload);
}
export function increment(payload: IncrementPayload) {
window.op?.('increment', payload);
}
export function decrement(payload: DecrementPayload) {
window.op('decrement', payload);
}
export function clear() {
window.op?.('clear');
}

View File

@@ -0,0 +1,27 @@
{
"name": "@openpanel/astro",
"version": "1.0.1-local",
"config": {
"transformPackageJson": false,
"transformEnvs": true
},
"exports": {
".": "./index.ts"
},
"scripts": {
"typecheck": "tsc --noEmit"
},
"files": ["src", "index.ts"],
"keywords": ["astro-component"],
"dependencies": {
"@openpanel/web": "workspace:1.0.1-local"
},
"devDependencies": {
"astro": "^5.7.7"
},
"peerDependencies": {
"astro": "^4.0.0 || ^5.0.0"
},
"private": false,
"type": "module"
}

View File

@@ -0,0 +1,9 @@
---
import type { IdentifyPayload } from '@openpanel/web';
import { filterProps } from './asto-utils';
interface Props extends IdentifyPayload {}
const props = Astro.props as Props;
---
<script is:inline set:html={`window.op('identify', ${JSON.stringify(filterProps(props))});`} />

View File

@@ -0,0 +1,66 @@
---
import type {
OpenPanelMethodNames,
OpenPanelOptions
} from '@openpanel/web';
type Props = Omit<OpenPanelOptions, 'filter'> & {
profileId?: string;
cdnUrl?: string;
filter?: string;
globalProperties?: Record<string, unknown>;
};
const { profileId, cdnUrl, globalProperties, ...options } = Astro.props;
const CDN_URL = 'https://openpanel.dev/op1.js';
const stringify = (obj: unknown) => {
if (typeof obj === 'object' && obj !== null && obj !== undefined) {
const entries = Object.entries(obj).map(([key, value]) => {
if (key === 'filter') {
return `"${key}":${value}`;
}
return `"${key}":${JSON.stringify(value)}`;
});
return `{${entries.join(',')}}`;
}
return JSON.stringify(obj);
};
const methods: { name: OpenPanelMethodNames; value: unknown }[] = [
{
name: 'init',
value: {
...options,
sdk: 'astro',
sdkVersion: '1.0.1',
},
},
];
if (profileId) {
methods.push({
name: 'identify',
value: {
profileId,
},
});
}
if (globalProperties) {
methods.push({
name: 'setGlobalProperties',
value: globalProperties,
});
}
const scriptContent = `window.op = window.op || function(...args) {(window.op.q = window.op.q || []).push(args)};
${methods
.map((method) => {
return `window.op('${method.name}', ${stringify(method.value)});`;
})
.join('\n')}`;
---
<script src={cdnUrl ?? CDN_URL} async defer />
<script is:inline set:html={scriptContent} />

View File

@@ -0,0 +1,9 @@
---
import { filterProps } from './asto-utils';
type Props = Record<string, unknown>;
const props = Astro.props as Props;
---
<script is:inline set:html={`window.op('setGlobalProperties', ${JSON.stringify(filterProps(props))});`} />

View File

@@ -0,0 +1,7 @@
const BLACKLISTED_PROPS = ['class'];
export function filterProps(props: Record<string, unknown>) {
return Object.fromEntries(
Object.entries(props).filter(([key]) => !BLACKLISTED_PROPS.includes(key)),
);
}

View File

@@ -0,0 +1,13 @@
{
"extends": "astro/tsconfigs/strict",
"plugins": [
{
"name": "@astrojs/ts-plugin"
}
],
"include": [".astro/types.d.ts", "**/*"],
"exclude": ["dist"],
"compilerOptions": {
"jsx": "preserve"
}
}

View File

@@ -71,6 +71,7 @@ export function OpenPanelComponent({
<>
<Script src={cdnUrl ?? CDN_URL} async defer />
<Script
strategy="beforeInteractive"
dangerouslySetInnerHTML={{
__html: `window.op = window.op || function(...args) {(window.op.q = window.op.q || []).push(args)};
${methods

View File

@@ -1,6 +1,6 @@
{
"name": "@openpanel/nextjs",
"version": "1.0.7-local",
"version": "1.0.8-local",
"module": "index.ts",
"scripts": {
"build": "rm -rf dist && tsup",

View File

@@ -1,6 +1,7 @@
import { defineConfig } from 'tsup';
export default defineConfig({
format: ['cjs', 'esm'],
entry: ['index.tsx', 'server.ts'],
external: ['react', 'next'],
dts: true,

1731
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,7 +4,7 @@
"private": true,
"license": "MIT",
"scripts": {
"publish": "pnpm dlx ts-node publish.ts",
"publish": "jiti publish.ts",
"typecheck": "tsc --noEmit"
},
"dependencies": {

View File

@@ -1,6 +1,6 @@
import { execSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { join, resolve } from 'node:path';
import arg from 'arg';
import type { ReleaseType } from 'semver';
import semver, { RELEASE_TYPES } from 'semver';
@@ -9,9 +9,14 @@ import semver, { RELEASE_TYPES } from 'semver';
interface PackageJson {
name: string;
version: string;
scripts?: Record<string, string>;
dependencies?: Record<string, string>;
devDependencies?: Record<string, string>;
[key: string]: unknown;
config?: {
transformPackageJson?: boolean;
transformEnvs: boolean;
};
}
interface PackageInfo extends PackageJson {
@@ -21,15 +26,16 @@ interface PackageInfo extends PackageJson {
interface PublishConfig {
registry: string;
test: boolean;
clear: boolean;
}
// Utility functions
const workspacePath = (relativePath: string) =>
path.resolve(__dirname, '../../', relativePath);
resolve(__dirname, '../../', relativePath);
const savePackageJson = (absPath: string, data: PackageJson) => {
fs.writeFileSync(absPath, JSON.stringify(data, null, 2), 'utf-8');
execSync(`npx biome format ${absPath} --fix`);
};
const exit = (message: string, error?: unknown) => {
@@ -54,7 +60,7 @@ const getNextVersion = (version: string, type: ReleaseType): string => {
const nextVersion = semver.inc(version, type);
if (!nextVersion) throw new Error('Invalid version');
return type.startsWith('pre')
? nextVersion.replace(/-.*$/, '-beta')
? nextVersion.replace(/-.*$/, '-rc')
: nextVersion;
};
@@ -70,7 +76,7 @@ const loadPackages = (
return Object.fromEntries(
sdks.map((sdk) => {
const pkgPath = path.join(sdksPath, sdk, 'package.json');
const pkgPath = join(sdksPath, sdk, 'package.json');
const pkgJson = JSON.parse(
fs.readFileSync(pkgPath, 'utf-8'),
) as PackageJson;
@@ -111,30 +117,10 @@ const updatePackageJsonForRelease = (
dependents: string[],
): void => {
const { nextVersion, localPath, ...restPkgJson } = packages[name]!;
const newPkgJson: PackageJson = {
let newPkgJson: PackageJson = {
...restPkgJson,
private: false,
type: 'module',
main: './dist/index.js',
module: './dist/index.mjs',
types: './dist/index.d.ts',
files: ['dist'],
exports: {
'.': {
import: './dist/index.js',
require: './dist/index.cjs',
types: './dist/index.d.ts',
},
...(name === '@openpanel/nextjs'
? {
'./server': {
import: './dist/server.js',
require: './dist/server.cjs',
types: './dist/server.d.ts',
},
}
: {}),
},
version: nextVersion,
dependencies: Object.fromEntries(
Object.entries(restPkgJson.dependencies || {}).map(
@@ -142,17 +128,87 @@ const updatePackageJsonForRelease = (
depName,
dependents.includes(depName)
? packages[depName]?.nextVersion ||
depVersion.replace(/-local$/, '')
: depVersion.replace(/-local$/, ''),
depVersion.replace(/-local$/, '').replace(/^workspace:/, '')
: depVersion.replace(/-local$/, '').replace(/^workspace:/, ''),
],
),
),
};
if (packages[name]!.config?.transformPackageJson !== false) {
newPkgJson = {
...newPkgJson,
main: './dist/index.js',
module: './dist/index.mjs',
types: './dist/index.d.ts',
files: ['dist'],
exports: restPkgJson.exports ?? {
'.': {
import: './dist/index.js',
require: './dist/index.cjs',
types: './dist/index.d.ts',
},
...(name === '@openpanel/nextjs'
? {
'./server': {
import: './dist/server.js',
require: './dist/server.cjs',
types: './dist/server.d.ts',
},
}
: {}),
},
};
}
savePackageJson(workspacePath(`${localPath}/package.json`), newPkgJson);
packages[name]!.dependencies = newPkgJson.dependencies;
};
const searchAndReplace = (path: string, search: RegExp, replace: string) => {
const files = fs.readdirSync(path);
for (const file of files) {
const fullpath = join(path, file);
if (file === 'node_modules') {
continue;
}
if (file.includes('.')) {
const content = fs.readFileSync(fullpath, {
encoding: 'utf-8',
});
const match = content.match(search);
if (match) {
console.log(`✏️ Will replace ${search} with ${replace} in ${file}`);
const newContent = content.replaceAll(search, replace);
fs.writeFileSync(fullpath, newContent, {
encoding: 'utf-8',
});
}
} else {
searchAndReplace(fullpath, search, replace);
}
}
};
const transformPackages = (
packages: Record<string, PackageInfo>,
dependents: string[],
): void => {
for (const dep of dependents) {
const pkg = packages[dep];
if (pkg && pkg.config?.transformEnvs === true) {
const currentVersion = pkg.version;
const nextVersion = pkg.nextVersion;
searchAndReplace(
workspacePath(pkg.localPath),
new RegExp(`${currentVersion}`, 'g'),
nextVersion,
);
}
}
};
const buildPackages = (
packages: Record<string, PackageInfo>,
dependents: string[],
@@ -166,6 +222,10 @@ const buildPackages = (
});
for (const dep of dependents) {
if (!packages[dep]?.scripts?.build) {
console.log(`🔨 Skipping build for ${dep}`);
continue;
}
console.log(`🔨 Building ${dep}`);
const cmd = `pnpm build ${versionEnvs.join(' ')}`;
console.log(` Running: ${cmd}`);
@@ -180,7 +240,7 @@ const publishPackages = (
dependents: string[],
config: PublishConfig,
): void => {
if (config.test) {
if (config.clear) {
execSync('rm -rf ~/.local/share/verdaccio/storage/@openpanel');
}
@@ -203,9 +263,8 @@ const restoreAndUpdateLocal = (
dependents: string[],
): void => {
const filesToRestore = dependents
.map((dep) => workspacePath(packages[dep]!.localPath))
.map((dep) => join(workspacePath(packages[dep]!.localPath), 'package.json'))
.join(' ');
console.log(`git checkout ${filesToRestore}`);
execSync(`git checkout ${filesToRestore}`);
@@ -221,9 +280,9 @@ const restoreAndUpdateLocal = (
([depName, depVersion]) => [
depName,
dependents.includes(depName)
? `${packages[depName]!.nextVersion}-local`
? `workspace:${packages[depName]!.nextVersion}-local`
: packages[depName]
? `${packages[depName]!.version}-local`
? `workspace:${packages[depName]!.version}-local`
: depVersion,
],
),
@@ -251,9 +310,10 @@ function main() {
const args = arg({
'--name': String,
'--publish': Boolean,
'--test': Boolean,
'--npm': Boolean,
'--skip-git': Boolean,
'--type': String,
'--clear': Boolean,
});
if (!args['--skip-git']) {
@@ -287,14 +347,16 @@ function main() {
updatePackageJsonForRelease(packages, dep, dependents);
}
transformPackages(packages, dependents);
buildPackages(packages, dependents);
if (args['--publish']) {
const config: PublishConfig = {
registry: args['--test']
? 'http://localhost:4873'
: 'https://registry.npmjs.org',
test: args['--test'] || false,
registry: args['--npm']
? 'https://registry.npmjs.org'
: 'http://localhost:4873',
clear: args['--clear'] || false,
};
publishPackages(packages, dependents, config);