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

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

1
.agents/skills Symbolic link
View File

@@ -0,0 +1 @@
../skills

23
.claude/launch.json Normal file
View File

@@ -0,0 +1,23 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "examples",
"runtimeExecutable": "yarn",
"runtimeArgs": ["dev"],
"port": 5420
},
{
"name": "dotcom-app",
"runtimeExecutable": "yarn",
"runtimeArgs": ["dev-app"],
"port": 3000
},
{
"name": "docs",
"runtimeExecutable": "yarn",
"runtimeArgs": ["dev-docs"],
"port": 3001
}
]
}

53
.claude/settings.json Normal file
View File

@@ -0,0 +1,53 @@
{
"spinnerVerbs": {
"mode": "replace",
"verbs": [
"panning",
"zooming",
"scrolling",
"drawing",
"selecting",
"nudging",
"resizing",
"rotating",
"flipping",
"stretching",
"aligning",
"distributing",
"stacking",
"packing",
"grouping",
"cropping",
"styling",
"embedding",
"scribbling",
"brushing",
"pointing",
"snapping to grid"
]
},
"permissions": {
"allow": [
"WebSearch",
"Bash(gh pr:*)",
"Bash(yarn install)",
"Bash(yarn test:*)",
"Bash(yarn lint:*)",
"Bash(yarn typecheck)",
"Bash(git add:*)"
]
},
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "cd \"$(git rev-parse --show-toplevel)\" && git diff --name-only --diff-filter=ACMR | grep -E '\\.(ts|tsx|js|jsx|json|md)$' | xargs -r yarn run -T prettier --write || true"
}
]
}
]
},
"enabledPlugins": {}
}

1
.claude/skills Symbolic link
View File

@@ -0,0 +1 @@
../skills

View File

@@ -0,0 +1,19 @@
{
"name": "tldraw",
"owner": {
"name": "tldraw",
"email": "tim@tldraw.com"
},
"metadata": {
"description": "Draw and visually collaborate with your agents inside Cursor",
"version": "0.1.0",
"pluginRoot": "apps/mcp-app/plugins"
},
"plugins": [
{
"name": "tldraw",
"source": "tldraw-mcp",
"description": "Draw and visually collaborate with your agents inside Cursor"
}
]
}

1
.cursor/skills Symbolic link
View File

@@ -0,0 +1 @@
../skills

74
.dockerignore Normal file
View File

@@ -0,0 +1,74 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
**/node_modules
.git
**/.git
dist
dist-cjs
dist-esm
.tsbuild*
.lazy
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# turborepo
.turbo
coverage
**/*.env
**/*.tsbuildinfo
**/*.css.map
**/*.js.map
apps/webdriver/www/index.js
apps/webdriver/www/index.css
apps/dotcom/*-worker/.dev.vars
nohup.out
packages/*/package
packages/*/*.tgz
tsconfig.build.json
.vercel
api-json
api-md
apps/webdriver/www
!apps/webdriver/www/index.html
# yarn v2
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/sdks
!.yarn/versions
packages/*/api
apps/examples/www/index.css
apps/examples/www/index.js
.tsbuild

49
.github/ISSUE_TEMPLATE/bug_report.yml vendored Normal file
View File

@@ -0,0 +1,49 @@
name: Bug report
description: File a bug report
type: Bug
labels: []
assignees: []
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to fill out this bug report!
- type: textarea
id: what-happened
attributes:
label: What happened?
description: Describe the bug and what you expected to happen instead.
validations:
required: true
- type: textarea
id: reproduction
attributes:
label: How can we reproduce the bug?
description: |
Please share the steps to reproduce this bug. A [StackBlitz reproduction](https://stackblitz.com/edit/vitejs-vite-ahoswhus?file=src%2FApp.tsx) or screen recording helps us fix it faster.
validations:
required: false
- type: dropdown
id: browsers
validations:
required: false
attributes:
label: What browsers are you seeing the problem on?
multiple: true
options:
- Firefox
- Chrome
- Safari
- Microsoft Edge
- type: dropdown
id: product
attributes:
label: Where did this happen?
options:
- tldraw SDK (@tldraw/tldraw npm package)
- tldraw.com
- tldraw.dev (documentation site)
- VS Code extension
- Obsidian plugin
validations:
required: true

8
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@@ -0,0 +1,8 @@
blank_issues_enabled: true
contact_links:
- name: Documentation
url: https://tldraw.dev
about: Looking for help with the tldraw SDK? Check out the docs.
- name: Discord
url: https://discord.tldraw.com
about: Chat with the community and get help from other developers.

View File

@@ -0,0 +1,16 @@
name: Example request
description: Submit a request for an example
labels: []
assignees: []
body:
- type: markdown
attributes:
value: |
Need an example of how to do something with tldraw? We're happy to help!
- type: textarea
id: description
attributes:
label: What's the example?
description: Describe the example or the use case you'd like to see demonstrated.
validations:
required: true

View File

@@ -0,0 +1,27 @@
name: Feature request
description: Suggest a new feature or improvement
type: Feature
labels: []
assignees: []
body:
- type: markdown
attributes:
value: |
Have an idea for a feature or improvement in tldraw? We'd love to hear it!
- type: textarea
id: description
attributes:
label: What's the feature?
description: Describe the feature, the problem it solves, and any examples from other apps.
validations:
required: true
- type: dropdown
id: product
attributes:
label: Where would you like this feature?
options:
- tldraw SDK (@tldraw/tldraw npm package)
- tldraw.com
- makereal.tldraw.com
validations:
required: false

13
.github/ISSUE_TEMPLATE/task.yml vendored Normal file
View File

@@ -0,0 +1,13 @@
name: Task
description: Create a task
type: Task
labels: []
assignees: []
body:
- type: textarea
id: description
attributes:
label: Description
description: Describe the task.
validations:
required: true

View File

@@ -0,0 +1,69 @@
name: Deploy templates
description: >
Build the starter-kit demos against the checked-out SDK and deploy the cloudflare-hosted ones to
*.templates.tldraw.dev. Shared by deploy-templates.yml (template changes, manual runs) and
publish.yml (releases). Expects the repo to be checked out already; runs the setup action itself.
# The cloudflare credentials are secrets on the deploy-production environment, so the job using
# this action has to select that environment and pass them in. This is a composite action rather
# than a workflow_call workflow because a called workflow only ever sees the secrets its caller
# hands it, and an `environment:` on a job inside the callee does not add that environment's
# secrets to them — see #10636.
inputs:
license-key:
description: tldraw license key the demos are built with (TEMPLATES_TLDRAW_LICENSE_KEY)
required: true
cloudflare-account-id:
description: Cloudflare account the workers deploy to (CLOUDFLARE_ACCOUNT_ID)
required: true
cloudflare-api-token:
description: Cloudflare API token with workers permissions (CLOUDFLARE_API_TOKEN)
required: true
runs:
using: composite
steps:
- uses: ./.github/actions/setup
# most templates run `tsc` as part of their build, which resolves the workspace packages
# through their project references and so needs the declarations built first
- name: Build types
shell: bash
env:
# build-types pulls in refresh-assets, which rewrites the generated version files. in CI it
# exits 1 on any change to a committed asset instead of writing it, which would fail the
# deploy on a release ref whose generated files aren't already in sync.
ALLOW_REFRESH_ASSETS_CHANGES: 1
run: yarn build-types
- name: Deploy templates
shell: bash
env:
CI: 1
PRINT_GITHUB_ANNOTATIONS: 1
ALLOW_REFRESH_ASSETS_CHANGES: 1
TEMPLATES_TLDRAW_LICENSE_KEY: ${{ inputs.license-key }}
CLOUDFLARE_API_TOKEN: ${{ inputs.cloudflare-api-token }}
CLOUDFLARE_ACCOUNT_ID: ${{ inputs.cloudflare-account-id }}
run: |
# the templates import tldraw/tldraw.css, which is generated and gitignored
yarn lazy prebuild
cd templates
all_templates=$(ls -d */ | cut -f1 -d'/')
cd ..
# the demos are independent of each other, so deploy them all before failing: one broken
# template shouldn't leave the rest stranded on an old build with no signal about which.
# templates with no `deploy` config in their package.json skip themselves.
failed=""
for template in $all_templates; do
echo "::group::Deploying $template"
yarn tsx ./internal/scripts/deploy-template.ts $template || failed="$failed $template"
echo "::endgroup::"
done
if [ -n "$failed" ]; then
echo "::error::Failed to deploy templates:$failed"
exit 1
fi

View File

@@ -0,0 +1,32 @@
name: Discord fail notify
description: Post a failure message to the Discord deploy/health channel.
inputs:
webhook-url:
description: Discord webhook URL
required: true
status:
description: What happened to the workflow, used in the message verb
required: false
default: failed
runs:
using: composite
steps:
- name: Notify Discord
shell: bash
env:
DISCORD_WEBHOOK_URL: ${{ inputs.webhook-url }}
WORKFLOW: ${{ github.workflow }}
REF: ${{ github.ref_name }}
STATUS: ${{ inputs.status }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
if [ -z "$DISCORD_WEBHOOK_URL" ]; then
echo "::warning::DISCORD_WEBHOOK_URL is empty; skipping Discord notification."
exit 0
fi
payload=$(jq -nc \
--arg content "<@&1414904550917144586> ❌ **${WORKFLOW}** ${STATUS} on \`${REF}\`: <${RUN_URL}>" \
'{content: $content}')
curl -fsS -X POST -H "Content-Type: application/json" -d "$payload" "$DISCORD_WEBHOOK_URL"

View File

@@ -0,0 +1,46 @@
name: 'Setup Playwright'
description: 'Install Playwright browsers with version-keyed caching shared across workflows.'
inputs:
browsers:
description: 'Space-separated Playwright browsers to install and cache.'
required: false
# Only bundled Chromium by default — every e2e project uses `devices['Desktop Chrome']` /
# `['Pixel 5']` with no `channel`, so the branded `chrome` build is never launched.
default: 'chromium'
runs:
using: 'composite'
steps:
# @playwright/test is hoisted to the root node_modules (it's a devDependency of the e2e
# workspaces), so Node resolves it from the repo root — no per-workspace cwd, nothing written
# to the tree. We also derive a key-safe browser token (spaces -> dashes) so multi-browser
# values like "chromium firefox" don't put a space in the cache key.
- name: Resolve Playwright version and browsers
shell: bash
env:
BROWSERS: ${{ inputs.browsers }}
run: |
echo "PLAYWRIGHT_VERSION=$(node -p "require('@playwright/test/package.json').version")" >> "$GITHUB_ENV"
echo "PLAYWRIGHT_BROWSERS_KEY=$(echo "$BROWSERS" | tr ' ' '-')" >> "$GITHUB_ENV"
# Keyed on os + arch + version + browsers. The arch matters (ARM vs x64 need different
# binaries); the browser set matters too — without it, adding a browser would be a stale cache
# hit that skips the install and never downloads the new one.
- name: Check for Playwright browsers cache
id: playwright-cache
uses: actions/cache@v5
with:
path: |
~/.cache/ms-playwright
key: ${{ runner.os }}-${{ runner.arch }}-playwright-${{ env.PLAYWRIGHT_VERSION }}-${{ env.PLAYWRIGHT_BROWSERS_KEY }}
# Cache miss only. `--with-deps` also installs the OS libraries the browsers need — a no-op for
# Chromium on GitHub's runner images, but it means adding webkit/firefox via `browsers` later
# just works without extra setup.
- name: Install Playwright browsers
if: steps.playwright-cache.outputs.cache-hit != 'true'
shell: bash
env:
BROWSERS: ${{ inputs.browsers }}
run: npx playwright install --with-deps $BROWSERS

39
.github/actions/setup/action.yml vendored Normal file
View File

@@ -0,0 +1,39 @@
name: Setup tldraw/tldraw
description: Set up node & yarn
runs:
using: composite
steps:
# see https://github.com/actions/setup-node/issues/899 for one of the reasons why this
# needs to be done before action/setup-node
- name: Enable corepack
run: npm i -g corepack
shell: bash
- name: Setup Node.js Environment
id: setup-node
uses: actions/setup-node@v6
with:
node-version: 24.17.0
- name: Restore node_modules cache
uses: actions/cache@v5
id: node-modules-cache
with:
path: |
node_modules
**/node_modules
.yarn/cache
.yarn/unplugged
.yarn/install-state.gz
key: ${{ runner.os }}-${{ runner.arch }}-node-${{ steps.setup-node.outputs.node-version }}-yarn-${{ hashFiles('yarn.lock', '.yarnrc.yml', '.yarn/patches/*') }}
restore-keys: |
${{ runner.os }}-${{ runner.arch }}-node-${{ steps.setup-node.outputs.node-version }}-yarn-
- name: Install dependencies
run: yarn install --immutable
shell: bash
env:
# Hardened mode enables --check-resolutions on public PRs, adding ~15s.
# See https://yarnpkg.com/features/security - Yarn recommends disabling in most jobs.
YARN_ENABLE_HARDENED_MODE: 0

21
.github/pull_request_template.md vendored Normal file
View File

@@ -0,0 +1,21 @@
Describe what your pull request does. If you can, add GIFs or images showing the before and after of your change.
### Change type
- [ ] `bugfix`
- [ ] `improvement`
- [ ] `feature`
- [ ] `api`
- [ ] `other`
### Test plan
1. Create a shape...
2.
- [ ] Unit tests
- [ ] End to end tests
### Release notes
- Fixed a bug with…

73
.github/workflows/bump-versions.yml vendored Normal file
View File

@@ -0,0 +1,73 @@
name: Bump versions to latest tldraw version
# This bumps all package versions to match the latest tldraw version from npm
# Prevent more than one version bumping job from running at the same time
concurrency:
group: version-bump
# Only run on main branch
on:
workflow_dispatch:
defaults:
run:
shell: bash
permissions:
contents: read
jobs:
bump_versions:
name: 'Bump package versions to latest tldraw version'
environment: npm deploy
timeout-minutes: 30
runs-on: ubuntu-latest-16-cores-arm-open
steps:
- name: Generate GH token
id: generate_token
uses: tibdex/github-app-token@b62528385c34dbc9f38e5f4225ac829252d1ea92
with:
app_id: ${{ secrets.HUPPY_APP_ID }}
private_key: ${{ secrets.HUPPY_APP_PRIVATE_KEY }}
- name: Check out code
id: checkout
uses: actions/checkout@v6
with:
persist-credentials: false
token: ${{ steps.generate_token.outputs.token }}
- name: Configure git credentials
run: git config --global url."https://x-access-token:${{ steps.generate_token.outputs.token }}@github.com/".insteadOf "https://github.com/"
- name: Check branch
run: |
if [[ "$(git rev-parse --abbrev-ref HEAD)" != "main" ]]; then
echo "ERROR This workflow only runs on the main branch."
exit 1
fi
- name: Run our setup
uses: ./.github/actions/setup
- name: Bump versions
run: |
git config --global user.name 'huppy-bot[bot]'
git config --global user.email '128400622+huppy-bot[bot]@users.noreply.github.com'
yarn tsx ./internal/scripts/bump-versions.ts
env:
GH_TOKEN: ${{ steps.generate_token.outputs.token }}
# Preserve the setup action for post-job cleanup when checkout already succeeded.
- name: Check out Discord notify action
if: failure() && steps.checkout.outcome != 'success'
uses: actions/checkout@v6
with:
sparse-checkout: .github/actions/discord-fail-notify
- name: Notify Discord on failure
if: failure()
uses: ./.github/actions/discord-fail-notify
with:
webhook-url: ${{ secrets.DISCORD_DEPLOY_WEBHOOK_URL }}

112
.github/workflows/checks.yml vendored Normal file
View File

@@ -0,0 +1,112 @@
name: Checks
on:
pull_request:
merge_group:
push:
branches: [main]
env:
CI: 1
PRINT_GITHUB_ANNOTATIONS: 1
defaults:
run:
shell: bash
permissions:
contents: read
# update-pr-template reads the repo's label list (octo.issues.listLabelsForRepo).
issues: read
jobs:
test:
name: 'Tests & checks'
timeout-minutes: 15
runs-on: ubuntu-latest-16-cores-arm-open # TODO: this should probably run on multiple OSes
steps:
- name: Check out code
uses: actions/checkout@v6
- uses: ./.github/actions/setup
- name: Check for duplicate dependencies
run: yarn dedupe --check
- name: Check packages
run: yarn check-packages
- name: Check circular dependencies
run: yarn check-circular-deps
- name: Restore build tool caches
uses: actions/cache@v5
with:
path: |
.lazy
**/.lazy
**/.tsbuild
**/*.tsbuildinfo
key: test-tools-${{ runner.os }}-${{ hashFiles('yarn.lock', '.oxlintrc.json', '.oxfmtrc.json', 'lazy.config.ts') }}
- name: Typecheck
run: yarn build-types
- name: Check bundle sizes
run: yarn lazy check-bundle-size
- name: Check PR template
run: yarn update-pr-template --check
env:
GH_TOKEN: ${{ github.token }}
- name: Check API declarations and docs work as intended
run: yarn api-check
- name: Lint
run: yarn lint
- name: Check dotcom localizations
run: |
yarn build-i18n
git diff --exit-code --quiet ./apps/dotcom/client/public/tla/locales/ ./apps/dotcom/client/public/tla/locales-compiled/ || (echo "Please run yarn build-i18n and commit the changes" && exit 1)
# vitest's fsModuleCache (~54MB gzipped). Entries are content-hashed, so a stale
# restore is safe - unmatched modules are just re-transformed. Keyed on yarn.lock so
# one entry is reused across commits instead of saving a new copy on every run.
- name: Restore vitest module cache
uses: actions/cache@v5
with:
path: node_modules/.vitest-cache
key: vitest-cache-${{ runner.os }}-${{ hashFiles('yarn.lock') }}
restore-keys: vitest-cache-${{ runner.os }}-
- name: Test
run: yarn test-ci
build:
name: 'Build all projects'
timeout-minutes: 15
runs-on: ubuntu-latest-16-cores-arm-open
steps:
- name: Check out code
uses: actions/checkout@v6
- uses: ./.github/actions/setup
- name: Restore build tool caches
uses: actions/cache@v5
with:
path: |
.lazy
**/.lazy
**/.tsbuild
**/*.tsbuildinfo
key: build-tools-${{ runner.os }}-${{ hashFiles('yarn.lock', 'lazy.config.ts') }}
- name: Build packages
# the sed pipe makes sure that github annotations come through without
# turbo's prefix
run: "yarn build-package | sed -E 's/^.*? ::/::/'"

52
.github/workflows/claude.yml vendored Normal file
View File

@@ -0,0 +1,52 @@
name: Claude Code
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
issues:
types: [opened, assigned]
pull_request_review:
types: [submitted]
permissions:
contents: read
jobs:
claude:
if: |
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
issues: read
id-token: write
actions: read # Required for Claude to read CI results on PRs
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
fetch-depth: 1
- name: Run Claude Code
id: claude
uses: anthropics/claude-code-action@v1
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
# This is an optional setting that allows Claude to read CI results on PRs
additional_permissions: |
actions: read
# Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
# prompt: 'Update the pull request description to include a summary of changes.'
# Optional: Add claude_args to customize behavior and configuration
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
# or https://docs.anthropic.com/en/docs/claude-code/sdk#command-line for available options
# claude_args: '--model claude-opus-4-1-20250805 --allowed-tools Bash(gh pr:*)'

View File

@@ -0,0 +1,37 @@
name: Close inactive issues
on:
workflow_dispatch:
schedule:
- cron: '0 0 1 * *'
defaults:
run:
shell: bash
permissions:
contents: read
jobs:
close-issues:
runs-on: ubuntu-latest
permissions:
issues: write
pull-requests: write
steps:
- uses: actions/stale@v10
with:
days-before-issue-stale: 150
days-before-issue-close: 30
stale-issue-label: 'stale'
stale-issue-message: >
This issue has been marked as stale because it has been open for 150 days with no activity.
If there's no further discussion, it'll be closed in 30 days time.
If you would like to ensure it stays open, you can comment on the issue and revive the discussion.
Or, you can add the 'keep' label to prevent it from being closed.
close-issue-message: 'This issue was closed because it has been inactive for 30 days since being marked as stale.'
close-issue-reason: 'not_planned'
days-before-pr-stale: -1
days-before-pr-close: -1
operations-per-run: 1000
exempt-issue-labels: keep
repo-token: ${{ secrets.GITHUB_TOKEN }}

41
.github/workflows/dependabot-dedupe.yml vendored Normal file
View File

@@ -0,0 +1,41 @@
name: Dedupe Dependabot PRs
on:
push:
branches: ['dependabot/**']
permissions:
contents: write
pull-requests: write
repository-projects: write
defaults:
run:
shell: bash
jobs:
dedupe:
name: Dedupe Dependabot PRs
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@v6
- uses: ./.github/actions/setup
- name: Configure Git
run: |
git config user.name 'github-actions[bot]'
git config user.email 'github-actions[bot]@users.noreply.github.com'
- name: Dedupe dependencies
run: yarn dedupe
- name: Commit changes
run: |
git add .
git commit -m 'Dedupe' || true
- name: Push changes
run: git push

55
.github/workflows/deploy-analytics.yml vendored Normal file
View File

@@ -0,0 +1,55 @@
name: Deploy analytics worker
permissions:
contents: read
deployments: write
on:
pull_request:
paths:
- 'apps/analytics-worker/**'
- 'internal/scripts/deploy-analytics.ts'
- 'internal/scripts/lib/deploy.ts'
- '.github/workflows/deploy-analytics.yml'
- '.github/actions/setup/**'
push:
branches:
- main
- production
env:
CI: 1
PRINT_GITHUB_ANNOTATIONS: 1
TLDRAW_ENV: ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }}
defaults:
run:
shell: bash
jobs:
deploy:
name: Deploy analytics worker to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }}
timeout-minutes: 10
runs-on: ubuntu-latest
environment: ${{ github.ref == 'refs/heads/production' && 'deploy-production' || 'deploy-staging' }}
concurrency: analytics-worker-${{ github.ref == 'refs/heads/production' && 'deploy-production' || github.ref }}
steps:
- name: Check out code
uses: actions/checkout@v6
with:
submodules: true
- uses: ./.github/actions/setup
- name: Build types
run: yarn build-types
- name: Deploy
run: yarn tsx internal/scripts/deploy-analytics.ts
env:
RELEASE_COMMIT_HASH: ${{ github.sha }}
GH_TOKEN: ${{ github.token }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
DISCORD_DEPLOY_WEBHOOK_URL: ${{ secrets.DISCORD_DEPLOY_WEBHOOK_URL }}

61
.github/workflows/deploy-bemo.yml vendored Normal file
View File

@@ -0,0 +1,61 @@
name: Deploy bemo
# TODO: add some sort of production trigger
on:
pull_request:
push:
branches:
- main
- bemo-production
env:
CI: 1
PRINT_GITHUB_ANNOTATIONS: 1
TLDRAW_ENV: ${{ (github.ref == 'refs/heads/bemo-production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }}
defaults:
run:
shell: bash
permissions:
contents: read
# createGithubDeployment (internal/scripts/lib/deploy.ts) registers the deployment that
# renders the 'View Deployment' button on the PR.
deployments: write
jobs:
deploy:
name: Deploy bemo to ${{ (github.ref == 'refs/heads/bemo-production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }}
timeout-minutes: 15
runs-on: ubuntu-latest
environment: ${{ github.ref == 'refs/heads/bemo-production' && 'bemo-production' || 'bemo-canary' }}
concurrency: bemo-${{ github.ref == 'refs/heads/bemo-production' && 'bemo-production' || github.ref }}
steps:
# - name: Notify initial start
# uses: MineBartekSA/discord-webhook@v2
# if: github.ref == 'refs/heads/bemo-production'
# with:
# webhook: ${{ secrets.DISCORD_DEPLOY_WEBHOOK_URL }}
# content: 'Preparing ${{ env.TLDRAW_ENV }} bemo deploy: ${{ github.event.head_commit.message }} by ${{ github.event.head_commit.author.name }}'
- name: Check out code
uses: actions/checkout@v6
with:
submodules: true
- uses: ./.github/actions/setup
- name: Build types
run: yarn build-types
- name: Deploy
run: yarn tsx internal/scripts/deploy-bemo.ts
env:
RELEASE_COMMIT_HASH: ${{ github.sha }}
GH_TOKEN: ${{ github.token }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
DISCORD_DEPLOY_WEBHOOK_URL: ${{ secrets.DISCORD_DEPLOY_WEBHOOK_URL }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_BEMO_WORKER_DSN: ${{ secrets.SENTRY_BEMO_WORKER_DSN }}

215
.github/workflows/deploy-dotcom.yml vendored Normal file
View File

@@ -0,0 +1,215 @@
name: Deploy .com
on:
pull_request:
types: [opened, synchronize, labeled, unlabeled]
push:
branches:
- main
- production
permissions:
contents: read
# createGithubDeployment (internal/scripts/lib/deploy.ts) registers the deployment that
# renders the 'View Deployment' button on the PR.
deployments: write
env:
CI: 1
PRINT_GITHUB_ANNOTATIONS: 1
TLDRAW_ENV: ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }}
defaults:
run:
shell: bash
jobs:
deploy:
name: Deploy dotcom to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }}
timeout-minutes: 15
runs-on: ubuntu-latest-16-cores-arm-open
environment: ${{ github.ref == 'refs/heads/production' && 'deploy-production' || 'deploy-staging' }}
concurrency: dotcom-${{ github.ref == 'refs/heads/production' && 'deploy-production' || github.ref }}
if: |
github.event_name == 'push' ||
(github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'dotcom-preview-please'))
steps:
# - name: Notify initial start
# uses: MineBartekSA/discord-webhook@v2
# if: github.ref == 'refs/heads/production'
# with:
# webhook: ${{ secrets.DISCORD_DEPLOY_WEBHOOK_URL }}
# content: 'Preparing ${{ env.TLDRAW_ENV }} dotcom deploy: ${{ github.event.head_commit.message }} by ${{ github.event.head_commit.author.name }}'
- name: Check out code
uses: actions/checkout@v6
env:
# Abort a stalled fetch so checkout's retry kicks in instead of hanging until the job timeout
GIT_HTTP_LOW_SPEED_LIMIT: 1000
GIT_HTTP_LOW_SPEED_TIME: 60
- uses: ./.github/actions/setup
- name: Determine zero deploy target
id: deploy_target
env:
GH_TOKEN: ${{ github.token }}
run: |
if [[ "${GITHUB_REF}" == "refs/heads/production" ]]; then
echo "DEPLOY_ZERO=flyio-multinode" >> $GITHUB_ENV
elif [[ "${GITHUB_REF}" == "refs/heads/main" ]]; then
echo "DEPLOY_ZERO=flyio-multinode" >> $GITHUB_ENV
else
echo "DEPLOY_ZERO=flyio" >> $GITHUB_ENV
fi
- name: Install Supabase CLI
if: github.event_name == 'pull_request'
uses: supabase/setup-cli@ab058987d8d6c725971f6cf9d0b5c98467e30bd1 # v1
with:
# Pinned: v2.112.0 rejects the Management API's own timestamps, which
# breaks `branches get` and `branches delete`. See supabase/cli#6115.
version: 2.111.0
- name: Delete Supabase branch
if: contains(github.event.pull_request.labels.*.name, 'reset-preview-db')
env:
SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_ACCESS_TOKEN }}
run: |
supabase branches delete "pr-${{ github.event.number }}" \
--project-ref "${{ vars.SUPABASE_PREVIEW_PROJECT_ID }}" || echo "::warning::Failed to delete Supabase branch (may not exist yet)"
- name: Create Supabase preview branch
if: github.event_name == 'pull_request'
env:
SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_ACCESS_TOKEN }}
run: |
BRANCH_NAME="pr-${{ github.event.number }}"
PROJECT_REF="${{ vars.SUPABASE_PREVIEW_PROJECT_ID }}"
# Create branch if it doesn't already exist.
if supabase branches get "$BRANCH_NAME" \
--project-ref "$PROJECT_REF" > /dev/null 2>&1; then
echo "Branch $BRANCH_NAME already exists."
else
supabase branches create "$BRANCH_NAME" --project-ref "$PROJECT_REF" > /dev/null
echo "Branch $BRANCH_NAME created."
fi
# Wait for branch to be ready, then extract connection strings.
# Write to a temp file to avoid secrets leaking into CI step output.
BRANCH_JSON=$(mktemp)
for i in $(seq 1 24); do
# While the branch is still provisioning, `branches get` may exit
# non-zero and/or write a non-JSON line to stdout (the newer CLI
# renders an error there). Tolerate both: `|| true` keeps `set -e`
# from aborting on a non-zero get, and the guarded jq below treats
# any non-JSON / missing field as "not ready yet" so the loop retries.
supabase branches get "$BRANCH_NAME" \
--project-ref "$PROJECT_REF" \
-o json > "$BRANCH_JSON" 2>/dev/null || true
POSTGRES_URL_TRANSACTION=$(jq -r '.POSTGRES_URL // empty' "$BRANCH_JSON" 2>/dev/null || true)
if [ -n "$POSTGRES_URL_TRANSACTION" ]; then
echo "Branch $BRANCH_NAME is ready."
break
fi
echo "Waiting for branch to be ready ($i/24)..."
sleep 5
done
POSTGRES_URL_NON_POOLING=$(jq -r '.POSTGRES_URL_NON_POOLING // empty' "$BRANCH_JSON" 2>/dev/null || true)
rm "$BRANCH_JSON"
if [ -z "$POSTGRES_URL_TRANSACTION" ]; then
echo "::error::Branch not ready after 2 minutes — POSTGRES_URL still empty"
exit 1
fi
if [ -z "$POSTGRES_URL_NON_POOLING" ] || [ "$POSTGRES_URL_NON_POOLING" = "null" ]; then
echo "::error::Failed to get POSTGRES_URL_NON_POOLING from branch"
exit 1
fi
# Rewrite transaction pooler (port 6543, IPv6-only) to session pooler
# (port 5432, IPv4-compatible) since GitHub Actions runners are IPv4-only.
POSTGRES_URL="${POSTGRES_URL_TRANSACTION/:6543/:5432}"
echo "::add-mask::$POSTGRES_URL"
echo "::add-mask::$POSTGRES_URL_TRANSACTION"
echo "::add-mask::$POSTGRES_URL_NON_POOLING"
echo "POSTGRES_URL=$POSTGRES_URL" >> "$GITHUB_ENV"
echo "POSTGRES_URL_NON_POOLING=$POSTGRES_URL_NON_POOLING" >> "$GITHUB_ENV"
- name: Build types
run: yarn build-types
- name: Setup Fly cli
if: env.DEPLOY_ZERO == 'flyio' || env.DEPLOY_ZERO == 'flyio-multinode'
uses: superfly/flyctl-actions/setup-flyctl@ed8efb33836e8b2096c7fd3ba1c8afe303ebbff1 # 1.6
- name: Deploy
run: yarn tsx internal/scripts/deploy-dotcom.ts
env:
RELEASE_COMMIT_HASH: ${{ github.sha }}
GH_TOKEN: ${{ github.token }}
APP_ORIGIN: ${{ vars.APP_ORIGIN }}
ASSET_UPLOAD: ${{ vars.ASSET_UPLOAD }}
IMAGE_WORKER: ${{ vars.IMAGE_WORKER }}
MULTIPLAYER_SERVER: ${{ vars.MULTIPLAYER_SERVER }}
USER_CONTENT_SENTRY_DSN: ${{ secrets.USER_CONTENT_SENTRY_DSN }}
USER_CONTENT_URL: ${{ vars.USER_CONTENT_URL }}
NEXT_PUBLIC_GOOGLE_CLOUD_PROJECT_NUMBER: ${{ vars.NEXT_PUBLIC_GOOGLE_CLOUD_PROJECT_NUMBER }}
SUPABASE_LITE_URL: ${{ vars.SUPABASE_LITE_URL }}
VERCEL_ORG_ID: ${{ vars.VERCEL_ORG_ID }}
VERCEL_PROJECT_ID: ${{ vars.VERCEL_DOTCOM_PROJECT_ID }}
ANALYTICS_API_URL: ${{ secrets.ANALYTICS_API_URL }}
ANALYTICS_API_TOKEN: ${{ secrets.ANALYTICS_API_TOKEN }}
ASSET_UPLOAD_SENTRY_DSN: ${{ secrets.ASSET_UPLOAD_SENTRY_DSN }}
BOTCOM_POSTGRES_CONNECTION_STRING: ${{ env.POSTGRES_URL_NON_POOLING || secrets.BOTCOM_POSTGRES_CONNECTION_STRING }}
BOTCOM_POSTGRES_POOLED_CONNECTION_STRING: ${{ env.POSTGRES_URL || secrets.BOTCOM_POSTGRES_POOLED_CONNECTION_STRING }}
CLERK_SECRET_KEY: ${{ secrets.CLERK_SECRET_KEY }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
MCP_SCREENSHOT_TOKEN_SECRET: ${{ secrets.MCP_SCREENSHOT_TOKEN_SECRET }}
DISCORD_DEPLOY_WEBHOOK_URL: ${{ secrets.DISCORD_DEPLOY_WEBHOOK_URL }}
DISCORD_FEEDBACK_WEBHOOK_URL: ${{ secrets.DISCORD_FEEDBACK_WEBHOOK_URL }}
PLAIN_API_KEY: ${{ secrets.PLAIN_API_KEY }}
PLAIN_LABEL_TYPE_ID: ${{ secrets.PLAIN_LABEL_TYPE_ID }}
PLAIN_WORKSPACE_ID: ${{ secrets.PLAIN_WORKSPACE_ID }}
DISCORD_HEALTH_WEBHOOK_URL: ${{ secrets.DISCORD_HEALTH_WEBHOOK_URL }}
GC_MAPS_API_KEY: ${{ secrets.GC_MAPS_API_KEY }}
GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }}
HEALTH_CHECK_BEARER_TOKEN: ${{ secrets.HEALTH_CHECK_BEARER_TOKEN }}
HEALTH_WORKER_UPDOWN_WEBHOOK_PATH: ${{ secrets.HEALTH_WORKER_UPDOWN_WEBHOOK_PATH }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
R2_ACCESS_KEY_SECRET: ${{ secrets.R2_ACCESS_KEY_SECRET }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_CSP_REPORT_URI: ${{ secrets.SENTRY_CSP_REPORT_URI }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
SUPABASE_LITE_ANON_KEY: ${{ secrets.SUPABASE_LITE_ANON_KEY }}
TLDRAW_LICENSE: ${{ secrets.TLDRAW_LICENSE }}
VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
VITE_CLERK_PUBLISHABLE_KEY: ${{ secrets.VITE_CLERK_PUBLISHABLE_KEY }}
VITE_POSTHOG_KEY: ${{ secrets.VITE_POSTHOG_KEY }}
VITE_GA4_MEASUREMENT_ID: ${{ secrets.VITE_GA4_MEASUREMENT_ID }}
WORKER_SENTRY_DSN: ${{ secrets.WORKER_SENTRY_DSN }}
FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
ZERO_ADMIN_PASSWORD: ${{ secrets.ZERO_ADMIN_PASSWORD }}
ZERO_R2_ENDPOINT: ${{ secrets.ZERO_R2_ENDPOINT }}
ZERO_R2_BUCKET_NAME: ${{ secrets.ZERO_R2_BUCKET_NAME }}
ZERO_R2_ACCESS_KEY_ID: ${{ secrets.ZERO_R2_ACCESS_KEY_ID }}
ZERO_R2_SECRET_ACCESS_KEY: ${{ secrets.ZERO_R2_SECRET_ACCESS_KEY }}
ZERO_OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.ZERO_OTEL_EXPORTER_OTLP_ENDPOINT }}
ZERO_OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.ZERO_OTEL_EXPORTER_OTLP_HEADERS }}
# The deploy script posts its own "Deploy failed" message, but a job timeout
# kills it without running that handler, so cover the cancelled case here.
- name: Notify Discord on cancelled deploy
if: cancelled() && github.event_name == 'push'
uses: ./.github/actions/discord-fail-notify
with:
webhook-url: ${{ secrets.DISCORD_DEPLOY_WEBHOOK_URL }}
status: was cancelled (job timeout?)

115
.github/workflows/deploy-grafana.yml vendored Normal file
View File

@@ -0,0 +1,115 @@
name: Deploy grafana resources
permissions:
contents: read
on:
pull_request:
paths:
- 'internal/grafana/**'
- '.github/workflows/deploy-grafana.yml'
push:
branches:
- main
paths:
- 'internal/grafana/**'
- '.github/workflows/deploy-grafana.yml'
defaults:
run:
shell: bash
env:
GCX_VERSION: 1.0.0
GCX_SHA256: c88c65958d19d83dc3a192a2a5037e08307a9c776019f24249b89f369c9c4d41
jobs:
deploy:
name: ${{ github.event_name == 'push' && 'Deploy grafana resources' || 'Validate grafana resources (dry run)' }}
# Validate and push both need the Grafana secrets, which fork PRs don't get.
if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository
timeout-minutes: 10
runs-on: ubuntu-latest
concurrency: grafana-deploy-${{ github.event_name == 'push' && 'main' || github.ref }}
steps:
- name: Check out code
uses: actions/checkout@v6
- name: Install gcx
run: |
curl -fsSL -o gcx.tar.gz "https://github.com/grafana/gcx/releases/download/v${GCX_VERSION}/gcx_${GCX_VERSION}_linux_amd64.tar.gz"
echo "${GCX_SHA256} gcx.tar.gz" | sha256sum -c -
tar xzf gcx.tar.gz gcx
sudo mv gcx /usr/local/bin/gcx
gcx --version
- name: Validate resources
run: gcx resources validate -p internal/grafana/resources
env:
GRAFANA_SERVER: ${{ secrets.GRAFANA_SERVER }}
GRAFANA_TOKEN: ${{ secrets.GRAFANA_TOKEN }}
- name: Dry run
if: github.event_name == 'pull_request'
run: gcx resources push -p internal/grafana/resources --dry-run --on-error abort
env:
GRAFANA_SERVER: ${{ secrets.GRAFANA_SERVER }}
GRAFANA_TOKEN: ${{ secrets.GRAFANA_TOKEN }}
# Everything except alert rules, folders first. Two orderings matter and neither is
# the alphabetical one this glob would otherwise give: folders have to exist before
# anything annotated into them, and alert rules go last (see the step below for why).
# A commit adding a folder and a dashboard inside it lands both in one push, so the
# folder cannot be assumed to already be there.
- name: Push resources
if: github.event_name == 'push'
run: |
shopt -s nullglob
for dir in internal/grafana/resources/*folders*/; do
gcx resources push -p "$dir" --on-error abort
done
for dir in internal/grafana/resources/*/; do
case "$dir" in *folders*|*alertrules*) continue ;; esac
gcx resources push -p "$dir" --on-error abort
done
env:
GRAFANA_SERVER: ${{ secrets.GRAFANA_SERVER }}
GRAFANA_TOKEN: ${{ secrets.GRAFANA_TOKEN }}
# Alert rules are the one resource type that can fail here having passed the PR
# dry run, because alerting has no server-side dry-run — the PR reports them as
# "skipped". A newly created rule carrying group labels is rejected outright
# ("cannot set group when creating a new rule"), so the first time any new rule
# lands, this step is where it surfaces. Kept last and separate so that failure
# costs the rule, not every dashboard in the tree. Still fails the job.
- name: Push alert rules
if: github.event_name == 'push'
run: |
shopt -s nullglob
for dir in internal/grafana/resources/*alertrules*/; do
gcx resources push -p "$dir" --on-error abort
done
env:
GRAFANA_SERVER: ${{ secrets.GRAFANA_SERVER }}
GRAFANA_TOKEN: ${{ secrets.GRAFANA_TOKEN }}
# Notification templates can't go through `gcx resources push` — gcx's discovery
# registry ignores the notifications.alerting.grafana.app API group entirely — so
# template files live in templates/ (not resources/) and go through the classic
# provisioning API. The PUT is idempotent and sets `api` provenance, which locks
# UI edits the same way provisioned resources are locked.
- name: Push notification templates
if: github.event_name == 'push'
run: |
shopt -s nullglob
for f in internal/grafana/templates/*.gotmpl; do
name=$(basename "$f" .gotmpl)
jq -Rs '{template: .}' "$f" | curl -fsS -X PUT \
"$GRAFANA_SERVER/api/v1/provisioning/templates/$name" \
-H "Authorization: Bearer $GRAFANA_TOKEN" \
-H "Content-Type: application/json" \
-d @- > /dev/null
done
env:
GRAFANA_SERVER: ${{ secrets.GRAFANA_SERVER }}
GRAFANA_TOKEN: ${{ secrets.GRAFANA_TOKEN }}

63
.github/workflows/deploy-mcp-app.yml vendored Normal file
View File

@@ -0,0 +1,63 @@
name: Deploy MCP app
on:
# Auto-deploy whenever production is updated, keeping the public MCP worker on
# the same commit and SDK build as production dotcom. Only production triggers a
# deploy: there is no staging worker, so a push to main must not overwrite the
# shared `tldraw-mcp-app` worker.
push:
branches:
- production
workflow_dispatch:
inputs:
target:
description: 'Target ref to deploy'
required: true
default: 'production'
env:
CI: 1
PRINT_GITHUB_ANNOTATIONS: 1
TLDRAW_ENV: production
TARGET: ${{ github.event.inputs.target || github.ref_name }}
defaults:
run:
shell: bash
permissions:
contents: read
jobs:
deploy:
name: Deploy MCP app to production
timeout-minutes: 10
runs-on: ubuntu-latest
environment: deploy-production
concurrency: mcp-app-deploy-production
steps:
- name: Check out code
uses: actions/checkout@v6
with:
ref: ${{ env.TARGET }}
submodules: true
fetch-depth: 0
- uses: ./.github/actions/setup
- name: Build types
run: yarn build-types
- name: Deploy
run: yarn workspace mcp-app deploy
env:
VITE_TLDRAW_LICENSE_KEY: ${{ secrets.MCP_APP_TLDRAW_LICENSE_KEY }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
- name: Notify Discord on failure
if: failure()
uses: ./.github/actions/discord-fail-notify
with:
webhook-url: ${{ secrets.DISCORD_DEPLOY_WEBHOOK_URL }}

57
.github/workflows/deploy-templates.yml vendored Normal file
View File

@@ -0,0 +1,57 @@
name: Deploy templates
# Deploys the cloudflare-hosted starter kit demos at *.templates.tldraw.dev, which are the live
# embeds on the docs pages. Two things should reach them: a change to a template (so edits go live
# without waiting for a release), and a release (so the demos pick up the new SDK — an SDK bump
# never touches templates/, so the path filter alone would never fire for one). This workflow
# covers the first; publish.yml covers the second with a job of its own, using the same
# .github/actions/deploy-templates steps.
#
# The vercel-hosted demos (chat, shader, workflow) deploy through vercel's own git integration and
# aren't handled here.
on:
push:
branches:
- main
paths:
- 'templates/**'
workflow_dispatch:
defaults:
run:
shell: bash
permissions:
contents: read
jobs:
deploy:
name: Deploy templates
# CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID are secrets on this environment rather than on
# the repository, so the job has to select it to see them at all.
environment: deploy-production
timeout-minutes: 60
runs-on: ubuntu-latest-16-cores-arm-open
# a release deploy and a template-change deploy can otherwise race each other onto the same
# custom domains, and the loser silently wins.
concurrency:
group: deploy-templates
cancel-in-progress: false
steps:
- name: Check out code
uses: actions/checkout@v6
with:
persist-credentials: false
- uses: ./.github/actions/deploy-templates
with:
license-key: ${{ secrets.TEMPLATES_TLDRAW_LICENSE_KEY }}
cloudflare-account-id: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
cloudflare-api-token: ${{ secrets.CLOUDFLARE_API_TOKEN }}
- name: Notify Discord on failure
if: failure()
uses: ./.github/actions/discord-fail-notify
with:
webhook-url: ${{ secrets.DISCORD_DEPLOY_WEBHOOK_URL }}

183
.github/workflows/e2e-device.yml vendored Normal file
View File

@@ -0,0 +1,183 @@
name: End to end tests (device)
# Run-on-request device-realistic e2e tests.
#
# These run real Mobile Safari (iOS Simulator) and real Chrome (Android emulator)
# through the OS input pipeline via Appium + WebdriverIO. Unlike the Playwright
# suite, this exercises the browser's own gesture recogniser and platform
# behaviour, so it can catch regressions that synthetic DOM events (CDP) cannot.
# It currently covers multi-touch gestures (pinch), but the harness is meant to
# grow to other device-specific behaviour (exports, clipboard, shortcuts, etc.)
# and other platforms.
#
# It is slower and a bit flaky by nature, so it is NOT part of the per-PR checks.
# Trigger it on demand:
#
# - Manually from the Actions tab (workflow_dispatch), choosing the platform.
# - By adding the `e2e-device-tests` label to a PR that touches device behaviour.
on:
workflow_dispatch:
inputs:
platform:
description: Which platform(s) to run
type: choice
default: both
options: [both, ios, android]
ios_device:
description: iOS simulator device name (optional; auto-picks newest iPhone if empty)
type: string
default: ''
pull_request:
types: [labeled]
env:
CI: 1
EXAMPLES_PORT: '5420'
defaults:
run:
shell: bash
# A new push to a PR (or a re-dispatch) cancels the previous in-flight run.
concurrency:
group: e2e-device-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
ios:
name: iOS Safari
# Run when dispatched with ios/both, or when a PR is labelled `e2e-device-tests`.
if: >
(github.event_name == 'workflow_dispatch' && inputs.platform != 'android') ||
(github.event_name == 'pull_request' && github.event.label.name == 'e2e-device-tests')
runs-on: macos-26 # pinned: image version determines Xcode + iOS sim runtimes
timeout-minutes: 40
steps:
- name: Check out code
uses: actions/checkout@v6
- name: Run our setup
uses: ./.github/actions/setup
- name: Build examples
run: yarn run -T lazy refresh-assets && yarn run -T lazy run prebuild && cd apps/examples && yarn build
- name: Install Appium and the XCUITest driver
run: |
npm i -g appium@3
appium driver install xcuitest
- name: Resolve Xcode version (WDA cache key)
id: xcode
run: echo "version=$(xcodebuild -version | head -1 | awk '{print $2}')" >> "$GITHUB_OUTPUT"
- name: Cache WebDriverAgent build
# WDA is rebuilt from source on a cold simulator (~3.5 min). Cache its
# DerivedData keyed on the Xcode version so iterating on test fixes reuses
# the prebuilt WDA instead of rebuilding. Bump the trailing -vN to bust.
id: wda-cache
uses: actions/cache@v5
with:
path: ${{ runner.temp }}/wda-derived
key: wda-${{ runner.os }}-xcode-${{ steps.xcode.outputs.version }}-v1
- name: Boot iOS simulator
id: sim
# A fresh iOS simulator's first boot runs slow one-time migration plugins
# that exceed XCUITest's boot-wait. So boot ONE simulator here, wait for it
# to finish (`bootstatus -b`), and hand its UDID to Appium so it attaches to
# the ready device instead of booting its own. The available iPhone models
# depend on the Xcode the runner image ships, so auto-pick rather than
# hardcode a name (an explicit `ios_device` input wins if it matches).
env:
DEVICE_NAME: ${{ inputs.ios_device }}
run: |
xcrun simctl list devices available
SEL=$(xcrun simctl list devices available --json | node -e '
const data = JSON.parse(require("fs").readFileSync(0, "utf8"));
const want = process.env.DEVICE_NAME || "";
const runtimes = Object.keys(data.devices).filter(r => r.includes("iOS")).sort().reverse();
const phones = [];
for (const r of runtimes) for (const d of (data.devices[r] || [])) {
if (d.isAvailable !== false && d.name.startsWith("iPhone")) phones.push(d);
}
const pick = (want && phones.find(d => d.name === want)) || phones[0];
if (!pick) process.exit(2);
process.stdout.write(pick.udid + "|" + pick.name);
') || { echo "No available iPhone simulator on this runner"; exit 1; }
UDID="${SEL%%|*}"
NAME="${SEL#*|}"
echo "Using $NAME ($UDID)"
xcrun simctl boot "$UDID" || true
xcrun simctl bootstatus "$UDID" -b
echo "udid=$UDID" >> "$GITHUB_OUTPUT"
echo "name=$NAME" >> "$GITHUB_OUTPUT"
- name: Serve built examples
run: yarn preview --port "$EXAMPLES_PORT" &
working-directory: apps/examples
- name: Wait for examples server
run: npx --yes wait-on "http://localhost:$EXAMPLES_PORT/end-to-end" -t 120000
- name: Run device tests (iOS)
run: yarn e2e-device-ios
working-directory: apps/examples
env:
APPIUM_DEVICE_NAME: ${{ steps.sim.outputs.name }}
APPIUM_UDID: ${{ steps.sim.outputs.udid }}
# Build WDA into the cached dir; reuse the prebuilt one on a cache hit.
APPIUM_DERIVED_DATA_PATH: ${{ runner.temp }}/wda-derived
APPIUM_USE_PREBUILT_WDA: ${{ steps.wda-cache.outputs.cache-hit }}
android:
name: Android Chrome
if: >
(github.event_name == 'workflow_dispatch' && inputs.platform != 'ios') ||
(github.event_name == 'pull_request' && github.event.label.name == 'e2e-device-tests')
runs-on: ubuntu-latest
timeout-minutes: 40
steps:
- name: Check out code
uses: actions/checkout@v6
- name: Run our setup
uses: ./.github/actions/setup
- name: Enable KVM (hardware acceleration for the emulator)
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \
| sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm
- name: Build examples
run: yarn run -T lazy refresh-assets && yarn run -T lazy run prebuild && cd apps/examples && yarn build
- name: Install Appium and the UiAutomator2 driver
run: |
npm i -g appium@3
appium driver install uiautomator2
- name: Serve built examples
run: yarn preview --port "$EXAMPLES_PORT" &
working-directory: apps/examples
- name: Wait for examples server
run: npx --yes wait-on "http://localhost:$EXAMPLES_PORT/end-to-end" -t 120000
- name: Run device tests on emulator
uses: reactivecircus/android-emulator-runner@v2
with:
api-level: 34
arch: x86_64
target: google_apis
# The emulator's `localhost` is the emulator itself, so forward the host
# dev server into it before running the tests.
script: |
adb reverse tcp:${{ env.EXAMPLES_PORT }} tcp:${{ env.EXAMPLES_PORT }}
cd apps/examples && yarn e2e-device-android

35
.github/workflows/get-changelog.yml vendored Normal file
View File

@@ -0,0 +1,35 @@
name: Get draft changelog
on:
workflow_dispatch:
defaults:
run:
shell: bash
permissions:
contents: read
jobs:
deploy:
name: 'Get draft changlog'
timeout-minutes: 60
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Run our setup
uses: ./.github/actions/setup
- name: Extract changelog
run: yarn extract-draft-changelog
- name: Upload changelog.md to artifacts
uses: actions/upload-artifact@v6
with:
name: changelog-output
path: changelog.md

View File

@@ -0,0 +1,73 @@
name: i18n download strings
on:
workflow_dispatch:
schedule:
- cron: '0 10 * * 1'
defaults:
run:
shell: bash
permissions:
contents: read
jobs:
download:
name: 'Download latest strings'
runs-on: ubuntu-latest
environment: 'deploy-production'
permissions:
# Give the default GITHUB_TOKEN write permission to commit and push the
# added or changed files to the repository.
contents: write
pull-requests: write
repository-projects: write
steps:
- name: Check out code
uses: actions/checkout@v6
with:
fetch-depth: 1
- uses: ./.github/actions/setup
- name: Download i18n strings
run: 'yarn i18n-download-strings'
env:
LOKALISE_API_TOKEN: ${{ secrets.LOKALISE_API_TOKEN }}
LOKALISE_PROJECT_ID: ${{ secrets.LOKALISE_PROJECT_ID }}
LOKALISE_TLDRAW_PROJECT_ID: ${{ secrets.LOKALISE_TLDRAW_PROJECT_ID }}
- name: Compile i18n strings
run: 'yarn build-i18n'
- name: Validate the strings
run: 'yarn i18n-check -f react-intl --locales ./apps/dotcom/client/public/tla/locales/ --source en --only invalidKeys'
- name: Commit and push changes
env:
GH_TOKEN: ${{ github.token }}
run: |
git config --global user.name 'huppy-bot[bot]'
git config --global user.email '128400622+huppy-bot[bot]@users.noreply.github.com'
# Check if there are any changes to JSON files
if ! git diff --quiet '*.json' || ! git diff --cached --quiet '*.json'; then
echo "Changes detected, creating PR..."
else
echo "No changes to commit"
exit 0
fi
# Delete the branch if it already exists
git push origin --delete update-i18n-strings 2>/dev/null || true
git checkout -b update-i18n-strings
git add "*.json"
git commit --no-verify -m '[automated] update i18n strings'
git push --set-upstream origin update-i18n-strings
gh pr create --title "[automated] update i18n strings" --body "This PR updates the i18n strings.
### Change type
- [x] \`other\`" --base main --head update-i18n-strings

View File

@@ -0,0 +1,35 @@
name: i18n upload strings
on:
workflow_dispatch:
push:
branches: [main]
defaults:
run:
shell: bash
permissions:
contents: read
jobs:
upload:
name: 'Upload latest strings'
runs-on: ubuntu-latest
environment: 'deploy-production'
steps:
- name: Check out code
uses: actions/checkout@v6
with:
fetch-depth: 1
- uses: ./.github/actions/setup
- name: Upload i18n strings
run: 'yarn i18n-upload-strings'
env:
LOKALISE_API_TOKEN: ${{ secrets.LOKALISE_API_TOKEN }}
LOKALISE_PROJECT_ID: ${{ secrets.LOKALISE_PROJECT_ID }}
LOKALISE_TLDRAW_PROJECT_ID: ${{ secrets.LOKALISE_TLDRAW_PROJECT_ID }}
LOKALISE_TEAM_ID: ${{ secrets.LOKALISE_TEAM_ID }}
LOKALISE_CARD_ID: ${{ secrets.LOKALISE_CARD_ID }}

122
.github/workflows/issue-triage.yml vendored Normal file
View File

@@ -0,0 +1,122 @@
name: Issue Triage
on:
issues:
types: [opened]
workflow_dispatch:
inputs:
issue_number:
description: 'Issue number to triage (for testing)'
required: true
type: number
permissions:
contents: read
jobs:
triage:
runs-on: ubuntu-latest
permissions:
contents: read
issues: write
id-token: write
env:
ISSUE_NUMBER: ${{ github.event.issue.number || inputs.issue_number }}
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
fetch-depth: 1
- name: Get issue details
id: issue
env:
GH_TOKEN: ${{ github.token }}
run: |
gh issue view ${{ env.ISSUE_NUMBER }} --json title,body,labels,state > /tmp/issue.json
# Fetch issue type via API (not yet supported in gh CLI)
ISSUE_TYPE=$(gh api repos/${{ github.repository }}/issues/${{ env.ISSUE_NUMBER }} --jq '.type.name // "none"')
# Merge the type into the JSON
jq --arg type "$ISSUE_TYPE" '. + {issueType: $type}' /tmp/issue.json > /tmp/issue_with_type.json
echo "details<<EOF" >> $GITHUB_OUTPUT
cat /tmp/issue_with_type.json >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
- name: Triage Issue with Claude
uses: anthropics/claude-code-action@v1
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
prompt: |
You are an issue triage bot for the tldraw repository. Tidy up issue #${{ env.ISSUE_NUMBER }}.
## Issue details
```json
${{ steps.issue.outputs.details }}
```
## Tasks
### 1. Improve the title (if needed)
- Make sure the title is clear and descriptive
- Use sentence case (capitalize only first word and proper nouns)
- For bugs: describe the symptom (e.g., "Arrow bindings break with rotated shapes")
- For features: use imperative mood (e.g., "Add padding option to zoomToFit")
- If the title is already good, leave it as-is
### 2. Improve the body (if needed)
- If the issue is written in a language other than English, add an English translation at the top of the body (keep the original text below)
- Make sure the body has enough context to understand the issue
- If the body is already good, leave it as-is
To update the title or body:
```bash
gh issue edit ${{ env.ISSUE_NUMBER }} --title "New title" --body "New body"
```
### 3. Set the issue type
Determine if this is a:
- **Bug**: Something is broken or not working as designed (crashes, rendering errors, data loss)
- **Feature**: A request for new functionality or enhancements (including UX preferences like "I wish X worked like Y")
- **Task**: Internal team task
- **Example**: Request for a new SDK example
Important: Distinguish between TRUE bugs and UX preferences disguised as bugs:
- TRUE BUG: "Clicking save crashes the app" or "Text doesn't render correctly"
- NOT A BUG (use Feature): "I don't like the color of the button" or "The menu should be on the left instead of right"
To set or change the issue type:
```bash
gh api repos/${{ github.repository }}/issues/${{ env.ISSUE_NUMBER }} -X PATCH -f type="Bug"
```
Replace "Bug" with the appropriate type (Bug, Feature, Task, or Example).
### 4. Apply appropriate labels
Use these labels as appropriate:
- `sdk` - Affects the tldraw SDK (@tldraw/tldraw package)
- `dotcom` - Related to tldraw.com
- `docs` - Changes only affect documentation
- `examples` - Request for a new SDK example
- `performance` - Performance-related issues
- `a11y` - Accessibility-related issues
- `bugfix` - For confirmed bug fixes
- `feature` - For new features
- `improvement` - For product improvements
- `More Info Needed` - If the issue lacks critical information needed to understand or reproduce
### 5. Assign a milestone (if appropriate)
If the issue clearly fits one of these milestones, assign it. Otherwise, leave the milestone empty.
Available milestones:
- **Improve developer resources**: For examples, documentation, improved code comments, starter kits, and `npm create tldraw` improvements
- **Improve automations**: For GitHub Actions, review bots, CI/CD, and other automation improvements
To assign a milestone:
```bash
gh issue edit ${{ env.ISSUE_NUMBER }} --milestone "Milestone Name"
```
Only assign a milestone if there's a clear fit. Most issues won't need a milestone.
## Output format
Use the GitHub CLI (`gh`) to make any changes. Do NOT add comments to the issue.
claude_args: '--allowedTools "Bash(gh issue:*),Bash(gh api:*)"'

52
.github/workflows/license-report.yml vendored Normal file
View File

@@ -0,0 +1,52 @@
name: Update license report
# Skipped on PRs from forks: the push below needs write access to the head branch.
permissions:
contents: write
on:
pull_request:
paths:
- '**/package.json'
- 'yarn.lock'
- 'internal/scripts/license-report.ts'
- '.github/workflows/license-report.yml'
env:
CI: 1
defaults:
run:
shell: bash
jobs:
update:
name: 'Update license report'
if: github.event.pull_request.head.repo.full_name == github.repository
timeout-minutes: 15
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@v6
with:
token: ${{ secrets.GITHUB_TOKEN }}
# `pull_request` checks out a detached merge commit, which we can't push back to.
ref: ${{ github.event.pull_request.head.ref }}
- uses: ./.github/actions/setup
- name: Configure Git
run: |
git config user.name 'github-actions[bot]'
git config user.email 'github-actions[bot]@users.noreply.github.com'
- name: Generate license report
run: yarn license-report --prod
- name: Commit changes
run: |
git add apps/docs/content/community/dependencies.mdx
git diff --cached --quiet || git commit -m 'Update license report'
git push

129
.github/workflows/playwright-dotcom.yml vendored Normal file
View File

@@ -0,0 +1,129 @@
name: End to end tests (dotcom)
on:
pull_request:
merge_group:
push:
branches: [main]
env:
CI: 1
PRINT_GITHUB_ANNOTATIONS: 1
TLDRAW_ENV: 'development'
# process-compose (the dev-app orchestrator) has no TTY in CI — run it headless.
PC_DISABLE_TUI: '1'
defaults:
run:
shell: bash
permissions:
contents: read
jobs:
check:
name: 'Check for relevant changes'
runs-on: ubuntu-latest
if: ${{ github.event.pull_request.head.repo.fork == false }}
outputs:
relevant: ${{ steps.includes.outputs.match == 'true' && steps.nondocs.outputs.match == 'true' }}
steps:
- name: Check out code
uses: actions/checkout@v6
- name: Check for changes to relevant paths
uses: dorny/paths-filter@v3
id: includes
with:
filters: |
match:
- 'packages/**'
- 'apps/dotcom/**'
- '.github/workflows/playwright-dotcom.yml'
- '.github/actions/**'
- 'yarn.lock'
- name: Check for any non-doc changes
uses: dorny/paths-filter@v3
id: nondocs
with:
predicate-quantifier: 'every'
filters: |
match:
- '**'
- '!**/*.md'
- '!**/*.mdx'
build:
name: 'End to end tests (dotcom)'
needs: check
if: needs.check.outputs.relevant == 'true'
timeout-minutes: 20
runs-on: ubuntu-latest-8-cores-open
environment: 'e2e-dotcom'
steps:
- name: Check out code
uses: actions/checkout@v6
- name: Run our setup
uses: ./.github/actions/setup
# No process-compose install step: `yarn dev-app` (the e2e webServer) calls the `process-compose`
# bin of @tldraw/scripts, which fetches the pinned binary on first use.
- name: Set up Playwright
uses: ./.github/actions/setup-playwright
- name: Build dotcom client
run: yarn run -T lazy refresh-assets && yarn run -T lazy run prebuild && yarn run -T lazy run build-i18n && cd apps/dotcom/client && yarn build
env:
VITE_CLERK_PUBLISHABLE_KEY: ${{ secrets.VITE_CLERK_PUBLISHABLE_KEY }}
- name: Run Playwright tests
run: |
echo "CLERK_PUBLISHABLE_KEY=${{ secrets.VITE_CLERK_PUBLISHABLE_KEY }}" > apps/dotcom/sync-worker/.dev.vars
echo "CLERK_SECRET_KEY=${{ secrets.CLERK_SECRET_KEY }}" >> apps/dotcom/sync-worker/.dev.vars
yarn e2e-dotcom
env:
VITE_CLERK_PUBLISHABLE_KEY: ${{ secrets.VITE_CLERK_PUBLISHABLE_KEY }}
CLERK_SECRET_KEY: ${{ secrets.CLERK_SECRET_KEY }}
if: ${{!contains(github.event.pull_request.labels.*.name, 'e2e-x10') }}
- name: Run Playwright tests (x10)
run: |
echo "CLERK_PUBLISHABLE_KEY=${{ secrets.VITE_CLERK_PUBLISHABLE_KEY }}" > apps/dotcom/sync-worker/.dev.vars
echo "CLERK_SECRET_KEY=${{ secrets.CLERK_SECRET_KEY }}" >> apps/dotcom/sync-worker/.dev.vars
yarn e2e-dotcom-x10
env:
VITE_CLERK_PUBLISHABLE_KEY: ${{ secrets.VITE_CLERK_PUBLISHABLE_KEY }}
CLERK_SECRET_KEY: ${{ secrets.CLERK_SECRET_KEY }}
if: contains(github.event.pull_request.labels.*.name, 'e2e-x10')
- uses: actions/upload-artifact@v6
if: always()
with:
name: playwright-report
path: apps/dotcom/client/playwright-report
retention-days: 30
- uses: shallwefootball/s3-upload-action@0d261a6f15b3b2e209dfebdecace4b100c04f95b # master
# only upload if we have AWS credentials:
env:
HAS_AWS_KEY: ${{ secrets.AWS_S3_SECRET_KEY && '1' || '' }}
if: always() && env.HAS_AWS_KEY == '1'
name: Upload S3
id: s3
with:
aws_key_id: ${{ secrets.AWS_S3_KEY_ID }}
aws_secret_access_key: ${{ secrets.AWS_S3_SECRET_KEY}}
aws_bucket: playwright.tldraw.xyz
source_dir: apps/dotcom/client/playwright-report
- name: Log report to summary
if: always()
run: |
report_url="https://playwright.tldraw.xyz/${{ steps.s3.outputs.object_key }}/index.html"
echo "Report: $report_url"
echo "## Playwright report" >> $GITHUB_STEP_SUMMARY
echo "* $report_url" >> $GITHUB_STEP_SUMMARY

View File

@@ -0,0 +1,106 @@
name: End to end tests (examples)
on:
pull_request:
merge_group:
push:
branches: [main]
env:
CI: 1
PRINT_GITHUB_ANNOTATIONS: 1
defaults:
run:
shell: bash
permissions:
contents: read
jobs:
check:
name: 'Check for relevant changes'
runs-on: ubuntu-latest
outputs:
relevant: ${{ steps.includes.outputs.match == 'true' && steps.nondocs.outputs.match == 'true' }}
steps:
- name: Check out code
uses: actions/checkout@v6
- name: Check for changes to relevant paths
uses: dorny/paths-filter@v3
id: includes
with:
filters: |
match:
- 'packages/**'
- 'apps/examples/**'
- '.github/workflows/playwright-examples.yml'
- '.github/actions/**'
- 'yarn.lock'
- name: Check for any non-doc changes
uses: dorny/paths-filter@v3
id: nondocs
with:
predicate-quantifier: 'every'
filters: |
match:
- '**'
- '!**/*.md'
- '!**/*.mdx'
build:
name: 'End to end tests (examples)'
needs: check
if: needs.check.outputs.relevant == 'true'
timeout-minutes: 20
runs-on: ubuntu-latest-32-cores-open
steps:
- name: Check out code
uses: actions/checkout@v6
- name: Run our setup
uses: ./.github/actions/setup
- name: Set up Playwright
uses: ./.github/actions/setup-playwright
- name: Build examples
run: yarn run -T lazy refresh-assets && yarn run -T lazy run prebuild && cd apps/examples && yarn build
- name: Run Playwright tests
run: 'yarn e2e'
working-directory: apps/examples
env:
GITHUB_REF_NAME: ${{ github.ref_name }}
GIT_COMMIT: ${{ github.sha }}
- uses: actions/upload-artifact@v6
if: always()
with:
name: playwright-report
path: apps/examples/playwright-report
retention-days: 30
- uses: shallwefootball/s3-upload-action@0d261a6f15b3b2e209dfebdecace4b100c04f95b # master
# only upload if we have AWS credentials:
env:
HAS_AWS_KEY: ${{ secrets.AWS_S3_SECRET_KEY && '1' || '' }}
if: always() && env.HAS_AWS_KEY == '1'
name: Upload S3
id: s3
with:
aws_key_id: ${{ secrets.AWS_S3_KEY_ID }}
aws_secret_access_key: ${{ secrets.AWS_S3_SECRET_KEY}}
aws_bucket: playwright.tldraw.xyz
source_dir: apps/examples/playwright-report
- name: Log report to summary
if: always()
run: |
report_url="https://playwright.tldraw.xyz/${{ steps.s3.outputs.object_key }}/index.html"
echo "Report: $report_url"
echo "## Playwright report" >> $GITHUB_STEP_SUMMARY
echo "* $report_url" >> $GITHUB_STEP_SUMMARY

View File

@@ -0,0 +1,71 @@
name: End to end tests (mermaid readability)
on:
pull_request:
merge_group:
push:
branches: [main]
env:
CI: 1
PRINT_GITHUB_ANNOTATIONS: 1
defaults:
run:
shell: bash
jobs:
check:
name: 'Check for relevant changes'
runs-on: ubuntu-latest
outputs:
relevant: ${{ steps.includes.outputs.match == 'true' }}
steps:
- name: Check out code
uses: actions/checkout@v6
# Only mermaid conversion changes can affect these checks, unlike the rest of the e2e suites.
- name: Check for changes to relevant paths
uses: dorny/paths-filter@v3
id: includes
with:
filters: |
match:
- 'packages/mermaid/**'
- 'apps/examples/src/examples/use-cases/hundred-mermaids/**'
- 'apps/examples/src/misc/mermaidReadability.ts'
- 'apps/examples/e2e/mermaid-readability/**'
- '.github/workflows/playwright-mermaid.yml'
- '.github/actions/**'
- 'yarn.lock'
build:
name: 'End to end tests (mermaid readability)'
needs: check
if: needs.check.outputs.relevant == 'true'
timeout-minutes: 20
runs-on: ubuntu-latest-16-cores-open
steps:
- name: Check out code
uses: actions/checkout@v6
- name: Run our setup
uses: ./.github/actions/setup
- name: Set up Playwright
uses: ./.github/actions/setup-playwright
- name: Build examples
run: yarn run -T lazy refresh-assets && yarn run -T lazy run prebuild && cd apps/examples && yarn build
- name: Run Playwright tests (mermaid readability)
run: 'yarn e2e-mermaid-readability'
working-directory: apps/examples
- uses: actions/upload-artifact@v6
if: always()
with:
name: playwright-report
path: apps/examples/playwright-report
retention-days: 30

107
.github/workflows/playwright-perf.yml vendored Normal file
View File

@@ -0,0 +1,107 @@
name: End to end tests (perf)
on:
pull_request:
merge_group:
push:
branches: [main]
env:
CI: 1
PRINT_GITHUB_ANNOTATIONS: 1
defaults:
run:
shell: bash
permissions:
contents: read
jobs:
check:
name: 'Check for relevant changes'
runs-on: ubuntu-latest
outputs:
relevant: ${{ steps.includes.outputs.match == 'true' && steps.nondocs.outputs.match == 'true' }}
steps:
- name: Check out code
uses: actions/checkout@v6
- name: Check for changes to relevant paths
uses: dorny/paths-filter@v3
id: includes
with:
filters: |
match:
- 'packages/**'
- 'apps/examples/**'
- '.github/workflows/playwright-perf.yml'
- '.github/actions/**'
- 'yarn.lock'
- name: Check for any non-doc changes
uses: dorny/paths-filter@v3
id: nondocs
with:
predicate-quantifier: 'every'
filters: |
match:
- '**'
- '!**/*.md'
- '!**/*.mdx'
build:
name: 'End to end tests (perf)'
needs: check
if: needs.check.outputs.relevant == 'true'
timeout-minutes: 20
runs-on: ubuntu-latest-16-cores-open
steps:
- name: Check out code
uses: actions/checkout@v6
- name: Run our setup
uses: ./.github/actions/setup
- name: Set up Playwright
uses: ./.github/actions/setup-playwright
- name: Build examples
run: yarn run -T lazy refresh-assets && yarn run -T lazy run prebuild && cd apps/examples && yarn build
- name: Run Playwright tests (perf)
run: 'yarn e2e-perf'
working-directory: apps/examples
env:
PERFORMANCE_ANALYTICS_ENABLED: true
GITHUB_REF_NAME: ${{ github.ref_name }}
GIT_COMMIT: ${{ github.sha }}
- uses: actions/upload-artifact@v6
if: always()
with:
name: playwright-report
path: apps/examples/playwright-report
retention-days: 30
- uses: shallwefootball/s3-upload-action@0d261a6f15b3b2e209dfebdecace4b100c04f95b # master
# only upload if we have AWS credentials:
env:
HAS_AWS_KEY: ${{ secrets.AWS_S3_SECRET_KEY && '1' || '' }}
if: always() && env.HAS_AWS_KEY == '1'
name: Upload S3
id: s3
with:
aws_key_id: ${{ secrets.AWS_S3_KEY_ID }}
aws_secret_access_key: ${{ secrets.AWS_S3_SECRET_KEY}}
aws_bucket: playwright.tldraw.xyz
source_dir: apps/examples/playwright-report
- name: Log report to summary
if: always()
run: |
report_url="https://playwright.tldraw.xyz/${{ steps.s3.outputs.object_key }}/index.html"
echo "Report: $report_url"
echo "## Playwright report" >> $GITHUB_STEP_SUMMARY
echo "* $report_url" >> $GITHUB_STEP_SUMMARY

View File

@@ -0,0 +1,84 @@
# Adapted from https://mmazzarolo.com/blog/2022-09-09-visual-regression-testing-with-playwright-and-github-actions/
# This workflow's goal is forcing an update of the reference snapshots used
# by Playwright tests. Add the 'update-snapshots' label to a PR.
# From a high-level perspective, it works like this:
# 1. Because of a GitHub Action limitation, this workflow is triggered on every
# label added to a PR. We manually interrupt it unless the label is
# "update-snapshots".
# 2. Use the GitHub API to grab the information about the branch name and SHA of
# the latest commit of the current pull request.
# 3. Remove the label from the PR.
# 4. Update the Playwright reference snapshots based on the UI of this branch.
# 5. Commit the newly generated Playwright reference snapshots into this branch.
name: Update playwright snapshots
on:
pull_request:
types: [labeled]
env:
CI: 1
PRINT_GITHUB_ANNOTATIONS: 1
defaults:
run:
shell: bash
permissions:
contents: read
jobs:
update_snapshots:
name: 'Run'
timeout-minutes: 60
runs-on: ubuntu-latest-8-cores-open
if: github.event.label.name == 'update-snapshots'
permissions:
# Give the default GITHUB_TOKEN write permission to commit and push the
# added or changed files to the repository.
contents: write
pull-requests: write
steps:
- name: Remove the update-snapshots label
uses: actions-ecosystem/action-remove-labels@v1
with:
labels: update-snapshots
- name: Check out code
uses: actions/checkout@v6
with:
fetch-depth: 5
token: ${{ secrets.GITHUB_TOKEN }}
ref: ${{ github.event.pull_request.head.ref }}
- name: Run our setup
uses: ./.github/actions/setup
- name: Set up Playwright
uses: ./.github/actions/setup-playwright
- name: Build examples
run: yarn run -T lazy refresh-assets && yarn run -T lazy run prebuild && cd apps/examples && yarn build
- name: Run Playwright tests & update snapshots
run: 'yarn e2e --update-snapshots'
working-directory: 'apps/examples'
- name: Commit and push changes
if: always()
run: |
set -eux
git config --global user.name 'huppy-bot[bot]'
git config --global user.email '128400622+huppy-bot[bot]@users.noreply.github.com'
git add -A
if git diff --cached --quiet; then
echo 'No snapshot changes to commit.'
exit 0
fi
git commit --no-verify -m '[automated] update snapshots'
git add -A && git reset --hard HEAD # we sometimes get a dirty tree because husky makes changes for some reason
git pull --rebase
git push

56
.github/workflows/post-release-crew.yml vendored Normal file
View File

@@ -0,0 +1,56 @@
name: Post release crew to Discord
# Every Wednesday at 07:07 UTC, post to #development naming the people whose critical
# fixes / significant features are in this week's dotcom release.
# GitHub cron is UTC-only. We use a single fixed UTC time (no BST/GMT adjustment) so
# the job never fires twice: 07:07 UTC is 07:07 in London during winter (GMT) and
# 08:07 during summer (BST). GitHub often starts scheduled runs 15-60 minutes late,
# so this is scheduled early (and off the congested :00/:30 slots) to land before
# the working day ramps up. To change the day, edit the day-of-week field
# (`3` = Wednesday) in the cron line.
on:
schedule:
- cron: '7 7 * * 3' # Wednesday 07:07 UTC
workflow_dispatch: # allow manual runs
concurrency:
group: post-release-crew
permissions:
contents: read
defaults:
run:
shell: bash
jobs:
post-release-crew:
name: Post release crew to Discord
timeout-minutes: 20
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@v6
with:
persist-credentials: false
- name: Fail fast if the Discord webhook is not configured
run: |
if [ -z "$DISCORD_RELEASE_WEBHOOK_URL" ]; then
echo "::error::DISCORD_RELEASE_WEBHOOK_URL secret is not set — the release crew message cannot be posted."
exit 1
fi
env:
DISCORD_RELEASE_WEBHOOK_URL: ${{ secrets.DISCORD_RELEASE_WEBHOOK_URL }}
- name: Install Claude Code
run: npm install -g @anthropic-ai/claude-code@2.1.206
- name: Run dotcom-release-crew skill
run: claude --dangerously-skip-permissions --print "/dotcom-release-crew"
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
GH_TOKEN: ${{ github.token }}
DISCORD_RELEASE_WEBHOOK_URL: ${{ secrets.DISCORD_RELEASE_WEBHOOK_URL }}

View File

@@ -0,0 +1,57 @@
name: Post release marketing summary to Discord
# Every Wednesday at 07:07 UTC, post a plain-language summary of what's shipping in
# this week's dotcom release to the marketing team's Discord channel. Same schedule as
# post-release-crew.yml.
# GitHub cron is UTC-only. We use a single fixed UTC time (no BST/GMT adjustment) so
# the job never fires twice: 07:07 UTC is 07:07 in London during winter (GMT) and
# 08:07 during summer (BST). GitHub often starts scheduled runs 15-60 minutes late,
# so this is scheduled early (and off the congested :00/:30 slots) to land before
# the working day ramps up. To change the day, edit the day-of-week field
# (`3` = Wednesday) in the cron line.
on:
schedule:
- cron: '7 7 * * 3' # Wednesday 07:07 UTC
workflow_dispatch: # allow manual runs
concurrency:
group: post-release-marketing
permissions:
contents: read
defaults:
run:
shell: bash
jobs:
post-release-marketing:
name: Post release marketing summary to Discord
timeout-minutes: 20
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@v6
with:
persist-credentials: false
- name: Fail fast if the Discord webhook is not configured
run: |
if [ -z "$DISCORD_MARKETING_WEBHOOK_URL" ]; then
echo "::error::DISCORD_MARKETING_WEBHOOK_URL secret is not set — the release marketing summary cannot be posted."
exit 1
fi
env:
DISCORD_MARKETING_WEBHOOK_URL: ${{ secrets.DISCORD_MARKETING_WEBHOOK_URL }}
- name: Install Claude Code
run: npm install -g @anthropic-ai/claude-code@2.1.206
- name: Run dotcom-release-marketing skill
run: claude --dangerously-skip-permissions --print "/dotcom-release-marketing"
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
GH_TOKEN: ${{ github.token }}
DISCORD_MARKETING_WEBHOOK_URL: ${{ secrets.DISCORD_MARKETING_WEBHOOK_URL }}

View File

@@ -0,0 +1,64 @@
name: Prune preview deploys
on:
schedule:
# run once per day at midnight or whatever
- cron: '0 0 * * *'
workflow_dispatch:
permissions:
id-token: 'write'
env:
CI: 1
PRINT_GITHUB_ANNOTATIONS: 1
defaults:
run:
shell: bash
jobs:
deploy:
name: Prune preview deploys
timeout-minutes: 30
runs-on: ubuntu-latest
environment: deploy-staging
steps:
- name: Check out code
uses: actions/checkout@v6
with:
submodules: true
fetch-depth: 0
- uses: ./.github/actions/setup
- name: Setup Fly IO
uses: superfly/flyctl-actions/setup-flyctl@ed8efb33836e8b2096c7fd3ba1c8afe303ebbff1 # 1.6
- name: Configure AWS credentials using OIDC
uses: aws-actions/configure-aws-credentials@v6
with:
role-to-assume: arn:aws:iam::571248596162:role/huppy-bot
aws-region: 'eu-north-1'
- name: Prune preview deploys
run: yarn tsx internal/scripts/prune-preview-deploys.ts
env:
GH_TOKEN: ${{ github.token }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_ACCESS_TOKEN }}
SUPABASE_PREVIEW_PROJECT_ID: ${{ vars.SUPABASE_PREVIEW_PROJECT_ID }}
FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
ZERO_R2_ENDPOINT: ${{ secrets.ZERO_R2_ENDPOINT }}
ZERO_R2_BUCKET_NAME: ${{ secrets.ZERO_R2_BUCKET_NAME }}
ZERO_R2_ACCESS_KEY_ID: ${{ secrets.ZERO_R2_ACCESS_KEY_ID }}
ZERO_R2_SECRET_ACCESS_KEY: ${{ secrets.ZERO_R2_SECRET_ACCESS_KEY }}
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
R2_ACCESS_KEY_SECRET: ${{ secrets.R2_ACCESS_KEY_SECRET }}
- name: Notify Discord on failure
if: failure()
uses: ./.github/actions/discord-fail-notify
with:
webhook-url: ${{ secrets.DISCORD_DEPLOY_WEBHOOK_URL }}

View File

@@ -0,0 +1,73 @@
name: Publish editor extensions
on:
push:
branches:
- main
- production
env:
CI: 1
PRINT_GITHUB_ANNOTATIONS: 1
defaults:
run:
shell: bash
permissions:
contents: read
jobs:
publish:
name: Publish editor extensions
environment: vsce publish
timeout-minutes: 20
runs-on: ubuntu-latest
permissions:
contents: write
concurrency: vscode-extension-publish
steps:
- name: Generate GH token
id: generate_token
uses: actions/create-github-app-token@v2
with:
app-id: ${{ secrets.HUPPY_APP_ID }}
private-key: ${{ secrets.HUPPY_APP_PRIVATE_KEY }}
- name: Check out code
id: checkout
uses: actions/checkout@v6
with:
persist-credentials: false
submodules: true
token: ${{ steps.generate_token.outputs.token }}
- name: Configure git credentials
run: git config --global url."https://x-access-token:${{ steps.generate_token.outputs.token }}@github.com/".insteadOf "https://github.com/"
- uses: ./.github/actions/setup
- name: Build types
run: yarn build-types
- name: Publish extension
timeout-minutes: 10
run: yarn tsx ./internal/scripts/publish-editor-extensions.ts
env:
VSCE_PAT: ${{ secrets.VSCE_PAT }}
OVSX_PAT: ${{ secrets.OVSX_PAT }}
TLDRAW_ENV: ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') }}
GH_TOKEN: ${{ steps.generate_token.outputs.token }}
# Preserve the setup action for post-job cleanup when checkout already succeeded.
- name: Check out Discord notify action
if: failure() && steps.checkout.outcome != 'success'
uses: actions/checkout@v6
with:
sparse-checkout: .github/actions/discord-fail-notify
- name: Notify Discord on failure
if: failure()
uses: ./.github/actions/discord-fail-notify
with:
webhook-url: ${{ secrets.DISCORD_DEPLOY_WEBHOOK_URL }}

71
.github/workflows/publish-templates.yml vendored Normal file
View File

@@ -0,0 +1,71 @@
name: Publish templates
on:
workflow_dispatch:
workflow_call:
secrets:
HUPPY_PRIVATE_KEY:
required: true
HUPPY_APP_ID:
required: true
DISCORD_DEPLOY_WEBHOOK_URL:
required: false
env:
CI: 1
PRINT_GITHUB_ANNOTATIONS: 1
ALLOW_REFRESH_ASSETS_CHANGES: 1
defaults:
run:
shell: bash
permissions:
contents: read
jobs:
publish:
name: Publish templates
timeout-minutes: 60
runs-on: ubuntu-latest-16-cores-arm-open
steps:
- name: Check out code
uses: actions/checkout@v6
with:
persist-credentials: false
- uses: ./.github/actions/setup
- name: Generate a token
id: generate_token
uses: actions/create-github-app-token@v2
with:
app-id: ${{ secrets.HUPPY_APP_ID }}
private-key: ${{ secrets.HUPPY_PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
- name: Configure git
run: |
git config --global user.name 'huppy-bot[bot]'
git config --global user.email '128400622+huppy-bot[bot]@users.noreply.github.com'
- name: Export templates
env:
GITHUB_TOKEN: ${{ steps.generate_token.outputs.token }}
run: |
cd templates
all_templates=$(ls -d */ | cut -f1 -d'/')
cd ..
for template in $all_templates; do
echo "::group::Exporting $template"
yarn tsx ./internal/scripts/export-template.ts $template
echo "::endgroup::"
done
- name: Notify Discord on failure
if: failure()
uses: ./.github/actions/discord-fail-notify
with:
webhook-url: ${{ secrets.DISCORD_DEPLOY_WEBHOOK_URL }}

View File

@@ -0,0 +1,70 @@
name: Publish VSCE/OVX
on:
workflow_dispatch:
env:
CI: 1
PRINT_GITHUB_ANNOTATIONS: 1
defaults:
run:
shell: bash
permissions:
contents: read
jobs:
publish:
name: Publish VSCE/OVX
environment: vsce publish
timeout-minutes: 20
runs-on: ubuntu-latest
permissions:
contents: write
concurrency: vscode-extension-publish
steps:
- name: Generate GH token
id: generate_token
uses: actions/create-github-app-token@v2
with:
app-id: ${{ secrets.HUPPY_APP_ID }}
private-key: ${{ secrets.HUPPY_APP_PRIVATE_KEY }}
- name: Check out code
id: checkout
uses: actions/checkout@v6
with:
persist-credentials: false
submodules: true
token: ${{ steps.generate_token.outputs.token }}
- name: Configure git credentials
run: git config --global url."https://x-access-token:${{ steps.generate_token.outputs.token }}@github.com/".insteadOf "https://github.com/"
- uses: ./.github/actions/setup
- name: Build types
run: yarn build-types
- name: Publish extension
timeout-minutes: 10
run: yarn tsx ./internal/scripts/publish-editor-extensions.ts
env:
VSCE_PAT: ${{ secrets.VSCE_PAT }}
OVSX_PAT: ${{ secrets.OVSX_PAT }}
TLDRAW_ENV: production
GH_TOKEN: ${{ steps.generate_token.outputs.token }}
# Preserve the setup action for post-job cleanup when checkout already succeeded.
- name: Check out Discord notify action
if: failure() && steps.checkout.outcome != 'success'
uses: actions/checkout@v6
with:
sparse-checkout: .github/actions/discord-fail-notify
- name: Notify Discord on failure
if: failure()
uses: ./.github/actions/discord-fail-notify
with:
webhook-url: ${{ secrets.DISCORD_DEPLOY_WEBHOOK_URL }}

330
.github/workflows/publish.yml vendored Normal file
View File

@@ -0,0 +1,330 @@
name: Publish SDK packages
on:
push:
branches:
- main
- production
- 'v*.*.x'
pull_request:
types: [labeled]
workflow_dispatch:
inputs:
publish_type:
type: choice
description: What to publish
required: true
default: manual
options:
- manual
- new
version:
description: Version to republish (manual only)
required: false
bump_type:
type: choice
description: Version bump type (new release only)
required: false
default: minor
options:
- minor
- major
- override
version_override:
type: string
description: Version override (new release only)
required: false
defaults:
run:
shell: bash
permissions:
contents: read
jobs:
remove-label:
name: Remove the publish-packages label
if: |
github.event_name == 'pull_request' &&
github.event.label.name == 'publish-packages' &&
github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
steps:
- name: Remove the publish-packages label
uses: actions-ecosystem/action-remove-labels@v1
with:
labels: publish-packages
publish:
name: Publish SDK packages
if: |
github.event_name != 'pull_request' ||
(
github.event.label.name == 'publish-packages' &&
github.event.pull_request.head.repo.full_name == github.repository
)
environment: npm deploy
timeout-minutes: 60
runs-on: ubuntu-latest-16-cores-arm-open
concurrency:
group: ${{ (github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/heads/v')) && 'npm-publish' || format('npm-publish-{0}', github.run_id) }}
permissions:
contents: read
id-token: write
outputs:
mode: ${{ steps.publish_mode.outputs.mode }}
version: ${{ steps.version.outputs.value }}
is_latest_version: ${{ steps.publish.outputs.is_latest_version }}
steps:
- name: Determine publish mode
id: publish_mode
env:
EVENT_NAME: ${{ github.event_name }}
EVENT_LABEL_NAME: ${{ github.event.label.name }}
REF: ${{ github.ref }}
REF_NAME: ${{ github.ref_name }}
DISPATCH_PUBLISH_TYPE: ${{ inputs.publish_type }}
DISPATCH_VERSION: ${{ inputs.version }}
DISPATCH_BUMP_TYPE: ${{ inputs.bump_type }}
DISPATCH_VERSION_OVERRIDE: ${{ inputs.version_override }}
run: |
mode=
bemo_url=https://demo.tldraw.xyz
use_huppy=false
case "$EVENT_NAME" in
push)
if [[ "$REF" == "refs/heads/main" ]]; then
mode=canary
bemo_url=https://canary-demo.tldraw.xyz
elif [[ "$REF" == "refs/heads/production" ]]; then
mode=next
bemo_url=https://canary-demo.tldraw.xyz
elif [[ "$REF_NAME" =~ ^v[0-9]+\.[0-9]+\.x$ ]]; then
mode=patch
use_huppy=true
fi
;;
pull_request)
if [[ "$EVENT_LABEL_NAME" == "publish-packages" ]]; then
mode=internal
bemo_url=https://canary-demo.tldraw.xyz
fi
;;
workflow_dispatch)
mode="$DISPATCH_PUBLISH_TYPE"
if [[ "$mode" == "manual" || "$mode" == "new" ]]; then
use_huppy=true
fi
;;
esac
if [[ -z "$mode" ]]; then
echo "ERROR Could not determine publish mode for $EVENT_NAME on $REF."
exit 1
fi
if [[ "$mode" == "manual" && -z "$DISPATCH_VERSION" ]]; then
echo "ERROR You must provide a version when manually republishing packages."
exit 1
fi
if [[ "$mode" == "new" ]]; then
if [[ "$REF_NAME" != "production" ]]; then
echo "ERROR You must be on the production branch to publish a new version."
exit 1
fi
if [[ "$DISPATCH_BUMP_TYPE" != "minor" && "$DISPATCH_BUMP_TYPE" != "major" && "$DISPATCH_BUMP_TYPE" != "override" ]]; then
echo "ERROR New releases must use a minor, major, or override bump type."
exit 1
fi
if [[ "$DISPATCH_BUMP_TYPE" != "override" && -n "$DISPATCH_VERSION_OVERRIDE" ]]; then
echo "ERROR You must set the bump type to override if you supply a custom version number override."
exit 1
fi
if [[ "$DISPATCH_BUMP_TYPE" == "override" && -z "$DISPATCH_VERSION_OVERRIDE" ]]; then
echo "ERROR You must supply a version override when using the override bump type."
exit 1
fi
fi
{
echo "mode=$mode"
echo "bemo_url=$bemo_url"
echo "use_huppy=$use_huppy"
} >> "$GITHUB_OUTPUT"
- name: Generate GH token
id: generate_token
if: steps.publish_mode.outputs.use_huppy == 'true'
uses: tibdex/github-app-token@b62528385c34dbc9f38e5f4225ac829252d1ea92
with:
app_id: ${{ secrets.HUPPY_APP_ID }}
private_key: ${{ secrets.HUPPY_APP_PRIVATE_KEY }}
- name: Check out code
id: checkout
uses: actions/checkout@v6
with:
fetch-depth: 0
persist-credentials: ${{ steps.publish_mode.outputs.use_huppy != 'true' }}
token: ${{ (steps.publish_mode.outputs.use_huppy == 'true' && steps.generate_token.outputs.token) || github.token }}
- name: Configure git credentials
if: steps.publish_mode.outputs.use_huppy == 'true'
run: |
git config --global url."https://x-access-token:${{ steps.generate_token.outputs.token }}@github.com/".insteadOf "https://github.com/"
git config --global user.name 'huppy-bot[bot]'
git config --global user.email '128400622+huppy-bot[bot]@users.noreply.github.com'
- name: Run our setup
uses: ./.github/actions/setup
- name: Publish
id: publish
env:
GH_TOKEN: ${{ (steps.publish_mode.outputs.use_huppy == 'true' && steps.generate_token.outputs.token) || github.token }}
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
R2_ACCESS_KEY_SECRET: ${{ secrets.R2_ACCESS_KEY_SECRET }}
TLDRAW_BEMO_URL: ${{ steps.publish_mode.outputs.bemo_url }}
TLDRAW_VERSION_STRING: ${{ inputs.version }}
PUBLISH_MODE: ${{ steps.publish_mode.outputs.mode }}
BUMP_TYPE: ${{ inputs.bump_type }}
VERSION_OVERRIDE: ${{ inputs.version_override }}
run: |
case "$PUBLISH_MODE" in
canary | internal | next)
yarn tsx ./internal/scripts/publish-prerelease.ts "$PUBLISH_MODE"
;;
patch)
yarn tsx ./internal/scripts/publish-patch.ts
;;
manual)
yarn tsx ./internal/scripts/publish-manual.ts
;;
new)
bump="${BUMP_TYPE:-minor}"
if [[ "$bump" == "override" ]]; then
bump="$VERSION_OVERRIDE"
fi
yarn tsx ./internal/scripts/publish-new.ts --bump "$bump"
;;
*)
echo "ERROR Unknown publish mode: $PUBLISH_MODE"
exit 1
;;
esac
- name: Get published version
id: version
run: echo "value=$(jq -r .version ./packages/tldraw/package.json)" >> "$GITHUB_OUTPUT"
# Tell tldraw-internal to update its pinned SDK version. Fires only on a
# `next` publish — the prerelease channel the internal monorepo tracks.
# The tldraw-internal "Update tldraw" workflow listens for the
# `tldraw-release` repository_dispatch and resolves the latest `next`
# version itself, so the payload version is informational.
- name: Generate tldraw-internal token
if: steps.publish_mode.outputs.mode == 'next'
id: generate_internal_token
uses: actions/create-github-app-token@v2
with:
app-id: ${{ vars.HUPPY_APP_ID }}
private-key: ${{ secrets.HUPPY_PRIVATE_KEY }}
owner: tldraw
repositories: tldraw-internal
- name: Notify tldraw-internal
if: steps.publish_mode.outputs.mode == 'next'
run: |
curl -X POST \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer ${{ steps.generate_internal_token.outputs.token }}" \
https://api.github.com/repos/tldraw/tldraw-internal/dispatches \
-d '{"event_type":"tldraw-release","client_payload":{"version":"${{ steps.version.outputs.value }}","tag":"next"}}'
# Preserve the setup action for post-job cleanup when checkout already succeeded.
- name: Check out Discord notify action
if: failure() && steps.checkout.outcome != 'success'
uses: actions/checkout@v6
with:
sparse-checkout: .github/actions/discord-fail-notify
- name: Notify Discord on failure
if: failure()
uses: ./.github/actions/discord-fail-notify
with:
webhook-url: ${{ secrets.DISCORD_DEPLOY_WEBHOOK_URL }}
comment:
name: Tell user released version
if: needs.publish.outputs.mode == 'internal'
needs: publish
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
steps:
- name: Tell user released version
uses: actions-ecosystem/action-create-comment@v1
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
body: |
Published version ${{ needs.publish.outputs.version }} to npm.
publish_templates:
name: Publishes code templates to separate repositories
uses: tldraw/tldraw/.github/workflows/publish-templates.yml@main
if: needs.publish.outputs.mode == 'new' || ((needs.publish.outputs.mode == 'patch' || needs.publish.outputs.mode == 'manual') && needs.publish.outputs.is_latest_version == 'true')
secrets:
HUPPY_PRIVATE_KEY: ${{ secrets.HUPPY_PRIVATE_KEY }}
HUPPY_APP_ID: ${{ vars.HUPPY_APP_ID }}
DISCORD_DEPLOY_WEBHOOK_URL: ${{ secrets.DISCORD_DEPLOY_WEBHOOK_URL }}
needs: publish
deploy_templates:
name: Deploys the template demos with the new SDK version
if: needs.publish.outputs.mode == 'new' || ((needs.publish.outputs.mode == 'patch' || needs.publish.outputs.mode == 'manual') && needs.publish.outputs.is_latest_version == 'true')
needs: publish
# CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID are secrets on this environment rather than on
# the repository, and a job has to select the environment to see them. Calling deploy-templates.yml
# as a reusable workflow doesn't do it: a called workflow only ever sees the secrets its caller
# hands it, so the v5.4.0 release reached the deploy with both empty (#10636) even though the
# called job selected deploy-production itself. The deploy runs here as a job of its own instead,
# sharing its steps with deploy-templates.yml through the composite action.
environment: deploy-production
timeout-minutes: 60
runs-on: ubuntu-latest-16-cores-arm-open
# same group as deploy-templates.yml: a release deploy and a template-change deploy can otherwise
# race each other onto the same custom domains, and the loser silently wins.
concurrency:
group: deploy-templates
cancel-in-progress: false
steps:
- name: Check out code
uses: actions/checkout@v6
with:
persist-credentials: false
- uses: ./.github/actions/deploy-templates
with:
license-key: ${{ secrets.TEMPLATES_TLDRAW_LICENSE_KEY }}
cloudflare-account-id: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
cloudflare-api-token: ${{ secrets.CLOUDFLARE_API_TOKEN }}
- name: Notify Discord on failure
if: failure()
uses: ./.github/actions/discord-fail-notify
with:
webhook-url: ${{ secrets.DISCORD_DEPLOY_WEBHOOK_URL }}

View File

@@ -0,0 +1,38 @@
name: Staging E2E Daily Cleanup
on:
schedule:
- cron: '45 1 * * *'
permissions:
contents: read
env:
CI: 1
defaults:
run:
shell: bash
jobs:
cleanup:
name: 'Daily Cleanup of Test Files'
timeout-minutes: 20
runs-on: ubuntu-latest
environment: 'e2e-dotcom'
steps:
- name: Check out code
uses: actions/checkout@v6
- name: Run our setup
uses: ./.github/actions/setup
- name: Cleanup test files
run: yarn tsx internal/scripts/dotcom/cleanup-test-files.ts
env:
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
R2_ACCESS_KEY_SECRET: ${{ secrets.R2_ACCESS_KEY_SECRET }}
SUPABASE_STAGING_DB_URL: ${{ secrets.SUPABASE_STAGING_DB_URL }}
STAGING_OWNER_ID: ${{ secrets.STAGING_OWNER_ID }}

68
.github/workflows/staging-e2e.yml vendored Normal file
View File

@@ -0,0 +1,68 @@
name: Staging E2E Tests
on:
workflow_dispatch:
inputs:
test_files_count:
description: 'Number of test files to copy to staging'
required: false
default: '10'
type: number
permissions:
contents: read
env:
CI: 1
PRINT_GITHUB_ANNOTATIONS: 1
defaults:
run:
shell: bash
jobs:
test:
name: 'Staging E2E Tests'
timeout-minutes: 40
runs-on: ubuntu-latest-8-cores-open
environment: 'e2e-dotcom'
steps:
- name: Check out code
uses: actions/checkout@v6
- name: Run our setup
uses: ./.github/actions/setup
- name: Set up Playwright
uses: ./.github/actions/setup-playwright
- name: Run cleanup of test files
run: yarn tsx internal/scripts/dotcom/cleanup-test-files.ts
env:
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
R2_ACCESS_KEY_SECRET: ${{ secrets.R2_ACCESS_KEY_SECRET }}
SUPABASE_STAGING_DB_URL: ${{ secrets.SUPABASE_STAGING_DB_URL }}
STAGING_OWNER_ID: ${{ secrets.STAGING_OWNER_ID }}
- name: Copy random files to staging
run: yarn tsx internal/scripts/dotcom/copy-random-files-to-staging.ts
env:
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
R2_ACCESS_KEY_SECRET: ${{ secrets.R2_ACCESS_KEY_SECRET }}
SUPABASE_PRODUCTION_DB_URL: ${{ secrets.SUPABASE_PRODUCTION_DB_URL }}
SUPABASE_STAGING_DB_URL: ${{ secrets.SUPABASE_STAGING_DB_URL }}
STAGING_OWNER_ID: ${{ secrets.STAGING_OWNER_ID }}
STAGING_TEST_FILES_COUNT: ${{ github.event.inputs.test_files_count || '10' }}
- name: Run staging e2e tests
run: yarn e2e-staging
working-directory: apps/dotcom/client
env:
CLERK_PUBLISHABLE_KEY: ${{ secrets.VITE_CLERK_PUBLISHABLE_KEY }}
CLERK_SECRET_KEY: ${{ secrets.CLERK_SECRET_KEY }}
STAGING_TEST_EMAIL: ${{ secrets.STAGING_TEST_EMAIL }}
STAGING_TEST_PASSWORD: ${{ secrets.STAGING_TEST_PASSWORD }}
STAGING_TESTS: 'true'

View File

@@ -0,0 +1,69 @@
name: Trigger dotcom hotfix
# pull_request_target rather than push/pull_request, for the same reason as
# trigger-sdk-hotfix.yml: release-notes PRs squash-merge with `[skip ci]` in the title, which
# suppresses the push-to-main run entirely, and the `labeled` event fires before the merge so the
# `merged == true` gate rejects it. Neither path ever reached the script. GitHub's skip-ci markers
# do not apply to pull_request_target. `closed` replaces the old push trigger (which ran on every
# merge to main just to check labels), and `labeled` allows re-triggering by re-adding the label to
# an already-merged PR. Secrets exposure is safe here: the job only runs for merged PRs carrying a
# maintainer-applied hotfix label, checkout uses the trusted base branch, and the only PR-derived
# code the script builds is the PR's already-merged commit from main.
on:
pull_request_target:
types: [closed, labeled]
branches:
- main
defaults:
run:
shell: bash
permissions:
contents: write
pull-requests: write
jobs:
trigger:
name: Trigger a dotcom hotfix
runs-on: ubuntu-latest
timeout-minutes: 20
concurrency: dotcom-hotfix
environment: npm deploy
if: |
github.event.pull_request.merged == true &&
((github.event.action == 'closed' &&
contains(github.event.pull_request.labels.*.name, 'dotcom-hotfix-please')) ||
(github.event.action == 'labeled' &&
github.event.label.name == 'dotcom-hotfix-please'))
steps:
- name: Generate a token
id: generate_token
uses: actions/create-github-app-token@v2
with:
app-id: ${{ secrets.HUPPY_APP_ID }}
private-key: ${{ secrets.HUPPY_APP_PRIVATE_KEY }}
- uses: actions/checkout@v6
with:
persist-credentials: false
token: ${{ steps.generate_token.outputs.token }}
fetch-depth: 0
- name: Configure git credentials
run: git config --global url."https://x-access-token:${{ steps.generate_token.outputs.token }}@github.com/".insteadOf "https://github.com/"
- uses: ./.github/actions/setup
- name: Maybe trigger dotcom hotfix
run: |
set -e
git config --global user.name 'huppy-bot[bot]'
git config --global user.email '128400622+huppy-bot[bot]@users.noreply.github.com'
git log --oneline -10
yarn tsx internal/scripts/trigger-dotcom-hotfix.ts
env:
GITHUB_TOKEN: ${{ steps.generate_token.outputs.token }}
DISCORD_DEPLOY_WEBHOOK_URL: ${{ secrets.DISCORD_DEPLOY_WEBHOOK_URL }}
PR_NUMBER: ${{ github.event.pull_request.number }}

View File

@@ -0,0 +1,118 @@
name: Trigger dotcom release
on:
push:
branches:
- hotfixes
workflow_dispatch:
inputs:
target:
description: 'Target ref to deploy'
required: true
default: 'main'
defaults:
run:
shell: bash
env:
TARGET: ${{ github.event.inputs.target }}
permissions:
contents: read
jobs:
trigger:
name: ${{ github.event_name == 'workflow_dispatch' && 'Manual trigger' || 'Hotfix' }}
runs-on: ubuntu-latest
concurrency: trigger-production
steps:
- name: Generate a token
id: generate_token
uses: actions/create-github-app-token@v2
with:
app-id: ${{ vars.HUPPY_APP_ID }}
private-key: ${{ secrets.HUPPY_PRIVATE_KEY }}
- uses: actions/checkout@v6
with:
persist-credentials: false
token: ${{ steps.generate_token.outputs.token }}
ref: refs/heads/production
fetch-depth: 0
- name: Configure git credentials
run: git config --global url."https://x-access-token:${{ steps.generate_token.outputs.token }}@github.com/".insteadOf "https://github.com/"
- name: Get target commit hash (manual dispatch)
if: github.event_name == 'workflow_dispatch'
run: |
set -x
# if the target exists on its own use that
if git rev-parse "$TARGET" --quiet; then
target_hash=$(git rev-parse "$TARGET")
fi
# if not try prefixed with origin:
if [ -z "$target_hash" ]; then
target_hash=$(git rev-parse "origin/$TARGET")
fi
echo "SHOULD_DEPLOY=true" >> $GITHUB_ENV
echo "TARGET_HASH=$target_hash" >> $GITHUB_ENV
- name: Get target commit hash (hotfix)
if: github.event_name == 'push'
run: |
set -x
echo "TARGET_HASH=$GITHUB_SHA" >> $GITHUB_ENV
echo "TARGET=hotfix" >> $GITHUB_ENV
# is the hotfix sha already on production?
if git merge-base --is-ancestor "$GITHUB_SHA" production; then
echo "SHOULD_DEPLOY=false" >> $GITHUB_ENV
else
echo "SHOULD_DEPLOY=true" >> $GITHUB_ENV
fi
- name: Author setup (manual dispatch)
if: github.event_name == 'workflow_dispatch'
run: |
set -x
git config --global user.name "${{ github.actor }}"
git config --global user.email 'huppy+${{ github.actor }}@tldraw.com'
- name: Author setup (hotfix)
if: github.event_name == 'push'
run: |
set -x
commit_author_name=$(git log -1 --pretty=format:%cn "$TARGET_HASH")
commit_author_email=$(git log -1 --pretty=format:%ce "$TARGET_HASH")
git config --global user.name "$commit_author_name"
git config --global user.email "$commit_author_email"
- name: Get target tree hash
run: |
set -x
tree_hash=$(git show --quiet --pretty=format:%T "$TARGET_HASH")
echo "TREE_HASH=$tree_hash" >> $GITHUB_ENV
- name: Create commit & update production branch
run: |
set -eux
now=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
message="Deploy from $TARGET ($TARGET_HASH) at $now"
current_prod_hash=$(git rev-parse HEAD)
commit=$(git commit-tree -m "$message" -p "$current_prod_hash" -p "$TARGET_HASH" "$TREE_HASH")
git update-ref refs/heads/production "$commit"
git checkout production
- name: Push commit to trigger deployment
if: env.SHOULD_DEPLOY == 'true'
run: |
set -x
git push origin production
# reset hotfixes to the latest production
git push origin production:hotfixes --force

View File

@@ -0,0 +1,70 @@
name: Trigger sdk hotfix
# pull_request_target rather than push/pull_request: hotfix PRs (e.g. the automated
# release-notes updates) carry `[skip ci]` in their commit messages, which suppresses
# push- and pull_request-triggered workflows entirely — the label had no effect.
# GitHub's skip-ci markers do not apply to pull_request_target. The `closed` event
# replaces the old push-to-main trigger (which ran on every merge just to check
# labels), and `labeled` allows re-triggering a hotfix by re-adding the label to an
# already-merged PR. Secrets exposure is safe here: the job only runs for merged PRs
# carrying a maintainer-applied hotfix label, checkout uses the trusted base branch,
# and the only PR-derived code the script builds is the PR's already-merged commit
# from main.
on:
pull_request_target:
types: [closed, labeled]
branches:
- main
defaults:
run:
shell: bash
permissions:
contents: read
jobs:
trigger:
name: Trigger an SDK hotfix
runs-on: ubuntu-latest
concurrency: npm-publish
environment: npm deploy
if: |
github.event.pull_request.merged == true &&
((github.event.action == 'closed' &&
(contains(github.event.pull_request.labels.*.name, 'sdk-hotfix-please') ||
contains(github.event.pull_request.labels.*.name, 'docs-hotfix-please'))) ||
(github.event.action == 'labeled' &&
(github.event.label.name == 'sdk-hotfix-please' || github.event.label.name == 'docs-hotfix-please')))
steps:
- name: Generate a token
id: generate_token
uses: actions/create-github-app-token@v2
with:
app-id: ${{ secrets.HUPPY_APP_ID }}
private-key: ${{ secrets.HUPPY_APP_PRIVATE_KEY }}
- uses: actions/checkout@v6
with:
persist-credentials: false
token: ${{ steps.generate_token.outputs.token }}
fetch-depth: 0
- name: Configure git credentials
run: git config --global url."https://x-access-token:${{ steps.generate_token.outputs.token }}@github.com/".insteadOf "https://github.com/"
- uses: ./.github/actions/setup
- name: Maybe trigger sdk hotfix
run: |
set -e
git config --global user.name 'huppy-bot[bot]'
git config --global user.email '128400622+huppy-bot[bot]@users.noreply.github.com'
git log --oneline -10
yarn tsx internal/scripts/trigger-sdk-hotfix.ts
env:
GITHUB_TOKEN: ${{ steps.generate_token.outputs.token }}
TLDRAW_BEMO_URL: https://demo.tldraw.xyz
DISCORD_DEPLOY_WEBHOOK_URL: ${{ secrets.DISCORD_DEPLOY_WEBHOOK_URL }}
PR_NUMBER: ${{ github.event.pull_request.number }}

View File

@@ -0,0 +1,160 @@
name: Update release notes
on:
# Update release notes whenever production is updated, keeping next.mdx (and the
# draft GitHub release synced by the skill) in step with each dotcom release —
# the same production push that deploys dotcom and publishes the `next` package.
# Production only: a push to main must not trigger a notes run on every merge.
# Release-notes PR titles carry `[skip ci]` so their squash-merge commits skip main
# push workflows. Branch commits omit the marker so required PR checks run.
push:
branches:
- production
workflow_dispatch:
concurrency:
group: update-release-notes
defaults:
run:
shell: bash
permissions:
contents: read
jobs:
update-release-notes:
name: 'Update release notes with Claude Code'
timeout-minutes: 30
runs-on: ubuntu-latest
steps:
- name: Generate GH token
id: generate_token
uses: tibdex/github-app-token@b62528385c34dbc9f38e5f4225ac829252d1ea92
with:
app_id: ${{ secrets.HUPPY_APP_ID }}
private_key: ${{ secrets.HUPPY_PRIVATE_KEY }}
- name: Check out code
uses: actions/checkout@v6
with:
persist-credentials: false
fetch-depth: 0
token: ${{ steps.generate_token.outputs.token }}
- name: Configure git credentials
run: git config --global url."https://x-access-token:${{ steps.generate_token.outputs.token }}@github.com/".insteadOf "https://github.com/"
- name: Run our setup
uses: ./.github/actions/setup
- name: Configure git
run: |
git config --global user.name 'huppy-bot[bot]'
git config --global user.email '128400622+huppy-bot[bot]@users.noreply.github.com'
- name: Create branch
id: branch
run: |
BRANCH_NAME="release-notes/update-$(date +%Y%m%d-%H%M%S)"
git checkout -b "$BRANCH_NAME"
echo "branch_name=$BRANCH_NAME" >> "$GITHUB_OUTPUT"
# Recorded before the skill runs so the close step below can tell an
# already-open PR from an earlier run apart from the one this run opens.
- name: Find open release notes PRs
id: existing_prs
run: |
{
echo 'numbers<<PR_LIST_EOF'
gh pr list --state open --base main --limit 100 --json number,headRefName \
--jq '.[] | select(.headRefName | test("^(release-notes/update-|update-release-notes-)")) | .number'
echo 'PR_LIST_EOF'
} >> "$GITHUB_OUTPUT"
env:
GH_TOKEN: ${{ steps.generate_token.outputs.token }}
- name: Install Claude Code
run: npm install -g @anthropic-ai/claude-code
- name: Run update-release-notes skill
run: |
claude --dangerously-skip-permissions --print "/update-release-notes"
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
GH_TOKEN: ${{ steps.generate_token.outputs.token }}
- name: Check for changes
id: changes
run: |
if [ -z "$(git status --porcelain)" ]; then
echo "has_changes=false" >> "$GITHUB_OUTPUT"
else
echo "has_changes=true" >> "$GITHUB_OUTPUT"
fi
- name: Commit and push
if: steps.changes.outputs.has_changes == 'true'
run: |
git add -A
git commit -m "Update release notes"
git push -u origin "${{ steps.branch.outputs.branch_name }}"
- name: Create PR
if: steps.changes.outputs.has_changes == 'true'
run: |
gh pr create \
--title "docs(releases): update release notes [skip ci]" \
--body "$(cat <<'EOF'
In order to keep the release notes up to date, this PR adds entries for PRs merged since the last update.
### Change type
- [x] `other`
### Test plan
- [ ] Unit tests
- [ ] End to end tests
EOF
)" \
--base main \
--head "${{ steps.branch.outputs.branch_name }}" \
--label "docs-hotfix-please"
env:
GH_TOKEN: ${{ steps.generate_token.outputs.token }}
# Each run regenerates the notes from scratch, so a PR opened by this run
# supersedes any release notes PR left open by an earlier run. The PR may come
# from the step above or from the skill itself, so this compares the currently
# open PRs against the ones recorded before the skill ran rather than relying on
# a single known PR number. When this run opened nothing, the older PR is still
# the pending update and is left alone.
- name: Close superseded PRs
run: |
EXISTING="${{ steps.existing_prs.outputs.numbers }}"
CURRENT=$(gh pr list --state open --base main --limit 100 --json number,headRefName \
--jq '.[] | select(.headRefName | test("^(release-notes/update-|update-release-notes-)")) | .number')
NEW_PRS=$(comm -13 <(echo "$EXISTING" | sort) <(echo "$CURRENT" | sort))
if [ -z "$NEW_PRS" ]; then
echo "This run did not open a release notes PR, nothing to supersede"
exit 0
fi
# The new PR is already open at this point, so a failure to tidy up an old
# one is a warning, not a reason to fail the run.
NEWEST=$(echo "$NEW_PRS" | sort -n | tail -n 1)
echo "$EXISTING" | while read -r PR; do
[ -n "$PR" ] || continue
echo "Closing superseded release notes PR #$PR"
gh pr close "$PR" --delete-branch --comment "Superseded by #$NEWEST." \
|| echo "Warning: could not close #$PR"
done
env:
GH_TOKEN: ${{ steps.generate_token.outputs.token }}
- name: No changes
if: steps.changes.outputs.has_changes == 'false'
run: echo "No release notes updates needed"

141
.gitignore vendored Normal file
View File

@@ -0,0 +1,141 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
/worktrees
.rooms
# Temporary files
*.tmp.ts
node_modules
dist
dist-cjs
dist-esm
.tsbuild*
.lazy
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# turborepo
.turbo
coverage
# vitest reports, attachments and blob output
.vitest
**/*.env
**/*.tsbuildinfo
**/*.css.map
**/*.js.map
apps/examples/www/index.js
apps/examples/www/index.css
nohup.out
packages/*/package
packages/*/*.tgz
tsconfig.build.json
.vercel
api-json
api-md
packages/tldraw/tldraw.css
packages/namespaced-tldraw/tldraw.css
packages/commenting/commenting.css
packages/collaboration/collaboration.css
packages/assets/embed-icons
packages/assets/fonts
packages/assets/icons
packages/assets/translations
packages/assets/watermarks
packages/commenting/assets
apps/examples/www/embed-icons
apps/examples/www/fonts
apps/examples/www/icons
apps/examples/www/translations
# yarn v2
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/sdks
!.yarn/versions
packages/*/api/temp
packages/*/api/api.json
packages/*/api/internal.d.ts
packages/*/api/public.d.ts
apps/examples/www/index.css
apps/examples/www/index.js
.tsbuild
apps/examples/build.esbuild.json
test-results
apps/examples/e2e/test-results
apps/examples/playwright-report
apps/dotcom/client/test-results
apps/dotcom/client/playwright-report
apps/dotcom/client/e2e/.auth
apps/dotcom/zero-cache/.schema.js
apps/docs/content/gen
apps/docs/api
.dev.vars
.env.local
.env.development.local
.env*
.wrangler
/vercel.json
license-report*.html
license-report*.md
license-report*.pdf
.bundle-meta.json
.zed
.mcp.json
# TypeScript profiling
.ts-profile
# ESLint cache
.eslintcache
opencode.json
# PR walkthrough intermediate files
.claude/worktrees
.claude/settings.local.json
skills/pr-walkthrough/tmp
skills/pr-walkthrough/out
skills/pr-walkthrough/video/node_modules
skills/pr-walkthrough/video/dist
skills/pr-walkthrough/video/public/audio-*
skills/pr-walkthrough/video/public/manifest.json
todo.md

15
.husky/post-checkout Executable file
View File

@@ -0,0 +1,15 @@
#!/usr/bin/env bash
. "$(dirname -- "$0")/_/husky.sh"
set -e
rm -rf *.tsbuildinfo */*.tsbuildinfo */*/*.tsbuildinfo */*/*/*.tsbuildinfo */*/*/*/*.tsbuildinfo
# This notifies the user if the yarn.lock file has changed.
CHANGED=$(git diff "$1" "$2" --stat -- ./yarn.lock | wc -l)
if (( CHANGED > 0 )); then
echo
echo "🚨 🚨 🚨 yarn.lock has changed! 🚨 🚨 🚨 "
echo "run 'yarn' to get the latest!"
echo
fi

13
.husky/post-merge Executable file
View File

@@ -0,0 +1,13 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
set -e
# This notifies the user if the yarn.lock file has changed.
CHANGED=$(git diff HEAD@{1} --stat -- ./yarn.lock | wc -l)
if (( CHANGED > 0 )); then
echo
echo "🚨 🚨 🚨 yarn.lock has changed! 🚨 🚨 🚨 "
echo "run 'yarn' to get the latest!"
echo
fi

31
.husky/pre-commit Executable file
View File

@@ -0,0 +1,31 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
# Only run yarn if package.json files have changed
CHANGED_PACKAGE_JSON_FILES=$(git diff --cached --name-only -- ':(glob)**/package.json')
if [ -n "$CHANGED_PACKAGE_JSON_FILES" ]; then
yarn install --immutable
fi
# Only run build-api for packages with staged changes
CHANGED_PACKAGES=$(git diff --cached --name-only -- packages | cut -d/ -f2 | sort -u)
FILTER_ARGS=""
for pkg in $CHANGED_PACKAGES; do
if [ -f "packages/$pkg/package.json" ] && grep -q '"build-api"' "packages/$pkg/package.json"; then
FILTER_ARGS="$FILTER_ARGS --filter=packages/$pkg"
fi
done
if [ -n "$FILTER_ARGS" ]; then
ONLY_REFRESH_ASSETS_IF_NECESSARY=1 npx lazy run build-api $FILTER_ARGS
git add packages/*/api-report.api.md
fi
# Only run build-i18n when dotcom client files change
CHANGED_DOTCOM_CLIENT=$(git diff --cached --name-only -- apps/dotcom/client)
if [ -n "$CHANGED_DOTCOM_CLIENT" ]; then
npx lazy run build-i18n --filter=apps/dotcom/client
git add apps/dotcom/client/public/tla/locales
git add apps/dotcom/client/public/tla/locales-compiled
fi
yarn lint-staged

3
.husky/pre-push Executable file
View File

@@ -0,0 +1,3 @@
#!/bin/sh
command -v git-lfs >/dev/null 2>&1 || { printf >&2 "\n%s\n\n" "This repository is configured for Git LFS but 'git-lfs' was not found on your path. If you no longer wish to use Git LFS, remove this hook by deleting the 'pre-push' file in the hooks directory (set by 'core.hookspath'; usually '.git/hooks')."; exit 2; }
git lfs pre-push "$@"

33
.ignore Normal file
View File

@@ -0,0 +1,33 @@
dist
.tsbuild-dev
.tsbuild-pub
.tsbuild
node_modules
*.d.ts
**/api-report.md
**/api-report.api.md
**/_archive
**/*.tsbuildinfo
yarn.lock
**/*.tldr
**/*.d.ts
**/*.d.ts.map
**/*.js.map
**/*.css.map
apps/example/www/index.css
**/dist/*
*.cjs
apps/docs/.next
packages/tldraw/tldraw.css
**/dist-cjs/**/*
**/dist-esm/**/*
**/*.js.map
**/*.api.json
apps/docs/utils/vector-db
apps/docs/content/releases/**/*
apps/docs/content/reference/**/*
packages/**/api
**/.clasp.json

13
.oxfmtrc.json Normal file
View File

@@ -0,0 +1,13 @@
{
"$schema": "./node_modules/oxfmt/configuration_schema.json",
"trailingComma": "es5",
"singleQuote": true,
"semi": false,
"printWidth": 100,
"tabWidth": 2,
"useTabs": true,
"sortImports": {
"newlinesBetween": false,
},
"sortPackageJson": false,
}

460
.oxlintrc.json Normal file
View File

@@ -0,0 +1,460 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["unicorn", "react", "typescript", "nextjs"],
"jsPlugins": ["./internal/scripts/oxlint/tldraw-plugin.mjs"],
"categories": {
"correctness": "off"
},
"options": {
"typeAware": true
},
"env": {
"builtin": true
},
"settings": {
"next": {
"rootDir": ["apps/*/", "packages/*/"]
},
"react": {
"version": "19.2"
}
},
"ignorePatterns": [
"skills/**/*",
".claude/**/*",
".agents/**/*",
".cursor/**/*",
"**/node_modules/*",
"**/out/*",
"**/dist/*",
"**/dist-cjs/*",
"**/dist-esm/*",
"**/.tsbuild*",
"**/.lazy/*",
"**/.next/*",
"**/.wrangler/*",
"**/.vercel/*",
"**/*.md",
"**/_archive/*",
"**/*.css.map",
"**/*.js.map",
"**/*.d.ts",
"**/api/*",
"!**/pages/api/*",
"**/*.json",
"**/lazy.config.ts",
"**/next.config.js",
"**/setupTests.js",
"apps/vscode/extension/editor",
"apps/examples/www",
"apps/docs/api-content.json",
"apps/docs/content.json",
"apps/vscode/extension/editor/index.js",
"apps/vscode/extension/editor/tldraw-assets.json",
"**/sentry.server.config.js",
"**/scripts/upload-sourcemaps.js",
"**/scripts/lib/auto-plugin.js",
"**/coverage/**/*",
"apps/docs/postcss.config.js",
"apps/docs/tailwind.config.js",
"apps/dotcom/client/public/sw.js",
"apps/dotcom/client/public/theme-init.js",
"apps/analytics/public/*",
"**/.clasp.json",
"**/*.mjs",
"**/.*.js",
"packages/assets/*",
"packages/create-tldraw/cli.cjs",
"**/*/vitest.config.ts",
"**/*/setupVitest.js",
"**/*/zod-locales-shim.js",
"**/*/scripts/vite-zod-locale-plugin.js"
],
"rules": {
"no-unexpected-multiline": "error",
"constructor-super": "error",
"for-direction": "error",
"getter-return": "error",
"no-async-promise-executor": "error",
"no-case-declarations": "error",
"no-class-assign": "error",
"no-compare-neg-zero": "error",
"no-cond-assign": "error",
"no-const-assign": "error",
"no-constant-binary-expression": "error",
"no-constant-condition": "error",
"no-control-regex": "error",
"no-debugger": "error",
"no-delete-var": "error",
"no-dupe-class-members": "error",
"no-dupe-else-if": "error",
"no-dupe-keys": "error",
"no-duplicate-case": "error",
"no-empty": "error",
"no-empty-character-class": "error",
"no-empty-pattern": "error",
"no-empty-static-block": "error",
"no-ex-assign": "error",
"no-extra-boolean-cast": "error",
"no-func-assign": "error",
"no-global-assign": "error",
"no-import-assign": "error",
"no-invalid-regexp": "error",
"no-irregular-whitespace": "error",
"no-loss-of-precision": "error",
"no-misleading-character-class": "error",
"no-new-native-nonconstructor": "error",
"no-nonoctal-decimal-escape": "error",
"no-obj-calls": "error",
"no-prototype-builtins": "error",
"no-redeclare": "error",
"no-regex-spaces": "error",
"no-self-assign": "error",
"no-setter-return": "error",
"no-shadow-restricted-names": "error",
"no-sparse-arrays": "error",
"no-this-before-super": "error",
"no-undef": "error",
"no-unreachable": "error",
"no-unsafe-finally": "error",
"no-unsafe-negation": "error",
"no-unsafe-optional-chaining": "error",
"no-unused-labels": "error",
"no-unused-private-class-members": "error",
"no-unused-vars": [
"error",
{
"argsIgnorePattern": "^_",
"varsIgnorePattern": "^_",
"caughtErrorsIgnorePattern": "^_",
"fix": {
"imports": "safe-fix",
"variables": "off"
}
}
],
"no-useless-backreference": "error",
"no-useless-catch": "error",
"no-useless-escape": "error",
"no-with": "error",
"require-yield": "error",
"use-isnan": "error",
"valid-typeof": "error",
"no-throw-literal": "error",
"no-array-constructor": "error",
"no-unused-expressions": "error",
"react/jsx-key": "error",
"react/jsx-no-comment-textnodes": "error",
"react/jsx-no-duplicate-props": "error",
"react/jsx-no-target-blank": "error",
"react/jsx-no-undef": "error",
"react/no-children-prop": "error",
"react/no-danger-with-children": "error",
"react/no-direct-mutation-state": "error",
"react/no-find-dom-node": "error",
"react/no-is-mounted": "error",
"react/no-render-return-value": "error",
"react/no-string-refs": "error",
"react/no-unescaped-entities": "error",
"react/no-unknown-property": "error",
"react/require-render-return": "error",
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "error",
"@typescript-eslint/no-duplicate-enum-values": "error",
"@typescript-eslint/no-empty-object-type": "error",
"@typescript-eslint/no-extra-non-null-assertion": "error",
"@typescript-eslint/no-misused-new": "error",
"@typescript-eslint/no-namespace": "error",
"@typescript-eslint/no-non-null-asserted-optional-chain": "error",
"@typescript-eslint/no-require-imports": "error",
"@typescript-eslint/no-this-alias": "error",
"@typescript-eslint/no-unnecessary-type-constraint": "error",
"@typescript-eslint/no-unsafe-declaration-merging": "error",
"@typescript-eslint/no-unsafe-function-type": "error",
"@typescript-eslint/no-wrapper-object-types": "error",
"@typescript-eslint/prefer-as-const": "error",
"@typescript-eslint/prefer-namespace-keyword": "error",
"@typescript-eslint/triple-slash-reference": "error",
"@typescript-eslint/no-deprecated": "error",
"@typescript-eslint/consistent-type-exports": [
"error",
{ "fixMixedExportsWithInlineTypeSpecifier": true }
],
"@typescript-eslint/consistent-type-definitions": ["error", "interface"],
"@next/next/google-font-display": "warn",
"@next/next/google-font-preconnect": "warn",
"@next/next/next-script-for-ga": "warn",
"@next/next/no-async-client-component": "warn",
"@next/next/no-before-interactive-script-outside-document": "warn",
"@next/next/no-css-tags": "warn",
"@next/next/no-head-element": "warn",
"@next/next/no-page-custom-font": "warn",
"@next/next/no-styled-jsx-in-document": "warn",
"@next/next/no-sync-scripts": "error",
"@next/next/no-title-in-document-head": "warn",
"@next/next/no-typos": "warn",
"@next/next/no-unwanted-polyfillio": "warn",
"@next/next/inline-script-id": "error",
"@next/next/no-assign-module-variable": "error",
"@next/next/no-document-import-in-page": "error",
"@next/next/no-duplicate-head": "error",
"@next/next/no-head-import-in-document": "error",
"@next/next/no-script-component-in-head": "error",
"no-restricted-globals": [
"error",
{
"name": "structuredClone",
"message": "Use structuredClone from @tldraw/util instead"
}
],
"no-restricted-imports": [
"error",
{
"paths": [
{
"name": "lodash",
"message": "The main `lodash` package is CommonJS and does not tree-shake. Use a deep import (e.g. `import throttle from 'lodash/throttle'`), `lodash-es`, a per-method package, or inline the helper."
}
]
}
],
"tldraw/no-export-star": "error",
"tldraw/no-internal-imports": "error",
"tldraw/tagged-components": "error",
"tldraw/prefer-class-methods": "error",
"tldraw/tsdoc-param-matching": "error",
"tldraw/no-whilst": "error",
"tldraw/no-tiptap-default-import": "error",
"tldraw/no-setter-getter": "error",
"tldraw/no-direct-storage": "error",
"tldraw/no-path2d-roundrect": "error",
"tldraw/no-exported-arrow-const": "error",
"tldraw/no-focused-tests": "error",
"tldraw/enforce-default-message": ["error", "literal"],
"tldraw/method-signature-style": "error",
"tldraw/claude-agents-pairing": "error"
},
"overrides": [
{
"files": ["**/*.ts", "**/*.tsx", "**/*.mts", "**/*.cts"],
"rules": {
"@typescript-eslint/explicit-module-boundary-types": "off",
"no-console": ["error", { "allow": ["warn", "error"] }],
"constructor-super": "off",
"getter-return": "off",
"no-class-assign": "off",
"no-const-assign": "off",
"no-dupe-class-members": "off",
"no-dupe-keys": "off",
"no-func-assign": "off",
"no-import-assign": "off",
"no-new-native-nonconstructor": "off",
"no-obj-calls": "off",
"no-redeclare": "off",
"no-setter-return": "off",
"no-this-before-super": "off",
"no-undef": "off",
"no-unreachable": "off",
"no-unsafe-negation": "off",
"no-with": "off",
"no-var": "error",
"prefer-const": "error",
"prefer-rest-params": "error",
"prefer-spread": "error"
}
},
{
"files": ["packages/**/*", "apps/**/*", "internal/**/*", "templates/**/*"],
"rules": {
"tldraw/no-undeclared-dependencies": "error"
}
},
{
"files": ["packages/editor/**/*", "packages/tldraw/**/*", "packages/utils/**/*"],
"rules": {
"no-restricted-globals": [
"error",
{
"name": "fetch",
"message": "Use the fetch from @tldraw/util instead."
},
{
"name": "Image",
"message": "Use the Image from @tldraw/util instead."
},
{
"name": "setTimeout",
"message": "Use the timers from editor.timers instead."
},
{
"name": "setInterval",
"message": "Use the timers from editor.timers instead."
},
{
"name": "requestAnimationFrame",
"message": "Use the timers from editor.timers instead."
},
{
"name": "structuredClone",
"message": "Use structuredClone from @tldraw/util instead"
}
],
"tldraw/no-restricted-properties": [
"error",
[
{
"object": "window",
"property": "fetch",
"message": "Use the fetch from @tldraw/util instead."
},
{
"object": "window",
"property": "Image",
"message": "Use the Image from @tldraw/util instead."
},
{
"object": "window",
"property": "setTimeout",
"message": "Use the timers from editor.timers instead."
},
{
"object": "window",
"property": "setInterval",
"message": "Use the timers from editor.timers instead."
},
{
"object": "window",
"property": "requestAnimationFrame",
"message": "Use the timers from editor.timers instead."
}
]
],
"tldraw/img-referrer-policy": "error"
}
},
{
"files": ["apps/dotcom/client/**/*"],
"rules": {
"no-restricted-globals": [
"error",
{
"name": "fetch",
"message": "Use the fetch from @tldraw/util instead."
},
{
"name": "Image",
"message": "Use the Image from @tldraw/util instead."
},
{
"name": "structuredClone",
"message": "Use structuredClone from @tldraw/util instead"
}
],
"tldraw/no-restricted-properties": [
"error",
[
{
"object": "window",
"property": "fetch",
"message": "Use the fetch from @tldraw/util instead."
},
{
"object": "window",
"property": "Image",
"message": "Use the Image from @tldraw/util instead."
},
{
"object": "crypto",
"property": "randomUUID",
"message": "Please use the makeUUID util instead."
}
]
]
}
},
{
"files": ["apps/dotcom/client/src/tla/**/*"],
"rules": {
"no-restricted-imports": [
"error",
{
"paths": [
{
"name": "react-intl",
"message": "Please import useIntl from src/utils/intl instead."
},
{
"name": "lodash",
"message": "The main `lodash` package is CommonJS and does not tree-shake. Use a deep import (e.g. `import throttle from 'lodash/throttle'`), `lodash-es`, a per-method package, or inline the helper."
}
]
}
],
"tldraw/jsx-no-literals": "error"
}
},
{
"files": ["**/scripts/**/*"],
"rules": {
"@typescript-eslint/no-require-imports": "off"
}
},
{
"files": ["internal/scripts/**/*"],
"rules": {
"tldraw/no-export-star": "off"
}
},
{
"files": ["internal/scripts/process-compose-bin.cjs"],
"env": { "node": true },
"globals": { "fetch": "readonly" }
},
{
"files": ["apps/mcp-app/**/*"],
"rules": {
"tldraw/no-export-star": "off"
}
},
{
"files": ["**/*.test.ts", "**/*.test.tsx", "**/*.spec.ts"],
"rules": {
"tldraw/no-restricted-properties": "off",
"no-restricted-globals": "off",
"react/jsx-key": "off",
"react/no-string-refs": "off"
}
},
{
"files": ["internal/**/*", "templates/simple-server-example/**/*"],
"rules": {
"no-console": "off"
}
},
{
"files": ["apps/examples/**/*"],
"rules": {
"tldraw/no-setter-getter": "off",
"tldraw/no-direct-storage": "off",
"tldraw/img-referrer-policy": "off"
}
},
{
"files": ["templates/**/*"],
"rules": {
"tldraw/prefer-class-methods": "off",
"tldraw/no-setter-getter": "off",
"tldraw/no-direct-storage": "off",
"tldraw/img-referrer-policy": "off",
"no-console": "off",
"tldraw/method-signature-style": "off"
}
}
]
}

45
.prettierignore Normal file
View File

@@ -0,0 +1,45 @@
**/node_modules/*
**/out/*
**/dist/*
**/dist-cjs/*
**/dist-esm/*
**/.next/*
**/api/*
!**/app/api/*
!**/pages/api/*
**/.tsbuild*
**/.next/*
**/_archive/*
apps/docs/api-content.json
apps/docs/content.json
apps/vscode/extension/editor/*
apps/examples/www
content.json
apps/docs/utils/vector-db/index.json
**/gen/**/*.md
.github/pull_request_template.md
skills
.agents
.cursor
**/api-report.md
**/api-report.api.md
**/CHANGELOG.md
apps/docs/content/releases/**/*
apps/docs/content/reference/**/*
**/.vercel/*
**/.wrangler/*
**/.out/*
**/.temp/*
apps/dotcom/client/public/**/*.*
apps/analytics/public/**/*
**/.clasp.json
apps/dotcom/zero-cache/docker/docker-compose.yml
lerna.json
internal/scripts/eslint/eslint-plugin.mjs
apps/dotcom/zero-cache/.schema.js

9
.prettierrc Normal file
View File

@@ -0,0 +1,9 @@
{
"trailingComma": "es5",
"singleQuote": true,
"semi": false,
"printWidth": 100,
"tabWidth": 2,
"useTabs": true,
"plugins": ["prettier-plugin-organize-imports"]
}

3
.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"recommendations": ["oxc.oxc-vscode"]
}

View File

@@ -0,0 +1,91 @@
diff --git a/lib-commonjs/emitters/TSDocEmitter.js b/lib-commonjs/emitters/TSDocEmitter.js
index 60535a109e3705baae7cb851e1f7cca6e2b5595b..5aedafa2da8ebcf22ec356d89921a93b33dca7ea 100644
--- a/lib-commonjs/emitters/TSDocEmitter.js
+++ b/lib-commonjs/emitters/TSDocEmitter.js
@@ -282,6 +282,9 @@ var TSDocEmitter = /** @class */ (function () {
var docPlainText = docNode;
this._writeContent(docPlainText.text);
break;
+ case DocNode_1.DocNodeKind.SoftBreak:
+ this._ensureAtStartOfLine();
+ break;
}
};
TSDocEmitter.prototype._renderInlineTag = function (docInlineTagBase, writeInlineTagContent) {
diff --git a/lib-commonjs/transforms/TrimSpacesTransform.js b/lib-commonjs/transforms/TrimSpacesTransform.js
index b5fe0c49a27a8df5b08e9862541c2449c61c1158..1fa36cab07267eaabbb9a0b9d640cfcec116dae4 100644
--- a/lib-commonjs/transforms/TrimSpacesTransform.js
+++ b/lib-commonjs/transforms/TrimSpacesTransform.js
@@ -20,6 +20,25 @@ var TrimSpacesTransform = /** @class */ (function () {
// We always trim leading whitespace for a paragraph. This flag gets set to true
// as soon as nonempty content is encountered.
var finishedSkippingLeadingSpaces = false;
+
+ function pushAccumulatedText() {
+ const lines = accumulatedTextChunks.join('').split('\n')
+ for (let i = 0; i < lines.length; i++) {
+ const line = lines[i]
+ transformedNodes.push(new nodes_1.DocPlainText({
+ configuration: docParagraph.configuration,
+ text: line
+ }));
+ if (i < lines.length - 1) {
+ transformedNodes.push(new nodes_1.DocSoftBreak({
+ configuration: docParagraph.configuration
+ }));
+ }
+ }
+ accumulatedTextChunks.length = 0;
+ accumulatedNodes.length = 0;
+ }
+
for (var _i = 0, _a = docParagraph.nodes; _i < _a.length; _i++) {
var node = _a[_i];
switch (node.kind) {
@@ -46,9 +65,15 @@ var TrimSpacesTransform = /** @class */ (function () {
}
break;
case nodes_1.DocNodeKind.SoftBreak:
- if (finishedSkippingLeadingSpaces) {
- pendingSpace = true;
- }
+ // by default, this transform strips out all soft-breaks and replaces them with
+ // spaces where needed. because we're rendering to markdown and markdown is
+ // whitespace-sensitive, we don't want to do this. instead, we accumulate the
+ // soft-breaks and emit them as normal.
+
+ // if (finishedSkippingLeadingSpaces) {
+ // pendingSpace = true;
+ // }
+ accumulatedTextChunks.push('\n');
accumulatedNodes.push(node);
break;
default:
@@ -60,12 +85,7 @@ var TrimSpacesTransform = /** @class */ (function () {
if (accumulatedTextChunks.length > 0) {
// TODO: We should probably track the accumulatedNodes somehow, e.g. so we can map them back to the
// original excerpts. But we need a developer scenario before we can design this API.
- transformedNodes.push(new nodes_1.DocPlainText({
- configuration: docParagraph.configuration,
- text: accumulatedTextChunks.join('')
- }));
- accumulatedTextChunks.length = 0;
- accumulatedNodes.length = 0;
+ pushAccumulatedText();
}
transformedNodes.push(node);
finishedSkippingLeadingSpaces = true;
@@ -73,12 +93,7 @@ var TrimSpacesTransform = /** @class */ (function () {
}
// Push the accumulated text
if (accumulatedTextChunks.length > 0) {
- transformedNodes.push(new nodes_1.DocPlainText({
- configuration: docParagraph.configuration,
- text: accumulatedTextChunks.join('')
- }));
- accumulatedTextChunks.length = 0;
- accumulatedNodes.length = 0;
+ pushAccumulatedText()
}
var transformedParagraph = new nodes_1.DocParagraph({
configuration: docParagraph.configuration

14
.yarnrc.yml Normal file
View File

@@ -0,0 +1,14 @@
compressionLevel: mixed
enableGlobalCache: false
# Don't run third-party dependency build/install scripts by default. Packages that
# genuinely need to build are allowlisted via dependenciesMeta.<pkg>.built in package.json.
# This closes the postinstall code-execution vector; see the note in AGENTS.md.
enableScripts: false
enableInlineBuilds: true
nodeLinker: node-modules
npmMinimalAgeGate: '7d'

222
AGENTS.md Normal file
View File

@@ -0,0 +1,222 @@
# AGENTS.md
This file provides guidance to AI coding agents working in this repository.
## Core rules
- Use `yarn`, not `npm`, for repo commands. This repo uses Yarn workspaces and Yarn 4.
- Run commands from the repo root unless a command explicitly says to run from a workspace.
- Never run bare `tsc`; use `yarn typecheck` from the repo root.
- Prefer targeted checks first. Avoid repo-wide test or e2e runs unless the change needs them.
- Keep changes scoped to the request and the affected package. Do not refactor unrelated code.
- Respect existing worktree changes. Do not revert user changes unless explicitly asked.
- Prefer editing existing files over creating new files. Do not add new documentation files unless requested.
- Use sentence case for headings, titles, labels, and documentation text.
## Repo overview
This is the tldraw monorepo, an infinite canvas SDK for React applications. It is organized with Yarn workspaces.
Core packages:
- `packages/editor` - foundational infinite canvas editor with no default shapes, tools, or UI
- `packages/tldraw` - complete SDK with default UI, shapes, tools, and interactions
- `packages/store` - reactive client-side database, persistence, and migrations
- `packages/tlschema` - shape, binding, and record type definitions and validators
- `packages/state` - reactive signals library
- `packages/sync` and `packages/sync-core` - multiplayer sync packages
- `packages/utils` and `packages/validate` - shared utilities and validation helpers
- `packages/assets` - icons, fonts, translations, and bundled assets
Apps and examples:
- `apps/examples` - SDK examples and demos; the main place for example development
- `apps/docs` - documentation site at tldraw.dev
- `apps/dotcom` - tldraw.com app and workers
- `apps/vscode` - VS Code extension
- `templates` - starter templates for supported frameworks
## Setup
Requires Node `>=22.12.0`. Enable Corepack before installing dependencies:
```bash
npm i -g corepack && yarn
```
## Common commands
Development:
- `yarn dev` - start the examples app at localhost:5420
- `yarn dev-app` - start the tldraw.com client app
- `yarn dev-docs` - start the docs site
- `yarn dev-vscode` - start VS Code extension development
- `yarn dev-template <template name>` - run a template
Always run dev commands from the repo root. The root `yarn dev` runs each package's `predev` step, which generates build artifacts like `packages/tldraw/tldraw.css`. Running a per-workspace command (`yarn workspace examples.tldraw.com dev`) skips `predev`, so imports such as `tldraw/tldraw.css` fail to resolve. In a fresh git worktree, run `yarn install` first since worktrees start without `node_modules`.
Build:
- `yarn build` - build all changed packages incrementally
- `yarn build-package` - build SDK packages only
- `yarn build-app` - build the tldraw.com client app
- `yarn build-docs` - build the docs site
Testing:
- `yarn test` in a workspace - run tests in watch mode
- `yarn test run` in a workspace - run tests once
- `yarn test run --grep "pattern"` in a workspace - run matching tests
- `yarn vitest` - run all tests across the repo; slow, avoid unless necessary
- `yarn e2e` - run examples e2e tests
- `yarn e2e-dotcom` - run tldraw.com e2e tests
Code quality:
- `yarn lint` - lint the package or workspace
- `yarn lint-current` - lint changed files
- `yarn typecheck` - type check all packages and refresh assets
- `yarn format` - format the repo
- `yarn format-current` - format changed files
- `yarn api-check` - validate public API reports
## Validation workflow
- For narrow package changes, run the relevant workspace test first, for example `cd packages/tldraw && yarn test run --grep "SelectTool"`.
- For changes that affect shared types, migrations, editor behavior, or cross-package contracts, run `yarn typecheck` from the repo root.
- For public API changes, run `yarn api-check` and include intentional API report updates.
- For asset changes, run `yarn refresh-assets` or `yarn typecheck` so generated assets stay current.
- For docs changes, run the narrow docs checks or docs build only when the change affects generated content, MDX behavior, or site structure.
- For e2e behavior changes, run the smallest relevant e2e suite and update snapshots only when behavior intentionally changed.
## Architecture notes
Reactive state:
- State is managed through `@tldraw/state` signals (`Atom`, `Computed`, and related primitives).
- Editor state is observable and dependency-tracked. Avoid bypassing existing reactive patterns.
Shapes:
- Shape behavior lives in `ShapeUtil` classes.
- Shape utils define geometry, rendering, handles, interactions, and SVG/export behavior.
- Add custom shape behavior through the established ShapeUtil patterns rather than one-off editor patches.
Tools:
- Tools are `StateNode` state machines.
- Complex tools use child states for pointer, keyboard, tick, and transition behavior.
- Keep interaction logic close to the tool state that owns it.
Bindings:
- Shape relationships use binding records and `BindingUtil` classes.
- Arrows and other connected shapes should update through binding utilities, not ad hoc shape mutation.
Managers:
- Editor subsystems live in `packages/editor/src/lib/editor/managers/` as classes owned and disposed by the `Editor`.
- A manager that subscribes to events or holds a resource should extend `EditorManager` and register its cleanup so it runs on `dispose()`: `addEditorEvent(event, fn)` for editor bus events, `register(fn)` for everything else (store side effects, reactions, DOM listeners, child resources). Use `editor.timers` for timeouts/intervals/frames and `editor.disposables` for cleanup on the editor itself.
- Don't extend `EditorManager` for managers with no teardown. See the `EditorManager` doc comment for the full decision guide.
Store and schema:
- Store changes should respect migrations, validators, and schema versioning.
- Schema-affecting changes usually need updates in `packages/tlschema` and focused migration tests.
## Where to work
- Use `packages/editor` for core editor primitives, geometry, managers, and UI-free behavior.
- Use `packages/tldraw` for default shapes, default tools, UI, and integration tests that need the full SDK.
- Use `apps/examples` for runnable SDK examples and demonstrations.
- Use `apps/docs/content` for documentation articles and release notes.
- Use `apps/dotcom/client` for tldraw.com frontend behavior.
- Use `apps/dotcom/*-worker` for Cloudflare worker behavior.
- Use `templates` for starter project changes.
## Testing guidance
- Unit tests live alongside source files as `*.test.ts`.
- Integration tests commonly live in `packages/tldraw/src/test/`.
- E2E tests live in `apps/examples/e2e/` and `apps/dotcom/client/e2e/`.
- Test in `packages/tldraw` when default shapes, tools, bindings, or UI are involved.
- Test in `packages/editor` for core editor behavior that should not depend on default shapes or UI.
- Prefer comparing whole objects in assertions when that gives a clearer failure than checking fields one by one.
- See `skills/write-unit-tests/` and `skills/write-e2e-tests/` for detailed test patterns.
## Documentation and examples
- Docs live in `apps/docs/content/`.
- Examples live in `apps/examples/src/examples/`.
- Example folders use lowercase kebab-case names.
- Example README frontmatter drives the examples site; keep titles and descriptions sentence case.
- Update docs or examples when an API or user-facing behavior changes.
- See `skills/write-docs/`, `skills/write-example/`, and `skills/write-release-notes/` for task-specific guidance.
## Skills
- Canonical agent skills live in `skills/`.
- `.agents/skills` is a symlink to `../skills` for generic agent compatibility.
- `.claude/skills` is a symlink to `../skills` for Claude compatibility. Keep `skills/` as the source of truth.
- `.cursor/skills` is a symlink to `../skills` for Cursor compatibility.
- Skill folders use `skill-name/SKILL.md` with YAML frontmatter containing at least `name` and `description`.
- Put reusable scripts, references, and assets inside the relevant skill folder.
- Do not duplicate skill content for different agents; add compatibility pointers or symlinks instead.
- See `skills/skill-creator/` before creating or restructuring skills.
- User-facing workflow skills include `skills/pr/`, `skills/issue/`, `skills/take/`, `skills/commit-changes/`, and `skills/clean-copy/`.
## Code conventions
TypeScript:
- Follow existing file-local style and abstractions.
- Use workspace types and helpers rather than duplicating definitions.
- Keep public API changes deliberate and reflected in API reports.
- Avoid boolean or ambiguous positional options in new APIs when a named object or enum would make call sites clearer.
React and UI:
- Follow existing component patterns in the relevant app or package.
- Keep user-facing text concise and sentence case.
- Avoid broad UI rewrites when a focused component change is enough.
Generated files:
- Do not hand-edit generated assets, API reports, or schemas unless the repo already expects that file to be edited directly.
- Run the owning generator command when generated output needs to change.
Dependencies:
- Keep dependencies workspace-appropriate.
- If changing dependency manifests or lockfiles, make sure the lockfile update is intentional and included.
- Every package a file imports must be declared in the owning workspace's own `package.json`. Yarn's `node-modules` linker hoists everything to the repo root, so an undeclared import still resolves here but breaks consumers on pnpm or Yarn PnP. The `tldraw/no-undeclared-dependencies` lint rule enforces this across `packages/*`, `apps/*`, `internal/*`, and `templates/*`. Shared dev tooling that every workspace runs (`vitest`, `tsx`, `typescript`, `lazyrepo`) may live only in the root `package.json`: the root `node_modules` is reachable from every workspace under any linker. Anything a published package's source imports must be declared by that package. Adding a workspace dependency also needs a matching `references` entry in that package's `tsconfig.json` (`yarn check-packages --fix`).
- Dependency install/build scripts are off by default (`enableScripts: false` in `.yarnrc.yml`), which closes the main supply-chain `postinstall` code-execution path. Packages that genuinely need to build (native/napi modules, binary downloaders) are allowlisted with `built: true` under `dependenciesMeta` in the root `package.json`. When adding a dependency that ships a native addon or downloads a platform binary, add an allowlist entry — Yarn silently skips unlisted scripts, so a missing entry shows up as a runtime or build failure, not an install error.
## Comments
A comment earns its place by saying something the code cannot: why this way, what breaks otherwise, which bug it guards. The litmus: a good comment names a failure mode, not a mechanism.
Scope: these rules apply to comments you write — new code, and lines you are already changing. Do not sweep existing comments while fixing a bug or refactoring; that buries a small change in a large diff. Leave an existing comment alone unless your change makes it inaccurate, you are rewriting the lines it is attached to, or the user asked for a cleanup. If you notice comments worth cleaning up, mention it or do it in a separate PR.
When writing comments:
- Don't restate the code (`/** Get the toolbar */` above `getToolbar()`), narrate it (`// Delete the shapes` above `editor.deleteShapes()`), add section banners, list call sites, or write `@param`/`@returns` that only repeat the signature.
- State a rationale once where the shared thing lives; don't copy it across sibling call sites.
- Keep comments shorter than the code they explain. Narrative that spans files belongs in a doc (`README.md`, `SPEC.md`, `apps/docs/content/`) with a short pointer from the code.
- Always keep: non-obvious invariants, issue numbers and provenance, constants nobody should tune blindly, diagrams, and enumerated cases the code must not break.
- In `packages/*`, doc comments on the `@public` surface become the API reference; density there is expected. In `apps/*` and `templates/*`, keep comments sparse.
## Writing style
- Use sentence case for Markdown headings, UI labels, docs titles, PR titles, and issue titles.
- Capitalize proper nouns, acronyms, and code names normally, for example `PostgreSQL`, `WebSocket`, and `NodeShapeUtil`.
- Use direct, concrete language.
- Do not include AI attribution in commits, PR descriptions, issues, docs, release notes, or generated written content.
## Git and PR notes
- Keep commits focused when asked to commit.
- Use semantic PR titles for pull requests: `<type>(<scope>): <description>`.
- Never add yourself or an AI tool as a co-author.
- See `skills/pr/` and `skills/issue/` for GitHub workflows, and `skills/write-pr/` and `skills/write-issue/` for repository content standards.

35
CLA.md Normal file
View File

@@ -0,0 +1,35 @@
# Contributor License Agreement
**Version 1.0 — June 8th 2023**
In order to clarify the intellectual property license granted with Contributions from any person, tldraw, Inc. (“tldraw”) must have a Contributor License Agreement on file that has been signed by each contributor, indicating agreement to the license terms below. This license is for Your protection as a contributor as well as the protection of tldraw; it does not change your rights to use Your own contributions for any other purpose.
You accept and agree to the following terms and conditions for Your Contributions (present and future) that you submit to tldraw. Except for the license granted herein to tldraw, You reserve all right, title, and interest in and to Your Contributions.
1. Definitions.
"You" (or "Your") means the individual identified above.
"Contribution" means any original work of authorship, including any modifications or additions to an existing work, that is intentionally submitted by You to tldraw for inclusion in, or documentation of, any of the products owned or managed by tldraw (each, a "Work"). For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to tldraw or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, tldraw for the purpose of discussing and improving the Works, but excluding communication that is conspicuously marked or otherwise designated in writing by You as "Not a Contribution."
2. Grant of Copyright License. You hereby grant to tldraw a perpetual, worldwide, non-exclusive, sublicensable (through multiple tiers), no-charge, royalty-free, irrevocable copyright license to reproduce, prepare derivative works of, publicly display, publicly perform, distribute, and otherwise exploit Your Contributions and such derivative works.
3. Grant of Patent License. You hereby grant to tldraw a perpetual, worldwide, non-exclusive, sublicensable (through multiple tiers), no-charge, royalty-free, irrevocable patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer and exploit the Works, where such license applies only to those patent claims licensable by You that are necessarily infringed by Your Contribution(s) alone or by combination of Your Contribution(s) with the Works to which such Contribution(s) was submitted.
4. You represent that you are legally entitled to grant the above license. If your employer(s) has rights to intellectual property that you create that includes your Contributions, you represent that you have received permission to make Contributions on behalf of that employer, that your employer has waived in writing any rights it may have in Your Contributions to tldraw, or that your employer has executed a separate Corporate CLA with tldraw.
5. You represent that each of Your Contributions is Your original creation and does not incorporate any material created by others. You represent that Your Contribution submissions include complete details of any patents or copyrights which are associated with any part of Your Contributions.
6. You are not expected to provide support for Your Contributions, except to the extent You desire to provide support. You may provide support for free, for a fee, or not at all. Unless required by applicable law or agreed to in writing, You provide Your Contributions on an "as is" basis, without warranties or conditions of any kind, either express or implied, including, without limitation, any warranties or conditions of title, non-infringement, merchantability, or fitness for a particular purpose.
7. You agree to notify tldraw of any facts or circumstances of which you become aware (now or in the future) that would make Your representations in this Agreement inaccurate in any respect.
8. You acknowledge that tldraw owns all right, title, and interest in and to the Works. Notwithstanding the foregoing, tldraw’s subsidiary, tldraw GB limited (the “Subsidiary”), is the beneficial owner of the Works, and tldraw will sublicense its rights in your Contributions under this Agreement to the Subsidiary in furtherance of the Subsidiary’s status as beneficial owner of the Works.
9. This Agreement is governed by the laws of Delaware, and the parties consent to exclusive jurisdiction in the courts sitting in Delaware. The parties waive all defenses of lack of personal jurisdiction and forum non-conveniens.
10. Entire Agreement/Assignment. This Agreement is the entire agreement between the parties, and supersedes any and all prior agreements, understandings or communications, written or oral, between the parties relating to the subject matter hereof. This Agreement may be assigned by tldraw without Your prior consent.
---
Questions or concerns? Email [hello@tldraw.com.](mailto:hello@tldraw.com)

1
CLAUDE.md Normal file
View File

@@ -0,0 +1 @@
@AGENTS.md

48
CODE_OF_CONDUCT.md Normal file
View File

@@ -0,0 +1,48 @@
# Contributor Covenant Code of Conduct
## Our Pledge
In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation.
## Our Standards
Examples of behavior that contributes to creating a positive environment include:
- Using welcoming and inclusive language
- Being respectful of differing viewpoints and experiences
- Gracefully accepting constructive criticism
- Focusing on what is best for the community
- Showing empathy towards other community members
Examples of unacceptable behavior by participants include:
- The use of sexualized language or imagery and unwelcome sexual attention or advances
- Trolling, insulting/derogatory comments, and personal or political attacks
- Public or private harassment
- Publishing others' private information, such as a physical or electronic
address, without explicit permission
- Other conduct which could reasonably be considered inappropriate in a professional setting
## Our Responsibilities
Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior.
Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful.
## Scope
This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at hello@tldraw.com. All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately.
Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html
[homepage]: https://www.contributor-covenant.org
For answers to common questions about this code of conduct, see https://www.contributor-covenant.org/faq

5
COMPANY.md Normal file
View File

@@ -0,0 +1,5 @@
# Company
tldraw is a Series A startup that builds the tldraw editor and the tldraw SDK. It is an American company (tldraw, Inc.) with an operating subsidiary based in the United Kingdom (tldraw GB Ltd.). The company's product, design, and engineering team is based in London.
Learn more about tldraw at tldraw.dev.

13
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,13 @@
# Contributing
We are **not accepting contributions** to [tldraw](https://github.com/tldraw/tldraw) at this time. Pull requests are turned off for this repository.
For more details on this policy, see [this issue](https://github.com/tldraw/tldraw/issues/7695).
## Create an issue instead
If you have found a bug, have a feature request, or want to suggest a change, please [create an issue](https://github.com/tldraw/tldraw/issues/new/choose). Issues are the best place to discuss proposed changes with the team, and we read every one.
If a code example would help the discussion, fork the repository and link to your branch in the issue.
Please also see our [Code of Conduct](https://github.com/tldraw/tldraw/blob/main/CODE_OF_CONDUCT.md) for our expectations around community culture.

3
FAQ.md Normal file
View File

@@ -0,0 +1,3 @@
# Frequently Asked Questions
Find our FAQs [here](https://tldraw.notion.site/faq).

70
LICENSE.md Normal file
View File

@@ -0,0 +1,70 @@
# tldraw license
This License from tldraw, Inc. (“tldraw”) governs your use of the accompanying Software. By using the Software, you accept the terms of this License. The tldraw software is copyrighted by tldraw Inc.
Alternative licenses are available from tldraw for commercial and non-commercial use. To get an alternative license or to learn more, visit https://tldraw.dev or contact sales@tldraw.com.
## Definitions
"Production Environment" means any production deployment of the Software that operates on servers, cloud platforms, web applications, or where the software is used to provide functionality to end users, customers, or the public. Production Environment excludes internal development.
"Development Environment" means any internal hosting or deployment of the Software for development, testing, or staging purposes, operated by your organization and not accessible to end users, customers, or the public.
"License Key" means the programmatically generated key that controls Software functionality and usage restrictions.
## Permissions
Subject to the following conditions, you are permitted to:
- Use the Software in Development Environments.
- Modify the Software to suit your needs.
- Bundle the Software with your own projects.
- Submit modifications of the Software to tldraw.
## Conditions
In exchange for these permissions, you agree:
- Not to use the Software in Production Environments.
- Not to disable, change, or interfere with the Software's License Key enforcement.
- Not to remove any copyright or other notices from the Software.
- Not to make the Software available under a license that supersedes or negates the effect of this License.
- Not to distribute the Software or modifications of the Software as a standalone product, but only as part of another application.
- To include a verbatim copy of this License in any distribution of the Software.
- To comply with tldraw's trademark policy.
## Trial license
In the case that tldraw makes the Software available to you on a trial basis, you are permitted to use the Software in Production Environments for the specified period beginning from the date of the trial’s License Key issuance. Business units are limited to one trial period unless otherwise approved by tldraw in writing.
## Commercial license
In the case that tldraw makes the Software available to you under the terms of a separate commercial agreement, you are permitted to use the Software in Production Environments for the specified period beginning from the date of the agreement’s License Key issuance.
## Termination
Your license to use the Software will terminate automatically if you breach any terms of this License or initiate a copyright, trade secret, or patent claim against tldraw, any of its affiliates, or any user of the Software (including as modified by you).
## Technical enforcement
The Software includes technical measures to verify License Key validity, detect deployment environments, enforce usage restrictions based on license type, and ensure proper watermark display. The Software may collect and transmit usage data to tldraw for license compliance purposes.
## Ownership of intellectual property
tldraw retains all right, title, and interest in the Software, including all intellectual property rights therein. This Agreement grants you only the specific, limited rights expressly set forth herein, and tldraw reserves all rights not expressly granted.
## Disclaimer of warranties
The Software is provided "AS IS," without any warranties. This includes any implied warranties of merchantability, fitness for a particular purpose, or non-infringement. You must pass this disclaimer on whenever you distribute the Software or derivative works.
## Limitation of liability
tldraw is not liable for any damages related to the Software or this License, including direct, indirect, special, or incidental damages, to the fullest extent permitted by law. You must pass this limitation of liability on whenever you distribute the Software or derivative works.
## Governing law
This License is governed by the laws of Delaware, and the parties consent to exclusive jurisdiction in Delaware courts. The parties waive all defenses of lack of personal jurisdiction and forum non-conveniens.
## Entire agreement / assignment
This License is the entire agreement between the parties, and supersedes any and all prior agreements, understandings or communications, written or oral, between the parties relating to the subject matter hereof. This License may be assigned by tldraw without your prior consent.

139
README.md Normal file
View File

@@ -0,0 +1,139 @@
<div alt style="text-align: center; transform: scale(.25);">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://github.com/tldraw/tldraw/raw/main/assets/github-hero-dark.png" />
<img alt="tldraw" src="https://github.com/tldraw/tldraw/raw/main/assets/github-hero-light.png" />
</picture>
</div>
<p align="center">
<a href="https://www.npmjs.com/package/tldraw"><img src="https://img.shields.io/npm/v/tldraw" alt="npm" /></a>
<a href="https://www.npmjs.com/package/tldraw"><img src="https://img.shields.io/npm/dm/tldraw" alt="npm downloads" /></a>
<a href="https://discord.tldraw.com/?utm_source=github&utm_medium=readme&utm_campaign=sociallink"><img src="https://img.shields.io/badge/discord-join-5865F2?logo=discord&logoColor=white" alt="Discord" /></a>
<a href="https://deepwiki.com/tldraw/tldraw"><img src="https://deepwiki.com/badge.svg" alt="Ask DeepWiki" /></a>
</p>
<h3 align="center">
Build infinite canvas apps in React with the tldraw SDK.
</h3>
<p align="center">
<a href="https://tldraw.dev/quick-start">Docs</a> · <a href="https://tldraw.dev/examples">Examples</a> · <a href="https://tldraw.dev/starter-kits/overview">Starter kits</a>
</p>
## Feature highlights
tldraw provides a feature-complete infinite canvas engine designed to be the foundation for any canvas app. Create custom shapes, tools, bindings and UI components for a custom experience. Use the default whiteboarding tool set or use the library's primitives to build entirely new shapes and interactions.
- **Multiplayer** — self-hostable real-time collaboration with [`@tldraw/sync`](https://tldraw.dev/docs/sync)
- **Drawing and diagramming** — pressure-sensitive drawing, geometric shapes, rich text, arrows, snapping to shapes, edge scrolling, image and video support, image export
- **Runtime API** - drive the canvas at runtime with the Editor API
- **Fully extensible** — custom [shapes](https://tldraw.dev/docs/shapes), [tools](https://tldraw.dev/docs/tools), [bindings](https://tldraw.dev/sdk-features/bindings), [UI components](https://tldraw.dev/sdk-features/ui-components), side effects, and event hooks
- **AI integrations** — canvas primitives for [building with LLMs](https://tldraw.dev/docs/ai)
- **DOM canvas** — web rendering supports anything the browser supports, including embedded websites from YouTube, Figma, GitHub, [and more](https://tldraw.dev/sdk-features/embed-shape)
- **Broad support** — works in any browser across desktop, touch screens, tablets, and mobile devices
## Who's using tldraw
The tldraw SDK powers canvas experiences in products from [Google](https://about.google), [Shopify](https://www.shopify.com), [BlackRock](https://www.blackrock.com), [Autodesk](https://blogs.autodesk.com/forma/2024/06/20/forma-board-collaborative-tool-for-architects/), [ClickUp](https://tldraw.dev/blog/clickup), [Replit](https://docs.replit.com/replitai/canvas), [Google Stitch](https://stitch.withgoogle.com), [Luma](https://lumalabs.ai/app), [Runway](https://runwayml.com), [Padlet](https://tldraw.dev/blog/padlet), [Mobbin](https://tldraw.dev/blog/mobbin), [Jam](https://tldraw.dev/blog/jam), [Craft](https://support.craft.do/en/write-and-edit/whiteboards), [Honeycomb](https://www.honeycomb.io/platform/canvas), [SchoolAI](https://schoolai.com/products/powerups), [Brisk](https://www.briskteaching.com/post/boost-whiteboard), [CADChat](https://cadchat.com), [bigpi](https://www.bigpi.ai), [Genio](https://genio.co), [Pollination](https://www.pollination.solutions), [Legendkeeper](https://www.legendkeeper.com), [Matilda](https://www.matilda.io), [Aries](https://aries.com/infinite), [Alai](https://getalai.com), [Dirac](https://www.diracinc.com), and many more.
See our [showcase](https://tldraw.dev/showcase) for case studies on how teams build with tldraw.
## Quick start
Install the tldraw package:
```bash
npm i tldraw
```
Then, use the `<Tldraw />` component in your React app:
```tsx
import { Tldraw } from 'tldraw'
import 'tldraw/tldraw.css'
export default function App() {
return (
<div style={{ position: 'fixed', inset: 0 }}>
<Tldraw />
</div>
)
}
```
## Starter kits
Starter kits provide the custom shapes, tools, and user interface needed for common applications. Each kit is MIT-licensed. Hack together a prototype, build out an app on top, or reference the code in a larger project.
Start building with:
```bash
npx create-tldraw@latest
```
- **Multiplayer** — self-hosted real-time collaboration powered by `@tldraw/sync` and Cloudflare Durable Objects, the same stack behind [tldraw.com](https://tldraw.com)
- **Agent** — AI agents that read, interpret, and modify canvas content
- **Workflow** — drag-and-drop node builder for automation pipelines, visual programming, and no-code platforms
- **Chat** — canvas-powered AI chat where users sketch, annotate, and mark up images alongside conversations
- **Image pipeline** — node-based builder for image generation pipelines
- **Branching chat** — AI chat with visual branching, letting users explore and compare different conversation paths
- **Shader** — WebGL shaders that respond to canvas interactions
## Local development
The development server runs the examples app at `localhost:5420`. You'll need [Node.js](https://nodejs.org) `^20.0.0`. Clone the repo, then enable [corepack](https://nodejs.org/api/corepack.html) for the correct yarn version:
```bash
npm i -g corepack
```
Install dependencies and start the dev server:
```bash
yarn
yarn dev
```
## Documentation
Documentation for the most recent release can be found on [tldraw.dev/docs](https://tldraw.dev/docs), including [reference docs](https://tldraw.dev/reference/editor/Editor). Our release notes can be found [here](https://tldraw.dev/releases).
For more agent-friendly docs, see our [LLMs.txt](https://tldraw.dev/llms.txt).
From 5.1.x onward, published packages also include a `DOCS.md` file with the appropriate docs-site content and a generated `RELEASE_NOTES.md` file with versioned release notes. If you or your coding agent need release notes for your installed version specifically, check these files alongside the source in your `node_modules` folder.
## Community
- [Discord](https://discord.tldraw.com/?utm_source=github&utm_medium=readme&utm_campaign=sociallink) — questions, feedback, and discussion
- [Twitter/X](https://twitter.com/tldraw) — news and updates
- [Submit an issue](https://github.com/tldraw/tldraw/issues/new) — bug reports and feature requests
## Contributing
We are not accepting contributions at this time. If you've found a bug or have a feature request, please [create an issue](https://github.com/tldraw/tldraw/issues/new/choose) and we can discuss it there. See our [contributing guide](https://github.com/tldraw/tldraw/blob/main/CONTRIBUTING.md) for details.
## License
The tldraw SDK is provided under the [tldraw license](https://github.com/tldraw/tldraw/blob/main/LICENSE.md). You can use the SDK freely in development. Production use requires a [license key](https://tldraw.dev/pricing). Visit [tldraw.dev](https://tldraw.dev) to learn more.
## 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.
## Contributors
<a href="https://github.com/tldraw/tldraw/graphs/contributors">
<img src="https://contrib.rocks/image?repo=tldraw/tldraw&max=400&columns=20" width="100%"/>
</a>
## Star History
<a href="https://www.star-history.com/?repos=tldraw%2Ftldraw&type=date&legend=top-left">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=tldraw/tldraw&type=date&theme=dark&legend=top-left&sealed_token=YTo3tyinAnKu6ejENN9D7GqA1uojjEwF1sh1zJsYGDOp0BmB2_UZbnYBZmhW6UZ7ObW96D5J9M5ww8M2d6AniQZ6fIWxWHqpnnFw02hhk5UfAsz52Y-VFJ0ooQDSyIwW6VL64YD4HGQIwmq_0_jIhposmAekoBMCsIZeMX2lQo3j9l59kintv74U5X0y" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=tldraw/tldraw&type=date&legend=top-left&sealed_token=YTo3tyinAnKu6ejENN9D7GqA1uojjEwF1sh1zJsYGDOp0BmB2_UZbnYBZmhW6UZ7ObW96D5J9M5ww8M2d6AniQZ6fIWxWHqpnnFw02hhk5UfAsz52Y-VFJ0ooQDSyIwW6VL64YD4HGQIwmq_0_jIhposmAekoBMCsIZeMX2lQo3j9l59kintv74U5X0y" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=tldraw/tldraw&type=date&legend=top-left&sealed_token=YTo3tyinAnKu6ejENN9D7GqA1uojjEwF1sh1zJsYGDOp0BmB2_UZbnYBZmhW6UZ7ObW96D5J9M5ww8M2d6AniQZ6fIWxWHqpnnFw02hhk5UfAsz52Y-VFJ0ooQDSyIwW6VL64YD4HGQIwmq_0_jIhposmAekoBMCsIZeMX2lQo3j9l59kintv74U5X0y" />
</picture>
</a>

92
RELEASES.md Normal file
View File

@@ -0,0 +1,92 @@
# Releases
## How tldraw is versioned
Unlike many JavaScript packages distributed on [NPM](https://www.npmjs.com/), the tldraw SDK does not follow [semantic versioning](https://semver.org/) in its release versions. Here's what we do instead:
- Major version bumps are very rare and we reserve them for special changes that signify a paradigm shift of some kind.
- Minor version bumps are released on a regular cadence. At the time of writing that cadence is monthly. **They may contain breaking changes**. We aim to make breaking changes as minimally disruptive as possible by providing warnings several releases in advance, and by providing tooling to help you migrate your code. We recommend updating tldraw at a similar pace to our release cadence, and be sure to check the release notes.
- Patch version bumps are for bugfixes and hotfixes that can't wait for the next cadence release.
## How to publish a new major or minor release
New cadence releases are published from `production`. You trigger a release manually by running the workflow defined in `publish.yml`.
### Before you publish
The release notes are read from two places at publish time, and both have to be current:
- **`production`'s `apps/docs/content/releases/next.mdx`.** `prepack.ts` generates the `RELEASE_NOTES.md` that ships inside the `tldraw` npm package from the checkout being published, so whatever `next.mdx` says on `production` at that moment is baked into the tarball. It can't be fixed afterwards without a patch release. During the freeze `production` only moves by hotfix, so release-notes updates merged to `main` have to be brought over (the `dotcom-hotfix-please` label on the release-notes PR does this). The gate is an empty diff:
```sh
git fetch origin production main
git diff --stat origin/production origin/main -- apps/docs/content/releases/next.mdx
```
- **The draft GitHub release named `vX.Y.0`.** `publish-new.ts` publishes its body verbatim and refuses to run if it doesn't exist. The `update-release-notes` skill keeps it in sync with `next.mdx` (`.claude/skills/update-release-notes/scripts/update-draft-release.sh`); re-run that if `next.mdx` changed after the last sync.
1. Go [here](https://github.com/tldraw/tldraw/actions/workflows/publish.yml), select the `production` branch, and click the 'Run workflow' button.
2. Set the publish type to `new`.
3. Fill out the form that appears. You can leave the defaults as they are if you want to publish a new 'minor' release. If you want to publish a new 'major' release, select that option from the dropdown.
4. If you need to publish an exact version number, select the override option and provide the version number, like `3.4.0`.
When you click the 'run' button after selecting how to bump the version number, the github action will do the following things:
- Update the version numbers in package.json files.
- Publish the draft GitHub release for the new version.
- Publish the new packages to npm.
- Create a new release branch for the new version. e.g. for version `3.4.0` it will create a branch called `v3.4.x`.
- Push `production`'s docs and examples to `docs-production`, and its bemo worker to `bemo-production`.
- Trigger a version bump on `main` so its package versions match.
### After you publish
- Check the notes that shipped inside the package match what you expected. The section for the new version should have the same entries as `next.mdx` did on `production`:
```sh
curl -sL "$(npm view tldraw@X.Y.0 dist.tarball)" | tar -xzO package/RELEASE_NOTES.md | head -40
```
- The version bump lands on `main`, not `production`, so the publish itself doesn't push to `production`. The post-release `update-release-notes` run (which archives `next.mdx` to `vX.Y.0.mdx` and needs `docs-hotfix-please` to reach the new release branch) is triggered by the next `production` push, normally the post-launch dotcom release from `main`. Dispatch `update-release-notes.yml` by hand if you want the docs page sooner.
## How to publish a new patch release
1. Make sure your git repo is up-to-date.
`git fetch`
2. Check out the latest release branch.
New major or minor releases will be given their own 'release branch' at publish time, with a name like `v2.0.x`. Every release branch starts with a `v` and ends in `.x`. Patch releases are published from these release branches.
To see the latest tldraw version number run `npm show tldraw version`. Then checkout the release branch for that number by prefixing the `v` and replacing the patch number with `x`. For example, if the latest version is `3.4.3`, you would run
`git checkout v3.4.x`
You can also patch older release branches if you need to. For example, if the latest version is `3.4.3` but you need to patch `2.8.2`, you would run
`git checkout v2.8.x`
3. Create a new branch based on the release branch.
`git checkout -b david/my-helpful-patches`
Replace `david/my-helpful-patches` with a branch name that makes sense for the patches you are about to make.
4. Cherry-pick the commits you want to include in the patch release.
`git cherry-pick <commit-hash>`
You can cherry-pick multiple commits if you want to include multiple bugfixes in the patch release.
5. Push the branch and make a PR targeting the release branch.
6. Merge the PR.
That's it! The patch release will be published automatically after merging. Changelog and version number updates will be committed back to the release branch, and deliberately not to `main`.
## What about documentation?
Our docs site is published in tandem with our npm packages. When you publish a new release, the docs site will be updated automatically so that the docs are always in sync with the latest version of tldraw.
If you make a docs change that you want to publish independently of a new cadence release, you can do so by following the same process as for creating a patch release. This will automatically detect that the packages themselves have not changed and will only update the docs site.

29
SECURITY.md Normal file
View File

@@ -0,0 +1,29 @@
# Security Policy
## Supported Versions
We currently support the following versions of tldraw project with security updates.
| Version | Supported |
| ------- | ------------------ |
| 3.x.x | :white_check_mark: |
## Reporting a Vulnerability
Please do not report security vulnerabilities through public GitHub issues.
Instead, please report them by emailing hello@tldraw.com.
You should receive a response within 24 hours. If for some reason you do not, please follow up via email to ensure we received your original message.
Please include the requested information listed below (as much as you can provide) to help us better understand the nature and scope of the possible issue:
- Type of issue (e.g. buffer overflow, SQL injection, cross-site scripting, etc.)
- Full paths of source file(s) related to the manifestation of the issue
- The location of the affected source code (tag/branch/commit or direct URL)
- Any special configuration required to reproduce the issue
- Step-by-step instructions to reproduce the issue
- Proof-of-concept or exploit code (if possible)
- Impact of the issue, including how an attacker might exploit the issue
This information will help us triage your report more quickly.

66
TRADEMARKS.md Normal file
View File

@@ -0,0 +1,66 @@
# tldraw Trademark Guidelines
This trademark policy was prepared to help you understand how to use the tldraw trademarks, service marks and logos.
While the copyright to our open source software is licensed under the tldraw license, our trademarks appearing in or on the open source software are the exclusive property of tldraw Inc. This means that our open source license does not include a license to use our trademarks.
Because we make some of our code available to download and modify, proper use of our trademarks is essential to inform people whether or not tldraw stands behind a product or service. When using tldraw trademarks, you must comply with these tldraw Trademark Guidelines.
This policy is intended to explain how to use our trademarks in a way that is consistent with background law and community expectations. This policy covers:
1. Our word trademarks and service marks: tldraw
2. Our logos: The tldraw logos
This policy encompasses all trademarks and service marks, whether they are registered or not.
## General guidelines
Whenever you use one of our marks, you must always do so in a way that does not mislead anyone about what they are getting and from whom.
Do not use the tldraw marks in any way that could mistakenly imply that tldraw has reviewed, approved or guaranteed your goods or services. You also cannot use our logo on your website in a way that suggests that your website is an official website or that we endorse your website. You can, though, say you like the tldraw software, that you use tldraw, that the analytics are powered by tldraw or that you participate in the tldraw community.
Do not use the "tl" prefix in a way that could mistakenly imply that your product is related to tldraw. For example, an analytics product that uses tldraw should not use the name "tlanalytics".
You may not use or register our marks or variations of them as part of your trademark, business, product, service, app, domain name, social media account or business indicator. You may not use our marks as a part of an advertising campaign. You may not display tldraw trademarks more prominently than your product, service or company name. You may not use tldraw trademarks on merchandise for sale (e.g., selling t-shirts, mugs, etc).
Trademark law does not allow your use of names or trademarks that are too similar to ours. You therefore may not use an obvious variation of any of our marks or any phonetic equivalent, foreign language equivalent, takeoff, or abbreviation for a similar or compatible product or service.
## Acceptable uses
You can use the tldraw name to truthfully and accurately refer to or identify tldraw and its products and services in the following instances:
- To refer to tldraw and its products and services in news articles and other content without alteration
- To discuss tldraw and its products in a fair and honest manner that does not suggest sponsorship or endorsement by or affiliation with tldraw
- To refer to and/or to link to the products and services hosted on tldraw’s servers and website
- To indicate if your product, service or solution integrates, or is interoperable or compatible, with tldraw, for example, “we offer a simple integration with tldraw”, provided that doing so does not create a likelihood of confusion as to the origin of such product, service, or solution
- You may use our word marks as part of a public subdomain solely for the purpose of serving as the URL for your self-managed tldraw instance, for example, tldraw.companyname.com
## Prohibited uses
Unless you have express written permission from tldraw, or your use is permitted pursuant to the acceptable uses set forth above, the use of tldraw trademarks is strictly prohibited. Here is a short, non-exhaustive list of the kinds of uses that are not permitted without tldraw’s express written permission but that tldraw may consider granting you the right to do should you request permission:
- Use of tldraw trademarks in connection with the provision of a public website that makes tldraw software available for installation and use on a server (rather than directing users to the official tldraw site)
- Use of tldraw trademarks in connection with versions of tldraw products made publicly available or made available in the cloud on a managed service provider, resale or other commercial basis
- Use of tldraw trademarks in connection with tldraw product bundled with other software
In the above cases:
- You must follow the terms of the open source license for tldraw software products and code
- You must remove all of our logos from it and choose your branding, logos and trademarks that denote your unique identity to clearly signal to users that there is no affiliation with or endorsement by tldraw
- You must not use any tldraw trademark in connection with the user-facing name, branding or marketing materials of your project
- You may use word marks, but not our logos, in truthful statements that describe the relationship between your software and ours, for example, “this software is derived from the source code of the tldraw software”, as long as you also include a statement that your project is not officially associated with tldraw or its products
- tldraw reserves the right in its sole discretion to (i) terminate, revoke, modify, or otherwise change permission to use the trademarks at any time and; (ii) object to any use or misuse of the trademarks in any jurisdiction worldwide. All changes to these guidelines are effective immediately when posted and your continued use of the trademarks following the posting of revised guidelines signifies your acceptance of such revision.
## To request the use of the trademarks
Anyone wishing to use any of tldraw’s trademarks in a manner other than the acceptable uses listed above, including but not limited to marketing, promotion or advertising, or on software derivative of tldraw software, must obtain tldraw’s express, written permission in advance.
To request the use of the trademarks in a manner or for a purpose not expressly permitted in these guidelines, including use for any purpose of the logos, please email [hello@tldraw.com](mailto:hello@tldraw.com) to discuss. If you need clarification on whether your use qualifies, please ask.
## To report misuse
If you want to report misuse of a tldraw trademark, please email [hello@tldraw.com](mailto:hello@tldraw.com).
Last updated: November 7 2023
These guidelines are based on the Model Trademark Guidelines, available at http://www.modeltrademarkguidelines.org., used under a Creative Commons Attribution 3.0 Unported license: https://creativecommons.org/licenses/by/3.0/deed.en_US

807
VOICE.md Normal file
View File

@@ -0,0 +1,807 @@
# Voice and style guide
This is how we write at tldraw. Use it when writing new docs, reviewing existing content, or checking whether something sounds like us.
## Core identity
**Expert-to-developer guidance**: We write as a knowledgeable colleague explaining a system they helped build. We're confident, practical, and focused on getting developers to working code quickly.
The overall feeling is: _"Here's how this works, here's exactly how to use it, and here's working code to prove it."_
## Tone characteristics
### What we are
| Trait | Description |
| ---------------------- | ------------------------------------------------------------------ |
| **Confident** | We make clear, direct assertions without hedging |
| **Upfront** | We present solutions early rather than showing what _doesn't_ work |
| **Pragmatic** | We focus on "here's how to do it" rather than theory |
| **Helpful** | We anticipate developer needs and provide escape hatches |
| **Honest** | We're transparent about limitations and work-in-progress |
| **Warm but efficient** | We have personality without being chatty |
### What we're not
- **Not dry or academic** — we have warmth and occasional personality
- **Not overly chatty** — we respect the reader's time
- **Not condescending** — we assume intelligence and competence
- **Not corporate** — we're human, sometimes playful
## Voice examples
### Confidence without hedging
**Do:**
> The Editor class is the main way of controlling tldraw's editor.
> By design, the Editor's surface area is very large.
> Custom shapes are shapes that were created by you or someone you love.
**Don't:**
> The Editor class can be used to control tldraw's editor.
> The Editor's surface area might seem large.
> Custom shapes are shapes that may have been created by developers.
### Pragmatic directness
**Do:**
> Need to create some shapes? Use `Editor#createShapes`. Need to delete them? Use `Editor#deleteShapes`.
> In tldraw, a shape is something that can exist on the page, like an arrow, an image, or some text.
> The sync demo is great for prototyping but you should not use it in production.
**Don't:**
> The following section describes the various methods available for creating and deleting shapes in the editor.
> A shape can be defined as an entity that exists within the canvas space.
> Production usage of the sync demo is discouraged.
### Honesty about limitations
**Do:**
> There are some features that we have not provided and you might want to add yourself.
> While we're working on docs for this part of the project, refer to our examples.
> We don't guarantee server backwards compatibility forever.
**Don't:**
> This comprehensive solution handles most scenarios.
> Documentation is forthcoming.
> Backwards compatibility is maintained between versions.
### Stay concrete
Avoid florid language, extended metaphors, and theoretical examples. We explain with real code and real scenarios, not imagination.
**Do:**
> The store holds all the data for your document.
> Let's create a custom shape for a card with a title and description.
> The editor manages state changes through its store.
**Don't:**
> Think of the store as a river of data, flowing through your application, carrying shapes like leaves on a current.
> Imagine you're building a spaceship dashboard with custom controls...
> The editor orchestrates a symphony of state changes...
Short clarifying comparisons are fine—"shapes are just records (JSON objects)"—but don't reach for extended metaphors when plain language works. Avoid distracting hypothetical scenarios in your prose.
## Avoiding AI writing tells
AI-generated text has recognizable patterns. Avoid these to keep our writing sounding human. For a comprehensive catalog, see [Wikipedia: Signs of AI writing](https://en.wikipedia.org/wiki/Wikipedia:Signs_of_AI_writing).
### Hollow importance claims
AI loves to emphasize significance without saying anything concrete. These phrases are red flags:
- "serves as a testament to"
- "plays a vital/crucial/significant role"
- "underscores its importance"
- "watershed moment," "key turning point," "pivotal moment"
- "deeply rooted," "profound heritage"
- "rich history," "enduring legacy"
**Don't:**
> The store plays a crucial role in tldraw's architecture, serving as a testament to the power of reactive state management.
**Do:**
> The store holds all shapes, bindings, and other records. The store is reactive: when data changes, the UI updates automatically.
### Trailing gerund phrases
**This is one of the most common AI writing patterns. Actively hunt for and eliminate trailing gerunds.**
AI ends sentences with gerund clauses (-ing phrases) that claim importance without substance:
- "...emphasizing the significance of X"
- "...reflecting the continued relevance of Y"
- "...highlighting the importance of Z"
- "...ensuring a seamless experience"
- "...underscoring its commitment to quality"
**Don't:**
> The editor batches updates automatically, ensuring optimal performance while highlighting the importance of reactive state management.
**Do:**
> The editor batches updates automatically. This keeps renders fast even when many shapes change at once.
**Even neutral trailing gerunds are a problem.** They weaken sentences by burying the point at the end, making prose feel monotonous and AI-generated. This isn't just about avoiding hollow importance claims—it's about sentence structure.
Common neutral trailing gerunds to eliminate:
- "...allowing you to X"
- "...enabling users to X"
- "...making it easy to X"
- "...giving you X"
- "...providing X"
- "...creating X"
- "...resulting in X"
- "...causing X to Y"
**Don't:**
> The store is reactive, allowing you to subscribe to changes.
> When `shrink` is greater than zero, the stroke width also decreases during fade-out, creating a smooth disappearance effect.
> The editor exposes methods for shape manipulation, making it easy to create complex diagrams.
**Do:**
> The store is reactive. You can subscribe to changes.
> When `shrink` is greater than zero, the stroke width also decreases during fade-out. This creates a smooth disappearance effect.
> The editor exposes methods for shape manipulation. You can use these to create complex diagrams.
Or lead with what matters:
> Set `shrink` above zero for a smooth disappearance effect—the stroke width decreases during fade-out.
> You can create complex diagrams using the editor's shape manipulation methods.
**The fix is simple**: Split into two sentences, or restructure so the important information comes first. When you see a comma followed by an -ing word near the end of a sentence, that's your signal to rewrite.
### Formulaic transitions
These transitions are overused by AI and often unnecessary:
- "Moreover," "Furthermore," "Additionally,"
- "It's important to note that..."
- "It is worth mentioning that..."
- "On the other hand,"
- "In addition to this,"
Usually you can just delete these and the sentence is stronger. If you need a transition, use a shorter one ("But," "And," "Also,") or restructure.
**Don't:**
> The editor manages all state changes. Moreover, it provides a reactive system for updates. Furthermore, it handles undo/redo automatically.
**Do:**
> The editor manages all state changes. It's reactive: when state changes, dependent values update automatically. It also handles undo/redo.
### The rule of three
AI overuses three-part lists. Real writing has lists of two, or four, or seven items. If you find yourself writing exactly three things, ask whether that's actually the right number or just a pattern.
**Don't:**
> The editor is fast, flexible, and powerful.
> This gives you control, clarity, and confidence.
**Do:**
> The editor is fast and flexible.
> This gives you precise control over rendering.
### Promotional language
AI picks up marketing speak from its training data. We're writing technical content, not ad copy:
- "breathtaking," "stunning," "beautiful"
- "seamless," "frictionless," "effortless"
- "robust," "comprehensive," "cutting-edge"
- "empowers developers to..."
- "unlock the full potential of..."
**Don't:**
> Tldraw empowers developers to unlock the full potential of infinite canvas experiences with a robust and comprehensive API.
**Do:**
> Tldraw gives you an infinite canvas with a large API surface. You can control almost everything.
### Em dash overuse
AI writing often features multiple em dashes where a comma or period would be more natural. One em dash per paragraph is fine; several is a red flag. Also avoid dramatic formulations that call for an em dash.
LLMs especially use em dashes in formulaic, punched-up ways—often mimicking sales copy by over-emphasizing clauses. They also use em dashes where humans would use commas, parentheses, or colons.
**Don't:**
> It's not just a history manager—it's a way to track changes across time.
> The store is reactive—it notifies subscribers—and it's fully typed—with TypeScript.
**Do:**
> You can also use the history manager to track changes across time.
> The store is reactive: it notifies subscribers when data changes. All records are fully typed.
### Negation parallelism
The "It's not X, it's Y" structure is an AI signature. Real writing just says what something is.
**Don't:**
> It's not just a canvas—it's a complete editing experience.
> The editor isn't simply a state container; it's a reactive system.
**Do:**
> The editor is a reactive system that manages all document state.
### Overused AI vocabulary
Certain words appear disproportionately in LLM output. Avoid these unless they're genuinely the right word:
| Avoid | Use instead |
| ------------ | -------------------------- |
| delve (into) | explore, examine, look at |
| pivotal | important, key, critical |
| underscore | emphasize, show, highlight |
| leverage | use |
| utilize | use |
| multifaceted | complex, varied |
| nuanced | subtle, detailed |
| foster | encourage, create |
| bolster | strengthen, support |
| spearhead | lead |
| paradigm | model, approach |
| synergy | (usually delete entirely) |
These words aren't wrong, but their overuse signals AI authorship. If you find yourself reaching for them, consider whether a simpler word works.
### Bullet points with bolded headers
In prose writing, this format is a ChatGPT signature:
**Don't:**
> - **Reactive updates**: The store automatically notifies subscribers when data changes.
> - **Type safety**: All records are fully typed with TypeScript.
> - **Persistence**: Data can be saved to IndexedDB or synced to a server.
**Do:**
> The store is reactive: it automatically notifies subscribers when data changes. All records are fully typed. You can persist data to IndexedDB or sync it to a server.
This format is fine for reference material (API docs, style guides, changelogs) where scanability matters more than flow. Use a table if you have genuinely parallel information to present.
### Regression to the mean
AI replaces specific, unusual details with generic positive-sounding language. LLMs are trained on text where notable things are described with important-sounding words, so they tend to smooth over unique facts in favor of statistical averages.
**Don't:**
> The arrow tool is a powerful and versatile feature that enables users to create professional-looking diagrams.
> Steve is a visionary leader who has made significant contributions to the field.
**Do:**
> The arrow tool draws arrows between shapes. Arrows can have different heads, labels, and curve styles.
> Steve invented the train-coupling device used in most modern rail systems.
The fix: preserve specific facts. If you don't know the specifics, research them or omit the claim entirely. Vague importance claims add nothing.
### Uniform sentence structure
AI defaults to sentences of similar length and paragraphs of similar size. Real writing has rhythm—short punchy sentences, then longer ones with more detail. Paragraphs vary based on content, not formula.
**Don't:**
> The editor manages document state. The store holds shape records. Bindings connect related shapes. Tools handle user interactions.
**Do:**
> The editor manages all document state. It holds shapes in a reactive store—when data changes, the UI updates automatically. Tools handle user interaction: each tool is a state machine that responds to pointer and keyboard events.
If your prose feels monotonous, vary your sentence lengths. Start some sentences with the subject, others with a clause. Let the content dictate structure.
## Grammar and mechanics
### Pronouns
**Use "you" for direct address:**
> You can access the editor in two ways.
> You can change the current active tool using `editor.setCurrentTool`.
> You should make sure that there's only ever one `TLSocketRoom` globally.
**Use "we" for recommendations and team perspective:**
> We've found it best to create the store, set its data, and then pass the store into the editor.
> We recommend the tldraw sync packages for collaboration.
In nuggets and blog-style content, "we" also works for narrative: "We tried X, but Y worked better."
**Use "the SDK" or "the editor" when describing what the software does:**
> The SDK has several features to support collaboration.
> The editor provides history methods for undo and redo.
> The editor's history manager handles history. It uses "stacks" for undos and redos.
Don't say "we support X" when you mean "the editor supports X"—it conflates the team with the software.
**Avoid:**
- First-person singular ("I recommend...")
- Passive constructions that obscure the actor ("It is recommended that...")
### Voice
**Active voice dominates:**
> The editor holds the raw state of the document in its store property.
> Each node will first handle the event and then pass the event to its active child state.
> Tldraw uses migrations to bring data from old snapshots up to date.
**Passive voice only when the actor genuinely doesn't matter:**
> Data is kept here as a table of JSON serializable records.
> The event is first processed in order to update its inputs.
**Tables still prefer active voice.** When describing states or behaviors in tables, make the subject act rather than be acted upon:
| Don't | Do |
| ------------------------------------ | ----------------------------------------------------------------- |
| "The scribble is temporarily paused" | "The manager pauses the scribble" or "Drawing pauses temporarily" |
| "The request is being processed" | "The server processes the request" |
| "Points are removed from the tail" | "The scribble removes points from its tail" |
### Sentence structure
**Write like a person.** Prefer short, clear sentences, but don't be robotic about it. Natural prose has rhythm—some sentences are short, others flow a bit longer. The goal is readability, not mechanical uniformity.
**Prefer:**
> In tldraw, a shape is something that can exist on the page.
> Shapes are just records (JSON objects) that sit in the store. For example, here's a shape record for a rectangle geo shape.
> When the editor receives an event, it first updates inputs and other state. Then it sends the event to the state chart.
**Avoid complex, nested constructions:**
> When the editor receives an event via its dispatch method, the event is first handled internally to update inputs and other state before being sent into the editor's state chart, where it cascades through the active states.
The problem isn't sentence length, but rather cognitive load. Break up ideas when a sentence asks the reader to hold too much in their head at once.
### Contractions
Use contractions naturally:
- it's, we've, you'll, won't, don't, can't, shouldn't
**Example:**
> It's our library for fast, fault-tolerant shared document syncing, and it's what we use to power collaboration on our flagship app.
### Headings
**Always use sentence case** (not Title Case):
- "Custom shapes" not "Custom Shapes"
- "Using the editor" not "Using the Editor"
- "Camera and coordinates" not "Camera and Coordinates"
**Exception:** Proper nouns and technical names remain capitalized:
- "PostgreSQL database"
- "WebSocket connections"
- "ShapeUtil implementation"
## Document structure
### Opening pattern
Start with a clear, direct definition:
> The Editor class is the main way of controlling tldraw's editor.
> In tldraw, a shape is something that can exist on the page, like an arrow, an image, or some text.
> In tldraw, persistence means storing information about the editor's state to a database and then restoring it later.
### Concept → Explanation → Code
Every concept should be followed by a working example:
> You can access the editor in two ways:
>
> 1. From the Tldraw component's `onMount` callback:
>
> ```tsx
> function App() {
> return (
> <Tldraw
> onMount={(editor) => {
> // your editor code here
> }}
> />
> )
> }
> ```
### Progressive disclosure
Move from simple to complex:
1. Start with the most common use case
2. Add complexity incrementally
3. Leave edge cases and advanced patterns for later sections
**Example from persistence docs:**
1. First: `persistenceKey` prop (simplest)
2. Then: State snapshots (more control)
3. Then: The `store` prop (full control)
4. Finally: Migrations (advanced)
### Short paragraphs
Keep paragraphs to 1-3 sentences. Dense blocks of text are hard to scan:
**Do:**
> Meta information is information that is not used by tldraw but is instead used by your application. For example, you might want to store the name of the user who created a shape, or the date that the shape was created.
**Don't:**
> Meta information is additional data that can be attached to shapes and is not used internally by tldraw but can be leveraged by your application for custom functionality. This could include things like the user who created the shape, timestamps, custom identifiers, or any other application-specific data that you want to associate with shapes but don't want to store in the props object.
### Tables for related information
Use tables to organize related methods, options, or concepts:
| Method | Description |
| ------------------ | ---------------------------------------------- |
| `Editor#setCamera` | Moves the camera to the provided coordinates. |
| `Editor#zoomIn` | Zooms the camera in to the nearest zoom step. |
| `Editor#zoomOut` | Zooms the camera out to the nearest zoom step. |
### Notes and callouts
Use blockquotes for important asides:
> If all you're interested in is the state below `root`, there is a convenience method, `Editor#getCurrentToolId`, that can help.
Use stronger callout syntax for warnings:
```
<Callout type="warning">
You must make sure that the tldraw version in your client matches the version on the server.
</Callout>
```
## Nuggets (tech blog posts)
Nuggets are short technical articles about how we solved interesting problems. They're different from reference documentation—more like posts you'd find on a company engineering blog.
### Different opening pattern
Reference docs start with definitions. Nuggets start by **framing the problem**—a sentence or two that tells the reader what this is about and why it's interesting before diving in.
**Reference doc opening:**
> The Editor class is the main way of controlling tldraw's editor.
**Nugget opening:**
> The tldraw SDK is all about making the little details work. If you've ever used dashed lines in tldraw, you might have noticed that the dashes always line up with the corners of your shape, the handles of a spline, or the start and end of an arrow. While this might seem like the obvious way that dashes _should_ work, you might be surprised to learn that SVG offers no such feature. We implement these perfect dashes entirely ourselves.
>
> Here's how it works.
The nugget opening establishes context (what we're talking about), tension (there's a problem or unmet expectation), and stakes (why you should care) before getting into the solution.
The goal is to root the technical article in some anecdotal context. These problems don't just emerge from nowhere, but rather they come from details, behaviors, conventions, or general "what feels right" expectations within the canvas domain. The real problem is how to write the code and convince the computer to do the thing that makes the experience feel right. Often times, that work is unintuitive and interesting in that it reveals something about the interaction or about the technologies involved.
### Structure
Nuggets typically follow this arc:
1. **Frame the problem** — What's this about? What problem did we encounter and solve? Why was it hard, unintuitive, or interesting?
2. **Show the insight** — What's the key idea that makes the solution work?
3. **Walk through the implementation** — Code and explanation, building up complexity
4. **Wrap up** — Where this lives in the codebase, tradeoffs, links to files. Also unexplored areas, more we could do, or related problems.
### Tone differences
Nuggets are warmer than reference docs. They can:
- Use "the trick is..." or "the insight is..." to signal key ideas
- Include brief asides about why something is hard or interesting
- Show the journey, not just the destination ("we tried X, but Y worked better")
- End with opinions ("that's worth the tradeoff")
They still shouldn't:
- Ramble or over-explain
- Use hollow importance claims ("this is crucial for...")
- Get too casual or jokey
### Describe what we did, not what to do
Nuggets explain how tldraw solved a problem—they're not tutorials. Frame solutions as "here's what we do" rather than prescriptive instructions.
**Don't:**
> The solution: don't decide immediately. Watch what the fingers do, then commit once the pattern is clear.
> Instead of guessing, implement a state machine that starts undecided.
**Do:**
> Since we don't have enough information to know either way, we defer the decision. The gesture handler watches what the pointers do, then commits once we know enough to recognize the interaction pattern.
> Instead of guessing, we use a state machine that starts undecided and resolves as more information comes in.
The reader learns from seeing our approach, not from being told what to do.
### Example openings
**Too abrupt (reads like docs):**
> Tldraw calculates dash patterns that fit paths exactly. Complete dashes at both ends, even spacing throughout.
**Better (starts with our experience):**
> When we added dashed lines to tldraw, we wanted them to look right—complete dashes at both ends, even spacing, corners that line up on rectangles. SVG's `stroke-dasharray` doesn't do this.
**Also good (frames the problem we faced):**
> Arrow routing sounds simple until you try it. Given two shapes, draw a line between them that doesn't pass through anything else. We spent a while getting this right.
## Code examples
### Complete and runnable, followed by fragments
When showing code examples, your _first_ snippet should provide a full working example. Following examples can be fragments.
**Do:**
```tsx
import { Tldraw } from 'tldraw'
import 'tldraw/tldraw.css'
export default function () {
return (
<div style={{ position: 'fixed', inset: 0 }}>
<Tldraw persistenceKey="my-persistence-key" />
</div>
)
}
```
**Don't:**
```tsx
// Add persistenceKey to your Tldraw component
<Tldraw persistenceKey="..." />
```
If you continue to reference the same code example, your following examples can be fragments of the first.
**Do:**
The `Tldraw` component will take up the size of its parent. In this example, the parent will take up the whole page:
```tsx
import { Tldraw } from 'tldraw'
import 'tldraw/tldraw.css'
export default function () {
return (
<div style={{ position: 'fixed', inset: 0 }}>
<Tldraw persistenceKey="my-persistence-key" />
</div>
)
}
```
If we wanted to show it inline, then we could style it like this:
```tsx
<div style={{ height: 500, width: 800 }}>
<Tldraw persistenceKey="my-persistence-key" />
</div>
```
**Don't:**
The `Tldraw` component will take up the size of its parent. In this example, the parent will take up the whole page:
```tsx
import { Tldraw } from 'tldraw'
import 'tldraw/tldraw.css'
export default function () {
return (
<div style={{ position: 'fixed', inset: 0 }}>
<Tldraw persistenceKey="my-persistence-key" />
</div>
)
}
```
If we wanted to show it inline, then we could style it like this:
```tsx
import { Tldraw } from 'tldraw'
import 'tldraw/tldraw.css'
export default function () {
return (
<div style={{ height: 500, width: 800 }}>
<Tldraw persistenceKey="my-persistence-key" />
</div>
)
}
```
When in doubt, show the full example.
### Comments are conversational
Use comments to provide context, not obvious descriptions:
```tsx
editor.run(
() => {
editor.createShapes(myShapes)
},
{ history: 'ignore' } // Changes won't affect undo/redo
)
```
```ts
// If you are building a multi-user app, you probably want to store
// the document and session states separately because the
// session state is user-specific and normally shouldn't be shared.
```
### Show realistic data
Use meaningful example data, not placeholders:
**Do:**
```json
{
"type": "geo",
"props": {
"geo": "rectangle",
"w": 200,
"h": 200,
"color": "blue",
"text": "diagram"
}
}
```
**Don't:**
```json
{
"type": "example-type",
"props": {
"prop1": "value1",
"prop2": "value2"
}
}
```
Note the use of branded types. Your snippets should be paste-able without causing TypeScript errors.
- for IDs, use either `"shape:123" as TLShapeId` or `createShapeId("123")`
## Cross-referencing
### Link liberally
Reference related concepts inline rather than explaining everything:
> For more information about how to synchronize the store with other processes, see the [Persistence](/docs/persistence) page.
### API references use consistent format
Link to API docs using the `[MethodName](?)` pattern:
> Use the [Editor#createShapes](?) method.
> See [TLInstancePresence](?) for the full record type.
### Point to working examples
Always link to runnable examples when available:
> For an example of how to create custom shapes, see our [custom shapes example](/examples/shapes/tools/custom-shape).
## General notes
- Do not include AI attribution in written content
- American English spelling
- Avoid complicated grammar, obscure vocabulary, jokes, or cultural idioms
## Evaluation checklist
When reviewing documentation, check:
- [ ] **Opening sentence** — Does it immediately define what this thing is?
- [ ] **Active voice** — Are most sentences active, not passive?
- [ ] **Code examples** — Is every concept followed by working code?
- [ ] **Confidence** — Are assertions direct, without hedging?
- [ ] **Readability** — Does it read naturally? Is cognitive load managed?
- [ ] **Pronouns** — Is "you" used for the reader and "we" for tldraw?
- [ ] **Sentence case** — Are headings in sentence case?
- [ ] **Progressive disclosure** — Does complexity build gradually?
- [ ] **Honesty** — Are limitations stated directly?
- [ ] **Links** — Are related concepts cross-referenced?
- [ ] **Human voice** — No hollow importance claims, trailing gerunds, or formulaic transitions?
## Summary
Write like a knowledgeable colleague who:
- Gets to the point quickly
- Shows working code immediately
- Respects the reader's intelligence
- Is honest about limitations
- Has occasional warmth without being chatty
The goal is documentation that developers trust, can scan quickly, and can copy-paste to get something working.
## Tips
- The apps/docs app is the public documentation website for the tldraw SDK. These articles presume a human audience. Articles there are allowed to include information that may be available elsewhere in the tldraw repository.

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

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