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
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:
2
apps/analytics-worker/.gitignore
vendored
Normal file
2
apps/analytics-worker/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
build
|
||||
.wrangler
|
||||
28
apps/analytics-worker/package.json
Normal file
28
apps/analytics-worker/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
117
apps/analytics-worker/src/worker.ts
Normal file
117
apps/analytics-worker/src/worker.ts
Normal 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 }
|
||||
)
|
||||
},
|
||||
}
|
||||
7
apps/analytics-worker/tsconfig.json
Normal file
7
apps/analytics-worker/tsconfig.json
Normal 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": []
|
||||
}
|
||||
15
apps/analytics-worker/vitest.config.ts
Normal file
15
apps/analytics-worker/vitest.config.ts
Normal 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',
|
||||
},
|
||||
},
|
||||
})
|
||||
44
apps/analytics-worker/wrangler.toml
Normal file
44
apps/analytics-worker/wrangler.toml
Normal 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
2
apps/analytics/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
.yarn
|
||||
public
|
||||
40
apps/analytics/index.html
Normal file
40
apps/analytics/index.html
Normal 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>
|
||||
27
apps/analytics/package.json
Normal file
27
apps/analytics/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
48
apps/analytics/src/analytics-services/analytics-service.ts
Normal file
48
apps/analytics/src/analytics-services/analytics-service.ts
Normal 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
|
||||
}
|
||||
119
apps/analytics/src/analytics-services/ga4.ts
Normal file
119
apps/analytics/src/analytics-services/ga4.ts
Normal 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()
|
||||
182
apps/analytics/src/analytics-services/gtm.ts
Normal file
182
apps/analytics/src/analytics-services/gtm.ts
Normal 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()
|
||||
23
apps/analytics/src/analytics-services/hubspot.ts
Normal file
23
apps/analytics/src/analytics-services/hubspot.ts
Normal 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()
|
||||
53
apps/analytics/src/analytics-services/posthog.ts
Normal file
53
apps/analytics/src/analytics-services/posthog.ts
Normal 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()
|
||||
39
apps/analytics/src/analytics-services/reo.ts
Normal file
39
apps/analytics/src/analytics-services/reo.ts
Normal 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()
|
||||
90
apps/analytics/src/components/CookieConsentBanner.ts
Normal file
90
apps/analytics/src/components/CookieConsentBanner.ts
Normal 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
|
||||
}
|
||||
132
apps/analytics/src/components/PrivacySettingsDialog.ts
Normal file
132
apps/analytics/src/components/PrivacySettingsDialog.ts
Normal 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
|
||||
}
|
||||
24
apps/analytics/src/constants.ts
Normal file
24
apps/analytics/src/constants.ts
Normal 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
305
apps/analytics/src/index.ts
Normal 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)
|
||||
}
|
||||
68
apps/analytics/src/state/cookie-consent-state.ts
Normal file
68
apps/analytics/src/state/cookie-consent-state.ts
Normal 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> {}
|
||||
57
apps/analytics/src/state/state.test.ts
Normal file
57
apps/analytics/src/state/state.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
59
apps/analytics/src/state/state.ts
Normal file
59
apps/analytics/src/state/state.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
47
apps/analytics/src/state/theme-state.ts
Normal file
47
apps/analytics/src/state/theme-state.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
297
apps/analytics/src/styles.css
Normal file
297
apps/analytics/src/styles.css
Normal 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;
|
||||
}
|
||||
56
apps/analytics/src/types.ts
Normal file
56
apps/analytics/src/types.ts
Normal 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'
|
||||
}
|
||||
27
apps/analytics/src/utils/consent-check.ts
Normal file
27
apps/analytics/src/utils/consent-check.ts
Normal 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
6
apps/analytics/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '*.css?inline' {
|
||||
const content: string
|
||||
export default content
|
||||
}
|
||||
17
apps/analytics/tsconfig.json
Normal file
17
apps/analytics/tsconfig.json
Normal 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": []
|
||||
}
|
||||
44
apps/analytics/vercel.json
Normal file
44
apps/analytics/vercel.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
20
apps/analytics/vite.config.ts
Normal file
20
apps/analytics/vite.config.ts
Normal 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
|
||||
},
|
||||
},
|
||||
})
|
||||
6
apps/analytics/vitest.config.ts
Normal file
6
apps/analytics/vitest.config.ts
Normal 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
2
apps/bemo-worker/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
build
|
||||
.wrangler
|
||||
60
apps/bemo-worker/README.md
Normal file
60
apps/bemo-worker/README.md
Normal 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).
|
||||
42
apps/bemo-worker/package.json
Normal file
42
apps/bemo-worker/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
266
apps/bemo-worker/src/BemoDO.ts
Normal file
266
apps/bemo-worker/src/BemoDO.ts
Normal 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'
|
||||
}
|
||||
49
apps/bemo-worker/src/makePermissiveSchema.ts
Normal file
49
apps/bemo-worker/src/makePermissiveSchema.ts
Normal 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
|
||||
}
|
||||
17
apps/bemo-worker/src/types.ts
Normal file
17
apps/bemo-worker/src/types.ts
Normal 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
|
||||
}
|
||||
5
apps/bemo-worker/src/worker.test.ts
Normal file
5
apps/bemo-worker/src/worker.test.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
describe('worker', () => {
|
||||
it('works', () => {
|
||||
// blank test to make ci happy
|
||||
})
|
||||
})
|
||||
91
apps/bemo-worker/src/worker.ts
Normal file
91
apps/bemo-worker/src/worker.ts
Normal 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,
|
||||
})
|
||||
}
|
||||
}
|
||||
15
apps/bemo-worker/tsconfig.json
Normal file
15
apps/bemo-worker/tsconfig.json
Normal 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" }
|
||||
]
|
||||
}
|
||||
15
apps/bemo-worker/vitest.config.ts
Normal file
15
apps/bemo-worker/vitest.config.ts
Normal 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',
|
||||
},
|
||||
},
|
||||
})
|
||||
109
apps/bemo-worker/wrangler.toml
Normal file
109
apps/bemo-worker/wrangler.toml
Normal 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
8
apps/docs/.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
content/reference
|
||||
api-content.json
|
||||
content.db
|
||||
.env
|
||||
.next
|
||||
.vercel
|
||||
.envrc
|
||||
llms*.txt
|
||||
5
apps/docs/.prettierignore
Normal file
5
apps/docs/.prettierignore
Normal file
@@ -0,0 +1,5 @@
|
||||
.next/*
|
||||
.lazy/*
|
||||
content.db
|
||||
node_modules
|
||||
utils/vector-db/index.json
|
||||
9
apps/docs/LICENSE.md
Normal file
9
apps/docs/LICENSE.md
Normal 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
167
apps/docs/README.md
Normal 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).
|
||||
139
apps/docs/app/(docs)/[...slug]/page.tsx
Normal file
139
apps/docs/app/(docs)/[...slug]/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
20
apps/docs/app/(docs)/layout.tsx
Normal file
20
apps/docs/app/(docs)/layout.tsx
Normal 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}
|
||||
</>
|
||||
)
|
||||
}
|
||||
31
apps/docs/app/.well-known/agent-skills/index.json/route.ts
Normal file
31
apps/docs/app/.well-known/agent-skills/index.json/route.ts
Normal 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': '*' } }
|
||||
)
|
||||
}
|
||||
@@ -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': '*',
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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
114
apps/docs/app/analytics.tsx
Normal 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
138
apps/docs/app/globals.css
Normal 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
102
apps/docs/app/layout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
72
apps/docs/app/not-found.tsx
Normal file
72
apps/docs/app/not-found.tsx
Normal 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
4
apps/docs/app/robots.txt
Normal file
@@ -0,0 +1,4 @@
|
||||
User-agent: *
|
||||
Allow: /
|
||||
|
||||
Sitemap: https://www.tldraw.dev/sitemap.xml
|
||||
14
apps/docs/app/search/layout.tsx
Normal file
14
apps/docs/app/search/layout.tsx
Normal 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
|
||||
}
|
||||
19
apps/docs/app/search/page.tsx
Normal file
19
apps/docs/app/search/page.tsx
Normal 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
20
apps/docs/app/sitemap.ts
Normal 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
|
||||
}
|
||||
30
apps/docs/components/common/autorefresh.tsx
Normal file
30
apps/docs/components/common/autorefresh.tsx
Normal 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
|
||||
16
apps/docs/components/common/back-to-top-button.tsx
Normal file
16
apps/docs/components/common/back-to-top-button.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
88
apps/docs/components/common/button.tsx
Normal file
88
apps/docs/components/common/button.tsx
Normal 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
|
||||
}
|
||||
13
apps/docs/components/common/icon/discord.tsx
Normal file
13
apps/docs/components/common/icon/discord.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
13
apps/docs/components/common/icon/github.tsx
Normal file
13
apps/docs/components/common/icon/github.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
30
apps/docs/components/common/icon/index.tsx
Normal file
30
apps/docs/components/common/icon/index.tsx
Normal 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} />
|
||||
}
|
||||
13
apps/docs/components/common/icon/linkedin.tsx
Normal file
13
apps/docs/components/common/icon/linkedin.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
16
apps/docs/components/common/icon/tldraw.tsx
Normal file
16
apps/docs/components/common/icon/tldraw.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
13
apps/docs/components/common/icon/twitter.tsx
Normal file
13
apps/docs/components/common/icon/twitter.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
46
apps/docs/components/common/logo.tsx
Normal file
46
apps/docs/components/common/logo.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
22
apps/docs/components/common/navigation.tsx
Normal file
22
apps/docs/components/common/navigation.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
20
apps/docs/components/common/page-title.tsx
Normal file
20
apps/docs/components/common/page-title.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
43
apps/docs/components/common/theme-switch.tsx
Normal file
43
apps/docs/components/common/theme-switch.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
69
apps/docs/components/common/tldraw-link.tsx
Normal file
69
apps/docs/components/common/tldraw-link.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
3
apps/docs/components/content/api-heading.tsx
Normal file
3
apps/docs/components/content/api-heading.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export function ApiHeading(props: any) {
|
||||
return <h4 className="!-mb-4 !mt-8 uppercase text-sm font-semibold" {...props} />
|
||||
}
|
||||
228
apps/docs/components/content/bento.tsx
Normal file
228
apps/docs/components/content/bento.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
5
apps/docs/components/content/blockquote.tsx
Normal file
5
apps/docs/components/content/blockquote.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { Callout } from './callout'
|
||||
|
||||
export function Blockquote(props: any) {
|
||||
return <Callout>{props.children}</Callout>
|
||||
}
|
||||
44
apps/docs/components/content/callout.tsx
Normal file
44
apps/docs/components/content/callout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
11
apps/docs/components/content/check-item.tsx
Normal file
11
apps/docs/components/content/check-item.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
82
apps/docs/components/content/code-files.tsx
Normal file
82
apps/docs/components/content/code-files.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
180
apps/docs/components/content/code.tsx
Normal file
180
apps/docs/components/content/code.tsx
Normal 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} />
|
||||
}
|
||||
39
apps/docs/components/content/copy-button.tsx
Normal file
39
apps/docs/components/content/copy-button.tsx
Normal 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}`}
|
||||
/>
|
||||
)
|
||||
}
|
||||
66
apps/docs/components/content/embed.tsx
Normal file
66
apps/docs/components/content/embed.tsx
Normal 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"
|
||||
/>
|
||||
)
|
||||
}
|
||||
23
apps/docs/components/content/example.tsx
Normal file
23
apps/docs/components/content/example.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
23
apps/docs/components/content/feature.tsx
Normal file
23
apps/docs/components/content/feature.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
27
apps/docs/components/content/image.tsx
Normal file
27
apps/docs/components/content/image.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
117
apps/docs/components/content/index.tsx
Normal file
117
apps/docs/components/content/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
17
apps/docs/components/content/masked-article-image.tsx
Normal file
17
apps/docs/components/content/masked-article-image.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export function ParametersTableDescription({ children }: { children: React.ReactNode }) {
|
||||
return <td>{children}</td>
|
||||
}
|
||||
3
apps/docs/components/content/parameters-table-name.tsx
Normal file
3
apps/docs/components/content/parameters-table-name.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export function ParametersTableName({ children }: { children: React.ReactNode }) {
|
||||
return <td>{children}</td>
|
||||
}
|
||||
3
apps/docs/components/content/parameters-table-row.tsx
Normal file
3
apps/docs/components/content/parameters-table-row.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export function ParametersTableRow({ children }: { children: React.ReactNode }) {
|
||||
return <tr>{children}</tr>
|
||||
}
|
||||
15
apps/docs/components/content/parameters-table.tsx
Normal file
15
apps/docs/components/content/parameters-table.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
32
apps/docs/components/content/pre.tsx
Normal file
32
apps/docs/components/content/pre.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
10
apps/docs/components/content/side-by-side-images.tsx
Normal file
10
apps/docs/components/content/side-by-side-images.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
54
apps/docs/components/content/title-with-source-link.tsx
Normal file
54
apps/docs/components/content/title-with-source-link.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
40
apps/docs/components/content/video.tsx
Normal file
40
apps/docs/components/content/video.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
26
apps/docs/components/content/youtube.tsx
Normal file
26
apps/docs/components/content/youtube.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
40
apps/docs/components/docs/copy-markdown-button.tsx
Normal file
40
apps/docs/components/docs/copy-markdown-button.tsx
Normal 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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
31
apps/docs/components/docs/docs-article-info.tsx
Normal file
31
apps/docs/components/docs/docs-article-info.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
58
apps/docs/components/docs/docs-category-menu.tsx
Normal file
58
apps/docs/components/docs/docs-category-menu.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
142
apps/docs/components/docs/docs-feedback-widget.tsx
Normal file
142
apps/docs/components/docs/docs-feedback-widget.tsx
Normal 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
Reference in New Issue
Block a user