snapshot of https://github.com/tldraw/tldraw.git (main, 2026-09-23; blobs > 131072B omitted)
Some checks failed
Deploy analytics worker / Deploy analytics worker to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
i18n upload strings / Upload latest strings (push) Failing after 1s
Deploy bemo / Deploy bemo to ${{ (github.ref == 'refs/heads/bemo-production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
Deploy grafana resources / ${{ github.event_name == 'push' && 'Deploy grafana resources' || 'Validate grafana resources (dry run)' }} (push) Failing after 0s
End to end tests (dotcom) / Check for relevant changes (push) Failing after 0s
End to end tests (examples) / Check for relevant changes (push) Failing after 0s
End to end tests (mermaid readability) / Check for relevant changes (push) Failing after 1s
End to end tests (perf) / Check for relevant changes (push) Failing after 0s
Publish editor extensions / Publish editor extensions (push) Failing after 1s
Publish SDK packages / Remove the publish-packages label (push) Has been skipped
Checks / Tests & checks (push) Has been cancelled
Checks / Build all projects (push) Has been cancelled
Deploy templates / Deploy templates (push) Has been cancelled
End to end tests (dotcom) / End to end tests (dotcom) (push) Has been cancelled
End to end tests (mermaid readability) / End to end tests (mermaid readability) (push) Has been cancelled
End to end tests (perf) / End to end tests (perf) (push) Has been cancelled
Deploy .com / Deploy dotcom to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Has been cancelled
End to end tests (examples) / End to end tests (examples) (push) Has been cancelled
Publish SDK packages / Publish SDK packages (push) Has been cancelled
Publish SDK packages / Tell user released version (push) Has been cancelled
Publish SDK packages / Publishes code templates to separate repositories (push) Has been cancelled
Publish SDK packages / Deploys the template demos with the new SDK version (push) Has been cancelled
Some checks failed
Deploy analytics worker / Deploy analytics worker to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
i18n upload strings / Upload latest strings (push) Failing after 1s
Deploy bemo / Deploy bemo to ${{ (github.ref == 'refs/heads/bemo-production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
Deploy grafana resources / ${{ github.event_name == 'push' && 'Deploy grafana resources' || 'Validate grafana resources (dry run)' }} (push) Failing after 0s
End to end tests (dotcom) / Check for relevant changes (push) Failing after 0s
End to end tests (examples) / Check for relevant changes (push) Failing after 0s
End to end tests (mermaid readability) / Check for relevant changes (push) Failing after 1s
End to end tests (perf) / Check for relevant changes (push) Failing after 0s
Publish editor extensions / Publish editor extensions (push) Failing after 1s
Publish SDK packages / Remove the publish-packages label (push) Has been skipped
Checks / Tests & checks (push) Has been cancelled
Checks / Build all projects (push) Has been cancelled
Deploy templates / Deploy templates (push) Has been cancelled
End to end tests (dotcom) / End to end tests (dotcom) (push) Has been cancelled
End to end tests (mermaid readability) / End to end tests (mermaid readability) (push) Has been cancelled
End to end tests (perf) / End to end tests (perf) (push) Has been cancelled
Deploy .com / Deploy dotcom to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Has been cancelled
End to end tests (examples) / End to end tests (examples) (push) Has been cancelled
Publish SDK packages / Publish SDK packages (push) Has been cancelled
Publish SDK packages / Tell user released version (push) Has been cancelled
Publish SDK packages / Publishes code templates to separate repositories (push) Has been cancelled
Publish SDK packages / Deploys the template demos with the new SDK version (push) Has been cancelled
This commit is contained in:
1
.agents/skills
Symbolic link
1
.agents/skills
Symbolic link
@@ -0,0 +1 @@
|
||||
../skills
|
||||
23
.claude/launch.json
Normal file
23
.claude/launch.json
Normal 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
53
.claude/settings.json
Normal 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
1
.claude/skills
Symbolic link
@@ -0,0 +1 @@
|
||||
../skills
|
||||
19
.cursor-plugin/marketplace.json
Normal file
19
.cursor-plugin/marketplace.json
Normal 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
1
.cursor/skills
Symbolic link
@@ -0,0 +1 @@
|
||||
../skills
|
||||
74
.dockerignore
Normal file
74
.dockerignore
Normal 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
49
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal 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
8
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal 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.
|
||||
16
.github/ISSUE_TEMPLATE/example_request.yml
vendored
Normal file
16
.github/ISSUE_TEMPLATE/example_request.yml
vendored
Normal 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
|
||||
27
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
27
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal 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
13
.github/ISSUE_TEMPLATE/task.yml
vendored
Normal 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
|
||||
69
.github/actions/deploy-templates/action.yml
vendored
Normal file
69
.github/actions/deploy-templates/action.yml
vendored
Normal 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
|
||||
32
.github/actions/discord-fail-notify/action.yml
vendored
Normal file
32
.github/actions/discord-fail-notify/action.yml
vendored
Normal 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"
|
||||
46
.github/actions/setup-playwright/action.yml
vendored
Normal file
46
.github/actions/setup-playwright/action.yml
vendored
Normal 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
39
.github/actions/setup/action.yml
vendored
Normal 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
21
.github/pull_request_template.md
vendored
Normal 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
73
.github/workflows/bump-versions.yml
vendored
Normal 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
112
.github/workflows/checks.yml
vendored
Normal 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
52
.github/workflows/claude.yml
vendored
Normal 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:*)'
|
||||
37
.github/workflows/close-stale-issues.yml
vendored
Normal file
37
.github/workflows/close-stale-issues.yml
vendored
Normal 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
41
.github/workflows/dependabot-dedupe.yml
vendored
Normal 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
55
.github/workflows/deploy-analytics.yml
vendored
Normal 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
61
.github/workflows/deploy-bemo.yml
vendored
Normal 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
215
.github/workflows/deploy-dotcom.yml
vendored
Normal 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
115
.github/workflows/deploy-grafana.yml
vendored
Normal 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
63
.github/workflows/deploy-mcp-app.yml
vendored
Normal 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
57
.github/workflows/deploy-templates.yml
vendored
Normal 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
183
.github/workflows/e2e-device.yml
vendored
Normal 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
35
.github/workflows/get-changelog.yml
vendored
Normal 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
|
||||
73
.github/workflows/i18n-download-strings.yml
vendored
Normal file
73
.github/workflows/i18n-download-strings.yml
vendored
Normal 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
|
||||
35
.github/workflows/i18n-upload-strings.yml
vendored
Normal file
35
.github/workflows/i18n-upload-strings.yml
vendored
Normal 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
122
.github/workflows/issue-triage.yml
vendored
Normal 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
52
.github/workflows/license-report.yml
vendored
Normal 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
129
.github/workflows/playwright-dotcom.yml
vendored
Normal 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
|
||||
106
.github/workflows/playwright-examples.yml
vendored
Normal file
106
.github/workflows/playwright-examples.yml
vendored
Normal 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
|
||||
71
.github/workflows/playwright-mermaid.yml
vendored
Normal file
71
.github/workflows/playwright-mermaid.yml
vendored
Normal 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
107
.github/workflows/playwright-perf.yml
vendored
Normal 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
|
||||
84
.github/workflows/playwright-update-snapshots.yml
vendored
Normal file
84
.github/workflows/playwright-update-snapshots.yml
vendored
Normal 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
56
.github/workflows/post-release-crew.yml
vendored
Normal 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 }}
|
||||
57
.github/workflows/post-release-marketing.yml
vendored
Normal file
57
.github/workflows/post-release-marketing.yml
vendored
Normal 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 }}
|
||||
64
.github/workflows/prune-preview-deploys.yml
vendored
Normal file
64
.github/workflows/prune-preview-deploys.yml
vendored
Normal 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 }}
|
||||
73
.github/workflows/publish-editor-extensions.yml
vendored
Normal file
73
.github/workflows/publish-editor-extensions.yml
vendored
Normal 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
71
.github/workflows/publish-templates.yml
vendored
Normal 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 }}
|
||||
70
.github/workflows/publish-vscode-extension.yml
vendored
Normal file
70
.github/workflows/publish-vscode-extension.yml
vendored
Normal 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
330
.github/workflows/publish.yml
vendored
Normal 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 }}
|
||||
38
.github/workflows/staging-cleanup-daily.yml
vendored
Normal file
38
.github/workflows/staging-cleanup-daily.yml
vendored
Normal 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
68
.github/workflows/staging-e2e.yml
vendored
Normal 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'
|
||||
69
.github/workflows/trigger-dotcom-hotfix.yml
vendored
Normal file
69
.github/workflows/trigger-dotcom-hotfix.yml
vendored
Normal 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 }}
|
||||
118
.github/workflows/trigger-production-build.yml
vendored
Normal file
118
.github/workflows/trigger-production-build.yml
vendored
Normal 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
|
||||
70
.github/workflows/trigger-sdk-hotfix.yml
vendored
Normal file
70
.github/workflows/trigger-sdk-hotfix.yml
vendored
Normal 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 }}
|
||||
160
.github/workflows/update-release-notes.yml
vendored
Normal file
160
.github/workflows/update-release-notes.yml
vendored
Normal 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
141
.gitignore
vendored
Normal 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
15
.husky/post-checkout
Executable 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
13
.husky/post-merge
Executable 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
31
.husky/pre-commit
Executable 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
3
.husky/pre-push
Executable 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
33
.ignore
Normal 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
13
.oxfmtrc.json
Normal 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
460
.oxlintrc.json
Normal 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
45
.prettierignore
Normal 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
9
.prettierrc
Normal 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
3
.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["oxc.oxc-vscode"]
|
||||
}
|
||||
91
.yarn/patches/@microsoft-tsdoc-npm-0.15.1-e24295d9bd.patch
Normal file
91
.yarn/patches/@microsoft-tsdoc-npm-0.15.1-e24295d9bd.patch
Normal 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
14
.yarnrc.yml
Normal 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
222
AGENTS.md
Normal 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
35
CLA.md
Normal 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)
|
||||
48
CODE_OF_CONDUCT.md
Normal file
48
CODE_OF_CONDUCT.md
Normal 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
5
COMPANY.md
Normal 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
13
CONTRIBUTING.md
Normal 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
3
FAQ.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# Frequently Asked Questions
|
||||
|
||||
Find our FAQs [here](https://tldraw.notion.site/faq).
|
||||
70
LICENSE.md
Normal file
70
LICENSE.md
Normal 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
139
README.md
Normal 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
92
RELEASES.md
Normal 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
29
SECURITY.md
Normal 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
66
TRADEMARKS.md
Normal 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
807
VOICE.md
Normal 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
2
apps/analytics-worker/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
build
|
||||
.wrangler
|
||||
28
apps/analytics-worker/package.json
Normal file
28
apps/analytics-worker/package.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "@tldraw/analytics-worker",
|
||||
"description": "tldraw infinite canvas SDK (analytics worker).",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"author": {
|
||||
"name": "tldraw GB Ltd.",
|
||||
"email": "hello@tldraw.com"
|
||||
},
|
||||
"main": "./src/worker.ts",
|
||||
"/* GOTCHA */": "files will include ./dist and index.d.ts by default, add any others you want to include in here",
|
||||
"files": [],
|
||||
"scripts": {
|
||||
"dev": "yarn run -T tsx ../../internal/scripts/workers/dev.ts",
|
||||
"test-ci": "yarn run -T vitest run --passWithNoTests",
|
||||
"test": "yarn run -T vitest --passWithNoTests",
|
||||
"test-coverage": "yarn run -T vitest run --coverage --passWithNoTests",
|
||||
"check-bundle-size": "yarn run -T tsx ../../internal/scripts/check-worker-bundle.ts --entry src/worker.ts --size-limit-bytes 350000",
|
||||
"lint": "yarn run -T tsx ../../internal/scripts/lint.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "^4.20260302.0",
|
||||
"esbuild": "^0.28.0",
|
||||
"lazyrepo": "0.0.0-alpha.27",
|
||||
"typescript": "^7.0.2",
|
||||
"wrangler": "^4.75.0"
|
||||
}
|
||||
}
|
||||
117
apps/analytics-worker/src/worker.ts
Normal file
117
apps/analytics-worker/src/worker.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
/// <reference no-default-lib="true"/>
|
||||
/// <reference types="@cloudflare/workers-types" />
|
||||
|
||||
/**
|
||||
* Simple CloudFlare worker that returns whether the user requires cookie consent
|
||||
* based on their geographic location (via CF-IPCountry header)
|
||||
*/
|
||||
|
||||
/**
|
||||
* Validates if an origin is allowed to make cross-origin requests.
|
||||
* Returns the origin if allowed, or undefined if blocked.
|
||||
*/
|
||||
function isAllowedOrigin(origin: string | null): string | undefined {
|
||||
if (!origin) return undefined
|
||||
if (origin === 'http://localhost:3000') return origin
|
||||
if (origin === 'http://localhost:3001') return origin
|
||||
if (origin === 'http://localhost:5420') return origin
|
||||
if (origin === 'https://meet.google.com') return origin
|
||||
if (origin === 'https://tldraw.dev') return origin
|
||||
if (origin.endsWith('.tldraw.com')) return origin
|
||||
if (origin.endsWith('.tldraw.dev')) return origin
|
||||
if (origin.endsWith('.tldraw.club')) return origin
|
||||
if (origin.endsWith('.tldraw.xyz')) return origin
|
||||
if (origin.endsWith('.tldraw.workers.dev')) return origin
|
||||
if (origin.endsWith('-tldraw.vercel.app')) return origin
|
||||
return undefined
|
||||
}
|
||||
|
||||
const EXPLICIT_CONSENT_COUNTRIES = [
|
||||
// EU Member States (GDPR)
|
||||
'AT',
|
||||
'BE',
|
||||
'BG',
|
||||
'HR',
|
||||
'CY',
|
||||
'CZ',
|
||||
'DK',
|
||||
'EE',
|
||||
'FI',
|
||||
'FR',
|
||||
'DE',
|
||||
'GR',
|
||||
'HU',
|
||||
'IE',
|
||||
'IT',
|
||||
'LV',
|
||||
'LT',
|
||||
'LU',
|
||||
'MT',
|
||||
'NL',
|
||||
'PL',
|
||||
'PT',
|
||||
'RO',
|
||||
'SK',
|
||||
'SI',
|
||||
'ES',
|
||||
'SE',
|
||||
// EEA/EFTA (GDPR)
|
||||
'IS',
|
||||
'LI',
|
||||
'NO',
|
||||
// Post-Brexit UK (UK PECR)
|
||||
'GB',
|
||||
// Switzerland (FADP)
|
||||
'CH',
|
||||
// Brazil (LGPD)
|
||||
'BR',
|
||||
]
|
||||
|
||||
export default {
|
||||
async fetch(request: Request): Promise<Response> {
|
||||
const origin = request.headers.get('origin')
|
||||
const allowedOrigin = isAllowedOrigin(origin)
|
||||
|
||||
// Handle CORS preflight
|
||||
if (request.method === 'OPTIONS') {
|
||||
const headers: Record<string, string> = {
|
||||
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type',
|
||||
'Access-Control-Max-Age': '86400',
|
||||
}
|
||||
if (allowedOrigin) {
|
||||
headers['Access-Control-Allow-Origin'] = allowedOrigin
|
||||
}
|
||||
return new Response(null, { headers })
|
||||
}
|
||||
|
||||
// Only allow GET requests
|
||||
if (request.method !== 'GET') {
|
||||
return new Response('Method not allowed', { status: 405 })
|
||||
}
|
||||
|
||||
// Get country code from CloudFlare header
|
||||
const countryCode = request.headers.get('CF-IPCountry')
|
||||
|
||||
// Check if consent is required
|
||||
// Default to requiring consent if country is unknown
|
||||
const requiresConsent = !countryCode || EXPLICIT_CONSENT_COUNTRIES.includes(countryCode)
|
||||
|
||||
// Return response with CORS headers
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'public, max-age=3600', // Cache for 1 hour
|
||||
}
|
||||
if (allowedOrigin) {
|
||||
headers['Access-Control-Allow-Origin'] = allowedOrigin
|
||||
}
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
requires_consent: requiresConsent,
|
||||
country_code: countryCode,
|
||||
}),
|
||||
{ headers }
|
||||
)
|
||||
},
|
||||
}
|
||||
7
apps/analytics-worker/tsconfig.json
Normal file
7
apps/analytics-worker/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "../../internal/config/tsconfig.base.json",
|
||||
"include": ["src", "scripts"],
|
||||
"exclude": ["node_modules", "dist", ".tsbuild*"],
|
||||
"compilerOptions": { "noEmit": true, "emitDeclarationOnly": false },
|
||||
"references": []
|
||||
}
|
||||
15
apps/analytics-worker/vitest.config.ts
Normal file
15
apps/analytics-worker/vitest.config.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
/// <reference types="vitest" />
|
||||
import { mergeConfig } from 'vitest/config'
|
||||
import baseConfig from '../../internal/config/vitest/node-preset'
|
||||
|
||||
// bemo-worker is a Node.js/Cloudflare Worker environment
|
||||
export default mergeConfig(baseConfig, {
|
||||
test: {
|
||||
// Use node environment instead of jsdom for worker apps
|
||||
environment: 'node',
|
||||
// Module name mapping for path aliases
|
||||
alias: {
|
||||
'^~(.*)$': './src/$1',
|
||||
},
|
||||
},
|
||||
})
|
||||
44
apps/analytics-worker/wrangler.toml
Normal file
44
apps/analytics-worker/wrangler.toml
Normal file
@@ -0,0 +1,44 @@
|
||||
main = "src/worker.ts"
|
||||
compatibility_date = "2024-06-20"
|
||||
upload_source_maps = true
|
||||
|
||||
#################### Environment names ####################
|
||||
|
||||
# Dev environment
|
||||
[env.dev]
|
||||
name = "tldraw-consent-dev"
|
||||
|
||||
# Staging environment
|
||||
[env.staging]
|
||||
name = "tldraw-consent-staging"
|
||||
|
||||
# Staging environment
|
||||
[env.preview]
|
||||
name = "tldraw-consent-preview"
|
||||
|
||||
# Production environment
|
||||
[env.production]
|
||||
name = "tldraw-consent"
|
||||
|
||||
#################### Routes ####################
|
||||
# in production, we use a custom domain. others get a default *.tldraw.workers.dev domain
|
||||
[[env.production.routes]]
|
||||
pattern = 'consent.tldraw.xyz'
|
||||
custom_domain = true
|
||||
zone_name = 'tldraw.xyz'
|
||||
|
||||
#################### Version metadata ####################
|
||||
[version_metadata]
|
||||
binding = "CF_VERSION_METADATA"
|
||||
|
||||
[env.dev.version_metadata]
|
||||
binding = "CF_VERSION_METADATA"
|
||||
|
||||
[env.preview.version_metadata]
|
||||
binding = "CF_VERSION_METADATA"
|
||||
|
||||
[env.staging.version_metadata]
|
||||
binding = "CF_VERSION_METADATA"
|
||||
|
||||
[env.production.version_metadata]
|
||||
binding = "CF_VERSION_METADATA"
|
||||
2
apps/analytics/.gitignore
vendored
Normal file
2
apps/analytics/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
.yarn
|
||||
public
|
||||
40
apps/analytics/index.html
Normal file
40
apps/analytics/index.html
Normal file
@@ -0,0 +1,40 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Analytics Banner Preview</title>
|
||||
<style>
|
||||
body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: white;
|
||||
color: black;
|
||||
}
|
||||
body.dark {
|
||||
background-color: black;
|
||||
color: white;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<button id="toggle-theme">toggle theme</button>
|
||||
<button id="cookie-dialog">show cookie preferences</button>
|
||||
</body>
|
||||
<script>
|
||||
const button = document.getElementById('toggle-theme')
|
||||
button.addEventListener('click', () => {
|
||||
document.body.classList.toggle('dark')
|
||||
document.body.setAttribute(
|
||||
'data-theme',
|
||||
document.body.classList.contains('dark') ? 'dark' : 'light'
|
||||
)
|
||||
})
|
||||
|
||||
const cookieButton = document.getElementById('cookie-dialog')
|
||||
cookieButton.addEventListener('click', () => {
|
||||
window.tlanalytics.openPrivacySettings()
|
||||
})
|
||||
</script>
|
||||
<script src="./public/tl-analytics.js"></script>
|
||||
</html>
|
||||
27
apps/analytics/package.json
Normal file
27
apps/analytics/package.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "@tldraw/analytics",
|
||||
"version": "0.1.0",
|
||||
"license": "",
|
||||
"private": true,
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"build": "tsc && vite build --outDir public",
|
||||
"dev": "vite",
|
||||
"lint": "yarn run -T tsx ../../internal/scripts/lint.ts",
|
||||
"test": "yarn run -T vitest --passWithNoTests",
|
||||
"test-ci": "yarn run -T vitest run --passWithNoTests",
|
||||
"test-coverage": "yarn run -T vitest run --coverage --passWithNoTests"
|
||||
},
|
||||
"dependencies": {
|
||||
"js-cookie": "^3.0.5",
|
||||
"posthog-js": "^1.281.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "^4.20260302.0",
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
"typescript": "^7.0.2",
|
||||
"vite": "^8.0.16",
|
||||
"vitest": "^5.0.0",
|
||||
"wrangler": "^4.75.0"
|
||||
}
|
||||
}
|
||||
48
apps/analytics/src/analytics-services/analytics-service.ts
Normal file
48
apps/analytics/src/analytics-services/analytics-service.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
import { ConsentPreferences } from '../types'
|
||||
|
||||
export class AnalyticsService {
|
||||
protected isEnabled = false
|
||||
// Initialize the service (regardless of cookie consent)
|
||||
initialize(): void {}
|
||||
// Clean up the service.
|
||||
dispose(): void {}
|
||||
// Enable the service when cookie consent is granted.
|
||||
enable(): void {}
|
||||
// Disable the service when cookie consent is revoked.
|
||||
disable(): void {}
|
||||
// Identify the user.
|
||||
identify(userId: string, properties?: { [key: string]: any }): void {}
|
||||
// Reset the user identity (call on logout).
|
||||
reset(): void {}
|
||||
// Track an event.
|
||||
trackEvent(name: string, data?: { [key: string]: any }): void {}
|
||||
// Track a pageview.
|
||||
trackPageview(): void {}
|
||||
// Track when consent banner is displayed (called before consent is granted).
|
||||
trackConsentBannerDisplayed?(data: { consent_opt_in_type: 'manual' | 'auto' }): void
|
||||
// Track when user selects consent preferences (called before consent is granted).
|
||||
trackConsentBannerSelected?(data: ConsentPreferences): void
|
||||
// Track when user copies a code snippet.
|
||||
trackCopyCode?(data: {
|
||||
page_category: string
|
||||
text_snippet: string
|
||||
user_email?: string
|
||||
user_email_sha256?: string
|
||||
user_first_name?: string
|
||||
user_last_name?: string
|
||||
user_phone_number?: string
|
||||
}): void
|
||||
// Track when user submits a form.
|
||||
trackFormSubmission?(data: {
|
||||
enquiry_type: string
|
||||
page_category?: string
|
||||
company_size?: string
|
||||
company_website?: string
|
||||
user_email?: string
|
||||
user_email_sha256?: string
|
||||
user_first_name?: string
|
||||
user_last_name?: string
|
||||
user_phone_number?: string
|
||||
}): void
|
||||
}
|
||||
119
apps/analytics/src/analytics-services/ga4.ts
Normal file
119
apps/analytics/src/analytics-services/ga4.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { AnalyticsService } from './analytics-service'
|
||||
|
||||
// Extend Window with gtag and dataLayer (other Window properties declared in types.ts)
|
||||
declare global {
|
||||
interface Window {
|
||||
gtag?(...args: any[]): void
|
||||
dataLayer?: any[]
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to safely call gtag
|
||||
function gtag(...args: any[]) {
|
||||
if (typeof window !== 'undefined' && window.gtag) {
|
||||
window.gtag(...args)
|
||||
}
|
||||
}
|
||||
|
||||
export function ga4Gtag(...args: any[]) {
|
||||
gtag(...args)
|
||||
}
|
||||
|
||||
class GA4AnalyticsService extends AnalyticsService {
|
||||
private measurementId: string | undefined
|
||||
private googleAdsId: string | undefined
|
||||
|
||||
override initialize() {
|
||||
if (typeof window === 'undefined') return
|
||||
|
||||
this.measurementId = window.TL_GA4_MEASUREMENT_ID ?? undefined
|
||||
this.googleAdsId = window.TL_GOOGLE_ADS_ID ?? undefined
|
||||
|
||||
if (this.measurementId) {
|
||||
gtag('consent', 'default', {
|
||||
ad_storage: 'denied',
|
||||
ad_user_data: 'denied',
|
||||
ad_personalization: 'denied',
|
||||
analytics_storage: 'denied',
|
||||
// Wait for our cookie to load.
|
||||
wait_for_update: 500,
|
||||
})
|
||||
|
||||
// Initialize dataLayer
|
||||
window.dataLayer = window.dataLayer || []
|
||||
window.gtag = function () {
|
||||
// eslint-disable-next-line prefer-rest-params
|
||||
window.dataLayer!.push(arguments)
|
||||
}
|
||||
gtag('js', new Date())
|
||||
|
||||
// Load the GA4 script
|
||||
const script = document.createElement('script')
|
||||
script.async = true
|
||||
script.id = `gtag-${this.measurementId}`
|
||||
script.src = `https://www.googletagmanager.com/gtag/js?id=${this.measurementId}`
|
||||
document.head.appendChild(script)
|
||||
|
||||
gtag('config', this.measurementId, {
|
||||
anonymize_ip: true,
|
||||
})
|
||||
}
|
||||
|
||||
if (this.googleAdsId) {
|
||||
gtag('config', this.googleAdsId)
|
||||
}
|
||||
}
|
||||
|
||||
override dispose() {
|
||||
const script = document.getElementById(`gtag-${this.measurementId}`)
|
||||
if (script) script.remove()
|
||||
}
|
||||
|
||||
override enable() {
|
||||
if (this.isEnabled) return
|
||||
gtag('set', { anonymize_ip: false })
|
||||
gtag('consent', 'update', {
|
||||
ad_user_data: 'granted',
|
||||
ad_personalization: 'granted',
|
||||
ad_storage: 'granted',
|
||||
analytics_storage: 'granted',
|
||||
})
|
||||
this.isEnabled = true
|
||||
}
|
||||
|
||||
override disable() {
|
||||
if (!this.isEnabled) return
|
||||
// Clear user properties
|
||||
if (this.measurementId) {
|
||||
gtag('config', this.measurementId, {
|
||||
client_id: undefined,
|
||||
user_id: undefined,
|
||||
})
|
||||
}
|
||||
gtag('set', { anonymize_ip: true })
|
||||
gtag('consent', 'update', {
|
||||
ad_user_data: 'denied',
|
||||
ad_personalization: 'denied',
|
||||
ad_storage: 'denied',
|
||||
analytics_storage: 'denied',
|
||||
})
|
||||
this.isEnabled = false
|
||||
}
|
||||
|
||||
override identify(userId: string, properties?: { [key: string]: any }) {
|
||||
gtag('set', { user_id: userId })
|
||||
if (properties) {
|
||||
gtag('set', properties)
|
||||
}
|
||||
}
|
||||
|
||||
override trackEvent(name: string, data?: { [key: string]: any }) {
|
||||
gtag('event', name, data)
|
||||
}
|
||||
|
||||
override trackPageview() {
|
||||
gtag('event', 'page_view')
|
||||
}
|
||||
}
|
||||
|
||||
export const ga4Service = new GA4AnalyticsService()
|
||||
182
apps/analytics/src/analytics-services/gtm.ts
Normal file
182
apps/analytics/src/analytics-services/gtm.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
import { GTM_SCRIPT_ID } from '../constants'
|
||||
import { ConsentPreferences } from '../types'
|
||||
import { AnalyticsService } from './analytics-service'
|
||||
|
||||
// Extend Window with dataLayer (other Window properties declared in types.ts)
|
||||
declare global {
|
||||
interface Window {
|
||||
dataLayer?: any[]
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to safely push to dataLayer (internal use only)
|
||||
function dataLayerPush(data: any) {
|
||||
if (typeof window !== 'undefined' && window.dataLayer) {
|
||||
window.dataLayer.push(data)
|
||||
}
|
||||
}
|
||||
|
||||
class GTMAnalyticsService extends AnalyticsService {
|
||||
private gtmContainerId: string | undefined
|
||||
|
||||
override initialize() {
|
||||
if (typeof window === 'undefined') return
|
||||
|
||||
this.gtmContainerId = window.TL_GTM_CONTAINER_ID ?? undefined
|
||||
|
||||
if (!this.gtmContainerId) return
|
||||
|
||||
// Set default consent state
|
||||
window.dataLayer = window.dataLayer || []
|
||||
dataLayerPush({
|
||||
'gtm.start': new Date().getTime(),
|
||||
event: 'gtm.js',
|
||||
})
|
||||
|
||||
// Load the GTM script
|
||||
const script = document.createElement('script')
|
||||
script.async = true
|
||||
script.id = GTM_SCRIPT_ID
|
||||
script.src = `https://www.googletagmanager.com/gtm.js?id=${this.gtmContainerId}`
|
||||
document.head.appendChild(script)
|
||||
}
|
||||
|
||||
override dispose() {
|
||||
const script = document.getElementById(GTM_SCRIPT_ID)
|
||||
if (script) script.remove()
|
||||
}
|
||||
|
||||
override enable() {
|
||||
this.isEnabled = true
|
||||
}
|
||||
|
||||
override disable() {
|
||||
this.isEnabled = false
|
||||
}
|
||||
|
||||
override identify(userId: string, properties?: { [key: string]: any }) {
|
||||
if (!this.isEnabled) return
|
||||
dataLayerPush({
|
||||
event: 'user_identify',
|
||||
user_id: userId,
|
||||
user_properties: properties,
|
||||
})
|
||||
}
|
||||
|
||||
override trackEvent(name: string, data?: { [key: string]: any }) {
|
||||
dataLayerPush({
|
||||
event: name,
|
||||
...data,
|
||||
})
|
||||
}
|
||||
|
||||
override trackPageview() {
|
||||
dataLayerPush({
|
||||
event: 'page_view',
|
||||
})
|
||||
}
|
||||
|
||||
override trackConsentBannerDisplayed(data: { consent_opt_in_type: 'manual' | 'auto' }) {
|
||||
dataLayerPush({
|
||||
event: 'display_consent_banner',
|
||||
id: crypto.randomUUID(),
|
||||
data,
|
||||
})
|
||||
}
|
||||
|
||||
override trackConsentBannerSelected(data: ConsentPreferences) {
|
||||
dataLayerPush({
|
||||
event: 'select_consent_banner',
|
||||
id: crypto.randomUUID(),
|
||||
data,
|
||||
})
|
||||
}
|
||||
|
||||
override trackCopyCode(data: {
|
||||
page_category: string
|
||||
text_snippet: string
|
||||
user_email?: string
|
||||
user_email_sha256?: string
|
||||
user_first_name?: string
|
||||
user_last_name?: string
|
||||
user_phone_number?: string
|
||||
}) {
|
||||
const payload: any = {
|
||||
event: 'click_copy_code',
|
||||
id: crypto.randomUUID(),
|
||||
page: {
|
||||
category: data.page_category.toLowerCase(),
|
||||
},
|
||||
data: {
|
||||
text_snippet: data.text_snippet.substring(0, 100),
|
||||
},
|
||||
_clear: true,
|
||||
}
|
||||
|
||||
// Only include user object if we have user data
|
||||
if (
|
||||
data.user_email ||
|
||||
data.user_email_sha256 ||
|
||||
data.user_first_name ||
|
||||
data.user_last_name ||
|
||||
data.user_phone_number
|
||||
) {
|
||||
payload.user = {}
|
||||
if (data.user_email) payload.user.email = data.user_email.toLowerCase()
|
||||
if (data.user_email_sha256) payload.user.email_sha256 = data.user_email_sha256.toLowerCase()
|
||||
if (data.user_first_name) payload.user.first_name = data.user_first_name.toLowerCase()
|
||||
if (data.user_last_name) payload.user.last_name = data.user_last_name.toLowerCase()
|
||||
if (data.user_phone_number) payload.user.phone_number = data.user_phone_number
|
||||
}
|
||||
|
||||
dataLayerPush(payload)
|
||||
}
|
||||
|
||||
override trackFormSubmission(data: {
|
||||
enquiry_type: string
|
||||
page_category?: string
|
||||
company_size?: string
|
||||
company_website?: string
|
||||
user_email?: string
|
||||
user_email_sha256?: string
|
||||
user_first_name?: string
|
||||
user_last_name?: string
|
||||
user_phone_number?: string
|
||||
}) {
|
||||
const payload: any = {
|
||||
event: 'generate_lead',
|
||||
id: crypto.randomUUID(),
|
||||
page: {
|
||||
category: (data.page_category || 'enquiry').toLowerCase(),
|
||||
},
|
||||
data: {
|
||||
enquiry_type: data.enquiry_type.toLowerCase(),
|
||||
},
|
||||
_clear: true,
|
||||
}
|
||||
|
||||
// Add optional company data
|
||||
if (data.company_size) payload.data.company_size = data.company_size.toLowerCase()
|
||||
if (data.company_website) payload.data.company_website = data.company_website.toLowerCase()
|
||||
|
||||
// Only include user object if we have user data
|
||||
if (
|
||||
data.user_email ||
|
||||
data.user_email_sha256 ||
|
||||
data.user_first_name ||
|
||||
data.user_last_name ||
|
||||
data.user_phone_number
|
||||
) {
|
||||
payload.user = {}
|
||||
if (data.user_email) payload.user.email = data.user_email.toLowerCase()
|
||||
if (data.user_email_sha256) payload.user.email_sha256 = data.user_email_sha256.toLowerCase()
|
||||
if (data.user_first_name) payload.user.first_name = data.user_first_name.toLowerCase()
|
||||
if (data.user_last_name) payload.user.last_name = data.user_last_name.toLowerCase()
|
||||
if (data.user_phone_number) payload.user.phone_number = data.user_phone_number
|
||||
}
|
||||
|
||||
dataLayerPush(payload)
|
||||
}
|
||||
}
|
||||
|
||||
export const gtmService = new GTMAnalyticsService()
|
||||
23
apps/analytics/src/analytics-services/hubspot.ts
Normal file
23
apps/analytics/src/analytics-services/hubspot.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { HUBSPOT_SCRIPT_ID, HUBSPOT_SCRIPT_URL } from '../constants'
|
||||
import { AnalyticsService } from './analytics-service'
|
||||
|
||||
class HubspotAnalyticsService extends AnalyticsService {
|
||||
// Does not need to be initialized
|
||||
override enable() {
|
||||
if (this.isEnabled) return
|
||||
const hubspotScriptTag = document.createElement('script')
|
||||
hubspotScriptTag.id = HUBSPOT_SCRIPT_ID
|
||||
hubspotScriptTag.src = HUBSPOT_SCRIPT_URL
|
||||
hubspotScriptTag.defer = true
|
||||
document.head.appendChild(hubspotScriptTag)
|
||||
this.isEnabled = true
|
||||
}
|
||||
|
||||
override dispose() {
|
||||
const hubspotScriptTag = document.getElementById(HUBSPOT_SCRIPT_ID)
|
||||
if (hubspotScriptTag) hubspotScriptTag.remove()
|
||||
this.isEnabled = false
|
||||
}
|
||||
}
|
||||
|
||||
export const hubspotService = new HubspotAnalyticsService()
|
||||
53
apps/analytics/src/analytics-services/posthog.ts
Normal file
53
apps/analytics/src/analytics-services/posthog.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import _posthog from 'posthog-js'
|
||||
import { POSTHOG_API_HOST, POSTHOG_TOKEN, POSTHOG_UI_HOST } from '../constants'
|
||||
import { AnalyticsService } from './analytics-service'
|
||||
|
||||
class PosthogAnalyticsService extends AnalyticsService {
|
||||
override initialize() {
|
||||
// Make posthog available globally
|
||||
if (typeof window !== 'undefined') {
|
||||
window.posthog = _posthog
|
||||
}
|
||||
|
||||
_posthog.init(POSTHOG_TOKEN, {
|
||||
api_host: POSTHOG_API_HOST,
|
||||
ui_host: POSTHOG_UI_HOST,
|
||||
capture_pageview: 'history_change',
|
||||
cookieless_mode: 'on_reject',
|
||||
})
|
||||
}
|
||||
|
||||
override enable() {
|
||||
if (this.isEnabled) return
|
||||
_posthog.opt_in_capturing()
|
||||
this.isEnabled = true
|
||||
}
|
||||
|
||||
override disable() {
|
||||
if (!this.isEnabled) return
|
||||
_posthog.setPersonProperties({ analytics_consent: false })
|
||||
_posthog.opt_out_capturing()
|
||||
this.isEnabled = false
|
||||
}
|
||||
|
||||
override identify(userId: string, properties?: { [key: string]: any }) {
|
||||
_posthog.identify(userId, {
|
||||
...properties,
|
||||
analytics_consent: true,
|
||||
})
|
||||
}
|
||||
|
||||
override reset() {
|
||||
_posthog.reset()
|
||||
}
|
||||
|
||||
override trackEvent(name: string, data?: { [key: string]: any }) {
|
||||
_posthog.capture(name, data)
|
||||
}
|
||||
|
||||
override trackPageview() {
|
||||
_posthog.capture('$pageview')
|
||||
}
|
||||
}
|
||||
|
||||
export const posthogService = new PosthogAnalyticsService()
|
||||
39
apps/analytics/src/analytics-services/reo.ts
Normal file
39
apps/analytics/src/analytics-services/reo.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { REO_CLIENT_ID, REO_SCRIPT_ID, REO_SCRIPT_URL } from '../constants'
|
||||
import { AnalyticsService } from './analytics-service'
|
||||
|
||||
class ReoAnalyticsService extends AnalyticsService {
|
||||
override enable() {
|
||||
if (this.isEnabled) return
|
||||
const reoScriptTag = document.createElement('script')
|
||||
reoScriptTag.id = REO_SCRIPT_ID
|
||||
reoScriptTag.src = REO_SCRIPT_URL
|
||||
reoScriptTag.defer = true
|
||||
reoScriptTag.onload = () => window.Reo?.init?.({ clientID: REO_CLIENT_ID })
|
||||
document.head.appendChild(reoScriptTag)
|
||||
this.isEnabled = true
|
||||
}
|
||||
|
||||
override disable() {
|
||||
if (!this.isEnabled) return
|
||||
window.Reo?.reset?.()
|
||||
this.isEnabled = false
|
||||
}
|
||||
|
||||
override dispose() {
|
||||
const reoScriptTag = document.getElementById(REO_SCRIPT_ID)
|
||||
if (reoScriptTag) reoScriptTag.remove()
|
||||
this.isEnabled = false
|
||||
}
|
||||
|
||||
override identify(userId: string, properties?: { [key: string]: any }) {
|
||||
window.Reo?.identify?.({
|
||||
...properties,
|
||||
userId,
|
||||
firstname: properties?.name || '',
|
||||
username: properties?.email || '',
|
||||
type: 'email',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const reoService = new ReoAnalyticsService()
|
||||
90
apps/analytics/src/components/CookieConsentBanner.ts
Normal file
90
apps/analytics/src/components/CookieConsentBanner.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { DOT_DEV_COOKIE_POLICY_URL } from '../constants'
|
||||
import { CookieConsentState } from '../state/cookie-consent-state'
|
||||
import { ThemeState } from '../state/theme-state'
|
||||
|
||||
export function createCookieConsentBanner(
|
||||
cookieConsentState: CookieConsentState,
|
||||
themeState: ThemeState
|
||||
): HTMLElement | null {
|
||||
const consent = cookieConsentState.getValue()
|
||||
const theme = themeState.getValue()
|
||||
|
||||
// Don't show banner if consent is already given
|
||||
if (consent !== 'unknown') return null
|
||||
|
||||
// Create banner element
|
||||
const banner = document.createElement('div')
|
||||
banner.className = 'tl-analytics-banner'
|
||||
banner.setAttribute('data-theme', theme)
|
||||
|
||||
// Enable pointer events after animation completes
|
||||
setTimeout(() => {
|
||||
banner.style.pointerEvents = 'auto'
|
||||
}, 3000)
|
||||
|
||||
// Create content
|
||||
banner.innerHTML = `
|
||||
<p class="tl-analytics-banner__text">
|
||||
We use cookies on this website.
|
||||
<br>
|
||||
Learn more in our <a class="tl-analytics-banner__link" href="${DOT_DEV_COOKIE_POLICY_URL}" target="_blank" rel="noreferrer">Cookie Policy</a>.
|
||||
</p>
|
||||
<div class="tl-analytics-banner__buttons">
|
||||
<button class="tl-analytics-button tl-analytics-button--secondary" data-action="opt-out">
|
||||
Opt out
|
||||
</button>
|
||||
<button class="tl-analytics-button tl-analytics-button--primary" data-action="accept">
|
||||
<div class="tl-analytics-button__text-wrapper">
|
||||
<div class="tl-analytics-button__text">Accept all</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
`
|
||||
|
||||
// Add event listeners
|
||||
banner.addEventListener('click', (e: Event) => {
|
||||
const target = e.target as HTMLElement
|
||||
const action = target.closest('[data-action]')?.getAttribute('data-action')
|
||||
if (action === 'opt-out') {
|
||||
cookieConsentState.setValue('opted-out')
|
||||
} else if (action === 'accept') {
|
||||
cookieConsentState.setValue('opted-in')
|
||||
}
|
||||
})
|
||||
|
||||
const consentCleanup = cookieConsentState.subscribe((consent) => {
|
||||
if (consent !== 'unknown') {
|
||||
banner.remove()
|
||||
}
|
||||
})
|
||||
|
||||
// Watch for theme changes
|
||||
const themeCleanup = themeState.subscribe((theme) => {
|
||||
banner.setAttribute('data-theme', theme)
|
||||
})
|
||||
|
||||
// Clean up observer when banner is removed
|
||||
const originalRemove = banner.remove.bind(banner)
|
||||
banner.remove = function () {
|
||||
originalRemove()
|
||||
themeCleanup()
|
||||
consentCleanup()
|
||||
}
|
||||
|
||||
return banner
|
||||
}
|
||||
|
||||
// Auto-mount function for easy integration
|
||||
export function mountCookieConsentBanner(
|
||||
cookieConsentState: CookieConsentState,
|
||||
themeState: ThemeState,
|
||||
container: HTMLElement
|
||||
): HTMLElement | null {
|
||||
const existingBanner = document.querySelector('.tl-analytics-banner')
|
||||
if (existingBanner) existingBanner.remove()
|
||||
|
||||
const banner = createCookieConsentBanner(cookieConsentState, themeState)
|
||||
if (banner) container.appendChild(banner)
|
||||
|
||||
return banner
|
||||
}
|
||||
132
apps/analytics/src/components/PrivacySettingsDialog.ts
Normal file
132
apps/analytics/src/components/PrivacySettingsDialog.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { DOT_DEV_COOKIE_POLICY_URL } from '../constants'
|
||||
import { CookieConsentState } from '../state/cookie-consent-state'
|
||||
import { ThemeState } from '../state/theme-state'
|
||||
|
||||
export function createPrivacySettingsDialog(
|
||||
cookieConsentState: CookieConsentState,
|
||||
themeState: ThemeState
|
||||
): HTMLElement {
|
||||
const consent = cookieConsentState.getValue()
|
||||
const theme = themeState.getValue()
|
||||
|
||||
// Create dialog container
|
||||
const dialogContainer = document.createElement('div')
|
||||
dialogContainer.className = 'tl-analytics-dialog'
|
||||
|
||||
// Create overlay and dialog
|
||||
const overlay = document.createElement('div')
|
||||
overlay.className = 'tl-analytics-dialog__overlay'
|
||||
overlay.setAttribute('data-theme', theme)
|
||||
|
||||
const dialogWrapper = document.createElement('div')
|
||||
dialogWrapper.className = 'tl-analytics-dialog__wrapper'
|
||||
dialogWrapper.setAttribute('data-theme', theme)
|
||||
|
||||
const dialog = document.createElement('div')
|
||||
dialog.className = 'tl-analytics-dialog__content'
|
||||
|
||||
// Create dialog content
|
||||
dialog.innerHTML = `
|
||||
<div class="tl-analytics-dialog__header">
|
||||
<h2 class="tl-analytics-dialog__title">Privacy settings</h2>
|
||||
<button class="tl-analytics-dialog__close" aria-label="Close">
|
||||
<svg width="15" height="15" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<p class="tl-analytics-dialog__body">
|
||||
This website uses cookies to collect analytics from visitors. Read our
|
||||
<a href="${DOT_DEV_COOKIE_POLICY_URL}" target="_blank" rel="noreferrer">cookie policy</a>
|
||||
to learn more.
|
||||
</p>
|
||||
<div class="tl-analytics-dialog__checkbox-group">
|
||||
<label class="tl-analytics-checkbox__label" for="privacy-analytics">
|
||||
<strong>Analytics</strong> help us understand how people use this website so that we can make it better.
|
||||
</label>
|
||||
<div>
|
||||
<button class="tl-analytics-checkbox ${consent === 'opted-in' ? 'checked' : ''}" id="privacy-analytics" role="switch" aria-checked="${consent === 'opted-in'}">
|
||||
<span class="tl-analytics-checkbox__thumb"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
// Add event listeners
|
||||
const closeButton = dialog.querySelector('.tl-analytics-dialog__close') as HTMLButtonElement
|
||||
const switchButton = dialog.querySelector('.tl-analytics-checkbox') as HTMLButtonElement
|
||||
|
||||
function closeDialog() {
|
||||
dialogContainer.remove()
|
||||
}
|
||||
|
||||
closeButton.addEventListener('click', closeDialog)
|
||||
|
||||
// Close on overlay click
|
||||
overlay.addEventListener('click', closeDialog)
|
||||
|
||||
// Prevent closing when clicking inside dialog
|
||||
dialog.addEventListener('click', (e: Event) => e.stopPropagation())
|
||||
|
||||
// Handle ESC key
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
closeDialog()
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
|
||||
// Handle switch toggle
|
||||
switchButton.addEventListener('click', () => {
|
||||
const currentConsent = cookieConsentState.getValue()
|
||||
cookieConsentState.setValue(currentConsent === 'opted-in' ? 'opted-out' : 'opted-in')
|
||||
})
|
||||
|
||||
// Watch for consent changes
|
||||
const cleanupConsent = cookieConsentState.subscribe((consent) => {
|
||||
if (consent === 'opted-in') {
|
||||
switchButton.classList.add('checked')
|
||||
switchButton.setAttribute('aria-checked', 'true')
|
||||
} else {
|
||||
switchButton.classList.remove('checked')
|
||||
switchButton.setAttribute('aria-checked', 'false')
|
||||
}
|
||||
})
|
||||
|
||||
// Watch for theme changes
|
||||
const cleanupTheme = themeState.subscribe((theme) => {
|
||||
overlay.setAttribute('data-theme', theme)
|
||||
dialogWrapper.setAttribute('data-theme', theme)
|
||||
})
|
||||
|
||||
// Clean up observer when dialog is removed
|
||||
const originalRemove = dialogContainer.remove.bind(dialogContainer)
|
||||
dialogContainer.remove = function () {
|
||||
document.removeEventListener('keydown', handleKeyDown)
|
||||
originalRemove()
|
||||
cleanupTheme()
|
||||
cleanupConsent()
|
||||
}
|
||||
|
||||
// Assemble dialog
|
||||
dialogWrapper.appendChild(dialog)
|
||||
dialogContainer.appendChild(overlay)
|
||||
dialogContainer.appendChild(dialogWrapper)
|
||||
|
||||
return dialogContainer
|
||||
}
|
||||
|
||||
// Auto-mount function for easy integration
|
||||
export function mountPrivacySettingsDialog(
|
||||
cookieConsentState: CookieConsentState,
|
||||
themeState: ThemeState,
|
||||
container: HTMLElement
|
||||
): HTMLElement {
|
||||
const existingDialog = document.querySelector('.tl-analytics-dialog')
|
||||
if (existingDialog) existingDialog.remove()
|
||||
|
||||
const dialog = createPrivacySettingsDialog(cookieConsentState, themeState)
|
||||
if (dialog) container.appendChild(dialog)
|
||||
|
||||
return dialog
|
||||
}
|
||||
24
apps/analytics/src/constants.ts
Normal file
24
apps/analytics/src/constants.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
// All analytics use the same cookie policy URL.
|
||||
export const DOT_DEV_COOKIE_POLICY_URL = 'https://tldraw.notion.site/devcookiepolicy'
|
||||
|
||||
// All analytics use the same cookie to store consent.
|
||||
export const LEGACY_CONSENT_COOKIE_NAME = 'allowTracking'
|
||||
export const CONSENT_COOKIE_NAME = 'trackingConsent'
|
||||
export const CONSENT_CHANGED_EVENT = 'tl-analytics-consent-changed'
|
||||
|
||||
// All analytics are fed into the same HubSpot project.
|
||||
export const HUBSPOT_SCRIPT_ID = 'hs-script-loader'
|
||||
export const HUBSPOT_SCRIPT_URL = 'https://js-eu1.hs-scripts.com/145620695.js'
|
||||
|
||||
// All analytics are fed into the same Reo project.
|
||||
export const REO_SCRIPT_ID = 'reo-script-loader'
|
||||
export const REO_CLIENT_ID = '47839e47a5ed202'
|
||||
export const REO_SCRIPT_URL = `https://static.reo.dev/${REO_CLIENT_ID}/reo.js`
|
||||
|
||||
// All analytics are fed into the same PostHog project.
|
||||
export const POSTHOG_TOKEN = 'phc_i8oKgMzgV38sn3GfjswW9mevQ3gFlo7bJXekZFeDN6'
|
||||
export const POSTHOG_API_HOST = 'https://analytics.tldraw.com/i'
|
||||
export const POSTHOG_UI_HOST = 'https://eu.i.posthog.com'
|
||||
|
||||
// Google Tag Manager configuration.
|
||||
export const GTM_SCRIPT_ID = 'gtm-script-loader'
|
||||
305
apps/analytics/src/index.ts
Normal file
305
apps/analytics/src/index.ts
Normal file
@@ -0,0 +1,305 @@
|
||||
import { ga4Gtag, ga4Service } from './analytics-services/ga4'
|
||||
import { gtmService } from './analytics-services/gtm'
|
||||
import { hubspotService } from './analytics-services/hubspot'
|
||||
import { posthogService } from './analytics-services/posthog'
|
||||
import { reoService } from './analytics-services/reo'
|
||||
import { mountCookieConsentBanner } from './components/CookieConsentBanner'
|
||||
import { mountPrivacySettingsDialog } from './components/PrivacySettingsDialog'
|
||||
import {
|
||||
CookieConsentState,
|
||||
getCookieConsent,
|
||||
setOrClearCookieConsent,
|
||||
} from './state/cookie-consent-state'
|
||||
import { ThemeState } from './state/theme-state'
|
||||
import { ConsentPreferences, CookieConsent } from './types'
|
||||
import { shouldRequireConsent } from './utils/consent-check'
|
||||
import styles from './styles.css?inline'
|
||||
|
||||
function cookieConsentToPreferences(
|
||||
consent: CookieConsent,
|
||||
consentOptInType: 'manual' | 'auto'
|
||||
): ConsentPreferences {
|
||||
if (consent === 'opted-in') {
|
||||
return { analytics: 'granted', marketing: 'granted', opt_in_type: consentOptInType }
|
||||
}
|
||||
return { analytics: 'denied', marketing: 'denied', opt_in_type: consentOptInType }
|
||||
}
|
||||
|
||||
class Analytics {
|
||||
private userId = '' as string
|
||||
private userProperties = {} as { [key: string]: any } | undefined
|
||||
private consent = 'unknown' as CookieConsent
|
||||
private consentOptInType: 'manual' | 'auto' = 'manual'
|
||||
private consentCallbacks: Array<(preferences: ConsentPreferences) => void> = []
|
||||
private isInitialized = false
|
||||
|
||||
private services = [posthogService, ga4Service, gtmService, hubspotService, reoService]
|
||||
|
||||
private maybeTrackConsentUpdate(consent: ConsentPreferences) {
|
||||
if (this.isInitialized) {
|
||||
this.track('consent_update', consent)
|
||||
} else {
|
||||
gtmService.trackEvent('consent_update', consent)
|
||||
}
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
// Inject styles
|
||||
const style = document.createElement('style')
|
||||
style.textContent = styles
|
||||
document.head.appendChild(style)
|
||||
|
||||
// Initialize the analytics services
|
||||
for (const service of this.services) {
|
||||
service.initialize()
|
||||
}
|
||||
|
||||
// Set up our mini reactive states
|
||||
|
||||
const themeState = new ThemeState('light')
|
||||
themeState.initialize()
|
||||
|
||||
const cookieConsentState = new CookieConsentState('unknown')
|
||||
cookieConsentState.initialize()
|
||||
|
||||
// Subscribe to consent changes
|
||||
cookieConsentState.subscribe((consent) => {
|
||||
// If the user changed the setting we want to change opt in type to manual
|
||||
if (this.isInitialized && consent !== 'unknown' && consent !== this.consent) {
|
||||
this.consentOptInType = 'manual'
|
||||
}
|
||||
// Set (or clear) the cookie value
|
||||
setOrClearCookieConsent(consent, this.consentOptInType)
|
||||
|
||||
const consentState = cookieConsentToPreferences(consent, this.consentOptInType)
|
||||
|
||||
// If the consent is the same as the current consent, do nothing
|
||||
if (consent === this.consent) {
|
||||
// We still send the event to gtm, so that we can send data to gtm on website reload
|
||||
gtmService.trackEvent('consent_update', consentState)
|
||||
return
|
||||
}
|
||||
|
||||
this.consent = consent
|
||||
|
||||
if (this.consent === 'opted-in') {
|
||||
// Enable the analytics services only when consent is granted
|
||||
for (const service of this.services) {
|
||||
service.enable()
|
||||
}
|
||||
|
||||
this.maybeTrackConsentUpdate(consentState)
|
||||
|
||||
// Identify the user if we have a userId. Most of the time
|
||||
// identify() should be called off of the window.tlanalytics object, ie. in an app
|
||||
// where we have a user id and properties for that app (like tldraw computer). We do
|
||||
// it here, too, so that we can re-identify a user who has opted out of analytics
|
||||
// and then opts back in within the same session. We would have their user id and properties
|
||||
// in memory (from the first time they called identify) and would re-use them here.
|
||||
if (this.userId) {
|
||||
this.identify(this.userId, this.userProperties)
|
||||
}
|
||||
} else {
|
||||
this.maybeTrackConsentUpdate(consentState)
|
||||
// Disable the analytics services when consent is revoked or when consent is unknown
|
||||
for (const service of this.services) {
|
||||
service.disable()
|
||||
}
|
||||
}
|
||||
|
||||
// Track consent selection only if user actually interacted with banner (not during initialization)
|
||||
if (this.isInitialized && consent !== 'unknown') {
|
||||
for (const service of this.services) {
|
||||
service.trackConsentBannerSelected?.(consentState)
|
||||
}
|
||||
}
|
||||
|
||||
// Notify consent callbacks
|
||||
if (consent !== 'unknown') {
|
||||
for (const callback of this.consentCallbacks) {
|
||||
callback(consentState)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// ...also we stash a few things onto the window in case we need them elsewhere
|
||||
window.tlanalytics = {
|
||||
identify: this.identify.bind(this),
|
||||
reset: this.reset.bind(this),
|
||||
track: this.track.bind(this),
|
||||
page: this.page.bind(this),
|
||||
gtag: this.gtag.bind(this),
|
||||
getConsentState: () => cookieConsentToPreferences(this.consent, this.consentOptInType),
|
||||
onConsentUpdate: (callback: (preferences: ConsentPreferences) => void) => {
|
||||
this.consentCallbacks.push(callback)
|
||||
return () => {
|
||||
const index = this.consentCallbacks.indexOf(callback)
|
||||
if (index > -1) this.consentCallbacks.splice(index, 1)
|
||||
}
|
||||
},
|
||||
trackCopyCode: this.trackCopyCode.bind(this),
|
||||
trackFormSubmission: this.trackFormSubmission.bind(this),
|
||||
openPrivacySettings() {
|
||||
mountPrivacySettingsDialog(cookieConsentState, themeState, document.body)
|
||||
},
|
||||
}
|
||||
|
||||
// Now that we have our subscriber set up, determine the initial consent state.
|
||||
// If the user has already made a choice (cookie exists), use that.
|
||||
// Otherwise, check their location to determine if we need explicit consent.
|
||||
const cookieData = getCookieConsent()
|
||||
let initialConsent: CookieConsent
|
||||
|
||||
if (cookieData) {
|
||||
// User has previously made a consent decision - respect it and restore opt-in type
|
||||
initialConsent = cookieData.consent
|
||||
this.consentOptInType = cookieData.optInType
|
||||
} else {
|
||||
// No existing consent decision - check if we need to ask based on location
|
||||
const requiresConsent = await shouldRequireConsent()
|
||||
if (requiresConsent === 'requires-consent') {
|
||||
initialConsent = 'unknown'
|
||||
this.consentOptInType = 'manual'
|
||||
} else {
|
||||
initialConsent = 'opted-in'
|
||||
this.consentOptInType = 'auto'
|
||||
}
|
||||
}
|
||||
|
||||
// This will trigger the subscriber we set up earlier, which will
|
||||
// enable/disable the analytics services accordingly
|
||||
cookieConsentState.setValue(initialConsent)
|
||||
|
||||
// Now that we have the consent value in memory, we can initialize
|
||||
// the cookie consent banner and start showing some UI (if consent is unknown)
|
||||
const banner = mountCookieConsentBanner(cookieConsentState, themeState, document.body)
|
||||
|
||||
// Track banner display if banner was shown
|
||||
if (banner) {
|
||||
for (const service of this.services) {
|
||||
service.trackConsentBannerDisplayed?.({
|
||||
consent_opt_in_type: this.consentOptInType,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Mark initialization as complete - any consent changes after this are user interactions
|
||||
this.isInitialized = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap and guard google analytics tag. This should be called from window.tlanalytics, ie. in an app where we have a user id and properties for that app (like tldraw computer).
|
||||
*
|
||||
* @param args - The arguments to pass to the google analytics tag
|
||||
* @returns void
|
||||
*/
|
||||
gtag(...args: any[]) {
|
||||
if (this.consent !== 'opted-in') return
|
||||
ga4Gtag(...args)
|
||||
}
|
||||
|
||||
/**
|
||||
* Identify the user across all analytics services. This should be called from window.tlanalytics, ie. in an app where we have a user id and properties for that app (like tldraw computer).
|
||||
*
|
||||
* @param userId - The user id to identify the user by
|
||||
* @param properties - The properties to identify the user by
|
||||
* @returns void
|
||||
*/
|
||||
identify(userId: string, properties?: { [key: string]: any }) {
|
||||
this.userId = userId
|
||||
this.userProperties = properties
|
||||
|
||||
if (this.consent !== 'opted-in') return
|
||||
|
||||
for (const service of this.services) {
|
||||
service.identify(userId, properties)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the user identity across all analytics services. This should be called from window.tlanalytics when a user logs out.
|
||||
*
|
||||
* @returns void
|
||||
*/
|
||||
reset() {
|
||||
this.userId = ''
|
||||
this.userProperties = undefined
|
||||
|
||||
for (const service of this.services) {
|
||||
service.reset()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Track an event across all analytics services. This should be called from window.tlanalytics, ie. in an app where we have a user id and properties for that app (like tldraw computer).
|
||||
*
|
||||
* @param name - The name of the event to track
|
||||
* @param data - The data to track with the event
|
||||
* @returns void
|
||||
*/
|
||||
track(name: string, data?: { [key: string]: any }) {
|
||||
for (const service of this.services) {
|
||||
service.trackEvent(name, data)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Track a page view across all analytics services. This should be called from window.tlanalytics, ie. in an app where we have a user id and properties for that app (like tldraw computer).
|
||||
*
|
||||
* @returns void
|
||||
*/
|
||||
page() {
|
||||
for (const service of this.services) {
|
||||
service.trackPageview()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Track code copy event. This should be called from window.tlanalytics when a user copies a code snippet.
|
||||
*
|
||||
* @param data - The code copy event data
|
||||
* @returns void
|
||||
*/
|
||||
trackCopyCode(data: {
|
||||
page_category: string
|
||||
text_snippet: string
|
||||
user_email?: string
|
||||
user_email_sha256?: string
|
||||
user_first_name?: string
|
||||
user_last_name?: string
|
||||
user_phone_number?: string
|
||||
}) {
|
||||
for (const service of this.services) {
|
||||
service.trackCopyCode?.(data)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Track form submission event. This should be called from window.tlanalytics when a user submits a form.
|
||||
*
|
||||
* @param data - The form submission event data
|
||||
* @returns void
|
||||
*/
|
||||
trackFormSubmission(data: {
|
||||
enquiry_type: string
|
||||
page_category?: string
|
||||
company_size?: string
|
||||
company_website?: string
|
||||
user_email?: string
|
||||
user_email_sha256?: string
|
||||
user_first_name?: string
|
||||
user_last_name?: string
|
||||
user_phone_number?: string
|
||||
}) {
|
||||
for (const service of this.services) {
|
||||
service.trackFormSubmission?.(data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const analytics = new Analytics()
|
||||
analytics.initialize()
|
||||
} catch (error) {
|
||||
console.error('Error initializing analytics manager:', error)
|
||||
}
|
||||
68
apps/analytics/src/state/cookie-consent-state.ts
Normal file
68
apps/analytics/src/state/cookie-consent-state.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import Cookies from 'js-cookie'
|
||||
import { CONSENT_COOKIE_NAME, LEGACY_CONSENT_COOKIE_NAME } from '../constants'
|
||||
import {
|
||||
CONSENT_OPT_IN_TYPES,
|
||||
COOKIE_CONSENT_VALUES,
|
||||
type ConsentOptInType,
|
||||
type CookieConsent,
|
||||
type CookieConsentData,
|
||||
} from '../types'
|
||||
import { AnalyticsState } from './state'
|
||||
|
||||
function isValidCookieConsentData(data: any): data is CookieConsentData {
|
||||
return (
|
||||
typeof data === 'object' &&
|
||||
data !== null &&
|
||||
COOKIE_CONSENT_VALUES.includes(data.consent) &&
|
||||
CONSENT_OPT_IN_TYPES.includes(data.optInType)
|
||||
)
|
||||
}
|
||||
|
||||
export function getCookieConsent(): CookieConsentData | undefined {
|
||||
// Check new cookie format first - takes precedence
|
||||
const cookieValue = Cookies.get(CONSENT_COOKIE_NAME)
|
||||
if (cookieValue) {
|
||||
// Parse JSON format
|
||||
try {
|
||||
const parsed = JSON.parse(cookieValue)
|
||||
if (isValidCookieConsentData(parsed)) {
|
||||
// Clean up legacy cookie if it exists
|
||||
Cookies.remove(LEGACY_CONSENT_COOKIE_NAME)
|
||||
return parsed
|
||||
}
|
||||
} catch {
|
||||
// Invalid JSON - fall through to legacy check
|
||||
}
|
||||
}
|
||||
|
||||
// Check for legacy cookie and migrate
|
||||
const legacyCookieValue = Cookies.get(LEGACY_CONSENT_COOKIE_NAME)
|
||||
if (legacyCookieValue) {
|
||||
let migrated: CookieConsentData | undefined
|
||||
|
||||
if (legacyCookieValue === 'true') {
|
||||
migrated = { consent: 'opted-in', optInType: 'manual' }
|
||||
} else if (legacyCookieValue === 'false') {
|
||||
migrated = { consent: 'opted-out', optInType: 'manual' }
|
||||
}
|
||||
|
||||
if (migrated) {
|
||||
// Write to new cookie and delete old one
|
||||
Cookies.set(CONSENT_COOKIE_NAME, JSON.stringify(migrated))
|
||||
Cookies.remove(LEGACY_CONSENT_COOKIE_NAME)
|
||||
return migrated
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function setOrClearCookieConsent(consent: CookieConsent, optInType: ConsentOptInType): void {
|
||||
if (consent === 'unknown') {
|
||||
Cookies.remove(CONSENT_COOKIE_NAME)
|
||||
} else {
|
||||
Cookies.set(CONSENT_COOKIE_NAME, JSON.stringify({ consent, optInType }))
|
||||
}
|
||||
}
|
||||
|
||||
export class CookieConsentState extends AnalyticsState<CookieConsent> {}
|
||||
57
apps/analytics/src/state/state.test.ts
Normal file
57
apps/analytics/src/state/state.test.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { AnalyticsState } from './state'
|
||||
|
||||
describe('mini reactive state', () => {
|
||||
test('creates a state with an initial value', () => {
|
||||
class MyState extends AnalyticsState<number> {}
|
||||
const myState = new MyState(10)
|
||||
expect(myState.getValue()).toBe(10)
|
||||
const myOtherState = new MyState(20)
|
||||
expect(myOtherState.getValue()).toBe(20)
|
||||
})
|
||||
|
||||
test('updates the state', () => {
|
||||
class MyState extends AnalyticsState<number> {}
|
||||
const myState = new MyState(10)
|
||||
myState.setValue(20)
|
||||
expect(myState.getValue()).toBe(20)
|
||||
})
|
||||
|
||||
test('handles subscribers', () => {
|
||||
class MyState extends AnalyticsState<number> {}
|
||||
const myState = new MyState(10)
|
||||
const fn = vi.fn()
|
||||
myState.subscribe((value) => fn(value))
|
||||
myState.setValue(20)
|
||||
expect(fn).toHaveBeenCalledWith(20)
|
||||
expect(fn).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
test('removes subscribers', () => {
|
||||
class MyState extends AnalyticsState<number> {}
|
||||
const myState = new MyState(10)
|
||||
const fn = vi.fn()
|
||||
const unsubscribe = myState.subscribe((value) => fn(value))
|
||||
unsubscribe()
|
||||
myState.setValue(20)
|
||||
expect(fn).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('handles initializer / dispose', () => {
|
||||
const someState = {} as { something?: boolean }
|
||||
|
||||
class MyState extends AnalyticsState<number> {
|
||||
override initialize(): void {
|
||||
someState.something = true
|
||||
}
|
||||
override dispose(): void {
|
||||
delete someState.something
|
||||
}
|
||||
}
|
||||
const myState = new MyState(10)
|
||||
expect(someState.something).not.toBeDefined()
|
||||
myState.initialize()
|
||||
expect(someState.something).toBeDefined()
|
||||
myState.dispose()
|
||||
expect(someState.something).not.toBeDefined()
|
||||
})
|
||||
})
|
||||
59
apps/analytics/src/state/state.ts
Normal file
59
apps/analytics/src/state/state.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
export class AnalyticsState<T> {
|
||||
// The current value of the state.
|
||||
protected value: T
|
||||
|
||||
// Set of listeners to notify when the state changes.
|
||||
protected listeners = new Set<(value: T) => void>()
|
||||
|
||||
constructor(initialValue: T) {
|
||||
this.value = initialValue
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to the state change.
|
||||
*
|
||||
* @param listener - The listener to notify when the state changes.
|
||||
* @returns A function to unsubscribe the listener.
|
||||
*/
|
||||
subscribe(listener: (value: T) => void): () => void {
|
||||
this.listeners.add(listener)
|
||||
return () => this.listeners.delete(listener)
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify all listeners of the state change.
|
||||
*/
|
||||
notify() {
|
||||
this.listeners.forEach((listener) => listener(this.value))
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the state.
|
||||
*/
|
||||
initialize(): void {}
|
||||
|
||||
/**
|
||||
* Clean up the state.
|
||||
*/
|
||||
dispose(): void {}
|
||||
|
||||
/**
|
||||
* Get the current value of the state.
|
||||
*
|
||||
* @returns The current value.
|
||||
*/
|
||||
getValue(): T {
|
||||
return this.value
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the value of the state and notify all listeners.
|
||||
*
|
||||
* @param value - The new value to set.
|
||||
*/
|
||||
setValue(value: T): void {
|
||||
if (value === this.value) return
|
||||
this.value = value
|
||||
this.notify()
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user