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-worker/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
build
.wrangler

View File

@@ -0,0 +1,28 @@
{
"name": "@tldraw/analytics-worker",
"description": "tldraw infinite canvas SDK (analytics worker).",
"version": "0.0.0",
"private": true,
"author": {
"name": "tldraw GB Ltd.",
"email": "hello@tldraw.com"
},
"main": "./src/worker.ts",
"/* GOTCHA */": "files will include ./dist and index.d.ts by default, add any others you want to include in here",
"files": [],
"scripts": {
"dev": "yarn run -T tsx ../../internal/scripts/workers/dev.ts",
"test-ci": "yarn run -T vitest run --passWithNoTests",
"test": "yarn run -T vitest --passWithNoTests",
"test-coverage": "yarn run -T vitest run --coverage --passWithNoTests",
"check-bundle-size": "yarn run -T tsx ../../internal/scripts/check-worker-bundle.ts --entry src/worker.ts --size-limit-bytes 350000",
"lint": "yarn run -T tsx ../../internal/scripts/lint.ts"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20260302.0",
"esbuild": "^0.28.0",
"lazyrepo": "0.0.0-alpha.27",
"typescript": "^7.0.2",
"wrangler": "^4.75.0"
}
}

View File

@@ -0,0 +1,117 @@
/// <reference no-default-lib="true"/>
/// <reference types="@cloudflare/workers-types" />
/**
* Simple CloudFlare worker that returns whether the user requires cookie consent
* based on their geographic location (via CF-IPCountry header)
*/
/**
* Validates if an origin is allowed to make cross-origin requests.
* Returns the origin if allowed, or undefined if blocked.
*/
function isAllowedOrigin(origin: string | null): string | undefined {
if (!origin) return undefined
if (origin === 'http://localhost:3000') return origin
if (origin === 'http://localhost:3001') return origin
if (origin === 'http://localhost:5420') return origin
if (origin === 'https://meet.google.com') return origin
if (origin === 'https://tldraw.dev') return origin
if (origin.endsWith('.tldraw.com')) return origin
if (origin.endsWith('.tldraw.dev')) return origin
if (origin.endsWith('.tldraw.club')) return origin
if (origin.endsWith('.tldraw.xyz')) return origin
if (origin.endsWith('.tldraw.workers.dev')) return origin
if (origin.endsWith('-tldraw.vercel.app')) return origin
return undefined
}
const EXPLICIT_CONSENT_COUNTRIES = [
// EU Member States (GDPR)
'AT',
'BE',
'BG',
'HR',
'CY',
'CZ',
'DK',
'EE',
'FI',
'FR',
'DE',
'GR',
'HU',
'IE',
'IT',
'LV',
'LT',
'LU',
'MT',
'NL',
'PL',
'PT',
'RO',
'SK',
'SI',
'ES',
'SE',
// EEA/EFTA (GDPR)
'IS',
'LI',
'NO',
// Post-Brexit UK (UK PECR)
'GB',
// Switzerland (FADP)
'CH',
// Brazil (LGPD)
'BR',
]
export default {
async fetch(request: Request): Promise<Response> {
const origin = request.headers.get('origin')
const allowedOrigin = isAllowedOrigin(origin)
// Handle CORS preflight
if (request.method === 'OPTIONS') {
const headers: Record<string, string> = {
'Access-Control-Allow-Methods': 'GET, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
'Access-Control-Max-Age': '86400',
}
if (allowedOrigin) {
headers['Access-Control-Allow-Origin'] = allowedOrigin
}
return new Response(null, { headers })
}
// Only allow GET requests
if (request.method !== 'GET') {
return new Response('Method not allowed', { status: 405 })
}
// Get country code from CloudFlare header
const countryCode = request.headers.get('CF-IPCountry')
// Check if consent is required
// Default to requiring consent if country is unknown
const requiresConsent = !countryCode || EXPLICIT_CONSENT_COUNTRIES.includes(countryCode)
// Return response with CORS headers
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'Cache-Control': 'public, max-age=3600', // Cache for 1 hour
}
if (allowedOrigin) {
headers['Access-Control-Allow-Origin'] = allowedOrigin
}
return new Response(
JSON.stringify({
requires_consent: requiresConsent,
country_code: countryCode,
}),
{ headers }
)
},
}

View File

@@ -0,0 +1,7 @@
{
"extends": "../../internal/config/tsconfig.base.json",
"include": ["src", "scripts"],
"exclude": ["node_modules", "dist", ".tsbuild*"],
"compilerOptions": { "noEmit": true, "emitDeclarationOnly": false },
"references": []
}

View File

@@ -0,0 +1,15 @@
/// <reference types="vitest" />
import { mergeConfig } from 'vitest/config'
import baseConfig from '../../internal/config/vitest/node-preset'
// bemo-worker is a Node.js/Cloudflare Worker environment
export default mergeConfig(baseConfig, {
test: {
// Use node environment instead of jsdom for worker apps
environment: 'node',
// Module name mapping for path aliases
alias: {
'^~(.*)$': './src/$1',
},
},
})

View File

@@ -0,0 +1,44 @@
main = "src/worker.ts"
compatibility_date = "2024-06-20"
upload_source_maps = true
#################### Environment names ####################
# Dev environment
[env.dev]
name = "tldraw-consent-dev"
# Staging environment
[env.staging]
name = "tldraw-consent-staging"
# Staging environment
[env.preview]
name = "tldraw-consent-preview"
# Production environment
[env.production]
name = "tldraw-consent"
#################### Routes ####################
# in production, we use a custom domain. others get a default *.tldraw.workers.dev domain
[[env.production.routes]]
pattern = 'consent.tldraw.xyz'
custom_domain = true
zone_name = 'tldraw.xyz'
#################### Version metadata ####################
[version_metadata]
binding = "CF_VERSION_METADATA"
[env.dev.version_metadata]
binding = "CF_VERSION_METADATA"
[env.preview.version_metadata]
binding = "CF_VERSION_METADATA"
[env.staging.version_metadata]
binding = "CF_VERSION_METADATA"
[env.production.version_metadata]
binding = "CF_VERSION_METADATA"

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, {})

2
apps/bemo-worker/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
build
.wrangler

View File

@@ -0,0 +1,60 @@
# @tldraw/bemo-worker
```
⠀⠀⠀⠀⠀⠀⢤⡴⠶⠷⠶⠶⠾⠷⠻⠶⠷⠿⠾⠶⠷⠿⠳⠷⠾⠾⠷⠷⠾⠾⠷⠿⠶⠿⠞⠷⠶⠷⢦⣤⡄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⢠⡼⠟⠋⠤⣤⠐⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠉⠛⠻⣤⡤⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⢸⡧⠀⠁⡀⢀⠉⠙⠠⣄⣀⠀⠀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣘⣛⣧⣤⣆⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⢸⡇⠐⠠⠀⣠⣤⡘⠀⠌⡈⣷⡶⠛⠋⠛⠙⠋⠛⠙⠋⠛⠙⠋⠛⠙⠋⠛⠙⠋⠛⠙⠋⠛⠙⠋⠛⠙⠋⠙⠉⠋⠛⠋⣶⣀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⢸⡧⢐⣶⣶⠙⠋⠃⣴⣮⠁⣿⠀⠀⠀⠀⢠⣄⣤⣠⣄⣤⣤⣤⣠⣤⣤⣤⣠⣤⣤⣤⣠⣤⣤⣤⣄⣤⣠⣄⣠⡄⠀⠀⠀⣿⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⢸⡗⢸⠏⢁⡶⣶⡶⠏⠉⡀⣿⠀⠀⠐⢸⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⡇⠀⠐⣿⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⢸⡧⠰⣿⡾⠁⠉⠀⣾⣿⠄⣿⠀⠀⠂⢸⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⡇⠀⠀⣿⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⢸⡧⠐⠉⡁⣰⣶⡆⠉⠉⠀⣿⠀⠀⡀⢸⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⡇⠀⠠⣿⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⢸⡇⢈⠐⠀⠀⠉⡀⢀⠁⠂⣿⠀⠀⠀⢸⡇⠀⠀⠀⠀⠀⠛⣤⡼⠋⠀⠀⠀⠀⠀⠀⠀⠀⠛⢣⣤⠛⠀⠀⠀⢸⡇⠀⠀⣿⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⢸⣏⢠⣷⣾⣾⣤⣠⡀⠂⡁⣿⠀⠀⠈⢸⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠶⣆⣀⣀⣰⠶⠀⠀⠀⠀⠀⠀⠀⠀⢸⡇⠀⢀⣿⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⢸⡇⢸⣿⣿⣿⢿⣿⡃⠄⡀⣿⠀⠀⠐⢸⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠉⠉⠉⠉⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⡇⠀⠀⣿⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⢸⡇⠸⣟⣿⣻⣿⣿⡄⠀⠄⣿⠀⠀⠠⢸⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⡇⠀⠀⣿⠀⠀⠀⠀⠀⠀⣤⣿⣿⣤
⠀⠀⠀⠀⢸⡗⠰⣿⣿⣿⣿⣾⠂⠄⠂⣿⠀⠀⢀⢸⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⡇⠀⠈⣿⠀⠀⠀⠀⠀⠀⣿⠀⢸⣯
⠀⠀⠀⠀⢸⡏⠘⠉⣿⣿⣿⡿⠆⠀⡁⣿⠀⠀⠀⢸⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⡇⠀⠀⣿⠀⠀⠀⠀⠀⠀⣿⠀⢸⣧
⠀⠀⠀⠀⢸⣇⠨⣿⣿⣿⣿⣿⡁⠂⠄⣿⠀⠀⠂⠸⢧⣠⣤⣤⣤⣤⣤⣤⣤⣤⣤⣤⣤⣤⣤⣤⣤⣤⣤⣤⣤⣤⣤⣤⣤⡼⠇⠀⠈⣿⠀⠀⠀⠀⠀⠀⣿⠀⢸⡿
⠀⠀⠀⠀⢸⡧⠘⠉⠙⠿⠻⠛⠂⢀⠂⣿⠀⠀⢀⠀⠀⢁⢈⡈⣁⢈⡁⣈⢁⡈⢁⠈⠁⠁⠈⠈⠀⠁⠈⠁⠉⠈⠁⠈⠀⠁⠀⠀⠠⣿⣤⠀⠀⠀⠀⠀⣿⡀⣸⣿
⠀⠀⠀⠀⢸⣇⣠⣢⣷⣶⣶⣦⡑⠀⠄⣿⠀⠀⠀⢰⣶⣶⣶⣶⣶⣶⣶⣶⣶⣶⣶⣶⣶⣶⣶⠀⠀⠠⠀⠀⢀⣶⣶⠀⠀⠄⠐⠀⠐⣿⣼⠟⢣⣤⣤⠟⠁⣰⠿⠀
⠀⣀⣰⣶⠾⠛⠛⠙⠛⠉⣿⣿⡀⠐⡀⣿⠀⠀⠄⠘⠛⠛⠛⠛⠛⠛⠛⠛⠛⠛⠛⠛⠛⠛⠉⠀⠀⢀⠠⠀⠘⠛⠛⠁⠀⠀⠀⠄⠈⣿⠙⠶⢧⣀⣀⣰⡾⠋⠀⠀
⣶⠋⠉⣘⣟⢿⣻⣟⡻⠻⠿⣿⠀⠄⡀⣿⠀⠀⠀⠀⠀⠀⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡀⠈⠀⠀⠀⡀⠀⠀⠀⡀⠐⠀⢀⠀⣿⠀⠀⠈⠉⠉⠉⠁⠀⠀⠀
⠛⣦⣤⣤⣤⣤⣤⣤⣤⣤⠀⢹⣟⠀⡀⣿⠀⠀⠈⢀⣀⣻⡏⠉⣇⣀⡐⠀⠀⠄⠀⠀⠂⠀⢀⠀⣠⡼⠧⣄⠀⣠⣦⠄⠀⢀⠠⠀⠀⣿⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⢸⡇⠠⠐⠠⠀⠛⠛⡀⠄⡀⣿⠀⠀⠠⢼⣇⣀⡀⢀⣀⣸⠇⠀⠀⠀⠀⠂⠀⠠⠀⠘⠛⠒⠒⠚⠻⣿⣂⠼⡇⠀⠀⢀⠠⣿⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⢸⡇⠠⠁⠂⠡⠈⡀⠄⠂⠄⣿⠀⠀⠀⠀⠀⠹⢇⣠⡏⠀⠀⠀⠀⠁⠀⡀⠐⠀⠀⠀⣠⣶⣿⣿⣷⣬⡉⠁⠀⠀⢀⠀⠀⣿⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠸⢷⣄⠂⣁⠂⠡⢀⠐⡈⠀⣿⠀⠀⠈⠀⠀⢂⣀⡠⠀⠀⠐⠀⠁⡀⠄⠀⠀⠀⠐⠀⣿⣿⣿⣿⣷⣿⡇⠀⠀⠠⠀⠀⠠⣿⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠘⢳⣤⡄⢁⠂⠐⡀⠁⣿⠀⠀⢀⠘⠛⠛⠛⠃⠀⠘⠛⠛⠛⠛⠀⠀⠀⠁⠀⠀⠈⠻⠿⠿⠿⠋⠀⠀⢀⠀⠀⠐⠀⣿⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠛⠛⡶⢦⣀⣄⣿⠀⠀⡀⠀⠠⠀⠀⠀⡀⠀⢀⠀⢀⠀⠀⡀⠂⠀⠈⠀⡀⠀⠀⠀⠀⠀⠀⠄⠀⠀⠈⠀⢀⣿⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠁⠘⠛⢻⣿⣤⡀⣁⣀⡀⣀⢂⣀⣁⣀⣈⡀⣈⣀⣀⣁⣀⡐⣀⣀⢁⡀⣂⢀⣀⢂⣀⣀⢀⢂⣀⣠⣼⠛⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠛⠛⠛⠛⠛⢻⣿⣻⣿⣛⠛⠛⠛⠛⠛⠛⠛⠛⠛⠛⠛⣻⣟⣿⣿⣿⡛⠛⠛⠛⠛⠃⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⣿⡿⣿⣀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣻⣿⣿⣿⣯⣷⡗⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠉⣿⢻⣧⠀⠀⠀⠀⠀⠀⠀⠀⢰⡞⣿⣿⡶⣯⣽⡿⠇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣿⢸⣿⠀⠀⠀⠀⠀⠀⠀⠀⠘⠛⠛⠃⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣿⣏⣿⠛⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣶⡟⢩⣟⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠉⠿⣧⣾⣿⣶⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠁⠉⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
```
The bemo worker does several things like handling bookmark unfurling, asset uploads, and helping web socket connections to rooms.
## Contribution
Found a bug? Please [submit an issue](https://github.com/tldraw/tldraw/issues/new).
## License
The code in this folder is Copyright (c) 2024-present tldraw Inc. The tldraw SDK is provided under the [tldraw license](https://github.com/tldraw/tldraw/blob/main/LICENSE.md).
## Trademarks
Copyright (c) 2024-present tldraw Inc. The tldraw name and logo are trademarks of tldraw. Please see our [trademark guidelines](https://github.com/tldraw/tldraw/blob/main/TRADEMARKS.md) for info on acceptable usage.
## Contact
Find us on Twitter/X at [@tldraw](https://twitter.com/tldraw).
## Community
Have questions, comments or feedback? [Join our discord](https://discord.tldraw.com/?utm_source=github&utm_medium=readme&utm_campaign=sociallink). For the latest news and release notes, visit [tldraw.dev](https://tldraw.dev).

View File

@@ -0,0 +1,42 @@
{
"name": "@tldraw/bemo-worker",
"description": "tldraw infinite canvas SDK (merge server).",
"version": "0.0.0",
"private": true,
"author": {
"name": "tldraw GB Ltd.",
"email": "hello@tldraw.com"
},
"main": "./src/worker.ts",
"/* GOTCHA */": "files will include ./dist and index.d.ts by default, add any others you want to include in here",
"files": [],
"scripts": {
"dev": "yarn run -T tsx ../../internal/scripts/workers/dev.ts",
"test-ci": "yarn run -T vitest run --passWithNoTests",
"test": "yarn run -T vitest --passWithNoTests",
"test-coverage": "yarn run -T vitest run --coverage --passWithNoTests",
"check-bundle-size": "yarn run -T tsx ../../internal/scripts/check-worker-bundle.ts --entry src/worker.ts --size-limit-bytes 350000",
"lint": "yarn run -T tsx ../../internal/scripts/lint.ts"
},
"dependencies": {
"@tldraw/dotcom-shared": "workspace:*",
"@tldraw/store": "workspace:*",
"@tldraw/sync-core": "workspace:*",
"@tldraw/tlschema": "workspace:*",
"@tldraw/utils": "workspace:*",
"@tldraw/validate": "workspace:*",
"@tldraw/worker-shared": "workspace:*",
"itty-router": "^5.0.18",
"react": "^19.2.1",
"react-dom": "^19.2.1"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20260302.0",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"esbuild": "^0.28.0",
"lazyrepo": "0.0.0-alpha.27",
"typescript": "^7.0.2",
"wrangler": "^4.75.0"
}
}

View File

@@ -0,0 +1,266 @@
import { AnalyticsEngineDataset } from '@cloudflare/workers-types'
import {
InMemorySyncStorage,
RoomSnapshot,
TLSocketRoom,
TLSyncErrorCloseEventCode,
TLSyncErrorCloseEventReason,
} from '@tldraw/sync-core'
import { TLRecord } from '@tldraw/tlschema'
import { ExecutionQueue, throttle } from '@tldraw/utils'
import { T } from '@tldraw/validate'
import { createSentry, parseRequestQuery } from '@tldraw/worker-shared'
import { DurableObject } from 'cloudflare:workers'
import { IRequest, Router } from 'itty-router'
import { makePermissiveSchema } from './makePermissiveSchema'
import { Environment } from './types'
const connectRequestQuery = T.object({
sessionId: T.string,
storeId: T.string.optional(),
})
interface AnalyticsEvent {
type: 'connect' | 'send_message' | 'receive_message'
origin: string
sessionId: string
slug: string
}
export class BemoDO extends DurableObject<Environment> {
r2: R2Bucket
_slug: string | null = null
analytics?: AnalyticsEngineDataset
writeEvent({ type, origin, sessionId, slug }: AnalyticsEvent) {
this.analytics?.writeDataPoint({
blobs: [type, origin, slug, sessionId],
})
}
constructor(
public state: DurableObjectState,
env: Environment
) {
super(state, env)
this.r2 = env.BEMO_BUCKET
this.analytics = env.BEMO_ANALYTICS
state.blockConcurrencyWhile(async () => {
this._slug = ((await this.state.storage.get('slug')) ?? null) as string | null
})
}
private reportError(e: unknown, request?: Request) {
const sentry = createSentry(this.ctx, this.env, request)
console.error(e)
// eslint-disable-next-line @typescript-eslint/no-deprecated
sentry?.captureException(e)
}
private readonly router = Router()
.all('/connect/:slug', async (req) => {
if (!this._slug) {
await this.state.blockConcurrencyWhile(async () => {
await this.state.storage.put('slug', req.params.slug)
this._slug = req.params.slug
})
}
return this.handleConnect(req)
})
.all('*', async () => new Response('Not found', { status: 404 }))
override async fetch(request: Request): Promise<Response> {
try {
return await this.router.fetch(request)
} catch (error) {
this.reportError(error, request)
return new Response('Something went wrong', {
status: 500,
statusText: 'Internal Server Error',
})
}
}
async handleConnect(req: IRequest) {
// extract query params from request, should include instanceId
const { sessionId } = parseRequestQuery(req, connectRequestQuery)
// Create the websocket pair for the client
const { 0: clientWebSocket, 1: serverWebSocket } = new WebSocketPair()
serverWebSocket.accept()
const origin = req.headers.get('origin') ?? 'unknown'
try {
const room = await this.getRoom()
// Don't connect if we're already at max connections
if (room.getNumActiveSessions() >= MAX_CONNECTIONS) {
// TODO: this is not handled on the client, it just gets stuck in a loading state.
// With hibernatable sockets it should be fine to send a .close() event here.
// but we should really handle unknown errors better on the client.
return new Response('Room is full', {
status: 403,
})
}
// all good
room.handleSocketConnect({
sessionId,
socket: serverWebSocket,
meta: { origin },
})
this.writeEvent({
type: 'connect',
origin,
sessionId,
slug: this.getSlug(),
})
return new Response(null, { status: 101, webSocket: clientWebSocket })
} catch (e) {
if (e === ROOM_NOT_FOUND) {
serverWebSocket.close(TLSyncErrorCloseEventCode, TLSyncErrorCloseEventReason.NOT_FOUND)
return new Response(null, { status: 101, webSocket: clientWebSocket })
}
throw e
}
}
// For TLSyncRoom
_room: Promise<TLSocketRoom<TLRecord, { origin: string }>> | null = null
getSlug() {
if (!this._slug) {
throw new Error('slug must be present')
}
return this._slug
}
getRoom() {
const slug = this.getSlug()
if (!this._room) {
this._room = this.loadFromDatabase(slug).then((result) => {
// create the storage layer that holds the document state
const storage = new InMemorySyncStorage<TLRecord>({
snapshot: result.type === 'room_found' ? result.snapshot : undefined,
onChange: () => {
this.triggerPersistSchedule()
},
})
return new TLSocketRoom<TLRecord, { origin: string }>({
schema: makePermissiveSchema(),
storage,
onAfterReceiveMessage: ({ sessionId, meta }) => {
this.writeEvent({
type: 'receive_message',
origin: meta.origin,
sessionId,
slug,
})
},
onBeforeSendMessage: ({ sessionId, meta }) => {
this.writeEvent({
type: 'send_message',
origin: meta.origin,
sessionId,
slug,
})
},
onSessionRemoved: async (room, args) => {
if (args.numSessionsRemaining > 0) return
if (!this._room) return
try {
await this.persistToDatabase()
} catch {
// already logged
}
this._room = null
room.close()
},
})
})
}
return this._room
}
triggerPersistSchedule = throttle(() => {
this.schedulePersist()
}, 2000)
async loadFromDatabase(persistenceKey: string): Promise<DBLoadResult> {
try {
const key = getR2KeyForSlug(persistenceKey)
// when loading, prefer to fetch documents from the bucket
const roomFromBucket = await this.r2.get(key)
if (roomFromBucket) {
return { type: 'room_found', snapshot: await roomFromBucket.json() }
}
return { type: 'room_not_found' }
} catch (error) {
console.error('failed to fetch doc', persistenceKey, error)
return { type: 'error', error: error as Error }
}
}
_lastPersistedClock: number | null = null
executionQueue = new ExecutionQueue()
// Save the room to supabase
async persistToDatabase() {
this.executionQueue
.push(async () => {
// check whether the worker was woken up to persist after having gone to sleep
if (!this._room) return
const slug = this.getSlug()
const room = await this.getRoom()
const clock = room.getCurrentDocumentClock()
if (this._lastPersistedClock === clock) return
const snapshot = JSON.stringify(room.storage.getSnapshot?.())
const key = getR2KeyForSlug(slug)
await Promise.all([this.r2.put(key, snapshot)])
this._lastPersistedClock = clock
// use a shorter timeout for this 'inner' loop than the 'outer' alarm-scheduled loop
// just in case there's any possibility of setting up a neverending queue
})
.catch((e) => {
this.reportError(e)
})
}
async schedulePersist() {
const existing = await this.state.storage.getAlarm()
if (!existing) {
this.state.storage.setAlarm(PERSIST_INTERVAL_MS)
}
}
// Will be called automatically when the alarm ticks.
override async alarm() {
this.persistToDatabase()
}
}
function getR2KeyForSlug(persistenceKey: string) {
return `rooms/${persistenceKey}`
}
const ROOM_NOT_FOUND = new Error('Room not found')
const MAX_CONNECTIONS = 30
const PERSIST_INTERVAL_MS = 5000
type DBLoadResult =
| {
type: 'error'
error?: Error | undefined
}
| {
type: 'room_found'
snapshot: RoomSnapshot
}
| {
type: 'room_not_found'
}

View File

@@ -0,0 +1,49 @@
import {
TLBaseBinding,
TLBaseShape,
TLSchema,
bindingIdValidator,
createTLSchema,
opacityValidator,
parentIdValidator,
shapeIdValidator,
} from '@tldraw/tlschema'
import { T } from '@tldraw/validate'
export function makePermissiveSchema(): TLSchema {
const schema = createTLSchema()
const shapeValidator = T.object<TLBaseShape<any, any>>({
id: shapeIdValidator,
typeName: T.literal('shape'),
x: T.number,
y: T.number,
rotation: T.number,
index: T.indexKey,
parentId: parentIdValidator,
type: T.string,
isLocked: T.boolean,
opacity: opacityValidator,
props: T.jsonValue as any,
meta: T.jsonValue as any,
}) as (typeof schema)['types']['shape']['validator']
const shapeType = schema.getType('shape')
// @ts-expect-error
shapeType.validator = shapeValidator
const bindingValidator = T.object<TLBaseBinding<any, any>>({
id: bindingIdValidator,
typeName: T.literal('binding'),
type: T.string,
fromId: shapeIdValidator,
toId: shapeIdValidator,
props: T.jsonValue as any,
meta: T.jsonValue as any,
}) as (typeof schema)['types']['binding']['validator']
const bindingType = schema.getType('binding')
// @ts-expect-error
bindingType.validator = bindingValidator
return schema
}

View File

@@ -0,0 +1,17 @@
import { AnalyticsEngineDataset } from '@cloudflare/workers-types'
import { BemoDO } from './BemoDO'
export interface Environment {
// bindings
BEMO_DO: DurableObjectNamespace<BemoDO>
BEMO_BUCKET: R2Bucket
CF_VERSION_METADATA: WorkerVersionMetadata
// environment variables
TLDRAW_ENV: string | undefined
SENTRY_DSN: string | undefined
IS_LOCAL: string | undefined
WORKER_NAME: string | undefined
BEMO_ANALYTICS?: AnalyticsEngineDataset
}

View File

@@ -0,0 +1,5 @@
describe('worker', () => {
it('works', () => {
// blank test to make ci happy
})
})

View File

@@ -0,0 +1,91 @@
/// <reference no-default-lib="true"/>
/// <reference types="@cloudflare/workers-types" />
import {
handleApiRequest,
handleExtractBookmarkMetadataRequest,
handleUserAssetGet,
handleUserAssetUpload,
notFound,
type R2BucketLike,
} from '@tldraw/worker-shared'
import { WorkerEntrypoint } from 'cloudflare:workers'
import { Router, cors } from 'itty-router'
import { Environment } from './types'
export { BemoDO } from './BemoDO'
const { preflight, corsify } = cors({ origin: '*' })
export default class Worker extends WorkerEntrypoint<Environment> {
private readonly router = Router()
.all('*', preflight)
.get('/uploads/:objectName', (request) => {
const bucket = this.env.BEMO_BUCKET as R2BucketLike
return handleUserAssetGet({
request,
bucket,
objectName: `asset-uploads/${request.params.objectName}`,
context: this.ctx,
})
})
.post('/uploads/:objectName', async (request) => {
const bucket = this.env.BEMO_BUCKET as R2BucketLike
return handleUserAssetUpload({
headers: request.headers,
body: request.body,
bucket,
objectName: `asset-uploads/${request.params.objectName}`,
})
})
.get('/bookmarks/unfurl', (request) => {
// legacy route: extract metadata without saving image
return handleExtractBookmarkMetadataRequest({ request })
})
.post('/bookmarks/unfurl', (request) => {
const bucket = this.env.BEMO_BUCKET as R2BucketLike
return handleExtractBookmarkMetadataRequest({
request,
uploadImage: async (headers, body, objectName) => {
const response = await handleUserAssetUpload({
body,
headers,
bucket,
objectName: `bookmark-assets/${objectName}`,
})
if (!response.ok) throw new Error('Failed to upload image')
const requestUrl = new URL(request.url)
return `${requestUrl.origin}/bookmarks/assets/${objectName}`
},
})
})
.get('/bookmarks/assets/:objectName', (request) => {
const bucket = this.env.BEMO_BUCKET as R2BucketLike
return handleUserAssetGet({
request,
bucket,
objectName: `bookmark-assets/${request.params.objectName}`,
context: this.ctx,
})
})
.get('/connect/:slug', (request) => {
const slug = request.params.slug
if (!slug) return new Response('Not found', { status: 404 })
// Set up the durable object for this room
const id = this.env.BEMO_DO.idFromName(slug)
return this.env.BEMO_DO.get(id).fetch(request)
})
.all('*', notFound)
override async fetch(request: Request): Promise<Response> {
return handleApiRequest({
router: this.router,
request,
env: this.env,
ctx: this.ctx,
after: corsify,
})
}
}

View File

@@ -0,0 +1,15 @@
{
"extends": "../../internal/config/tsconfig.base.json",
"include": ["src", "scripts"],
"exclude": ["node_modules", "dist", ".tsbuild*"],
"compilerOptions": { "noEmit": true, "emitDeclarationOnly": false },
"references": [
{ "path": "../../packages/dotcom-shared" },
{ "path": "../../packages/store" },
{ "path": "../../packages/sync-core" },
{ "path": "../../packages/tlschema" },
{ "path": "../../packages/utils" },
{ "path": "../../packages/validate" },
{ "path": "../../packages/worker-shared" }
]
}

View File

@@ -0,0 +1,15 @@
/// <reference types="vitest" />
import { mergeConfig } from 'vitest/config'
import baseConfig from '../../internal/config/vitest/node-preset'
// bemo-worker is a Node.js/Cloudflare Worker environment
export default mergeConfig(baseConfig, {
test: {
// Use node environment instead of jsdom for worker apps
environment: 'node',
// Module name mapping for path aliases
alias: {
'^~(.*)$': './src/$1',
},
},
})

View File

@@ -0,0 +1,109 @@
main = "src/worker.ts"
compatibility_date = "2024-06-20"
upload_source_maps = true
[dev]
port = 8989
ip = "0.0.0.0"
# these migrations are append-only. you can't change them. if you do need to change something, do so
# by creating new migrations
[[migrations]]
tag = "v1" # Should be unique for each entry
new_classes = ["BemoDO"]
#################### Environment names ####################
# dev should never actually get deployed anywhere
[env.dev]
name = "dev-bemo"
# we don't have a hard-coded name for preview. we instead have to generate it at build time and append it to this file.
# staging is the same as a preview on main:
[env.staging]
name = "canary-bemo"
routes = [{ pattern = "canary-demo.tldraw.xyz", custom_domain = true }]
# production gets the proper name
[env.production]
name = "production-bemo"
routes = [{ pattern = "demo.tldraw.xyz", custom_domain = true }]
#################### Durable objects ####################
# durable objects have the same configuration in all environments:
[durable_objects]
bindings = [{ name = "BEMO_DO", class_name = "BemoDO" }]
[env.dev.durable_objects]
bindings = [{ name = "BEMO_DO", class_name = "BemoDO" }]
[env.preview.durable_objects]
bindings = [{ name = "BEMO_DO", class_name = "BemoDO" }]
[env.staging.durable_objects]
bindings = [{ name = "BEMO_DO", class_name = "BemoDO" }]
[env.production.durable_objects]
bindings = [{ name = "BEMO_DO", class_name = "BemoDO" }]
#################### R2 bucket ####################
# in dev, we write to the preview bucket and need a `preview_bucket_name`
[[env.dev.r2_buckets]]
binding = 'BEMO_BUCKET'
bucket_name = 'sync-demo-preview'
preview_bucket_name = 'sync-demo-preview'
# in preview and staging we write to the preview bucket
[[env.preview.r2_buckets]]
binding = 'BEMO_BUCKET'
bucket_name = 'sync-demo-preview'
[[env.staging.r2_buckets]]
binding = 'BEMO_BUCKET'
bucket_name = 'sync-demo-preview'
# in production, we write to the main bucket
[[env.production.r2_buckets]]
binding = "BEMO_BUCKET"
bucket_name = "sync-demo"
#################### Version metadata ####################
[version_metadata]
binding = "CF_VERSION_METADATA"
[env.dev.version_metadata]
binding = "CF_VERSION_METADATA"
[env.preview.version_metadata]
binding = "CF_VERSION_METADATA"
[env.staging.version_metadata]
binding = "CF_VERSION_METADATA"
[env.production.version_metadata]
binding = "CF_VERSION_METADATA"
#################### Analytics engine ####################
# analytics engine has the same configuration in all environments:
[[analytics_engine_datasets]]
binding = "BEMO_ANALYTICS"
dataset = "BEMO_ANALYTICS"
[[env.dev.analytics_engine_datasets]]
binding = "BEMO_ANALYTICS"
dataset = "BEMO_ANALYTICS_DEV"
[[env.preview.analytics_engine_datasets]]
binding = "BEMO_ANALYTICS"
dataset = "BEMO_ANALYTICS_PREVIEW"
[[env.staging.analytics_engine_datasets]]
binding = "BEMO_ANALYTICS"
dataset = "BEMO_ANALYTICS_STAGING"
[[env.production.analytics_engine_datasets]]
binding = "BEMO_ANALYTICS"
dataset = "BEMO_ANALYTICS"

8
apps/docs/.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
content/reference
api-content.json
content.db
.env
.next
.vercel
.envrc
llms*.txt

View File

@@ -0,0 +1,5 @@
.next/*
.lazy/*
content.db
node_modules
utils/vector-db/index.json

9
apps/docs/LICENSE.md Normal file
View File

@@ -0,0 +1,9 @@
MIT License
Copyright (c) 2024 tldraw Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

167
apps/docs/README.md Normal file
View File

@@ -0,0 +1,167 @@
# tldraw-docs
Welcome to the source for the [tldraw docs site](https://tldraw.dev).
This site is a [Next.js](https://nextjs.org/) app that uses [MDX](https://mdxjs.com/) for content. It contains human-written docs in the `content` folder as well as generated docs in the `api` folder.
We have several scripts that build these files into a SQLite database that is used to generate the site's pages.
To pull the most recent docs from the tldraw repo, create an .env file with a GitHub personal access token and the SHA of the commit or branch that you'd like to pull from.
```
ACCESS_TOKEN=your_github_access_token
SOURCE_SHA=main
```
The files are also provided in this repo.
## Building the content
You can build the markdown and API content using the following scripts:
- `yarn refresh-everything` to reset the database, generate the markdown from the API docs, and populate the database with articles from both the regular content and the generated API content
- `yarn refresh-content` to generate just the regular content
# Content
The docs has two types of content: regular content that is written by the team and auto-generated content that is created using [tsdoc](https://tsdoc.org/) and [API extractor](https://api-extractor.com/).
The `content` folder contains all content in the form of MDX files. All articles belong to a "section" and a "category". The `sections.json` defines each section and any categories belonging to that section.
A section looks like this:
```json
{
"id": "community",
"title": "Community",
"description": "Community resources for tldraw.",
"categories": []
}
```
The content is organized into folders for each section. The `gen` folder contains auto-generated content.
## Regular Content
The `content` folder contains all "regular" content in the form of MDX files.All articles belong to a "section" and a "category". The content is organized into folders for each "section".
An article's frontmatter looks like this:
```md
---
title: User Interface
description: How to customize the tldraw user interface using overrides.
status: published
author: steveruizok
date: 3/22/2023
order: 8
keywords:
- ui
- interface
- tools
- shapes
- custom
- button
- toolbar
- styles
---
```
### Title
The `title` is displayed in the article's header, in the page title, in the search bar, and in search results. It is used to find an article through the site's search feature.
### Description
The `description` is hidden in the article's frontmatter, but is used to populate the article's meta description tag. It is also used to find an article through the site's search feature.
### Hero
The `hero` is used for the article's social media image. It is not displayed in the article. It should refer to a page in the `public/images` folder.
### Category
An article may declare its `category` in its frontmatter. Any article that does not declare a category will be placed into the "ucg" category for "uncategorized" articles.
### Order
The `order` property defines the article's order in its category. Uncategorized articles are placed at the end of the list of categories sorted by its `order`. For a section without categories, the `order` keyword effectively defines the order that the article will appear in the section list.
### Author
The `author` must refer to an author named in the `content/authors.json` file.
An author looks like this:
```json
"steveruizok": {
"name": "Steve Ruiz",
"email": "steve@tldraw.com",
"twitter": "steveruizok",
"image": "steve_ruiz.jpg"
}
```
The image should refer to an image in `public/avatars`.
### Date
The `date` is formatted as DD/MM/YYYY.
### Status
An article's `status` may be either `draft` or `published`. A `draft` article is hidden in production.
### Keywords
The `keywords` are used to find an article through the site's search feature.
## Auto-generated content
The auto-generated docs content is created using [tsdoc](https://tsdoc.org/) and [API extractor](https://api-extractor.com/). The source is the API documentation created by `yarn build` or `yarn build-api`. The output is placed in the `gen` folder.
## Release notes
Release notes live in `content/releases/` and are the source of truth for SDK release documentation.
### File structure
- `next.mdx` - Accumulates changes for the upcoming release
- `vX.Y.0.mdx` - Published release notes (one file per minor release)
### Workflow
1. As PRs merge to main, update `next.mdx` with release notes using the `update-release-notes` skill
2. When publishing a release:
- Rename `next.mdx` to `vX.Y.0.mdx`
- Update the frontmatter `order` field
- Create a new `next.mdx` for the next version
3. The release notes can then be copied to GitHub releases
See `skills/update-release-notes/` for the maintenance workflow and `skills/write-release-notes/` for writing guidance.
## Developing the docs
You'll need to install Git LFS to get our blog post images (which can be large). See instructions [here](https://docs.github.com/en/repositories/working-with-files/managing-large-files/installing-git-large-file-storage).
When developing the docs, any change to the `content` folder will cause the page to refresh. This is a little shitty but it mostly works.
## Contribution
Found a bug? Please [submit an issue](https://github.com/tldraw/tldraw/issues/new).
## License
This project is provided under the MIT license found [here](https://github.com/tldraw/tldraw/blob/main/apps/docs/LICENSE.md). The tldraw SDK is provided under the [tldraw license](https://github.com/tldraw/tldraw/blob/main/LICENSE.md).
## Trademarks
Copyright (c) 2024-present tldraw Inc. The tldraw name and logo are trademarks of tldraw. Please see our [trademark guidelines](https://github.com/tldraw/tldraw/blob/main/TRADEMARKS.md) for info on acceptable usage.
## Contact
Find us on Twitter/X at [@tldraw](https://twitter.com/tldraw).
## Community
Have questions, comments or feedback? [Join our discord](https://discord.tldraw.com/?utm_source=github&utm_medium=readme&utm_campaign=sociallink). For the latest news and release notes, visit [tldraw.dev](https://tldraw.dev).

View File

@@ -0,0 +1,139 @@
import type { Metadata } from 'next'
import { notFound } from 'next/navigation'
import { Content } from '@/components/content'
import { StarterKitEmbed } from '@/components/content/embed'
import { Example } from '@/components/content/example'
import { DocsFeedbackWidget } from '@/components/docs/docs-feedback-widget'
import { DocsFooter } from '@/components/docs/docs-footer'
import { DocsHeader } from '@/components/docs/docs-header'
import { DocsMobileSidebar } from '@/components/docs/docs-mobile-sidebar'
import { DocsSidebar } from '@/components/docs/docs-sidebar'
import { DocsStarterSidebar } from '@/components/docs/docs-starter-sidebar'
import { DocsTableOfContents } from '@/components/docs/docs-table-of-contents'
import { SearchButton } from '@/components/search/SearchButton'
import { cn } from '@/utils/cn'
import { db } from '@/utils/ContentDatabase'
import { parseMarkdown } from '@/utils/parse-markdown'
import { canonicalDocsPageUrl } from '@/utils/sitemap-canonical-paths'
export async function generateStaticParams() {
const paths = await db.getAllPaths()
return paths.map((path) => ({ slug: path.replace(/^\//, '').split('/') }))
}
export const dynamicParams = false
export async function generateMetadata(props: {
params: Promise<{ slug: string | string[] }>
}): Promise<Metadata> {
const params = await props.params
const path = typeof params.slug === 'string' ? [params.slug] : params.slug
const pagePath = `/${path.join('/')}`
const content = await db.getPageContent(pagePath)
if (!content || content.type !== 'article') notFound()
const metadata: Metadata = {
title: content.article.title,
alternates: { canonical: canonicalDocsPageUrl(pagePath) },
}
if (content.article.description) {
metadata.description = content.article.description
} else {
const parsed = parseMarkdown(content.article.content ?? '', content.article.id)
const initialContentText = parsed.initialContentText.trim()
if (initialContentText) metadata.description = initialContentText
}
return metadata
}
export default async function Page(props: { params: Promise<{ slug: string | string[] }> }) {
const params = await props.params
const path = typeof params.slug === 'string' ? [params.slug] : params.slug
const content = await db.getPageContent(`/${path.join('/')}`)
if (!content || content.type !== 'article') notFound()
return (
<div className="w-full max-w-screen-2xl mx-auto md:px-5 md:flex md:items-start md:pt-12 isolate">
<DocsSidebar
sectionId={content.article.sectionId}
categoryId={content.article.categoryId}
articleId={content.article.id}
/>
<div className="sticky top-14 z-10 flex items-center justify-between w-full h-12 px-5 bg-white border-b border-zinc-100 dark:border-zinc-800 dark:bg-zinc-950 backdrop-blur md:hidden">
<DocsMobileSidebar
sectionId={content.article.sectionId}
categoryId={content.article.categoryId}
articleId={content.article.id}
/>
<SearchButton type="docs" layout="mobile" className="hidden -mr-2 sm:block" />
</div>
{content.article.sectionId === 'examples' ? (
<main className="relative w-full px-5 pt-12 shrink md:pt-0 min-w-[1px]">
<DocsHeader article={content.article} />
{content.article.description && (
<Content mdx={content.article.description} type={content.article.sectionId} />
)}
<Example article={content.article} />
{content.article.content && (
<Content mdx={content.article.content} type={content.article.sectionId} />
)}
<div className="mx-auto w-full max-w-sm mt-8 mb-16">
<DocsFeedbackWidget />
</div>
<DocsFooter article={content.article} />
</main>
) : content.article.sectionId === 'starter-kits' &&
content.article.id !== 'starter-kits/starter-kits_ucg/overview' ? (
<main className="relative w-full px-5 pt-12 shrink md:pt-0 min-w-[1px]">
<DocsHeader article={content.article} />
<div
className={cn(
'mb-8',
'prose dark:prose-invert prose-sm prose-zinc text-zinc-800 dark:text-zinc-200 sm:prose-base w-full max-w-full',
'prose-code:before:content-none prose-code:after:content-none prose-code:bg-zinc-100 dark:prose-code:!bg-zinc-800 prose-code:px-1 prose-code:py-0.5 prose-code:rounded prose-code:font-normal',
'prose-a:no-underline prose-a:text-blue-500 hover:prose-a:text-blue-600 dark:hover:prose-a:text-blue-400 prose-a:font-normal',
'prose-blockquote:text-zinc-800 dark:prose-blockquote:text-zinc-200 prose-blockquote:font-normal prose-blockquote:border-none prose-blockquote:px-4 prose-blockquote:leading-normal prose-blockquote:bg-zinc-50 dark:prose-blockquote:bg-zinc-900 prose-blockquote:py-3 prose-blockquote:rounded-xl',
'prose-table:bg-zinc-50 dark:prose-table:bg-zinc-900 prose-table:rounded-xl prose-table:text-sm',
'prose-th:text-left prose-th:font-semibold prose-th:uppercase prose-th:text-xs prose-th:border-l prose-th:border-white dark:prose-th:border-zinc-950 prose-th:py-3 prose-th:px-4',
'prose-tr:border-t prose-tr:border-white dark:prose-tr:border-zinc-950',
'prose-td:border-l first:prose-td:border-l-0 prose-td:border-white dark:prose-td:border-zinc-950 prose-td:py-3 prose-td:px-4',
'prose-hr:border-zinc-100 dark:prose-hr:border-zinc-800',
'prose-h1:scroll-mt-20 prose-h2:scroll-mt-20 prose-h3:scroll-mt-20 prose-h4:scroll-mt-20 prose-h5:scroll-mt-20'
)}
>
{content.article.description && (
<Content
mdx={content.article.description}
type={content.article.sectionId}
className="mb-6"
/>
)}
{content.article.embed && <StarterKitEmbed id={content.article.embed} />}
</div>
<div className="flex flex-col xl:flex-row gap-6 relative">
<DocsStarterSidebar article={content.article} />
<Content mdx={content.article.content ?? ''} type={content.article.sectionId} />
</div>
<DocsFooter article={content.article} />
</main>
) : content.article.sectionId === 'starter-kits' ? (
<main className="relative w-full px-5 pt-12 shrink md:pt-0 min-w-[1px]">
<DocsHeader article={content.article} />
<Content mdx={content.article.content ?? ''} type={content.article.sectionId} />
<DocsFooter article={content.article} />
<div className="mx-auto w-full max-w-sm mt-8 mb-16">
<DocsFeedbackWidget />
</div>
</main>
) : (
<>
<main className="relative grow px-5 pt-12 md:pr-0 lg:pl-12 xl:pr-12 md:pt-0 min-w-[1px]">
<DocsHeader article={content.article} />
<Content mdx={content.article.content ?? ''} type={content.article.sectionId} />
<DocsFooter article={content.article} />
</main>
<DocsTableOfContents article={content.article} />
</>
)}
</div>
)
}

View File

@@ -0,0 +1,20 @@
import { Metadata } from 'next'
import { DocsSearchBar } from '@/components/docs/docs-search-bar'
export const metadata: Metadata = {
metadataBase: new URL('https://tldraw.dev'),
title: {
default: 'Docs',
template: `%s • tldraw Docs`,
},
description: 'Documentation for the tldraw SDK.',
}
export default async function Layout({ children }: { children: React.ReactNode }) {
return (
<>
<DocsSearchBar />
{children}
</>
)
}

View File

@@ -0,0 +1,31 @@
import {
AGENT_SKILLS_INDEX_SCHEMA,
PUBLISHED_SKILLS,
readPublishedSkill,
} from '@/utils/agent-skills'
// Read at build time, so a skill whose frontmatter has gone missing fails the build rather than
// serving a broken index.
export const dynamic = 'force-static'
export function GET() {
const skills = Object.keys(PUBLISHED_SKILLS).map((key) => {
const skill = readPublishedSkill(key as keyof typeof PUBLISHED_SKILLS)
return {
name: skill.name,
// An archive rather than `skill-md`, because these skills run their own helper scripts.
// See `SkillDocument.archive`.
type: 'archive',
description: skill.description,
url: skill.url,
digest: skill.digest,
}
})
return Response.json(
{ $schema: AGENT_SKILLS_INDEX_SCHEMA, skills },
// Readable cross-origin: an agent running in a browser is a normal consumer of this, and
// everything here is already public.
{ headers: { 'access-control-allow-origin': '*' } }
)
}

View File

@@ -0,0 +1,15 @@
import { readPublishedSkill } from '@/utils/agent-skills'
export const dynamic = 'force-static'
// The installable form of the skill: the whole directory, which the index publishes a digest over.
// A client that unpacks this and one that reads the `SKILL.md` route next door see the same
// instructions; only this one carries the helper scripts those instructions run.
export function GET() {
return new Response(new Uint8Array(readPublishedSkill('tldraw-migrate').archive), {
headers: {
'content-type': 'application/gzip',
'access-control-allow-origin': '*',
},
})
}

View File

@@ -0,0 +1,14 @@
import { readPublishedSkill } from '@/utils/agent-skills'
export const dynamic = 'force-static'
// Served verbatim from `skills/tldraw-migrate/SKILL.md` so the instructions can be read without
// unpacking anything. The index points at the archive instead — this file alone cannot run.
export function GET() {
return new Response(readPublishedSkill('tldraw-migrate').markdown, {
headers: {
'content-type': 'text/markdown; charset=utf-8',
'access-control-allow-origin': '*',
},
})
}

114
apps/docs/app/analytics.tsx Normal file
View File

@@ -0,0 +1,114 @@
'use client'
import Script from 'next/script'
import { useEffect } from 'react'
export default function Analytics() {
useEffect(() => {
window.TL_GA4_MEASUREMENT_ID = process.env.NEXT_PUBLIC_GA4_MEASUREMENT_ID
window.TL_GOOGLE_ADS_ID = process.env.NEXT_PUBLIC_GOOGLE_ADS_ID
window.TL_GTM_CONTAINER_ID = process.env.NEXT_PUBLIC_GTM_CONTAINER_ID
}, [])
useEffect(() => {
const handleCopy = (copyEvent: ClipboardEvent) => {
const isWithinCodeBlock = (copyEvent.target as HTMLElement | null)?.closest('pre, code')
if (isWithinCodeBlock) {
const copiedText = window.getSelection()?.toString() || ''
if (window.tlanalytics?.trackCopyCode) {
window.tlanalytics.trackCopyCode({
page_category: 'docs',
text_snippet: copiedText,
})
}
// Track Google Ads conversion for code block copies
if (window.tlanalytics?.gtag) {
window.tlanalytics.gtag('event', 'conversion', {
send_to: 'AW-17268182782/qIuDCMnhl_EaEP6djqpA',
value: 1.0,
currency: 'USD',
})
}
}
}
document.addEventListener('copy', handleCopy)
return () => {
document.removeEventListener('copy', handleCopy)
}
}, [])
const analyticsScriptSrc =
process.env.NODE_ENV === 'development'
? 'http://localhost:5173/tl-analytics.js'
: 'https://analytics.tldraw.com/tl-analytics.js'
return (
<>
<Script
id="tldraw-analytics"
type="text/javascript"
strategy="afterInteractive"
async
defer
src={analyticsScriptSrc}
/>
</>
)
}
interface ConsentPreferences {
analytics: 'granted' | 'denied'
marketing: 'granted' | 'denied'
}
declare global {
interface Window {
tlanalytics: {
openPrivacySettings(): 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
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
}
TL_GA4_MEASUREMENT_ID: string | undefined
TL_GOOGLE_ADS_ID?: string
TL_GTM_CONTAINER_ID?: string
posthog: any
}
}
export function PrivacySettingsLink() {
const onCookieClick = () => window.tlanalytics.openPrivacySettings()
return (
<>
<button onClick={onCookieClick}>Privacy settings</button>
</>
)
}
export function track(name: string, data?: { [key: string]: any }) {
window.tlanalytics?.track(name, data)
}

138
apps/docs/app/globals.css Normal file
View File

@@ -0,0 +1,138 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
.prose h2,
.prose h3,
.prose h4,
.prose h5,
.prose h6 {
scroll-margin-top: 90px;
}
@media (max-width: 768px) {
.prose h2,
.prose h3,
.prose h4,
.prose h5,
.prose h6 {
scroll-margin-top: 130px;
}
}
.prose h2 a,
.prose h3 a,
.prose h4 a {
@apply font-bold;
}
.prose blockquote p {
margin: 0;
}
.prose blockquote p:before,
.prose blockquote p:after {
content: none;
}
.prose details.article__table-of-contents {
display: none !important;
}
input[type='search']::-webkit-search-decoration,
input[type='search']::-webkit-search-cancel-button,
input[type='search']::-webkit-search-results-button,
input[type='search']::-webkit-search-results-decoration {
-webkit-appearance: none;
}
@media (max-width: 768px) {
.prose table {
table-layout: fixed;
}
.prose td {
word-break: break-word;
}
}
}
.hs-form-field.hs-fieldtype-text label,
.hs-form-field.hs-fieldtype-select label,
.hs-form-field.hs-fieldtype-textarea label {
display: none;
}
/* We don't show any descriptions or lables */
.hs-field-desc {
display: block !important;
}
/* Make text areas span both columns */
div.hs-fieldtype-textarea {
@apply sm:col-span-2;
}
[data-hs-forms-root] form {
@apply flex flex-col sm:grid-cols-2 sm:grid gap-4;
}
[data-hs-forms-root] select {
@apply w-full h-9 px-4 appearance-none rounded-lg text-white bg-zinc-700/50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 focus:ring-offset-zinc-900;
}
[data-hs-forms-root] select.is-placeholder {
@apply text-white/50;
}
[data-hs-forms-root] input {
@apply w-full h-9 px-4 rounded-lg text-white bg-zinc-700/50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 focus:ring-offset-zinc-900;
}
[data-hs-forms-root] textarea {
@apply -mb-1.5 w-full px-4 py-2 rounded-lg resize-none text-white bg-zinc-700/50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 focus:ring-offset-zinc-900;
height: 112px;
}
.hs-submit {
@apply overflow-hidden items-center col-start-1 col-span-2 p-2;
}
.hs-submit input {
@apply overflow-hidden items-center h-9 rounded-lg text-base !bg-blue-500 !text-white hover:!bg-blue-600 dark:hover:!bg-blue-400;
}
.hs_error_rollup {
@apply flex items-center;
}
.masked-logo {
position: relative;
background-color: currentColor;
z-index: 1;
color: inherit;
}
mark {
margin: -1px -1px;
padding: 1px 1px;
border-radius: 3px;
background-color: #ffc80046;
color: inherit;
-webkit-box-decoration-break: clone;
box-decoration-break: clone;
}
.special {
--r: 18px;
--rl: -1;
--b: 4px;
background: none;
text-shadow:
calc(var(--r) * 1) 0px var(--b) rgba(255, 208, 0, 0.03),
calc(var(--r) * 0.75) 0px var(--b) rgba(255, 140, 0, 0.04),
calc(var(--r) * 0.5) 0px var(--b) rgba(255, 187, 0, 0.09),
calc(var(--r) * 0.25) 0px var(--b) rgba(255, 187, 0, 0.12),
calc(var(--r) * 0.12) 0px var(--b) rgba(255, 217, 0, 0.32),
calc(var(--r) * 1 * var(--rl)) 0px rgba(255, 140, 0, 0.02),
calc(var(--r) * 0.75 * var(--rl)) 0px var(--b) rgba(255, 208, 0, 0.04),
calc(var(--r) * 0.5 * var(--rl)) 0px var(--b) rgba(255, 187, 0, 0.08),
calc(var(--r) * 0.25 * var(--rl)) 0px var(--b) rgba(255, 187, 0, 0.12),
calc(var(--r) * 0.12 * var(--rl)) 0px var(--b) rgba(255, 217, 0, 0.32);
}

102
apps/docs/app/layout.tsx Normal file
View File

@@ -0,0 +1,102 @@
import { GeistSans } from 'geist/font/sans'
import { Metadata, Viewport } from 'next'
import { ThemeProvider } from 'next-themes'
import localFont from 'next/font/local'
import AutoRefresh from '@/components/common/autorefresh'
import { Footer } from '@/components/navigation/footer'
import { Header } from '@/components/navigation/header'
import { assetUrl } from '@/utils/asset-url'
import { cn } from '@/utils/cn'
import Analytics from './analytics'
import './globals.css'
function Wrapper({ children }: { children: React.ReactNode }) {
if (process.env.NODE_ENV === 'development') {
return <AutoRefresh>{children}</AutoRefresh>
}
return children
}
export const metadata: Metadata = {
metadataBase: new URL('https://tldraw.dev'),
title: {
default: 'tldraw: Build whiteboards in React with the tldraw SDK',
template: `%s • tldraw`,
},
description:
'The tldraw SDK provides tools, services, and APIs to build beautiful whiteboards and infinite canvas applications with real-time collaboration and a powerful React-based canvas.',
twitter: {
creator: '@tldraw',
},
applicationName: 'tldraw SDK',
appleWebApp: {
capable: true,
title: 'tldraw SDK',
statusBarStyle: 'black',
},
icons: [
{ rel: 'shortcut icon', url: assetUrl('/favicon.svg') },
{ rel: 'icon', url: assetUrl('/favicon-32x32.svg'), sizes: '32x32' },
{ rel: 'icon', url: assetUrl('/favicon-16x16.svg'), sizes: '16x16' },
{ rel: 'apple-touch-icon', url: assetUrl('/apple-touch-icon.png') },
{
rel: 'apple-touch-icon',
url: assetUrl('/apple-touch-icon-152x152.svg'),
sizes: '152x152',
},
{
rel: 'apple-touch-icon',
url: assetUrl('/apple-touch-icon-180x180.svg'),
sizes: '180x180',
},
{
rel: 'apple-touch-icon',
url: assetUrl('/apple-touch-icon-167x167.svg'),
sizes: '167x167',
},
],
}
export const viewport: Viewport = {
initialScale: 1,
maximumScale: 1,
width: 'device-width',
height: 'device-height',
themeColor: '#ffffff',
}
const ShantellSans = localFont({
src: './shantell-sans.woff2',
weight: '400',
display: 'swap',
variable: '--font-shantell-sans',
})
export default async function Layout({ children }: { children: React.ReactNode }) {
return (
<Wrapper>
<html
lang="en"
className={cn(GeistSans.variable, ShantellSans.variable, 'font-sans antialiased')}
>
<body className="overflow-x-hidden bg-white text-zinc-800 dark:bg-zinc-950 dark:text-zinc-300">
<ThemeProvider attribute="class" defaultTheme="light" enableSystem={false}>
<Header />
{children}
<Footer />
<Analytics />
</ThemeProvider>
<noscript>
<iframe
src="https://www.googletagmanager.com/ns.html?id=GTM-KMMLPMMN"
height="0"
width="0"
style={{ display: 'none', visibility: 'hidden' }}
/>
</noscript>
</body>
</html>
</Wrapper>
)
}

View File

@@ -0,0 +1,72 @@
/* eslint-disable react/no-unescaped-entities */
'use client'
import { RocketLaunchIcon } from '@heroicons/react/16/solid'
import { AcademicCapIcon, BookOpenIcon, NewspaperIcon, PlayIcon } from '@heroicons/react/20/solid'
import Image from 'next/image'
import Link from 'next/link'
import { DiscordIcon } from '@/components/common/icon/discord'
import { DocsSearchBar } from '@/components/docs/docs-search-bar'
import { NavigationLink } from '@/components/navigation/link'
import Handwritten404 from '@/public/images/404.svg'
const links = [
{
caption: 'Quick start',
icon: RocketLaunchIcon,
href: '/quick-start',
},
{
caption: 'Guides',
icon: AcademicCapIcon,
href: '/docs/editor',
},
{
caption: 'Reference',
icon: BookOpenIcon,
href: '/reference/editor/Editor',
},
{
caption: 'Examples',
icon: PlayIcon,
href: '/examples/basic',
},
{
caption: 'Blog',
icon: NewspaperIcon,
href: '/blog',
},
]
export default function NotFound() {
return (
<div className="min-h-screen">
<DocsSearchBar />
<section className="flex flex-col items-center justify-center py-16 md:py-32 px-5">
<Image src={Handwritten404} alt="404 - Page not found" className="w-64" />
<h1 className="font-hand text-black dark:text-white text-2xl">page not found :(</h1>
<p className="mt-8 max-w-md text-center text-balance">
There's nothing here. You may want to look at these pages to find what you're looking for:
</p>
<ul className="mt-8 flex flex-wrap justify-center gap-8">
{links.map((item, index) => (
<li key={index}>
<NavigationLink {...item} active={false} />
</li>
))}
</ul>
<p className="mt-32 max-w-md text-center text-balance text-sm">
You can always ask for help in our welcoming community on{' '}
<Link
href="https://discord.tldraw.com/?utm_source=docs&utm_medium=organic&utm_campaign=sociallink"
className="text-blue-500 hover:text-blue-600 dark:hover:text-blue-400"
>
<DiscordIcon className="h-3.5 mb-0.5 ml-0.5 inline" />
<span> Discord</span>
</Link>
.
</p>
</section>
</div>
)
}

4
apps/docs/app/robots.txt Normal file
View File

@@ -0,0 +1,4 @@
User-agent: *
Allow: /
Sitemap: https://www.tldraw.dev/sitemap.xml

View File

@@ -0,0 +1,14 @@
import { Metadata } from 'next'
export const metadata: Metadata = {
metadataBase: new URL('https://tldraw.dev'),
title: {
default: 'Search',
template: `%s • tldraw SDK`,
},
description: 'Documentation for the tldraw SDK.',
}
export default async function Layout({ children }: { children: React.ReactNode }) {
return children
}

View File

@@ -0,0 +1,19 @@
import { DocsMobileSidebar } from '@/components/docs/docs-mobile-sidebar'
import { DocsSidebar } from '@/components/docs/docs-sidebar'
import { FullPageSearch } from '@/components/search/FullPageSearch'
export const dynamic = 'force-dynamic'
export default function Page() {
return (
<div className="w-full max-w-screen-2xl mx-auto md:px-5 md:flex md:pt-8 isolate md:isolation-auto">
<DocsSidebar />
<div className="sticky top-14 z-10 flex items-center justify-between w-full h-12 px-5 bg-white border-b border-zinc-100 dark:border-zinc-800 dark:bg-zinc-950 backdrop-blur md:hidden">
<DocsMobileSidebar />
</div>
<main className="relative w-full max-w-3xl px-5 pt-7 shrink md:pr-0 lg:pl-12 xl:pr-12 md:pt-0 min-w-[1px]">
<FullPageSearch indexName="docs" />
</main>
</div>
)
}

20
apps/docs/app/sitemap.ts Normal file
View File

@@ -0,0 +1,20 @@
import { MetadataRoute } from 'next'
import { db } from '@/utils/ContentDatabase'
import { canonicalizeDocsSitemapPaths } from '@/utils/sitemap-canonical-paths'
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const paths = canonicalizeDocsSitemapPaths(await db.getAllPaths())
// Docs-only sitemap. Marketing pages are now owned by the dotdev app.
const docsSitemap: MetadataRoute.Sitemap = [
{
url: 'https://tldraw.dev/',
lastModified: new Date(),
},
...paths.map((path: string) => ({
url: 'https://tldraw.dev' + path,
lastModified: new Date(),
})),
]
return docsSitemap
}

View File

@@ -0,0 +1,30 @@
'use client'
import { useRouter } from 'next/navigation'
import { ReactNode, useEffect } from 'react'
let AutoRefresh = ({ children }: { children: ReactNode }) => {
return children
}
if (process.env.NODE_ENV === 'development') {
AutoRefresh = function AutoRefresh({ children }) {
const router = useRouter()
useEffect(() => {
const ws = new WebSocket('ws://localhost:3201')
ws.onmessage = (event) => {
if (event.data === 'refresh') {
router.refresh()
}
}
return () => {
ws.close()
}
}, [router])
return children
}
}
export default AutoRefresh

View File

@@ -0,0 +1,16 @@
'use client'
import { ArrowLongUpIcon } from '@heroicons/react/20/solid'
export function BackToTopButton() {
return (
<button
id="back-to-top"
onClick={() => window.scrollTo({ top: 0, behavior: 'smooth' })}
className="flex items-center gap-1.5 text-blue-500 hover:text-blue-600 dark:hover:bg-blue-400"
>
<ArrowLongUpIcon className="h-3.5 -mr-px" />
<span>Scroll to top</span>
</button>
)
}

View File

@@ -0,0 +1,88 @@
'use client'
import { ArrowLongLeftIcon, ArrowLongRightIcon } from '@heroicons/react/20/solid'
import Link from 'next/link'
import { MouseEventHandler } from 'react'
import { track } from '@/app/analytics'
import { Icon, IconName } from '@/components/common/icon'
import { cn } from '@/utils/cn'
export function Button({
id,
href,
newTab,
onClick,
caption,
icon,
arrow,
className,
size = 'base',
type = 'primary',
darkRingOffset,
}: {
id?: string
href?: string
newTab?: boolean
onClick?: MouseEventHandler<HTMLButtonElement | HTMLAnchorElement>
caption: string
icon?: IconName
arrow?: 'left' | 'right'
className?: string
size?: 'xs' | 'sm' | 'base' | 'lg'
type?: 'primary' | 'secondary' | 'tertiary' | 'black'
darkRingOffset?: boolean
}) {
const iconSizes = { xs: 'h-3', sm: 'h-3.5', base: 'h-4', lg: 'h-5' }
className = cn(
'relative overflow-hidden flex items-center focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 dark:focus:ring-offset-zinc-900',
darkRingOffset ? 'focus:ring-offset-zinc-900' : 'focus:ring-offset-zinc-50',
size === 'xs' && 'h-6 px-2 gap-1.5 rounded-md text-xs',
size === 'sm' && 'h-7 px-3 gap-2 rounded-md text-sm',
size === 'base' && 'h-9 px-4 gap-2.5 rounded-lg text-base',
size === 'lg' && 'h-11 px-5 gap-3 rounded-xl text-lg',
type === 'primary' && 'bg-blue-500 text-white hover:bg-blue-600 dark:hover:bg-blue-400',
type === 'black' &&
'bg-black text-white dark:text-black hover:bg-zinc-800 dark:bg-white dark:hover:bg-zinc-100',
type === 'secondary' &&
'bg-zinc-100 text-zinc-800 dark:text-zinc-100 hover:bg-zinc-200 dark:bg-zinc-800 dark:hover:bg-zinc-700',
type === 'tertiary' &&
'text-black dark:text-white dark:hover:text-zinc-200 hover:text-zinc-700',
className
)
// no word wrapping
const spanClassName = cn('whitespace-nowrap')
if (href)
return (
<Link
href={href}
target={newTab ? '_blank' : undefined}
rel={newTab ? 'noopener noreferrer' : undefined}
className={className}
onClick={(e) => {
onClick?.(e)
if (id) track('button', { id })
}}
>
{arrow === 'left' && <ArrowLongLeftIcon className={cn(iconSizes[size])} />}
{icon && <Icon icon={icon} className={cn(iconSizes[size])} />}
<span className={spanClassName}>{caption}</span>
{arrow === 'right' && <ArrowLongRightIcon className={cn(iconSizes[size])} />}
</Link>
)
if (onClick)
return (
<button
onClick={(e) => {
if (id) track('button', { id })
onClick?.(e)
}}
className={className}
>
{arrow === 'left' && <ArrowLongLeftIcon className={cn(iconSizes[size])} />}
{icon && <Icon icon={icon} className={cn(iconSizes[size])} />}
<span className={spanClassName}>{caption}</span>
{arrow === 'right' && <ArrowLongRightIcon className={cn(iconSizes[size])} />}
</button>
)
return null
}

View File

@@ -0,0 +1,13 @@
import { cn } from '@/utils/cn'
export function DiscordIcon({ className }: { className: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 640 512"
className={cn('fill-current', className)}
>
<path d="M524.5 69.8a1.5 1.5 0 0 0 -.8-.7A485.1 485.1 0 0 0 404.1 32a1.8 1.8 0 0 0 -1.9 .9 337.5 337.5 0 0 0 -14.9 30.6 447.8 447.8 0 0 0 -134.4 0 309.5 309.5 0 0 0 -15.1-30.6 1.9 1.9 0 0 0 -1.9-.9A483.7 483.7 0 0 0 116.1 69.1a1.7 1.7 0 0 0 -.8 .7C39.1 183.7 18.2 294.7 28.4 404.4a2 2 0 0 0 .8 1.4A487.7 487.7 0 0 0 176 479.9a1.9 1.9 0 0 0 2.1-.7A348.2 348.2 0 0 0 208.1 430.4a1.9 1.9 0 0 0 -1-2.6 321.2 321.2 0 0 1 -45.9-21.9 1.9 1.9 0 0 1 -.2-3.1c3.1-2.3 6.2-4.7 9.1-7.1a1.8 1.8 0 0 1 1.9-.3c96.2 43.9 200.4 43.9 295.5 0a1.8 1.8 0 0 1 1.9 .2c2.9 2.4 6 4.9 9.1 7.2a1.9 1.9 0 0 1 -.2 3.1 301.4 301.4 0 0 1 -45.9 21.8 1.9 1.9 0 0 0 -1 2.6 391.1 391.1 0 0 0 30 48.8 1.9 1.9 0 0 0 2.1 .7A486 486 0 0 0 610.7 405.7a1.9 1.9 0 0 0 .8-1.4C623.7 277.6 590.9 167.5 524.5 69.8zM222.5 337.6c-29 0-52.8-26.6-52.8-59.2S193.1 219.1 222.5 219.1c29.7 0 53.3 26.8 52.8 59.2C275.3 311 251.9 337.6 222.5 337.6zm195.4 0c-29 0-52.8-26.6-52.8-59.2S388.4 219.1 417.9 219.1c29.7 0 53.3 26.8 52.8 59.2C470.7 311 447.5 337.6 417.9 337.6z" />
</svg>
)
}

View File

@@ -0,0 +1,13 @@
import { cn } from '@/utils/cn'
export function GithubIcon({ className }: { className: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 496 512"
className={cn('fill-current', className)}
>
<path d="M165.9 397.4c0 2-2.3 3.6-5.2 3.6-3.3 .3-5.6-1.3-5.6-3.6 0-2 2.3-3.6 5.2-3.6 3-.3 5.6 1.3 5.6 3.6zm-31.1-4.5c-.7 2 1.3 4.3 4.3 4.9 2.6 1 5.6 0 6.2-2s-1.3-4.3-4.3-5.2c-2.6-.7-5.5 .3-6.2 2.3zm44.2-1.7c-2.9 .7-4.9 2.6-4.6 4.9 .3 2 2.9 3.3 5.9 2.6 2.9-.7 4.9-2.6 4.6-4.6-.3-1.9-3-3.2-5.9-2.9zM244.8 8C106.1 8 0 113.3 0 252c0 110.9 69.8 205.8 169.5 239.2 12.8 2.3 17.3-5.6 17.3-12.1 0-6.2-.3-40.4-.3-61.4 0 0-70 15-84.7-29.8 0 0-11.4-29.1-27.8-36.6 0 0-22.9-15.7 1.6-15.4 0 0 24.9 2 38.6 25.8 21.9 38.6 58.6 27.5 72.9 20.9 2.3-16 8.8-27.1 16-33.7-55.9-6.2-112.3-14.3-112.3-110.5 0-27.5 7.6-41.3 23.6-58.9-2.6-6.5-11.1-33.3 2.6-67.9 20.9-6.5 69 27 69 27 20-5.6 41.5-8.5 62.8-8.5s42.8 2.9 62.8 8.5c0 0 48.1-33.6 69-27 13.7 34.7 5.2 61.4 2.6 67.9 16 17.7 25.8 31.5 25.8 58.9 0 96.5-58.9 104.2-114.8 110.5 9.2 7.9 17 22.9 17 46.4 0 33.7-.3 75.4-.3 83.6 0 6.5 4.6 14.4 17.3 12.1C428.2 457.8 496 362.9 496 252 496 113.3 383.5 8 244.8 8zM97.2 352.9c-1.3 1-1 3.3 .7 5.2 1.6 1.6 3.9 2.3 5.2 1 1.3-1 1-3.3-.7-5.2-1.6-1.6-3.9-2.3-5.2-1zm-10.8-8.1c-.7 1.3 .3 2.9 2.3 3.9 1.6 1 3.6 .7 4.3-.7 .7-1.3-.3-2.9-2.3-3.9-2-.6-3.6-.3-4.3 .7zm32.4 35.6c-1.6 1.3-1 4.3 1.3 6.2 2.3 2.3 5.2 2.6 6.5 1 1.3-1.3 .7-4.3-1.3-6.2-2.2-2.3-5.2-2.6-6.5-1zm-11.4-14.7c-1.6 1-1.6 3.6 0 5.9 1.6 2.3 4.3 3.3 5.6 2.3 1.6-1.3 1.6-3.9 0-6.2-1.4-2.3-4-3.3-5.6-2z" />
</svg>
)
}

View File

@@ -0,0 +1,30 @@
import * as HeroiconsSolid from '@heroicons/react/20/solid'
import * as HeroiconsOutline from '@heroicons/react/24/outline'
import { DiscordIcon } from './discord'
import { GithubIcon } from './github'
import { LinkedinIcon } from './linkedin'
import { TldrawIcon } from './tldraw'
import { TwitterIcon } from './twitter'
const icons = {
check: HeroiconsOutline.CheckIcon,
copy: HeroiconsOutline.ClipboardIcon,
discord: DiscordIcon,
github: GithubIcon,
linkedin: LinkedinIcon,
paperclip: HeroiconsOutline.PaperClipIcon,
play: HeroiconsSolid.PlayIcon,
tldraw: TldrawIcon,
twitter: TwitterIcon,
bolt: HeroiconsOutline.BoltIcon,
cube: HeroiconsOutline.CubeIcon,
wrench: HeroiconsOutline.WrenchScrewdriverIcon,
shield: HeroiconsOutline.ShieldCheckIcon,
}
export type IconName = keyof typeof icons
export function Icon({ icon, className }: { icon: IconName; className: string }) {
const IconComponent = icons[icon]
return <IconComponent className={className} />
}

View File

@@ -0,0 +1,13 @@
import { cn } from '@/utils/cn'
export function LinkedinIcon({ className }: { className: string }) {
return (
<svg
viewBox="0 0 16 16"
xmlns="http://www.w3.org/2000/svg"
className={cn('fill-current', className)}
>
<path d="M0 1.146C0 .513.526 0 1.175 0h13.65C15.474 0 16 .513 16 1.146v13.708c0 .633-.526 1.146-1.175 1.146H1.175C.526 16 0 15.487 0 14.854zm4.943 12.248V6.169H2.542v7.225zm-1.2-8.212c.837 0 1.358-.554 1.358-1.248-.015-.709-.52-1.248-1.342-1.248S2.4 3.226 2.4 3.934c0 .694.521 1.248 1.327 1.248zm4.908 8.212V9.359c0-.216.016-.432.08-.586.173-.431.568-.878 1.232-.878.869 0 1.216.662 1.216 1.634v3.865h2.401V9.25c0-2.22-1.184-3.252-2.764-3.252-1.274 0-1.845.7-2.165 1.193v.025h-.016l.016-.025V6.169h-2.4c.03.678 0 7.225 0 7.225z"></path>
</svg>
)
}

View File

@@ -0,0 +1,16 @@
import { cn } from '@/utils/cn'
export function TldrawIcon({ className }: { className: string }) {
return (
<svg
width="14"
height="14"
viewBox="0 0 15 14"
xmlns="http://www.w3.org/2000/svg"
className={cn('fill-current', className)}
>
<path d="M8.78578 3.4513C9.15358 3.0593 9.33748 2.58208 9.33748 2.01964C9.33748 1.45722 9.15358 0.980001 8.78578 0.587998C8.41806 0.195995 7.97035 0 7.44271 0C6.89906 0 6.44337 0.195995 6.07562 0.587998C5.70786 0.980001 5.52398 1.45722 5.52398 2.01964C5.52398 2.58208 5.70786 3.0593 6.07562 3.4513C6.44337 3.84331 6.89906 4.0393 7.44271 4.0393C7.97035 4.0393 8.41806 3.84331 8.78578 3.4513Z" />
<path d="M6.05162 7.666C5.68387 8.05801 5.5 8.53521 5.5 9.09764C5.5 9.62606 5.66788 10.0691 6.00366 10.427C6.33943 10.7849 6.66722 10.9724 6.987 10.9894C7.11492 10.9894 7.17089 11.0577 7.1549 11.194C7.10693 11.5008 7.00299 11.7991 6.8431 12.0888C6.6832 12.3786 6.48334 12.6171 6.2435 12.8046C5.81179 13.1795 5.73185 13.5034 6.00366 13.7761C6.29147 14.0658 6.65922 14.0744 7.10693 13.8017C7.92238 13.3245 8.54597 12.6342 8.97772 11.7308C9.42541 10.8276 9.58532 9.9158 9.45732 8.99537C9.39338 8.50108 9.16953 8.05801 8.78578 7.666C8.40207 7.25696 7.95436 7.05243 7.44271 7.05243C6.89906 7.05243 6.43538 7.25696 6.05162 7.666Z" />
</svg>
)
}

View File

@@ -0,0 +1,13 @@
import { cn } from '@/utils/cn'
export function TwitterIcon({ className }: { className: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 512 512"
className={cn('fill-current', className)}
>
<path d="M389.2 48h70.6L305.6 224.2 487 464H345L233.7 318.6 106.5 464H35.8L200.7 275.5 26.8 48H172.4L272.9 180.9 389.2 48zM364.4 421.8h39.1L151.1 88h-42L364.4 421.8z" />
</svg>
)
}

View File

@@ -0,0 +1,46 @@
import { cn } from '@/utils/cn'
export function Logo({ className, forceWhite }: { className: string; forceWhite?: boolean }) {
return (
<svg
viewBox="0 0 4081 1000"
xmlns="http://www.w3.org/2000/svg"
className={cn(
'fill-current',
forceWhite ? 'text-white' : 'text-black dark:text-white',
className
)}
>
<path
d="M1303.69 270.972C1303.69 240.816 1328.13 216.369 1358.29 216.369H1428.49C1458.65 216.369 1483.09 240.816 1483.09 270.972V315.954C1483.09 327.011 1492.06 335.975 1503.12 335.975H1541C1561.11 335.975 1577.4 352.272 1577.4 372.376V429.579C1577.4 449.683 1561.11 465.98 1541 465.98H1503.12C1492.06 465.98 1483.09 474.944 1483.09 486.001V674.64C1483.09 681.573 1484.29 687.424 1486.67 692.19C1489.05 696.741 1492.74 700.207 1497.72 702.591C1502.7 704.758 1522.56 705.841 1530.37 705.841C1549.5 705.841 1560.72 712.765 1564.67 731.492L1577.4 791.954C1581.54 811.61 1569.85 830.984 1550.28 835.522C1535.55 838.989 1511.26 841.264 1490.9 842.347C1449.73 844.514 1415.17 840.289 1387.22 829.672C1359.26 818.838 1338.25 801.829 1324.16 778.644C1310.08 755.46 1303.25 726.425 1303.69 691.54V486.001C1303.69 474.944 1294.72 465.98 1283.67 465.98H1266.96C1246.85 465.98 1230.56 449.683 1230.56 429.579V372.376C1230.56 352.272 1246.85 335.975 1266.96 335.975H1283.67C1294.72 335.975 1303.69 327.011 1303.69 315.954V270.972Z"
fill="currentColor"
/>
<path
d="M1974.54 814.96C1944.85 796.326 1921.02 768.049 1903.03 730.131C1885.27 692.213 1876.38 644.436 1876.38 586.8C1876.38 501.729 1898.19 403.692 1977.14 357.015C2038.76 320.844 2119.52 320.677 2175.17 368.834C2185.85 378.077 2214.4 369.799 2214.4 355.676V225.384C2214.4 195.228 2238.84 170.781 2269 170.781H2339.2C2369.36 170.781 2393.81 195.228 2393.81 225.384V781.808C2393.81 811.964 2369.36 836.411 2339.2 836.411H2249.9C2231.01 836.411 2215.7 821.099 2215.7 802.211C2215.7 791.902 2195.36 785.728 2188.33 793.263C2186.18 795.558 2184.03 797.807 2181.9 800.009C2128.34 855.383 2037.25 854.041 1974.54 814.96ZM2218.3 586.8C2218.3 537.286 2200.48 468.495 2138.99 468.495C2077.29 468.495 2060.99 537.551 2060.99 586.8C2060.99 623.113 2066.97 666.411 2096.74 691.13C2165.89 746.195 2218.3 647.886 2218.3 586.8Z"
fill="currentColor"
/>
<path
d="M2513.66 836.412C2483.51 836.412 2459.06 811.965 2459.06 781.809V391.792C2459.06 361.636 2483.51 337.19 2513.66 337.19H2603.2C2619.81 337.19 2633.27 350.651 2633.27 367.256C2633.27 382.997 2638.97 387.954 2649.77 387.954C2668.35 387.954 2672.24 328.627 2758.31 330.689C2813.1 330.689 2820.44 385.406 2820.44 412.863C2820.44 475.557 2800.52 481.496 2737.51 481.496C2681.18 481.496 2638.47 519.992 2638.47 576.4V781.809C2638.47 811.965 2614.02 836.412 2583.87 836.412H2513.66Z"
fill="currentColor"
/>
<path
d="M2970.68 842.913C2916.63 842.913 2859.79 826.876 2828.98 779.21C2815.11 757.759 2808.18 730.458 2808.18 697.306C2808.18 651.927 2823.66 607.404 2861.15 579.651C2898.44 552.053 2946.07 542.134 2991.49 538.699C3020.52 536.448 3107.19 538.281 3107.19 494.497C3107.19 448.71 3041.54 443.568 3011.96 464.271C2970.82 492.82 2973.83 498.397 2910.23 498.397C2834.58 498.397 2857.58 387.482 2932.33 352.791C2965.48 337.19 3006.87 329.39 3056.49 329.39C3114.93 329.39 3177.44 341.047 3225.17 376.842C3245.1 391.576 3260.27 408.91 3270.67 428.844C3281.29 448.562 3286.6 470.013 3286.6 493.197V780.51C3286.6 810.666 3262.15 835.112 3232 835.112H3152.37C3133.88 835.112 3118.89 820.125 3118.89 801.638C3118.89 792.06 3103.8 787.48 3097.15 794.364C3063.59 829.054 3017.92 842.913 2970.68 842.913ZM3097.11 696.981C3108 681.743 3109.4 662.66 3109.14 643.805C3108.97 630.868 3095.3 621.942 3082.84 625.429C3067.81 629.638 3052.39 632.57 3036.99 634.904C3017.05 638.018 2994.44 645.215 2983.36 663.505C2973.07 680.83 2975.13 706.154 2991.81 718.757C3023.43 742.651 3075.6 727.106 3097.11 696.981Z"
fill="currentColor"
/>
<path
d="M1761.82 163C1792.33 163 1817.06 187.732 1817.06 218.24V781.158C1817.06 811.666 1792.33 836.398 1761.82 836.398H1690.8C1660.29 836.398 1635.56 811.666 1635.56 781.158V218.24C1635.56 187.732 1660.29 163 1690.8 163H1761.82Z"
fill="currentColor"
/>
<path
d="M3480.44 836.409C3455.39 836.409 3433.55 819.357 3427.47 795.049L3329.98 405.032C3321.37 370.57 3347.43 337.187 3382.95 337.187H3447.12C3473.38 337.187 3495.92 355.886 3500.78 381.699L3534.84 562.897C3538.14 580.407 3563.06 580.816 3566.93 563.423L3607.7 379.944C3613.25 354.961 3635.4 337.187 3660.99 337.187H3748.03C3773.42 337.187 3795.45 354.684 3801.21 379.411L3843.62 561.638C3847.65 578.962 3872.52 578.345 3875.69 560.842L3908.05 382.062C3912.75 356.08 3935.37 337.187 3961.77 337.187H4025.63C4061.15 337.187 4087.21 370.57 4078.6 405.032L3981.11 795.049C3975.03 819.357 3953.19 836.409 3928.14 836.409H3818.64C3794.09 836.409 3772.56 820.015 3766.02 796.343L3719.13 626.567C3714.69 610.471 3691.83 610.567 3687.52 626.7L3642.32 795.901C3635.94 819.79 3614.3 836.409 3589.57 836.409H3480.44Z"
fill="currentColor"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M0.557373 130C0.557373 58 58.5574 0 130.557 0H870.557C942.557 0 1000.56 58 1000.56 130V870C1000.56 942 942.557 1000 870.557 1000H130.557C58.5574 1000 0.557373 942 0.557373 870V130ZM590.557 300C590.557 350 550.557 390 500.557 390C450.557 390 410.557 350 410.557 300C410.557 250 450.557 210 500.557 210C550.557 210 590.557 250 590.557 300ZM470.557 810C513.557 810 554.557 751 569.557 719C588.557 678 599.557 605 580.557 562C567.557 532 537.557 510 497.557 510C449.557 510 410.557 549 410.557 597C410.557 640 441.557 674 481.557 681C483.557 681 485.557 684 485.557 686C481.557 711 470.557 743 453.557 759C432.557 779 438.557 810 470.557 810Z"
fill="currentColor"
/>
</svg>
)
}

View File

@@ -0,0 +1,22 @@
'use client'
import { cn } from '@/utils/cn'
export function Navigation({
className,
children,
}: {
className?: string
children: React.ReactNode
}) {
return (
<nav
className={cn('w-52 lg:w-60 shrink-0 flex flex-col sticky top-24 overflow-hidden', className)}
style={{
height: 'calc(100vh - 6rem)',
}}
>
{children}
</nav>
)
}

View File

@@ -0,0 +1,20 @@
import { cn } from '@/utils/cn'
export function PageTitle({
children,
className,
}: {
children: React.ReactNode
className?: string
}) {
return (
<h1
className={cn(
'font-black text-black dark:text-white text-3xl sm:text-4xl break-words max-w-full',
className
)}
>
{children}
</h1>
)
}

View File

@@ -0,0 +1,43 @@
'use client'
import { MoonIcon, SunIcon } from '@heroicons/react/20/solid'
import { useTheme } from 'next-themes'
import { useEffect, useState } from 'react'
import { cn } from '@/utils/cn'
const themes = [
{ id: 'light', name: 'Light Mode', icon: SunIcon },
{ id: 'dark', name: 'Dark Mode', icon: MoonIcon },
// { id: 'system', name: 'System Preference', icon: Cog6ToothIcon },
]
export function ThemeSwitch() {
const { theme: initialTheme, setTheme: persistTheme } = useTheme()
const [theme, setTheme] = useState<string>()
const Icon = themes.find((t) => t.id === theme)?.icon ?? SunIcon
useEffect(() => {
setTheme(initialTheme)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const setThemeAndPersist = () => {
const nextTheme = theme === 'light' ? 'dark' : 'light'
persistTheme(nextTheme)
setTheme(nextTheme)
}
return (
<div className="flex flex-col items-end sm:flex-row sm:items-center gap-4 ml-2 md:ml-0">
<div className="hidden md:block h-px sm:h-5 w-full sm:w-px bg-zinc-100 sm:bg-zinc-200 dark:bg-zinc-800 dark:sm:bg-zinc-700" />
<button
onClick={() => setThemeAndPersist()}
className="hover:text-zinc-600 dark:hover:text-zinc-100"
>
<div className={cn('transition-opacity duration-300', theme ? 'opacity-100' : 'opacity-0')}>
<Icon className="size-5" />
</div>
</button>
</div>
)
}

View File

@@ -0,0 +1,69 @@
'use client'
import Link from 'next/link'
import { useEffect, useState } from 'react'
export function TldrawLink(props: React.ComponentProps<'a'>) {
const { href, children } = props
const windowObject = typeof window !== 'undefined' ? window : null
// Start empty so the first client render matches the server HTML — reading
// posthog during render appends `#session_id` to hrefs before hydration and
// causes a hydration mismatch whenever posthog loads first.
const [sessionId, setSessionId] = useState<string | undefined>(undefined)
const [distinctId, setDistinctId] = useState<string | undefined>(undefined)
useEffect(() => {
// Note this href check is intentionally loose, just to avoid
// doing this setInterval for every link. We do the real check below.
if (sessionId || !href?.includes('tldraw.com')) return
if (windowObject?.posthog) {
setSessionId(windowObject.posthog.get_session_id())
setDistinctId(windowObject.posthog.get_distinct_id())
return
}
// XXX: have to wait a bit for posthog to be ready.
// there's unfortunately no event callback for this.
const timeout = setInterval(() => {
setSessionId(windowObject?.posthog?.get_session_id())
setDistinctId(windowObject?.posthog?.get_distinct_id())
clearTimeout(timeout)
}, 1000)
return () => {
clearTimeout(timeout)
}
}, [sessionId, windowObject, href])
const isLocalUrl = href?.startsWith('/') || href?.startsWith('#')
let maybeParsedUrl
try {
maybeParsedUrl = isLocalUrl ? null : href ? new URL(href) : null
} catch {
console.error(`Invalid URL: ${href}`)
}
const derivedTarget =
isLocalUrl ||
maybeParsedUrl?.host.includes('tldraw.com') ||
maybeParsedUrl?.host.includes('localhost')
? undefined
: '_blank'
const target = props.target ?? derivedTarget
let newHref = href
if (
maybeParsedUrl?.host.includes('tldraw.com') &&
sessionId &&
distinctId &&
!href?.includes('#')
) {
newHref = `${href}#session_id=${sessionId}&distinct_id=${distinctId}`
}
return (
<Link {...props} href={newHref ?? ''} target={target}>
{children}
</Link>
)
}

View File

@@ -0,0 +1,3 @@
export function ApiHeading(props: any) {
return <h4 className="!-mb-4 !mt-8 uppercase text-sm font-semibold" {...props} />
}

View File

@@ -0,0 +1,228 @@
import { assetUrl } from '@/utils/asset-url'
import { cn } from '@/utils/cn'
// Content definitions
const KITS = {
multiplayer: {
items: [
{
title: 'Collaborative whiteboards',
desc: 'Real-time drawing and diagramming tools where multiple users can contribute simultaneously.',
},
{
title: 'Educational canvases',
desc: 'Shared learning environments for remote teaching, brainstorming, and visual collaboration.',
},
{
title: 'Design review platforms',
desc: 'Interactive design collaboration spaces with persistent sessions and asset sharing.',
},
{
title: 'Project planning tools',
desc: 'Visual project management interfaces with real-time updates across team members.',
},
{
title: 'Interactive documentation',
desc: 'Living documents that teams can annotate and modify together in real-time.',
},
],
},
agent: {
items: [
{
title: 'Visual AI assistants',
desc: 'Create AI agents that can read, interpret, and modify drawings while providing analysis, insights, and automated annotations.',
},
{
title: 'Diagram generation tools',
desc: 'Build systems that automatically generate flowcharts, system architecture diagrams, or technical illustrations from text descriptions.',
},
{
title: 'Shape recognition systems',
desc: 'Develop applications that identify and classify hand-drawn shapes, converting sketches into structured digital content.',
},
],
},
workflow: {
items: [
{
title: 'AI and agentic workflows',
desc: 'Build visual AI pipelines where agents process data through connected nodes.',
},
{
title: 'Automation platforms',
desc: 'Create no-code automation tools where users visually connect services and data transformations.',
},
{
title: 'Data processing pipelines',
desc: 'Design ETL tools where users drag nodes to transform, filter, and route data between sources and destinations.',
},
{
title: 'Visual programming interfaces',
desc: 'Build domain-specific scripting environments where complex logic becomes intuitive drag-and-drop workflows.',
},
{
title: 'Interactive diagramming tools',
desc: 'Create specialized diagram builders for database design, circuit boards, or business process flows with executable functionality.',
},
],
},
chat: {
items: [
{
title: 'AI chat apps',
desc: 'Add visual context to conversational AI by letting users sketch diagrams, annotate screenshots, and draw concepts to supplement text conversations.',
},
{
title: 'Design feedback apps',
desc: 'Add annotation to your design review tools so teams can mark up mockups, wireframes, and prototypes within chat conversations.',
},
{
title: 'Educational platforms',
desc: 'Add drawing capabilities to tutoring interfaces so students can sketch math problems, create diagrams, and get visual help.',
},
{
title: 'Documentation systems',
desc: 'Add visual markup to help desk or support chat so users can annotate screenshots and create flowcharts.',
},
{
title: 'Content creation tools',
desc: 'Add sketching and annotation to creative collaboration platforms so teams can mark up references and brainstorm visually.',
},
],
},
branching: {
items: [
{
title: 'Interactive chatbots',
desc: 'Create conversational AI interfaces with multiple dialogue paths where users can explore different response branches and see how conversations evolve in different directions.',
},
{
title: 'Conversation design tools',
desc: 'Build visual prototyping environments for UX designers to map out chatbot flows, customer service scripts, or user interaction patterns before implementation.',
},
{
title: 'Interactive storytelling',
desc: 'Develop choose-your-own-adventure applications where narrative branches visually connect, allowing readers to see story paths and backtrack through different choices.',
},
{
title: 'Training simulations',
desc: 'Create educational tools with branching scenarios for customer service training, medical diagnosis practice, or sales conversation rehearsal with AI-powered responses.',
},
{
title: 'AI agent workflows',
desc: 'Design complex AI assistant interfaces where conversation history and context branch based on user choices, enabling sophisticated multi-turn dialogue management.',
},
],
},
'image-pipeline': {
items: [
{
title: 'AI image generation tools',
desc: 'Build visual pipeline builders where users chain prompts, models, and processing steps to create and iterate on AI-generated images.',
},
{
title: 'Image processing workflows',
desc: 'Create node-based editors for multi-step image manipulation combining upscaling, style transfer, blending, and adjustments.',
},
{
title: 'Creative asset pipelines',
desc: 'Design visual tools for generating and refining creative assets through connected processing nodes with real-time preview.',
},
{
title: 'Batch image generation',
desc: 'Build interfaces that iterate over prompt variations, generating multiple images from parameterized pipelines.',
},
{
title: 'Visual prompt engineering',
desc: 'Create interactive environments for exploring how different models, parameters, and prompt combinations affect image outputs.',
},
],
},
shader: {
items: [
{
title: 'Immersive creative tools',
desc: 'Layer animated, touch-responsive backgrounds behind your drawing UI for design or storytelling apps.',
},
{
title: 'Data-rich dashboards',
desc: 'Highlight state changes or live metrics using GPU-driven gradients and particle systems that react to editor data.',
},
{
title: 'Games and interactive experiences',
desc: 'Combine tldraw shapes with shader-based effects to build puzzle overlays, interactive maps, or ambient scenes.',
},
{
title: 'Live events and streaming overlays',
desc: 'Drive real-time graphics for broadcast overlays or virtual stages that react to presenter actions.',
},
{
title: 'Educational demos',
desc: 'Teach graphics concepts with interactive examples that expose shader parameters through the config panel.',
},
],
},
} as const
export type StarterKitType = keyof typeof KITS
export function StarterKitBento({
type,
href,
img,
}: {
type: StarterKitType
href: string
img?: { src: string; alt?: string }
}) {
const content = KITS[type]
if (!content) return null
return (
<div className="my-8 rounded-xl border border-zinc-200 bg-zinc-100 p-2 shadow-sm not-prose">
{img && (
<a
href={href}
className="block relative mb-2 overflow-hidden rounded-lg border border-zinc-200 bg-zinc-50"
>
<img
src={assetUrl(img.src)}
alt={img.alt || type}
className="!my-0 h-auto w-full object-cover"
/>
</a>
)}
<div className="grid grid-cols-1 gap-2 md:grid-cols-6">
{content.items.map((item, i) => {
const span = i < 3 ? 2 : 3
return <BentoItem key={item.title} title={item.title} span={span} desc={item.desc} />
})}
</div>
</div>
)
}
function BentoItem({ title, desc, span }: { title: string; desc: string; span: number }) {
const colSpan =
{
2: 'md:col-span-2',
3: 'md:col-span-3',
}[span] || 'md:col-span-2'
return (
<div
className={cn(
'flex flex-col justify-between rounded-lg border border-zinc-200 bg-white p-5 shadow-sm transition-all hover:shadow-md',
colSpan
)}
>
<div>
<h3 className="mb-2 text-lg font-bold leading-tight text-zinc-900">{title}</h3>
<p className="text-sm leading-relaxed text-zinc-600">{desc}</p>
</div>
</div>
)
}

View File

@@ -0,0 +1,5 @@
import { Callout } from './callout'
export function Blockquote(props: any) {
return <Callout>{props.children}</Callout>
}

View File

@@ -0,0 +1,44 @@
import {
ExclamationCircleIcon,
ExclamationTriangleIcon,
InformationCircleIcon,
} from '@heroicons/react/20/solid'
import { cn } from '@/utils/cn'
export function Callout({
type = 'info',
children,
}: {
type?: 'info' | 'warning' | 'critical' | 'quote'
children: React.ReactNode
}) {
return (
<div
className={cn(
'py-1 -mx-5 md:mx-0',
type === 'info' && 'md:px-1 md:rounded-2xl bg-blue-50 dark:bg-blue-950/75',
type === 'warning' && 'md:px-1 md:rounded-2xl bg-amber-50 dark:bg-amber-900/50',
type === 'critical' && 'md:px-1 md:rounded-2xl bg-rose-50 dark:bg-rose-950/50'
)}
>
<div
className={cn(
'flex flex-col sm:flex-row gap-4 w-full p-5 ',
type === 'quote' && 'border-right border-zinc-400 dark:border-zinc-700 border-l-4',
type === 'info' && 'md:rounded-xl shadow dark:bg-blue-900/25',
type === 'warning' && 'md:rounded-xl shadow dark:bg-amber-900/25',
type === 'critical' && 'md:rounded-xl shadow dark:bg-rose-900/25'
)}
>
{type === 'info' && <InformationCircleIcon className="size-5 shrink-0 text-blue-500" />}
{type === 'warning' && (
<ExclamationTriangleIcon className="size-5 shrink-0 text-amber-500 dark:text-amber-400" />
)}
{type === 'critical' && (
<ExclamationCircleIcon className="size-5 shrink-0 text-rose-500 dark:text-rose-400" />
)}
<div className="-mt-0.5 [&>*:first-child]:mt-0 [&>*:last-child]:mb-0">{children}</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,11 @@
import type React from 'react'
import { Icon } from '@/components/common/icon'
export function CheckItem({ children }: { children: React.ReactNode }) {
return (
<div className="flex items-start gap-3 text-zinc-600 dark:text-zinc-400">
<Icon icon="check" className="mt-1 h-5 w-5 shrink-0 text-zinc-900 dark:text-zinc-100" />
<span>{children}</span>
</div>
)
}

View File

@@ -0,0 +1,82 @@
import { Tab, TabGroup, TabList, TabPanel, TabPanels } from '@headlessui/react'
import { createJavaScriptRegexEngine } from '@shikijs/engine-javascript'
import { createHighlighterCoreSync, hastToHtml } from 'shiki/core'
import css from 'shiki/dist/langs/css.mjs'
import tsx from 'shiki/dist/langs/tsx.mjs'
import ts from 'shiki/dist/langs/typescript.mjs'
import theme from 'shiki/dist/themes/github-dark.mjs'
import { cn } from '@/utils/cn'
import { CopyButton } from './copy-button'
// The normal shiki import is async, which we can't use here easily because of SSR complications.
// So here we create a synchronous version of the highlighter.
const shiki = createHighlighterCoreSync({
themes: [theme],
langs: [ts, tsx, css],
engine: createJavaScriptRegexEngine(),
})
export function CodeFiles({
files,
hideTabs,
className,
}: {
files: { name: string; content: any }[]
hideCopyButton?: boolean
hideTabs?: boolean
className?: string
}) {
return (
<TabGroup
className={cn(
'max-h-[550px] group relative not-prose bg-zinc-100 dark:bg-zinc-800 py-1 md:rounded-2xl -mx-5 md:-mx-1 md:px-1 my-6 flex flex-col',
'[td_&]:m-0 [td_&]:mb-2 [td_&]:p-0 [td_&]:bg-transparent [td_&]:rounded-none',
className
)}
>
<TabList
className={cn(
'bg-zinc-900 shrink-0 text-sm text-zinc-400 shadow md:rounded-t-xl border-b border-zinc-700/50 px-5 md:px-4 gap-4 flex',
hideTabs && 'hidden'
)}
>
{files.map(({ name }, index) => (
<Tab
key={index}
className="h-10 data-[selected]:text-white border-b border-transparent data-[selected]:border-white -mb-px focus:outline-none"
>
{name}
</Tab>
))}
</TabList>
<TabPanels
className={cn(
'bg-zinc-900 grow text-sm text-white shadow md:rounded-b-xl overflow-x-auto px-5 md:px-4 py-0',
hideTabs && 'md:rounded-t-xl'
)}
>
{files.map(({ content, name }, index) => {
// the typescript grammar has no JSX, so .tsx/.jsx need the tsx grammar
const lang = name.endsWith('.css')
? 'css'
: name.endsWith('.tsx') || name.endsWith('.jsx')
? 'tsx'
: 'ts'
const ast = shiki.codeToHast(content, { lang, theme })
const codeElem = (ast.children[0] as any).children[0]
return (
<TabPanel key={index}>
<pre
className="overflow-y-auto p-4"
dangerouslySetInnerHTML={{
__html: hastToHtml(codeElem),
}}
></pre>
<CopyButton copy={content} />
</TabPanel>
)
})}
</TabPanels>
</TabGroup>
)
}

View File

@@ -0,0 +1,180 @@
'use client'
import { assert, getOwnProperty } from '@tldraw/utils'
import React, {
Fragment,
ReactElement,
ReactNode,
createContext,
isValidElement,
useContext,
useMemo,
} from 'react'
import { cn } from '@/utils/cn'
import { TldrawLink } from '../common/tldraw-link'
const CodeLinksContext = createContext<Record<string, string>>({})
export function CodeLinks({
children,
links,
}: {
children: React.ReactNode
links: Record<string, string>
}) {
return <CodeLinksContext.Provider value={links}>{children}</CodeLinksContext.Provider>
}
const FocusLinesContext = createContext<null | number[]>(null)
export function FocusLines({ children, lines }: { children: ReactNode; lines: number[] }) {
return <FocusLinesContext.Provider value={lines}>{children}</FocusLinesContext.Provider>
}
const blurredLineClassName = '[&_*]:!opacity-60 [&_*]:!text-white'
function CodeLink({ children, href }: { children: ReactNode; href: string }) {
return (
<TldrawLink
href={href}
className="group-[.not-prose]:underline group-[.not-prose]:hover:no-underline decoration-[#667e8b]"
>
{children}
</TldrawLink>
)
}
// does this token look like a JS identifier?
function isIdentifier(token: string): boolean {
return /^[_$a-zA-Z\xA0-\uFFFF][_$a-zA-Z0-9\xA0-\uFFFF]*$/g.test(token)
}
// split the code into an array of identifiers, and the bits in between them
function tokenize(code: string): string[] {
const identifierRegex = /[_$a-zA-Z\xA0-\uFFFF][_$a-zA-Z0-9\xA0-\uFFFF]*/g
let currentIdx = 0
const tokens = []
for (const identifierMatch of code.matchAll(identifierRegex)) {
const [identifier] = identifierMatch
const idx = identifierMatch.index
if (idx > currentIdx) {
tokens.push(code.slice(currentIdx, idx))
}
tokens.push(identifier)
currentIdx = idx + identifier.length
}
if (currentIdx < code.length) {
tokens.push(code.slice(currentIdx))
}
return tokens
}
// this is for inline code blocks (e.g. `code`)
// these don't get highlighted, so the children is just a string
function InlineCodeElem({ children, ...props }: React.ComponentProps<'code'>) {
const code = children as string
assert(typeof code === 'string', 'InlineCodeElem children must be a string')
const codeLinks = useContext(CodeLinksContext)
return useMemo(
() => (
<code {...props}>
{codeLinks
? tokenize(code).map((token, i) => {
if (isIdentifier(token)) {
const link = getOwnProperty(codeLinks, token)
if (link) {
return (
<CodeLink key={i} href={link}>
{token}
</CodeLink>
)
}
}
return <Fragment key={i}>{token}</Fragment>
})
: code}
</code>
),
[code, codeLinks, props]
)
}
// This is code that has been highlighted by shikijs
// Children is an array of spans, each representing a line and containing an array of spans which are tokens
// There is sometimes whitespace between the lines
function HighlightedCodeElem({ children, ...props }: React.ComponentProps<'code'>) {
const codeLinks = useContext(CodeLinksContext)
const focusLines = useContext(FocusLinesContext)
let lineNumber = 0
return (
<code {...props}>
{React.Children.map(children, (line) => {
// ignore whitespace
if (!line || !isValidElement(line)) return line
assert(line.type === 'span', 'Invalid code child (not a span)')
const lineElement = line as ReactElement<{ className?: string; children?: ReactNode }>
assert(lineElement.props.className === 'line', 'Invalid code child (not a line)')
// apply line lowlights
lineNumber++
let updatedLine = lineElement
if (focusLines && !focusLines?.includes(lineNumber)) {
updatedLine = React.cloneElement(lineElement, {
...lineElement.props,
className: cn(lineElement.props?.className, blurredLineClassName),
})
}
// inject links where needed
if (codeLinks) {
updatedLine = React.cloneElement(updatedLine, {
...updatedLine.props,
children: React.Children.map(updatedLine.props.children, (elem) => {
if (!isValidElement(elem)) return elem
if (elem.type !== 'span') return elem
const spanElement = elem as ReactElement<{ children?: ReactNode }>
const token = spanElement.props.children
if (typeof token !== 'string') return elem
const tokens = tokenize(token)
return React.cloneElement(spanElement, {
...spanElement.props,
children: tokens.map((token, i) => {
if (!isIdentifier(token)) return token
const link = getOwnProperty(codeLinks, token)
if (!link) return token
return (
<CodeLink href={link} key={i}>
{token}
</CodeLink>
)
}),
})
}),
})
}
return updatedLine
})}
</code>
)
}
export function Code(props: React.ComponentProps<'code'>) {
if (typeof props.children === 'string') {
return <InlineCodeElem {...props} />
}
if (Array.isArray(props.children) && props.children[0].props?.className === 'line') {
return <HighlightedCodeElem {...props} />
}
if (
isValidElement<any>(props.children) &&
props.children.type === 'span' &&
(props.children.props as { className?: string })?.className === 'line'
) {
// eslint-disable-next-line react/no-children-prop
return <HighlightedCodeElem {...props} children={[props.children]} />
}
console.warn('Invalid code children', props.children)
return <code {...props} />
}

View File

@@ -0,0 +1,39 @@
'use client'
import { RefObject, useState } from 'react'
import { Button } from '../common/button'
export function CopyButton({
copy,
className,
}: {
copy: string | RefObject<HTMLElement | null>
className?: string
}) {
const [copied, setCopied] = useState<boolean>(false)
const handleClick = () => {
const code: string = typeof copy === 'string' ? copy : (copy.current?.innerText ?? '')
navigator.clipboard.writeText(code)
if (window.tlanalytics?.trackCopyCode) {
window.tlanalytics.trackCopyCode({
page_category: 'docs',
text_snippet: code,
})
}
setCopied(true)
setTimeout(() => setCopied(false), 1500)
}
return (
<Button
onClick={handleClick}
caption={'Copy'}
icon={copied ? 'check' : 'copy'}
size="xs"
className={`absolute transition-all duration-100 translate-y-4 opacity-0 -top-2 right-4 group-hover:opacity-100 group-hover:translate-y-0 ${className}`}
/>
)
}

View File

@@ -0,0 +1,66 @@
'use client'
import { useRef, useState } from 'react'
import { cn } from '@/utils/cn'
import { Button } from '../common/button'
export function Embed(props: {
className?: string
src: string
caption?: string
tryButton?: string
}) {
const [isExampleFocused, setIsExampleFocused] = useState(false)
const iframeRef = useRef<HTMLIFrameElement>(null)
function handleClick() {
setIsExampleFocused(true)
iframeRef.current?.focus()
}
function handleBlur() {
setIsExampleFocused(false)
}
return (
<div>
<div className="bg-zinc-100 dark:bg-zinc-700 py-1 md:rounded-2xl -mx-5 md:-mx-1 md:px-1">
<div
className={cn(
props.className,
'md:rounded-xl overflow-hidden shadow bg-white dark:bg-zinc-950 relative'
)}
>
<iframe
ref={iframeRef}
className="iframe"
src={props.src}
width="100%"
height={550}
allow="autoplay; clipboard-read; clipboard-write"
onBlur={handleBlur}
/>
{!isExampleFocused && (
<div
className="absolute inset-0 bg-[#FBFCFE]/50 dark:bg-[#101011]/50 flex items-center justify-center cursor-pointer"
onClick={handleClick}
>
<Button
onClick={handleClick}
caption={props.tryButton ?? 'Try example'}
icon="play"
className="shadow"
/>
</div>
)}
</div>
</div>
{props.caption && <span className="block text-xs text-zinc-500 mt-3">{props.caption}</span>}
</div>
)
}
export function StarterKitEmbed(props: { id: string }) {
return (
<Embed
src={`https://${props.id}.templates.tldraw.dev/?utm_source=docs-embed`}
tryButton="Try starter kit"
/>
)
}

View File

@@ -0,0 +1,23 @@
import { Embed } from '@/components/content/embed'
import { Article } from '@/types/content-types'
import { CodeFiles } from './code-files'
export function Example({ article }: { article: Article }) {
const server = 'https://examples.tldraw.com'
// both are null when the README has no `component:` frontmatter
const additionalFiles = article.componentCodeFiles ? JSON.parse(article.componentCodeFiles) : {}
const files = [
...(article.componentCode ? [{ name: 'App.tsx', content: article.componentCode }] : []),
...Object.keys(additionalFiles).map((key) => ({ name: key, content: additionalFiles[key] })),
]
// article.id is in format "sectionId/categoryId/articleId", we need just the articleId
const slug = article.id.split('/').pop()
return (
<div className="w-full mt-8">
<Embed src={`${server}/${slug}/full?utm_source=docs-embed`} />
<CodeFiles files={files} />
</div>
)
}

View File

@@ -0,0 +1,23 @@
import type React from 'react'
import { Icon, type IconName } from '@/components/common/icon'
export function Feature({
icon,
title,
children,
}: {
icon: IconName
title: string
children: React.ReactNode
}) {
return (
<div className="flex flex-col gap-1 not-prose">
<div className="flex items-center gap-2">
<Icon icon={icon} className="h-5 w-5 text-zinc-900 dark:text-zinc-100" />
<h3 className="font-bold text-zinc-900 dark:text-zinc-100">{title}</h3>
</div>
{/* MDX renders block children as their own `<p>`s, so this wrapper must not be a `<p>` — nested `<p>`s break hydration. */}
<div className="leading-relaxed text-zinc-600 dark:text-zinc-400">{children}</div>
</div>
)
}

View File

@@ -0,0 +1,27 @@
import { assetUrl } from '@/utils/asset-url'
import { TldrawLink } from '../common/tldraw-link'
export function Image(props: any) {
const src = typeof props.src === 'string' ? assetUrl(props.src) : props.src
const href = props.href ?? src
return (
<span className="block">
<TldrawLink
href={href}
className="block bg-zinc-100 dark:bg-zinc-800 py-1 sm:rounded-2xl -mx-5 sm:mx-0 sm:px-1"
>
<img alt={props.title} {...props} src={src} className="w-full sm:rounded-xl !my-0 shadow" />
</TldrawLink>
{props.caption && (
<span className="block text-xs text-zinc-500 mt-3 text-center text-balance">
{props.caption}
</span>
)}
{!props.caption && props.title && (
<span className="block text-xs text-zinc-500 mt-3 text-center text-balance">
{props.title}
</span>
)}
</span>
)
}

View File

@@ -0,0 +1,117 @@
import shikiRehype from '@shikijs/rehype'
import { MDXRemote } from 'next-mdx-remote-client/rsc'
import rehypeSlug from 'rehype-slug-custom-id'
import remarkGfm from 'remark-gfm'
import { ApiHeading } from '@/components/content/api-heading'
import { StarterKitBento } from '@/components/content/bento'
import { Blockquote } from '@/components/content/blockquote'
import { Callout } from '@/components/content/callout'
import { CheckItem } from '@/components/content/check-item'
import { Code, CodeLinks, FocusLines } from '@/components/content/code'
import { Embed, StarterKitEmbed } from '@/components/content/embed'
import { Feature } from '@/components/content/feature'
import { Image } from '@/components/content/image'
import { MaskedArticleImage } from '@/components/content/masked-article-image'
import { ParametersTable } from '@/components/content/parameters-table'
import { ParametersTableDescription } from '@/components/content/parameters-table-description'
import { ParametersTableName } from '@/components/content/parameters-table-name'
import { ParametersTableRow } from '@/components/content/parameters-table-row'
import { Pre } from '@/components/content/pre'
import { SideBySideImages } from '@/components/content/side-by-side-images'
import { ApiMemberTitle } from '@/components/content/title-with-source-link'
import { Video } from '@/components/content/video'
import { YouTube } from '@/components/content/youtube'
import { cn } from '@/utils/cn'
import { TldrawLink } from '../common/tldraw-link'
export function Content({
mdx,
type,
className,
}: {
mdx: string
type?: string
className?: string
}) {
return (
<section
className={cn(
type === 'reference' && 'prose-hr:hidden prose-h2:!mt-8 md:prose-h2:!mt-24',
'prose dark:prose-invert prose-sm prose-zinc text-zinc-800 dark:text-zinc-200 sm:prose-base w-full max-w-full',
'prose-code:before:content-none prose-code:after:content-none prose-code:bg-zinc-100 dark:prose-code:!bg-zinc-800 prose-code:px-1 prose-code:py-0.5 prose-code:rounded prose-code:font-normal',
'prose-a:no-underline prose-a:text-blue-500 hover:prose-a:text-blue-600 dark:hover:prose-a:text-blue-400 prose-a:font-normal',
'prose-blockquote:text-zinc-800 dark:prose-blockquote:text-zinc-200 prose-blockquote:font-normal prose-blockquote:border-none prose-blockquote:px-4 prose-blockquote:leading-normal prose-blockquote:bg-zinc-50 dark:prose-blockquote:bg-zinc-900 prose-blockquote:py-3 prose-blockquote:rounded-xl',
'prose-table:bg-zinc-50 dark:prose-table:bg-zinc-900 prose-table:rounded-xl prose-table:text-sm',
'prose-th:text-left prose-th:font-semibold prose-th:uppercase prose-th:text-xs prose-th:border-l prose-th:border-white dark:prose-th:border-zinc-950 prose-th:py-3 prose-th:px-4',
'prose-tr:border-t prose-tr:border-white dark:prose-tr:border-zinc-950',
'prose-td:border-l first:prose-td:border-l-0 prose-td:border-white dark:prose-td:border-zinc-950 prose-td:py-3 prose-td:px-4',
'prose-hr:border-zinc-100 dark:prose-hr:border-zinc-800',
'prose-h1:scroll-mt-20 prose-h2:scroll-mt-20 prose-h3:scroll-mt-20 prose-h4:scroll-mt-20 prose-h5:scroll-mt-20',
className
)}
>
<MDXRemote
source={mdx}
components={{
a: TldrawLink,
Embed,
StarterKitEmbed,
pre: Pre,
code: Code,
// Markdown images and `<Image />` — both resolve `/…` static URLs via `assetUrl` in `image.tsx`.
Image,
img: Image,
MaskedArticleImage,
ApiHeading,
StarterKitBento,
Feature,
Callout,
FocusLines,
CodeLinks,
ParametersTable,
ParametersTableDescription,
ParametersTableName,
ParametersTableRow,
ApiMemberTitle,
CheckItem,
blockquote: Blockquote,
Video,
YouTube,
TwoImages: SideBySideImages,
}}
options={{
mdxOptions: {
remarkPlugins: [remarkGfm],
rehypePlugins: [
[
rehypeSlug,
{
enableCustomId: true,
maintainCase: true,
removeAccents: true,
},
],
[
shikiRehype as any,
{
themes: {
dark: 'github-dark-default',
light: 'github-light-default',
},
defaultColor: false,
// Without `langs` the plugin loads all ~300 bundled grammars (seconds and
// >100MB per build worker / dev server). Anything not listed is still
// loaded on demand thanks to `lazy`.
langs: ['ts', 'tsx', 'bash', 'css', 'toml', 'html'],
lazy: true,
},
],
],
format: 'mdx',
},
parseFrontmatter: true,
}}
/>
</section>
)
}

View File

@@ -0,0 +1,17 @@
import { assetUrl } from '@/utils/asset-url'
/**
* Hero image with CSS mask — MDX cannot run `assetUrl` inside inline `style` strings, so paths in
* `mask: url(/images/…)` would stay root-relative and break when docs use `ASSET_PREFIX`.
*/
export function MaskedArticleImage(props: { gifSrc: string; maskSrc: string; alt: string }) {
const gif = assetUrl(props.gifSrc)
const mask = assetUrl(props.maskSrc)
const maskCss = `url(${mask}) luminance center 100% / 100% no-repeat`
return (
<div className="article__image" style={{ border: 'none' }}>
<img alt={props.alt} src={gif} style={{ mask: maskCss, WebkitMask: maskCss }} />
</div>
)
}

View File

@@ -0,0 +1,3 @@
export function ParametersTableDescription({ children }: { children: React.ReactNode }) {
return <td>{children}</td>
}

View File

@@ -0,0 +1,3 @@
export function ParametersTableName({ children }: { children: React.ReactNode }) {
return <td>{children}</td>
}

View File

@@ -0,0 +1,3 @@
export function ParametersTableRow({ children }: { children: React.ReactNode }) {
return <tr>{children}</tr>
}

View File

@@ -0,0 +1,15 @@
export function ParametersTable({ children }: { children: React.ReactNode }) {
return (
<div className="prose-p:my-0 prose-pre:hidden prose-code:!bg-zinc-200 dark:prose-code:!bg-zinc-800">
<table>
<thead>
<tr>
<th>Name</th>
<th>Description</th>
</tr>
</thead>
<tbody>{children}</tbody>
</table>
</div>
)
}

View File

@@ -0,0 +1,32 @@
'use client'
import { DetailedHTMLProps, HTMLAttributes, useRef } from 'react'
import { cn } from '@/utils/cn'
import { CopyButton } from './copy-button'
export function Pre(props: DetailedHTMLProps<HTMLAttributes<HTMLPreElement>, HTMLPreElement>) {
const container = useRef<HTMLPreElement>(null)
return (
<div
className={cn(
'group relative not-prose bg-zinc-100 dark:bg-zinc-800 py-1 md:rounded-2xl -mx-5 md:-mx-1 md:px-1 my-6',
'[td_&]:m-0 [td_&]:mb-2 [td_&]:p-0 [td_&]:bg-transparent [td_&]:rounded-none dark:[td_&]:rounded-lg'
)}
>
<pre
ref={container}
className={cn(
'bg-zinc-900 text-sm text-white shadow md:rounded-xl overflow-x-auto px-5 md:px-4 py-4 font-medium',
'[td_&]:bg-zinc-200 dark:[td_&]:bg-zinc-800 [td_&]:shadow-none [td_&]:rounded-lg [td_&]:p-0 [td_&]:px-1.5',
// this rule enables inverted dark/light mode inside of table cells
'[&_span]:text-[--shiki-dark]',
'[td_&_span]:!text-[--shiki-light]',
'dark:[td_&_span]:!text-[--shiki-dark]'
)}
>
{props.children}
</pre>
<CopyButton copy={container} />
</div>
)
}

View File

@@ -0,0 +1,10 @@
import { Image } from './image'
export function SideBySideImages({ left, right }: { left: any; right: any }) {
return (
<span className="flex gap-8 sm:gap-2 flex-col sm:flex-row">
<Image {...left} />
<Image {...right} />
</span>
)
}

View File

@@ -0,0 +1,54 @@
import { ArrowRightIcon } from '@heroicons/react/16/solid'
import { CodeBracketIcon } from '@heroicons/react/20/solid'
import Link from 'next/link'
import { cn } from '@/utils/cn'
function Tag({ children }: { children: React.ReactNode; tag: string }) {
return <span className="border border-zinc-800 rounded-full px-1.5 text-xs">{children}</span>
}
export function ApiMemberTitle({
children,
source,
tags,
inherited,
large,
}: {
children: React.ReactNode
source?: string | null
large?: boolean
tags?: string[]
inherited?: { name: string; link: string }
}) {
return (
<>
<div className="flex items-end gap-2 prose-headings:mb-0">
{children}
<div className={cn('flex gap-2 items-center pb-1.5', !large && 'ml-auto')}>
{tags?.map((tag) => (
<Tag key={tag} tag={tag}>
{tag}
</Tag>
))}
{source && (
<a href={source} target="_blank" rel="noopener noreferrer" title="Source code">
<CodeBracketIcon className="size-20" />
</a>
)}
</div>
</div>
{inherited && (
<div className="not-prose text-xs mb-6">
from{' '}
<Link
href={inherited.link}
className="text-blue-500 hover:text-blue-600 dark:hover:text-blue-400 inline-flex items-center"
>
<span>{inherited.name}</span>
<ArrowRightIcon className="h-3 -rotate-45" />
</Link>
</div>
)}
</>
)
}

View File

@@ -0,0 +1,40 @@
import { TldrawLink } from '../common/tldraw-link'
export function Video({
src,
href,
thumbnail,
caption,
lazy = false,
autoplay = false,
}: {
src: string
thumbnail?: string
caption?: string
autoplay?: boolean
href?: string
lazy?: boolean
}) {
return (
<span className="block">
<TldrawLink
href={href ?? src}
target="_blank"
rel="noreferrer"
className="block bg-zinc-100 dark:bg-zinc-800 py-1 sm:rounded-2xl sm:px-1"
>
<video
className="w-full sm:rounded-xl !my-0 shadow"
src={src}
poster={thumbnail}
controls={false}
preload={lazy ? 'metadata' : 'auto'}
autoPlay={autoplay}
muted={autoplay}
loop={autoplay}
/>
</TldrawLink>
{caption && <span className="block text-xs text-zinc-500 mt-3 text-center">{caption}</span>}
</span>
)
}

View File

@@ -0,0 +1,26 @@
export function YouTube({ src, caption }: { src: string; caption?: string }) {
// Extracts youtube ID from any format, eg:
// https://youtu.be/tHQW_K2pEgI
// https://www.youtube.com/watch?v=tHQW_K2pEgI
// https://youtu.be/tHQW_K2pEgI?si=sKtDDhygzBezfean
// https://www.youtube.com/watch?v=tHQW_K2pEgI&si=sKtDDhygzBezfean
const id = ((src.split('/').pop() ?? src).split('v=').pop() ?? src)
.split('&si=')[0]
.split('?si=')[0]
return (
<span className="block mb-5">
<span className="block bg-zinc-100 dark:bg-zinc-800 py-1 md:rounded-2xl -mx-5 md:mx-0 md:px-1">
<iframe
src={`https://www.youtube.com/embed/${id}?rel=0`}
title={caption}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
referrerPolicy="strict-origin-when-cross-origin"
allowFullScreen
className="w-full aspect-video md:rounded-xl overflow-hidden !my-0 shadow"
></iframe>
</span>
{caption && <span className="block text-xs text-zinc-500 mt-3 text-center">{caption}</span>}
</span>
)
}

View File

@@ -0,0 +1,40 @@
'use client'
import { useState } from 'react'
import { Button } from '../common/button'
export function CopyMarkdownButton({
markdown,
className,
}: {
markdown: string
className?: string
}) {
const [copied, setCopied] = useState(false)
const handleClick = () => {
navigator.clipboard.writeText(markdown)
if (window.tlanalytics?.trackCopyCode) {
window.tlanalytics.trackCopyCode({
page_category: 'docs',
text_snippet: markdown.slice(0, 500),
})
}
setCopied(true)
setTimeout(() => setCopied(false), 1500)
}
return (
<Button
id="copy-markdown"
onClick={handleClick}
caption={copied ? 'Copy markdown' : 'Copy markdown'}
icon={copied ? 'check' : 'copy'}
size="sm"
type="secondary"
className={className}
/>
)
}

View File

@@ -0,0 +1,31 @@
import { format } from 'date-fns'
import Link from 'next/link'
import { BackToTopButton } from '@/components/common/back-to-top-button'
import { GithubIcon } from '@/components/common/icon/github'
import { Article } from '@/types/content-types'
const githubContentRoot = 'https://github.com/tldraw/tldraw/blob/main/apps/docs/content/'
export function DocsArticleInfo({ article }: { article: Article }) {
if (article.sectionId === 'reference') {
// Reference pages are source code and already link to it via 'See source code'. Keyed on
// section, not `authorId === 'api'`: that's also the default for articles with no `author:`.
return null
}
return (
<div className="shrink-0 text-xs flex flex-col gap-2 mb-12">
{article.date && <p>Last edited on {format(new Date(article.date), 'MMM dd, yyyy')}</p>}
{article.sourceUrl && (
<Link
href={`${githubContentRoot}${article.sourceUrl}`}
className="flex items-center gap-1.5 text-blue-500 hover:text-blue-600 dark:hover:text-blue-400"
>
<GithubIcon className="h-3.5" />
<span>Edit this page on GitHub</span>
</Link>
)}
<BackToTopButton />
</div>
)
}

View File

@@ -0,0 +1,58 @@
'use client'
import { RocketLaunchIcon } from '@heroicons/react/16/solid'
import { AcademicCapIcon, CommandLineIcon, CubeIcon, PlayIcon } from '@heroicons/react/20/solid'
import { usePathname } from 'next/navigation'
import { NavigationLink } from '@/components/navigation/link'
const categoryLinks = [
{
caption: 'Quick start',
icon: RocketLaunchIcon,
href: '/quick-start',
// usePathname() is /getting-started/* on the server (prerendered path) and /quick-start
// etc. in the browser (rewrite); match both or hydration mismatches.
active: (pathname: string) =>
['/quick-start', '/installation', '/releases', '/getting-started'].some((e) =>
pathname.startsWith(e)
),
},
{
caption: 'Documentation',
icon: AcademicCapIcon,
href: '/docs/editor',
active: (pathname: string) =>
['/docs', '/sdk-features', '/community'].some((e) => pathname.startsWith(e)),
},
{
caption: 'Reference',
icon: CommandLineIcon,
href: '/reference/editor/Editor',
active: (pathname: string) => pathname.startsWith('/reference'),
},
{
caption: 'Starter kits',
icon: CubeIcon,
href: '/starter-kits/overview',
active: (pathname: string) => ['/starter-kits'].some((e) => pathname.startsWith(e)),
},
{
caption: 'Examples',
icon: PlayIcon,
href: '/examples/basic',
active: (pathname: string) => pathname.startsWith('/examples'),
},
]
export function DocsCategoryMenu() {
const pathname = usePathname()
return (
<ul className="flex flex-col gap-3 shrink-0 pb-4">
{categoryLinks.map((item, index) => (
<li key={index}>
<NavigationLink {...item} active={item.active(pathname)} />
</li>
))}
</ul>
)
}

View File

@@ -0,0 +1,142 @@
'use client'
import { Field, Label, Textarea } from '@headlessui/react'
import { ArrowLongRightIcon, CheckCircleIcon, HandThumbDownIcon } from '@heroicons/react/20/solid'
import { HandThumbUpIcon } from '@heroicons/react/24/solid'
import { usePathname } from 'next/navigation'
import { FormEventHandler, useState } from 'react'
import { track } from '@/app/analytics'
import { cn } from '@/utils/cn'
import { useLocalStorageState } from '@/utils/storage'
const DEBUGGING = false
async function submitFeedback(
_sessionId: string,
_pathname: string,
_feedback: 1 | -1 | 0,
_note: string
) {
const feedback: { value: number; message?: string } = { value: _feedback }
if (_note !== '') feedback.message = _note
track('Feedback', feedback)
return
}
async function submitThumbsFeedback(_sessionId: string, _pathname: string, _feedback: 1 | -1 | 0) {
const feedback: { value: number; message?: string } = { value: _feedback }
track('Helpful', feedback)
return
}
export function DocsFeedbackWidget({ className }: { className?: string }) {
const pathname = usePathname()
const [didSubmit, setDidSubmit] = useLocalStorageState(pathname + '-feedback-submitted', false)
// Set the feedback session id to a random id and stash it in storage
const [sessionId] = useLocalStorageState(
'feedback-session-id',
Math.random().toString(36).substring(7)
)
const [state, setState] = useState<
'idle' | 'thumbs-up' | 'thumbs-down' | 'loading' | 'success' | 'error'
>('idle')
// Track outside the setState updater: React double-invokes updaters in StrictMode, which
// would double-count the vote.
const handleThumbs = (thumb: 'thumbs-up' | 'thumbs-down') => {
const next = state === thumb ? 'idle' : thumb
if (next === thumb) {
submitThumbsFeedback(sessionId, pathname, thumb === 'thumbs-up' ? 1 : -1)
}
setState(next)
}
const handleSubmit: FormEventHandler<HTMLFormElement> = async (e) => {
e.preventDefault()
if (state === 'loading') return
if (!sessionId) return
try {
const form = e.currentTarget
const formData = new FormData(form)
const feedback = formData.get('feedback') as string
setState('loading')
await submitFeedback(sessionId, pathname, state === 'thumbs-up' ? 1 : -1, feedback)
setState('success')
setTimeout(() => {
setDidSubmit(true)
}, 3000)
} catch {
setState('error')
}
}
// todo, improve this so that thumbs ups and thumbs downs are also captured
if (didSubmit && !(DEBUGGING && process.env.NODE_ENV === 'development')) return null
return (
<div
className={cn(
'-ml-4 px-4 bg-zinc-50 dark:bg-zinc-900 rounded-lg shrink-0 text-xs h-auto',
className
)}
>
{state !== 'success' && (
<div className="flex justify-between items-center">
<span>Is this page helpful?</span>
<div className="flex">
<button
onClick={() => handleThumbs('thumbs-down')}
className={cn(
'h-9 w-7 flex items-center justify-center pt-0.5',
state === 'thumbs-down' && 'text-blue-500',
state !== 'thumbs-down' && 'hover:text-black dark:hover:text-white'
)}
>
<HandThumbDownIcon className="h-4" />
</button>
<button
onClick={() => handleThumbs('thumbs-up')}
className={cn(
'h-9 w-7 flex items-center justify-center pb-0.5 -mr-1',
state === 'thumbs-up' && 'text-blue-500',
state !== 'thumbs-up' && 'hover:text-black dark:hover:text-white'
)}
>
<HandThumbUpIcon className="h-4" />
</button>
</div>
</div>
)}
{(state === 'thumbs-up' || state === 'thumbs-down' || state === 'loading') && (
<form onSubmit={handleSubmit} className="pb-3">
<Field>
<Label className="sr-only">Your feedback</Label>
<Textarea
name="feedback"
placeholder="Your feedback..."
rows={3}
className="resize-none bg-zinc-200/50 dark:bg-zinc-700/50 w-full rounded-md placeholder-zinc-400 dark:placeholder-zinc-600 text-black dark:text-white px-2 py-1 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 focus:ring-offset-zinc-50 dark:focus:ring-offset-zinc-900"
/>
</Field>
<button
type="submit"
disabled={state === 'loading'}
className="bg-blue-500 rounded-md h-6 px-3 flex items-center text-white gap-1.5 font-medium ml-auto mt-2 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 focus:ring-offset-zinc-50 hover:bg-blue-600 dark:hover:bg-blue-400 dark:focus:ring-offset-zinc-900"
>
<span>Submit</span>
<ArrowLongRightIcon className="h-3.5" />
</button>
</form>
)}
{state === 'success' && (
<p className="h-9 flex items-center gap-1.5">
<CheckCircleIcon className="h-4 text-emerald-500 dark:text-emerald-400" />
<span>Thanks for your feedback!</span>
</p>
)}
</div>
)
}

Some files were not shown because too many files have changed in this diff Show More