snapshot of https://github.com/tldraw/tldraw.git (main, 2026-09-23; blobs > 131072B omitted)
Some checks failed
Deploy analytics worker / Deploy analytics worker to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
i18n upload strings / Upload latest strings (push) Failing after 1s
Deploy bemo / Deploy bemo to ${{ (github.ref == 'refs/heads/bemo-production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
Deploy grafana resources / ${{ github.event_name == 'push' && 'Deploy grafana resources' || 'Validate grafana resources (dry run)' }} (push) Failing after 0s
End to end tests (dotcom) / Check for relevant changes (push) Failing after 0s
End to end tests (examples) / Check for relevant changes (push) Failing after 0s
End to end tests (mermaid readability) / Check for relevant changes (push) Failing after 1s
End to end tests (perf) / Check for relevant changes (push) Failing after 0s
Publish editor extensions / Publish editor extensions (push) Failing after 1s
Publish SDK packages / Remove the publish-packages label (push) Has been skipped
Checks / Tests & checks (push) Has been cancelled
Checks / Build all projects (push) Has been cancelled
Deploy templates / Deploy templates (push) Has been cancelled
End to end tests (dotcom) / End to end tests (dotcom) (push) Has been cancelled
End to end tests (mermaid readability) / End to end tests (mermaid readability) (push) Has been cancelled
End to end tests (perf) / End to end tests (perf) (push) Has been cancelled
Deploy .com / Deploy dotcom to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Has been cancelled
End to end tests (examples) / End to end tests (examples) (push) Has been cancelled
Publish SDK packages / Publish SDK packages (push) Has been cancelled
Publish SDK packages / Tell user released version (push) Has been cancelled
Publish SDK packages / Publishes code templates to separate repositories (push) Has been cancelled
Publish SDK packages / Deploys the template demos with the new SDK version (push) Has been cancelled

This commit is contained in:
upstream-mirror
2026-09-23 16:12:52 +05:00
commit a774ac673a
4541 changed files with 770499 additions and 0 deletions

2
apps/analytics/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
.yarn
public

40
apps/analytics/index.html Normal file
View File

@@ -0,0 +1,40 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Analytics Banner Preview</title>
<style>
body {
width: 100%;
height: 100%;
background-color: white;
color: black;
}
body.dark {
background-color: black;
color: white;
}
</style>
</head>
<body>
<button id="toggle-theme">toggle theme</button>
<button id="cookie-dialog">show cookie preferences</button>
</body>
<script>
const button = document.getElementById('toggle-theme')
button.addEventListener('click', () => {
document.body.classList.toggle('dark')
document.body.setAttribute(
'data-theme',
document.body.classList.contains('dark') ? 'dark' : 'light'
)
})
const cookieButton = document.getElementById('cookie-dialog')
cookieButton.addEventListener('click', () => {
window.tlanalytics.openPrivacySettings()
})
</script>
<script src="./public/tl-analytics.js"></script>
</html>

View File

@@ -0,0 +1,27 @@
{
"name": "@tldraw/analytics",
"version": "0.1.0",
"license": "",
"private": true,
"main": "dist/index.js",
"scripts": {
"build": "tsc && vite build --outDir public",
"dev": "vite",
"lint": "yarn run -T tsx ../../internal/scripts/lint.ts",
"test": "yarn run -T vitest --passWithNoTests",
"test-ci": "yarn run -T vitest run --passWithNoTests",
"test-coverage": "yarn run -T vitest run --coverage --passWithNoTests"
},
"dependencies": {
"js-cookie": "^3.0.5",
"posthog-js": "^1.281.0"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20260302.0",
"@types/js-cookie": "^3.0.6",
"typescript": "^7.0.2",
"vite": "^8.0.16",
"vitest": "^5.0.0",
"wrangler": "^4.75.0"
}
}

View File

@@ -0,0 +1,48 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import { ConsentPreferences } from '../types'
export class AnalyticsService {
protected isEnabled = false
// Initialize the service (regardless of cookie consent)
initialize(): void {}
// Clean up the service.
dispose(): void {}
// Enable the service when cookie consent is granted.
enable(): void {}
// Disable the service when cookie consent is revoked.
disable(): void {}
// Identify the user.
identify(userId: string, properties?: { [key: string]: any }): void {}
// Reset the user identity (call on logout).
reset(): void {}
// Track an event.
trackEvent(name: string, data?: { [key: string]: any }): void {}
// Track a pageview.
trackPageview(): void {}
// Track when consent banner is displayed (called before consent is granted).
trackConsentBannerDisplayed?(data: { consent_opt_in_type: 'manual' | 'auto' }): void
// Track when user selects consent preferences (called before consent is granted).
trackConsentBannerSelected?(data: ConsentPreferences): void
// Track when user copies a code snippet.
trackCopyCode?(data: {
page_category: string
text_snippet: string
user_email?: string
user_email_sha256?: string
user_first_name?: string
user_last_name?: string
user_phone_number?: string
}): void
// Track when user submits a form.
trackFormSubmission?(data: {
enquiry_type: string
page_category?: string
company_size?: string
company_website?: string
user_email?: string
user_email_sha256?: string
user_first_name?: string
user_last_name?: string
user_phone_number?: string
}): void
}

View File

@@ -0,0 +1,119 @@
import { AnalyticsService } from './analytics-service'
// Extend Window with gtag and dataLayer (other Window properties declared in types.ts)
declare global {
interface Window {
gtag?(...args: any[]): void
dataLayer?: any[]
}
}
// Helper to safely call gtag
function gtag(...args: any[]) {
if (typeof window !== 'undefined' && window.gtag) {
window.gtag(...args)
}
}
export function ga4Gtag(...args: any[]) {
gtag(...args)
}
class GA4AnalyticsService extends AnalyticsService {
private measurementId: string | undefined
private googleAdsId: string | undefined
override initialize() {
if (typeof window === 'undefined') return
this.measurementId = window.TL_GA4_MEASUREMENT_ID ?? undefined
this.googleAdsId = window.TL_GOOGLE_ADS_ID ?? undefined
if (this.measurementId) {
gtag('consent', 'default', {
ad_storage: 'denied',
ad_user_data: 'denied',
ad_personalization: 'denied',
analytics_storage: 'denied',
// Wait for our cookie to load.
wait_for_update: 500,
})
// Initialize dataLayer
window.dataLayer = window.dataLayer || []
window.gtag = function () {
// eslint-disable-next-line prefer-rest-params
window.dataLayer!.push(arguments)
}
gtag('js', new Date())
// Load the GA4 script
const script = document.createElement('script')
script.async = true
script.id = `gtag-${this.measurementId}`
script.src = `https://www.googletagmanager.com/gtag/js?id=${this.measurementId}`
document.head.appendChild(script)
gtag('config', this.measurementId, {
anonymize_ip: true,
})
}
if (this.googleAdsId) {
gtag('config', this.googleAdsId)
}
}
override dispose() {
const script = document.getElementById(`gtag-${this.measurementId}`)
if (script) script.remove()
}
override enable() {
if (this.isEnabled) return
gtag('set', { anonymize_ip: false })
gtag('consent', 'update', {
ad_user_data: 'granted',
ad_personalization: 'granted',
ad_storage: 'granted',
analytics_storage: 'granted',
})
this.isEnabled = true
}
override disable() {
if (!this.isEnabled) return
// Clear user properties
if (this.measurementId) {
gtag('config', this.measurementId, {
client_id: undefined,
user_id: undefined,
})
}
gtag('set', { anonymize_ip: true })
gtag('consent', 'update', {
ad_user_data: 'denied',
ad_personalization: 'denied',
ad_storage: 'denied',
analytics_storage: 'denied',
})
this.isEnabled = false
}
override identify(userId: string, properties?: { [key: string]: any }) {
gtag('set', { user_id: userId })
if (properties) {
gtag('set', properties)
}
}
override trackEvent(name: string, data?: { [key: string]: any }) {
gtag('event', name, data)
}
override trackPageview() {
gtag('event', 'page_view')
}
}
export const ga4Service = new GA4AnalyticsService()

View File

@@ -0,0 +1,182 @@
import { GTM_SCRIPT_ID } from '../constants'
import { ConsentPreferences } from '../types'
import { AnalyticsService } from './analytics-service'
// Extend Window with dataLayer (other Window properties declared in types.ts)
declare global {
interface Window {
dataLayer?: any[]
}
}
// Helper to safely push to dataLayer (internal use only)
function dataLayerPush(data: any) {
if (typeof window !== 'undefined' && window.dataLayer) {
window.dataLayer.push(data)
}
}
class GTMAnalyticsService extends AnalyticsService {
private gtmContainerId: string | undefined
override initialize() {
if (typeof window === 'undefined') return
this.gtmContainerId = window.TL_GTM_CONTAINER_ID ?? undefined
if (!this.gtmContainerId) return
// Set default consent state
window.dataLayer = window.dataLayer || []
dataLayerPush({
'gtm.start': new Date().getTime(),
event: 'gtm.js',
})
// Load the GTM script
const script = document.createElement('script')
script.async = true
script.id = GTM_SCRIPT_ID
script.src = `https://www.googletagmanager.com/gtm.js?id=${this.gtmContainerId}`
document.head.appendChild(script)
}
override dispose() {
const script = document.getElementById(GTM_SCRIPT_ID)
if (script) script.remove()
}
override enable() {
this.isEnabled = true
}
override disable() {
this.isEnabled = false
}
override identify(userId: string, properties?: { [key: string]: any }) {
if (!this.isEnabled) return
dataLayerPush({
event: 'user_identify',
user_id: userId,
user_properties: properties,
})
}
override trackEvent(name: string, data?: { [key: string]: any }) {
dataLayerPush({
event: name,
...data,
})
}
override trackPageview() {
dataLayerPush({
event: 'page_view',
})
}
override trackConsentBannerDisplayed(data: { consent_opt_in_type: 'manual' | 'auto' }) {
dataLayerPush({
event: 'display_consent_banner',
id: crypto.randomUUID(),
data,
})
}
override trackConsentBannerSelected(data: ConsentPreferences) {
dataLayerPush({
event: 'select_consent_banner',
id: crypto.randomUUID(),
data,
})
}
override trackCopyCode(data: {
page_category: string
text_snippet: string
user_email?: string
user_email_sha256?: string
user_first_name?: string
user_last_name?: string
user_phone_number?: string
}) {
const payload: any = {
event: 'click_copy_code',
id: crypto.randomUUID(),
page: {
category: data.page_category.toLowerCase(),
},
data: {
text_snippet: data.text_snippet.substring(0, 100),
},
_clear: true,
}
// Only include user object if we have user data
if (
data.user_email ||
data.user_email_sha256 ||
data.user_first_name ||
data.user_last_name ||
data.user_phone_number
) {
payload.user = {}
if (data.user_email) payload.user.email = data.user_email.toLowerCase()
if (data.user_email_sha256) payload.user.email_sha256 = data.user_email_sha256.toLowerCase()
if (data.user_first_name) payload.user.first_name = data.user_first_name.toLowerCase()
if (data.user_last_name) payload.user.last_name = data.user_last_name.toLowerCase()
if (data.user_phone_number) payload.user.phone_number = data.user_phone_number
}
dataLayerPush(payload)
}
override trackFormSubmission(data: {
enquiry_type: string
page_category?: string
company_size?: string
company_website?: string
user_email?: string
user_email_sha256?: string
user_first_name?: string
user_last_name?: string
user_phone_number?: string
}) {
const payload: any = {
event: 'generate_lead',
id: crypto.randomUUID(),
page: {
category: (data.page_category || 'enquiry').toLowerCase(),
},
data: {
enquiry_type: data.enquiry_type.toLowerCase(),
},
_clear: true,
}
// Add optional company data
if (data.company_size) payload.data.company_size = data.company_size.toLowerCase()
if (data.company_website) payload.data.company_website = data.company_website.toLowerCase()
// Only include user object if we have user data
if (
data.user_email ||
data.user_email_sha256 ||
data.user_first_name ||
data.user_last_name ||
data.user_phone_number
) {
payload.user = {}
if (data.user_email) payload.user.email = data.user_email.toLowerCase()
if (data.user_email_sha256) payload.user.email_sha256 = data.user_email_sha256.toLowerCase()
if (data.user_first_name) payload.user.first_name = data.user_first_name.toLowerCase()
if (data.user_last_name) payload.user.last_name = data.user_last_name.toLowerCase()
if (data.user_phone_number) payload.user.phone_number = data.user_phone_number
}
dataLayerPush(payload)
}
}
export const gtmService = new GTMAnalyticsService()

View File

@@ -0,0 +1,23 @@
import { HUBSPOT_SCRIPT_ID, HUBSPOT_SCRIPT_URL } from '../constants'
import { AnalyticsService } from './analytics-service'
class HubspotAnalyticsService extends AnalyticsService {
// Does not need to be initialized
override enable() {
if (this.isEnabled) return
const hubspotScriptTag = document.createElement('script')
hubspotScriptTag.id = HUBSPOT_SCRIPT_ID
hubspotScriptTag.src = HUBSPOT_SCRIPT_URL
hubspotScriptTag.defer = true
document.head.appendChild(hubspotScriptTag)
this.isEnabled = true
}
override dispose() {
const hubspotScriptTag = document.getElementById(HUBSPOT_SCRIPT_ID)
if (hubspotScriptTag) hubspotScriptTag.remove()
this.isEnabled = false
}
}
export const hubspotService = new HubspotAnalyticsService()

View File

@@ -0,0 +1,53 @@
import _posthog from 'posthog-js'
import { POSTHOG_API_HOST, POSTHOG_TOKEN, POSTHOG_UI_HOST } from '../constants'
import { AnalyticsService } from './analytics-service'
class PosthogAnalyticsService extends AnalyticsService {
override initialize() {
// Make posthog available globally
if (typeof window !== 'undefined') {
window.posthog = _posthog
}
_posthog.init(POSTHOG_TOKEN, {
api_host: POSTHOG_API_HOST,
ui_host: POSTHOG_UI_HOST,
capture_pageview: 'history_change',
cookieless_mode: 'on_reject',
})
}
override enable() {
if (this.isEnabled) return
_posthog.opt_in_capturing()
this.isEnabled = true
}
override disable() {
if (!this.isEnabled) return
_posthog.setPersonProperties({ analytics_consent: false })
_posthog.opt_out_capturing()
this.isEnabled = false
}
override identify(userId: string, properties?: { [key: string]: any }) {
_posthog.identify(userId, {
...properties,
analytics_consent: true,
})
}
override reset() {
_posthog.reset()
}
override trackEvent(name: string, data?: { [key: string]: any }) {
_posthog.capture(name, data)
}
override trackPageview() {
_posthog.capture('$pageview')
}
}
export const posthogService = new PosthogAnalyticsService()

View File

@@ -0,0 +1,39 @@
import { REO_CLIENT_ID, REO_SCRIPT_ID, REO_SCRIPT_URL } from '../constants'
import { AnalyticsService } from './analytics-service'
class ReoAnalyticsService extends AnalyticsService {
override enable() {
if (this.isEnabled) return
const reoScriptTag = document.createElement('script')
reoScriptTag.id = REO_SCRIPT_ID
reoScriptTag.src = REO_SCRIPT_URL
reoScriptTag.defer = true
reoScriptTag.onload = () => window.Reo?.init?.({ clientID: REO_CLIENT_ID })
document.head.appendChild(reoScriptTag)
this.isEnabled = true
}
override disable() {
if (!this.isEnabled) return
window.Reo?.reset?.()
this.isEnabled = false
}
override dispose() {
const reoScriptTag = document.getElementById(REO_SCRIPT_ID)
if (reoScriptTag) reoScriptTag.remove()
this.isEnabled = false
}
override identify(userId: string, properties?: { [key: string]: any }) {
window.Reo?.identify?.({
...properties,
userId,
firstname: properties?.name || '',
username: properties?.email || '',
type: 'email',
})
}
}
export const reoService = new ReoAnalyticsService()

View File

@@ -0,0 +1,90 @@
import { DOT_DEV_COOKIE_POLICY_URL } from '../constants'
import { CookieConsentState } from '../state/cookie-consent-state'
import { ThemeState } from '../state/theme-state'
export function createCookieConsentBanner(
cookieConsentState: CookieConsentState,
themeState: ThemeState
): HTMLElement | null {
const consent = cookieConsentState.getValue()
const theme = themeState.getValue()
// Don't show banner if consent is already given
if (consent !== 'unknown') return null
// Create banner element
const banner = document.createElement('div')
banner.className = 'tl-analytics-banner'
banner.setAttribute('data-theme', theme)
// Enable pointer events after animation completes
setTimeout(() => {
banner.style.pointerEvents = 'auto'
}, 3000)
// Create content
banner.innerHTML = `
<p class="tl-analytics-banner__text">
We use cookies on this website.
<br>
Learn more in our <a class="tl-analytics-banner__link" href="${DOT_DEV_COOKIE_POLICY_URL}" target="_blank" rel="noreferrer">Cookie Policy</a>.
</p>
<div class="tl-analytics-banner__buttons">
<button class="tl-analytics-button tl-analytics-button--secondary" data-action="opt-out">
Opt out
</button>
<button class="tl-analytics-button tl-analytics-button--primary" data-action="accept">
<div class="tl-analytics-button__text-wrapper">
<div class="tl-analytics-button__text">Accept all</div>
</div>
</button>
</div>
`
// Add event listeners
banner.addEventListener('click', (e: Event) => {
const target = e.target as HTMLElement
const action = target.closest('[data-action]')?.getAttribute('data-action')
if (action === 'opt-out') {
cookieConsentState.setValue('opted-out')
} else if (action === 'accept') {
cookieConsentState.setValue('opted-in')
}
})
const consentCleanup = cookieConsentState.subscribe((consent) => {
if (consent !== 'unknown') {
banner.remove()
}
})
// Watch for theme changes
const themeCleanup = themeState.subscribe((theme) => {
banner.setAttribute('data-theme', theme)
})
// Clean up observer when banner is removed
const originalRemove = banner.remove.bind(banner)
banner.remove = function () {
originalRemove()
themeCleanup()
consentCleanup()
}
return banner
}
// Auto-mount function for easy integration
export function mountCookieConsentBanner(
cookieConsentState: CookieConsentState,
themeState: ThemeState,
container: HTMLElement
): HTMLElement | null {
const existingBanner = document.querySelector('.tl-analytics-banner')
if (existingBanner) existingBanner.remove()
const banner = createCookieConsentBanner(cookieConsentState, themeState)
if (banner) container.appendChild(banner)
return banner
}

View File

@@ -0,0 +1,132 @@
import { DOT_DEV_COOKIE_POLICY_URL } from '../constants'
import { CookieConsentState } from '../state/cookie-consent-state'
import { ThemeState } from '../state/theme-state'
export function createPrivacySettingsDialog(
cookieConsentState: CookieConsentState,
themeState: ThemeState
): HTMLElement {
const consent = cookieConsentState.getValue()
const theme = themeState.getValue()
// Create dialog container
const dialogContainer = document.createElement('div')
dialogContainer.className = 'tl-analytics-dialog'
// Create overlay and dialog
const overlay = document.createElement('div')
overlay.className = 'tl-analytics-dialog__overlay'
overlay.setAttribute('data-theme', theme)
const dialogWrapper = document.createElement('div')
dialogWrapper.className = 'tl-analytics-dialog__wrapper'
dialogWrapper.setAttribute('data-theme', theme)
const dialog = document.createElement('div')
dialog.className = 'tl-analytics-dialog__content'
// Create dialog content
dialog.innerHTML = `
<div class="tl-analytics-dialog__header">
<h2 class="tl-analytics-dialog__title">Privacy settings</h2>
<button class="tl-analytics-dialog__close" aria-label="Close">
<svg width="15" height="15" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<p class="tl-analytics-dialog__body">
This website uses cookies to collect analytics from visitors. Read our
<a href="${DOT_DEV_COOKIE_POLICY_URL}" target="_blank" rel="noreferrer">cookie policy</a>
to learn more.
</p>
<div class="tl-analytics-dialog__checkbox-group">
<label class="tl-analytics-checkbox__label" for="privacy-analytics">
<strong>Analytics</strong> help us understand how people use this website so that we can make it better.
</label>
<div>
<button class="tl-analytics-checkbox ${consent === 'opted-in' ? 'checked' : ''}" id="privacy-analytics" role="switch" aria-checked="${consent === 'opted-in'}">
<span class="tl-analytics-checkbox__thumb"></span>
</button>
</div>
</div>
`
// Add event listeners
const closeButton = dialog.querySelector('.tl-analytics-dialog__close') as HTMLButtonElement
const switchButton = dialog.querySelector('.tl-analytics-checkbox') as HTMLButtonElement
function closeDialog() {
dialogContainer.remove()
}
closeButton.addEventListener('click', closeDialog)
// Close on overlay click
overlay.addEventListener('click', closeDialog)
// Prevent closing when clicking inside dialog
dialog.addEventListener('click', (e: Event) => e.stopPropagation())
// Handle ESC key
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') {
closeDialog()
}
}
document.addEventListener('keydown', handleKeyDown)
// Handle switch toggle
switchButton.addEventListener('click', () => {
const currentConsent = cookieConsentState.getValue()
cookieConsentState.setValue(currentConsent === 'opted-in' ? 'opted-out' : 'opted-in')
})
// Watch for consent changes
const cleanupConsent = cookieConsentState.subscribe((consent) => {
if (consent === 'opted-in') {
switchButton.classList.add('checked')
switchButton.setAttribute('aria-checked', 'true')
} else {
switchButton.classList.remove('checked')
switchButton.setAttribute('aria-checked', 'false')
}
})
// Watch for theme changes
const cleanupTheme = themeState.subscribe((theme) => {
overlay.setAttribute('data-theme', theme)
dialogWrapper.setAttribute('data-theme', theme)
})
// Clean up observer when dialog is removed
const originalRemove = dialogContainer.remove.bind(dialogContainer)
dialogContainer.remove = function () {
document.removeEventListener('keydown', handleKeyDown)
originalRemove()
cleanupTheme()
cleanupConsent()
}
// Assemble dialog
dialogWrapper.appendChild(dialog)
dialogContainer.appendChild(overlay)
dialogContainer.appendChild(dialogWrapper)
return dialogContainer
}
// Auto-mount function for easy integration
export function mountPrivacySettingsDialog(
cookieConsentState: CookieConsentState,
themeState: ThemeState,
container: HTMLElement
): HTMLElement {
const existingDialog = document.querySelector('.tl-analytics-dialog')
if (existingDialog) existingDialog.remove()
const dialog = createPrivacySettingsDialog(cookieConsentState, themeState)
if (dialog) container.appendChild(dialog)
return dialog
}

View File

@@ -0,0 +1,24 @@
// All analytics use the same cookie policy URL.
export const DOT_DEV_COOKIE_POLICY_URL = 'https://tldraw.notion.site/devcookiepolicy'
// All analytics use the same cookie to store consent.
export const LEGACY_CONSENT_COOKIE_NAME = 'allowTracking'
export const CONSENT_COOKIE_NAME = 'trackingConsent'
export const CONSENT_CHANGED_EVENT = 'tl-analytics-consent-changed'
// All analytics are fed into the same HubSpot project.
export const HUBSPOT_SCRIPT_ID = 'hs-script-loader'
export const HUBSPOT_SCRIPT_URL = 'https://js-eu1.hs-scripts.com/145620695.js'
// All analytics are fed into the same Reo project.
export const REO_SCRIPT_ID = 'reo-script-loader'
export const REO_CLIENT_ID = '47839e47a5ed202'
export const REO_SCRIPT_URL = `https://static.reo.dev/${REO_CLIENT_ID}/reo.js`
// All analytics are fed into the same PostHog project.
export const POSTHOG_TOKEN = 'phc_i8oKgMzgV38sn3GfjswW9mevQ3gFlo7bJXekZFeDN6'
export const POSTHOG_API_HOST = 'https://analytics.tldraw.com/i'
export const POSTHOG_UI_HOST = 'https://eu.i.posthog.com'
// Google Tag Manager configuration.
export const GTM_SCRIPT_ID = 'gtm-script-loader'

305
apps/analytics/src/index.ts Normal file
View File

@@ -0,0 +1,305 @@
import { ga4Gtag, ga4Service } from './analytics-services/ga4'
import { gtmService } from './analytics-services/gtm'
import { hubspotService } from './analytics-services/hubspot'
import { posthogService } from './analytics-services/posthog'
import { reoService } from './analytics-services/reo'
import { mountCookieConsentBanner } from './components/CookieConsentBanner'
import { mountPrivacySettingsDialog } from './components/PrivacySettingsDialog'
import {
CookieConsentState,
getCookieConsent,
setOrClearCookieConsent,
} from './state/cookie-consent-state'
import { ThemeState } from './state/theme-state'
import { ConsentPreferences, CookieConsent } from './types'
import { shouldRequireConsent } from './utils/consent-check'
import styles from './styles.css?inline'
function cookieConsentToPreferences(
consent: CookieConsent,
consentOptInType: 'manual' | 'auto'
): ConsentPreferences {
if (consent === 'opted-in') {
return { analytics: 'granted', marketing: 'granted', opt_in_type: consentOptInType }
}
return { analytics: 'denied', marketing: 'denied', opt_in_type: consentOptInType }
}
class Analytics {
private userId = '' as string
private userProperties = {} as { [key: string]: any } | undefined
private consent = 'unknown' as CookieConsent
private consentOptInType: 'manual' | 'auto' = 'manual'
private consentCallbacks: Array<(preferences: ConsentPreferences) => void> = []
private isInitialized = false
private services = [posthogService, ga4Service, gtmService, hubspotService, reoService]
private maybeTrackConsentUpdate(consent: ConsentPreferences) {
if (this.isInitialized) {
this.track('consent_update', consent)
} else {
gtmService.trackEvent('consent_update', consent)
}
}
async initialize() {
// Inject styles
const style = document.createElement('style')
style.textContent = styles
document.head.appendChild(style)
// Initialize the analytics services
for (const service of this.services) {
service.initialize()
}
// Set up our mini reactive states
const themeState = new ThemeState('light')
themeState.initialize()
const cookieConsentState = new CookieConsentState('unknown')
cookieConsentState.initialize()
// Subscribe to consent changes
cookieConsentState.subscribe((consent) => {
// If the user changed the setting we want to change opt in type to manual
if (this.isInitialized && consent !== 'unknown' && consent !== this.consent) {
this.consentOptInType = 'manual'
}
// Set (or clear) the cookie value
setOrClearCookieConsent(consent, this.consentOptInType)
const consentState = cookieConsentToPreferences(consent, this.consentOptInType)
// If the consent is the same as the current consent, do nothing
if (consent === this.consent) {
// We still send the event to gtm, so that we can send data to gtm on website reload
gtmService.trackEvent('consent_update', consentState)
return
}
this.consent = consent
if (this.consent === 'opted-in') {
// Enable the analytics services only when consent is granted
for (const service of this.services) {
service.enable()
}
this.maybeTrackConsentUpdate(consentState)
// Identify the user if we have a userId. Most of the time
// identify() should be called off of the window.tlanalytics object, ie. in an app
// where we have a user id and properties for that app (like tldraw computer). We do
// it here, too, so that we can re-identify a user who has opted out of analytics
// and then opts back in within the same session. We would have their user id and properties
// in memory (from the first time they called identify) and would re-use them here.
if (this.userId) {
this.identify(this.userId, this.userProperties)
}
} else {
this.maybeTrackConsentUpdate(consentState)
// Disable the analytics services when consent is revoked or when consent is unknown
for (const service of this.services) {
service.disable()
}
}
// Track consent selection only if user actually interacted with banner (not during initialization)
if (this.isInitialized && consent !== 'unknown') {
for (const service of this.services) {
service.trackConsentBannerSelected?.(consentState)
}
}
// Notify consent callbacks
if (consent !== 'unknown') {
for (const callback of this.consentCallbacks) {
callback(consentState)
}
}
})
// ...also we stash a few things onto the window in case we need them elsewhere
window.tlanalytics = {
identify: this.identify.bind(this),
reset: this.reset.bind(this),
track: this.track.bind(this),
page: this.page.bind(this),
gtag: this.gtag.bind(this),
getConsentState: () => cookieConsentToPreferences(this.consent, this.consentOptInType),
onConsentUpdate: (callback: (preferences: ConsentPreferences) => void) => {
this.consentCallbacks.push(callback)
return () => {
const index = this.consentCallbacks.indexOf(callback)
if (index > -1) this.consentCallbacks.splice(index, 1)
}
},
trackCopyCode: this.trackCopyCode.bind(this),
trackFormSubmission: this.trackFormSubmission.bind(this),
openPrivacySettings() {
mountPrivacySettingsDialog(cookieConsentState, themeState, document.body)
},
}
// Now that we have our subscriber set up, determine the initial consent state.
// If the user has already made a choice (cookie exists), use that.
// Otherwise, check their location to determine if we need explicit consent.
const cookieData = getCookieConsent()
let initialConsent: CookieConsent
if (cookieData) {
// User has previously made a consent decision - respect it and restore opt-in type
initialConsent = cookieData.consent
this.consentOptInType = cookieData.optInType
} else {
// No existing consent decision - check if we need to ask based on location
const requiresConsent = await shouldRequireConsent()
if (requiresConsent === 'requires-consent') {
initialConsent = 'unknown'
this.consentOptInType = 'manual'
} else {
initialConsent = 'opted-in'
this.consentOptInType = 'auto'
}
}
// This will trigger the subscriber we set up earlier, which will
// enable/disable the analytics services accordingly
cookieConsentState.setValue(initialConsent)
// Now that we have the consent value in memory, we can initialize
// the cookie consent banner and start showing some UI (if consent is unknown)
const banner = mountCookieConsentBanner(cookieConsentState, themeState, document.body)
// Track banner display if banner was shown
if (banner) {
for (const service of this.services) {
service.trackConsentBannerDisplayed?.({
consent_opt_in_type: this.consentOptInType,
})
}
}
// Mark initialization as complete - any consent changes after this are user interactions
this.isInitialized = true
}
/**
* Wrap and guard google analytics tag. This should be called from window.tlanalytics, ie. in an app where we have a user id and properties for that app (like tldraw computer).
*
* @param args - The arguments to pass to the google analytics tag
* @returns void
*/
gtag(...args: any[]) {
if (this.consent !== 'opted-in') return
ga4Gtag(...args)
}
/**
* Identify the user across all analytics services. This should be called from window.tlanalytics, ie. in an app where we have a user id and properties for that app (like tldraw computer).
*
* @param userId - The user id to identify the user by
* @param properties - The properties to identify the user by
* @returns void
*/
identify(userId: string, properties?: { [key: string]: any }) {
this.userId = userId
this.userProperties = properties
if (this.consent !== 'opted-in') return
for (const service of this.services) {
service.identify(userId, properties)
}
}
/**
* Reset the user identity across all analytics services. This should be called from window.tlanalytics when a user logs out.
*
* @returns void
*/
reset() {
this.userId = ''
this.userProperties = undefined
for (const service of this.services) {
service.reset()
}
}
/**
* Track an event across all analytics services. This should be called from window.tlanalytics, ie. in an app where we have a user id and properties for that app (like tldraw computer).
*
* @param name - The name of the event to track
* @param data - The data to track with the event
* @returns void
*/
track(name: string, data?: { [key: string]: any }) {
for (const service of this.services) {
service.trackEvent(name, data)
}
}
/**
* Track a page view across all analytics services. This should be called from window.tlanalytics, ie. in an app where we have a user id and properties for that app (like tldraw computer).
*
* @returns void
*/
page() {
for (const service of this.services) {
service.trackPageview()
}
}
/**
* Track code copy event. This should be called from window.tlanalytics when a user copies a code snippet.
*
* @param data - The code copy event data
* @returns void
*/
trackCopyCode(data: {
page_category: string
text_snippet: string
user_email?: string
user_email_sha256?: string
user_first_name?: string
user_last_name?: string
user_phone_number?: string
}) {
for (const service of this.services) {
service.trackCopyCode?.(data)
}
}
/**
* Track form submission event. This should be called from window.tlanalytics when a user submits a form.
*
* @param data - The form submission event data
* @returns void
*/
trackFormSubmission(data: {
enquiry_type: string
page_category?: string
company_size?: string
company_website?: string
user_email?: string
user_email_sha256?: string
user_first_name?: string
user_last_name?: string
user_phone_number?: string
}) {
for (const service of this.services) {
service.trackFormSubmission?.(data)
}
}
}
try {
const analytics = new Analytics()
analytics.initialize()
} catch (error) {
console.error('Error initializing analytics manager:', error)
}

View File

@@ -0,0 +1,68 @@
import Cookies from 'js-cookie'
import { CONSENT_COOKIE_NAME, LEGACY_CONSENT_COOKIE_NAME } from '../constants'
import {
CONSENT_OPT_IN_TYPES,
COOKIE_CONSENT_VALUES,
type ConsentOptInType,
type CookieConsent,
type CookieConsentData,
} from '../types'
import { AnalyticsState } from './state'
function isValidCookieConsentData(data: any): data is CookieConsentData {
return (
typeof data === 'object' &&
data !== null &&
COOKIE_CONSENT_VALUES.includes(data.consent) &&
CONSENT_OPT_IN_TYPES.includes(data.optInType)
)
}
export function getCookieConsent(): CookieConsentData | undefined {
// Check new cookie format first - takes precedence
const cookieValue = Cookies.get(CONSENT_COOKIE_NAME)
if (cookieValue) {
// Parse JSON format
try {
const parsed = JSON.parse(cookieValue)
if (isValidCookieConsentData(parsed)) {
// Clean up legacy cookie if it exists
Cookies.remove(LEGACY_CONSENT_COOKIE_NAME)
return parsed
}
} catch {
// Invalid JSON - fall through to legacy check
}
}
// Check for legacy cookie and migrate
const legacyCookieValue = Cookies.get(LEGACY_CONSENT_COOKIE_NAME)
if (legacyCookieValue) {
let migrated: CookieConsentData | undefined
if (legacyCookieValue === 'true') {
migrated = { consent: 'opted-in', optInType: 'manual' }
} else if (legacyCookieValue === 'false') {
migrated = { consent: 'opted-out', optInType: 'manual' }
}
if (migrated) {
// Write to new cookie and delete old one
Cookies.set(CONSENT_COOKIE_NAME, JSON.stringify(migrated))
Cookies.remove(LEGACY_CONSENT_COOKIE_NAME)
return migrated
}
}
return undefined
}
export function setOrClearCookieConsent(consent: CookieConsent, optInType: ConsentOptInType): void {
if (consent === 'unknown') {
Cookies.remove(CONSENT_COOKIE_NAME)
} else {
Cookies.set(CONSENT_COOKIE_NAME, JSON.stringify({ consent, optInType }))
}
}
export class CookieConsentState extends AnalyticsState<CookieConsent> {}

View File

@@ -0,0 +1,57 @@
import { AnalyticsState } from './state'
describe('mini reactive state', () => {
test('creates a state with an initial value', () => {
class MyState extends AnalyticsState<number> {}
const myState = new MyState(10)
expect(myState.getValue()).toBe(10)
const myOtherState = new MyState(20)
expect(myOtherState.getValue()).toBe(20)
})
test('updates the state', () => {
class MyState extends AnalyticsState<number> {}
const myState = new MyState(10)
myState.setValue(20)
expect(myState.getValue()).toBe(20)
})
test('handles subscribers', () => {
class MyState extends AnalyticsState<number> {}
const myState = new MyState(10)
const fn = vi.fn()
myState.subscribe((value) => fn(value))
myState.setValue(20)
expect(fn).toHaveBeenCalledWith(20)
expect(fn).toHaveBeenCalledOnce()
})
test('removes subscribers', () => {
class MyState extends AnalyticsState<number> {}
const myState = new MyState(10)
const fn = vi.fn()
const unsubscribe = myState.subscribe((value) => fn(value))
unsubscribe()
myState.setValue(20)
expect(fn).not.toHaveBeenCalled()
})
test('handles initializer / dispose', () => {
const someState = {} as { something?: boolean }
class MyState extends AnalyticsState<number> {
override initialize(): void {
someState.something = true
}
override dispose(): void {
delete someState.something
}
}
const myState = new MyState(10)
expect(someState.something).not.toBeDefined()
myState.initialize()
expect(someState.something).toBeDefined()
myState.dispose()
expect(someState.something).not.toBeDefined()
})
})

View File

@@ -0,0 +1,59 @@
export class AnalyticsState<T> {
// The current value of the state.
protected value: T
// Set of listeners to notify when the state changes.
protected listeners = new Set<(value: T) => void>()
constructor(initialValue: T) {
this.value = initialValue
}
/**
* Subscribe to the state change.
*
* @param listener - The listener to notify when the state changes.
* @returns A function to unsubscribe the listener.
*/
subscribe(listener: (value: T) => void): () => void {
this.listeners.add(listener)
return () => this.listeners.delete(listener)
}
/**
* Notify all listeners of the state change.
*/
notify() {
this.listeners.forEach((listener) => listener(this.value))
}
/**
* Initialize the state.
*/
initialize(): void {}
/**
* Clean up the state.
*/
dispose(): void {}
/**
* Get the current value of the state.
*
* @returns The current value.
*/
getValue(): T {
return this.value
}
/**
* Set the value of the state and notify all listeners.
*
* @param value - The new value to set.
*/
setValue(value: T): void {
if (value === this.value) return
this.value = value
this.notify()
}
}

View File

@@ -0,0 +1,47 @@
// This module provides theme detection utilities for analytics components
// Since the analytics library is used on many sites, which all may use different themes,
// we use the most general way of detecting light or dark theme by checking the
// `color-scheme` CSS property on the `html` element.
import { AnalyticsState } from './state'
export class ThemeState extends AnalyticsState<'light' | 'dark'> {
private observer: MutationObserver | null = null
override initialize(): void {
// If document is not available, default to light theme
if (typeof document === 'undefined') {
this.value = 'light'
return
}
// Read initial theme from document
const docElm = document.documentElement
this.value = docElm.getAttribute('style')?.includes('color-scheme: dark') ? 'dark' : 'light'
if (typeof MutationObserver === 'undefined') {
// weird browser, we just won't set up the observer (we won't be able to detect theme changes)
} else {
const observer = new MutationObserver((mutations) => {
if (mutations.some((mutation) => mutation.attributeName === 'style')) {
this.setValue(
docElm.getAttribute('style')?.includes('color-scheme: dark') ? 'dark' : 'light'
)
}
})
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['style'],
})
this.observer = observer
}
}
override dispose() {
if (this.observer) {
this.observer.disconnect()
this.observer = null
}
}
}

View File

@@ -0,0 +1,297 @@
:root {
--tl-analytics-button-bg: hsl(0, 0%, 100%);
--tl-analytics-button-text: hsl(235, 6.8%, 13.5%);
--tl-analytics-bg: hsl(235, 6.8%, 13.5%);
--tl-analytics-text: hsl(0, 9%, 94%);
--tl-analytics-text-secondary: hsl(204, 4%, 75%);
--tl-analytics-link: #60a5fa;
--tl-analytics-link-hover: #93c5fd;
--tl-analytics-overlay: rgba(0, 0, 0, 0.2);
--tl-analytics-shadow:
0px 1px 3px hsl(0, 0%, 0%, 66.6%), 0px 2px 6px hsl(0, 0%, 0%, 33%),
inset 0px 0px 0px 1px var(--tl-color-panel-contrast);
}
[data-theme='dark'] {
--tl-analytics-button-bg: hsl(0, 0%, 0%);
--tl-analytics-button-text: hsl(0, 0%, 99%);
--tl-analytics-bg: hsl(0, 0%, 99%);
--tl-analytics-text: hsl(0, 0%, 0%);
--tl-analytics-text-secondary: hsl(204, 4%, 45%);
--tl-analytics-link: #3b82f6;
--tl-analytics-link-hover: #2563eb;
--tl-analytics-overlay: rgba(0, 0, 0, 0.45);
--tl-analytics-shadow:
0px 0px 2px hsl(0, 0%, 0%, 16%), 0px 2px 3px hsl(0, 0%, 0%, 24%),
0px 2px 6px hsl(0, 0%, 0%, 0.1), inset 0px 0px 0px 1px var(--tl-color-panel-contrast);
}
.tl-analytics-banner {
position: fixed;
z-index: 1000;
padding: 6px;
border-radius: 8px;
box-shadow: var(--tl-analytics-shadow);
background-color: var(--tl-analytics-bg);
display: flex;
flex-direction: column;
gap: 4px;
/* Inter is usually available on our properties */
font-family: 'Inter', sans-serif;
font-weight: 400;
/* Safe area aware overrides */
bottom: max(4px, env(safe-area-inset-bottom));
right: max(4px, env(safe-area-inset-right));
max-width: 240px;
/* Slide-in animation */
transform: translateY(110%);
animation: slideUp 3s ease-in forwards;
animation-delay: 2s;
pointer-events: none;
}
@keyframes slideUp {
0% {
transform: translateY(110%);
}
100% {
transform: translateY(0);
}
}
.tl-analytics-banner__text {
font-size: 12px;
line-height: 1.4;
color: var(--tl-analytics-text);
margin: 0;
padding: 8px 8px 12px;
}
.tl-analytics-banner__link {
color: var(--tl-analytics-link);
text-decoration: none;
}
.tl-analytics-banner__link:hover {
color: var(--tl-analytics-link-hover);
}
.tl-analytics-banner__buttons {
display: flex;
gap: 4px;
justify-content: space-between;
width: 100%;
flex-wrap: wrap;
}
.tl-analytics-button {
font-size: 12px;
cursor: pointer;
text-decoration: none;
border: none;
padding: 4px 8px;
border-radius: 4px;
white-space: nowrap;
flex-shrink: 0;
}
.tl-analytics-button--primary {
font-weight: 500;
background-color: var(--tl-analytics-button-bg);
color: var(--tl-analytics-button-text);
overflow: hidden;
position: relative;
padding: 6px 6px;
min-width: 96px;
}
.tl-analytics-button__text-wrapper {
overflow: hidden;
}
.tl-analytics-button__text {
display: inline-block;
animation: marquee 2.5s linear infinite;
white-space: nowrap;
transform: translateX(72px);
}
@keyframes marquee {
0% {
transform: translateX(72px);
}
100% {
transform: translateX(-72px);
}
}
/* Respect user's motion preferences */
@media (prefers-reduced-motion: reduce) {
.tl-analytics-banner {
animation: none;
transform: translateY(0);
}
.tl-analytics-button__text {
animation: none;
transform: translateX(0);
}
}
.tl-analytics-button--secondary {
background: none;
color: var(--tl-analytics-text);
}
.tl-analytics-dialog {
font-family: 'Inter', sans-serif;
/* The dialog renders inside the host page, so reset inherited text styles
like tldraw.com's letter-spacing (#9363) */
letter-spacing: normal;
color: var(--tl-analytics-text);
position: fixed;
inset: 0;
z-index: 10000;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.tl-analytics-dialog__overlay {
position: fixed;
inset: 0;
background-color: var(--tl-analytics-overlay);
z-index: 1;
}
.tl-analytics-dialog__wrapper {
z-index: 2;
}
.tl-analytics-dialog__content {
position: relative;
max-width: 350px;
min-width: 350px;
background-color: var(--tl-analytics-bg);
border-radius: 8px;
box-shadow: var(--tl-analytics-shadow);
font-size: 12px;
line-height: 1.6;
text-align: left;
}
.tl-analytics-dialog__header {
height: 40px;
padding-left: 12px;
display: flex;
align-items: center;
justify-content: space-between;
}
.tl-analytics-dialog__title {
font-weight: 500;
font-size: 12px;
color: var(--tl-analytics-text);
margin: 0;
}
.tl-analytics-dialog__close {
position: relative;
height: 40px;
width: 40px;
display: flex;
align-items: center;
justify-content: center;
padding: 4px;
border: none;
background: none;
cursor: pointer;
color: var(--tl-analytics-text);
}
@media (hover: hover) {
.tl-analytics-dialog__close::after {
content: '';
position: absolute;
inset: 4px;
background-color: var(--tl-analytics-overlay);
border-radius: 4px;
opacity: 0;
}
.tl-analytics-dialog__close:hover::after {
opacity: 0.2;
}
}
.tl-analytics-dialog__body {
margin: 12px;
}
.tl-analytics-dialog__body p {
margin: 0;
color: var(--tl-analytics-text-secondary);
}
.tl-analytics-dialog__body a {
display: inline-block;
color: var(--tl-analytics-link);
text-decoration: none;
}
.tl-analytics-dialog__body a:hover {
color: var(--tl-analytics-link-hover);
}
.tl-analytics-dialog__checkbox-group {
display: flex;
align-items: center;
gap: 12px;
margin: 12px;
padding-bottom: 12px;
}
.tl-analytics-checkbox {
width: 32px;
height: 20px;
background-color: var(--tl-analytics-text-secondary);
border-radius: 9999px;
position: relative;
cursor: pointer;
border: 0;
padding: 0;
}
.tl-analytics-checkbox__thumb {
display: block;
width: 16px;
height: 16px;
background-color: var(--tl-analytics-bg);
border-radius: 9999px;
transition: transform 100ms;
transform: translateX(2px);
}
.tl-analytics-checkbox[aria-checked='true'] {
background-color: var(--tl-analytics-link);
}
.tl-analytics-checkbox[aria-checked='true'] .tl-analytics-checkbox__thumb {
transform: translateX(14px);
}
.tl-analytics-checkbox__label {
margin: 0;
font-size: 12px;
}
.tl-analytics-dialog__footer {
padding: 0 8px 8px;
display: flex;
justify-content: flex-end;
gap: 4px;
}
body:has(.tl-analytics-dialog) .tl-analytics-banner {
opacity: 0;
}

View File

@@ -0,0 +1,56 @@
// Expose global functions
declare global {
interface Window {
tlanalytics: {
openPrivacySettings(): void
page(): void
identify(userId: string, properties?: { [key: string]: any }): void
reset(): void
track(name: string, data?: { [key: string]: any }): void
gtag(...args: any[]): void
getConsentState(): ConsentPreferences
onConsentUpdate(callback: (preferences: ConsentPreferences) => void): () => void
trackCopyCode(data: {
page_category: string
text_snippet: string
user_email?: string
user_email_sha256?: string
user_first_name?: string
user_last_name?: string
user_phone_number?: string
}): void
trackFormSubmission(data: {
enquiry_type: string
company_size?: string
company_website?: string
user_email: string
user_email_sha256: string
user_first_name: string
user_last_name: string
user_phone_number?: string
}): void
}
TL_GA4_MEASUREMENT_ID: string | undefined
TL_GOOGLE_ADS_ID?: string
TL_GTM_CONTAINER_ID?: string
Reo: any
posthog: any
}
}
export const COOKIE_CONSENT_VALUES = ['unknown', 'opted-in', 'opted-out'] as const
export type CookieConsent = (typeof COOKIE_CONSENT_VALUES)[number]
export const CONSENT_OPT_IN_TYPES = ['manual', 'auto'] as const
export type ConsentOptInType = (typeof CONSENT_OPT_IN_TYPES)[number]
export interface CookieConsentData {
consent: CookieConsent
optInType: ConsentOptInType
}
export interface ConsentPreferences {
analytics: 'granted' | 'denied'
marketing: 'granted' | 'denied'
opt_in_type: 'manual' | 'auto'
}

View File

@@ -0,0 +1,27 @@
type ConsentCheckResult = 'requires-consent' | 'no-consent-needed'
/**
* Checks if consent is required based on CloudFlare geolocation headers
*
* @returns Promise<'requires-consent' | 'no-consent-needed'> - 'requires-consent' if explicit consent is required, 'no-consent-needed' if confident it's not
*/
export async function shouldRequireConsent(): Promise<ConsentCheckResult> {
// CloudFlare provides the CF-IPCountry header on all requests
// Our consent worker checks this and returns whether consent is required
try {
const response = await fetch('https://consent.tldraw.xyz')
if (response.ok) {
const data = await response.json()
// Worker returns { requires_consent: boolean, country_code: string }
if (typeof data.requires_consent === 'boolean') {
return data.requires_consent ? 'requires-consent' : 'no-consent-needed'
}
}
} catch (error) {
// Fetch failed or timed out - will default to requiring consent
console.warn('Consent check failed, defaulting to requiring consent:', error)
}
// Conservative default: require consent
return 'requires-consent'
}

6
apps/analytics/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,6 @@
/// <reference types="vite/client" />
declare module '*.css?inline' {
const content: string
export default content
}

View File

@@ -0,0 +1,17 @@
{
"extends": "../../internal/config/tsconfig.base.json",
"compilerOptions": {
"moduleResolution": "bundler",
"composite": false,
"declaration": false,
"declarationMap": false,
"noEmit": true,
"emitDeclarationOnly": false,
"incremental": false,
"types": ["vite/client", "vitest/globals"],
"lib": ["DOM", "DOM.Iterable", "ESNext"]
},
"exclude": ["node_modules", "public", ".tsbuild*", "worker"],
"include": ["src", "./vite.config.ts", "./vitest.config.ts"],
"references": []
}

View File

@@ -0,0 +1,44 @@
{
"version": 2,
"ignoreCommand": "bash ../../internal/scripts/vercel/should-build-analytics.sh",
"installCommand": "yarn workspaces focus @tldraw/analytics @tldraw/monorepo config",
"buildCommand": "yarn build",
"outputDirectory": "public",
"headers": [
{
"source": "/(.*)",
"headers": [
{
"key": "Access-Control-Allow-Origin",
"value": "*"
},
{
"key": "Access-Control-Allow-Methods",
"value": "GET, POST"
}
]
}
],
"rewrites": [
{
"source": "/ingest/static/:path(.*)",
"destination": "https://eu-assets.i.posthog.com/static/:path*"
},
{
"source": "/ingest/:path(.*)",
"destination": "https://eu.i.posthog.com/:path*"
},
{
"source": "/i/static/:path(.*)",
"destination": "https://eu-assets.i.posthog.com/static/:path*"
},
{
"source": "/i/:path(.*)",
"destination": "https://eu.i.posthog.com/:path*"
},
{
"source": "/(.*)",
"destination": "/$1"
}
]
}

View File

@@ -0,0 +1,20 @@
import { defineConfig } from 'vite'
export default defineConfig({
plugins: [],
define: {
'process.env.NODE_ENV': JSON.stringify('production'),
},
build: {
outDir: 'public',
lib: {
entry: 'src/index.ts',
name: 'TLAnalytics',
fileName: () => `tl-analytics.js`,
formats: ['umd'],
},
rolldownOptions: {
// React and ReactDOM are now bundled instead of external
},
},
})

View File

@@ -0,0 +1,6 @@
/// <reference types="vitest" />
import { mergeConfig } from 'vitest/config'
import baseConfig from '../../internal/config/vitest/node-preset'
// State package uses the standard node preset with no overrides
export default mergeConfig(baseConfig, {})