diff --git a/apps/docs/app/layout.tsx b/apps/docs/app/layout.tsx index 1531ba3f60db..b7ee5df5fa57 100644 --- a/apps/docs/app/layout.tsx +++ b/apps/docs/app/layout.tsx @@ -5,6 +5,7 @@ import localFont from 'next/font/local' import AutoRefresh from '@/components/common/autorefresh' import { Footer } from '@/components/navigation/footer' import { Header } from '@/components/navigation/header' +import { assetUrl } from '@/utils/asset-url' import { cn } from '@/utils/cn' import Analytics from './analytics' import './github-dark.css' @@ -37,13 +38,13 @@ export const metadata: Metadata = { statusBarStyle: 'black', }, icons: [ - { rel: 'shortcut icon', url: '/favicon.svg' }, - { rel: 'icon', url: '/favicon-32x32.svg', sizes: '32x32' }, - { rel: 'icon', url: '/favicon-16x16.svg', sizes: '16x16' }, - { rel: 'apple-touch-icon', url: '/apple-touch-icon.png' }, - { rel: 'apple-touch-icon', url: '/apple-touch-icon-152x152.svg', sizes: '152x152' }, - { rel: 'apple-touch-icon', url: '/apple-touch-icon-180x180.svg', sizes: '180x180' }, - { rel: 'apple-touch-icon', url: '/apple-touch-icon-167x167.svg', sizes: '167x167' }, + { rel: 'shortcut icon', url: assetUrl('/favicon.svg') }, + { rel: 'icon', url: assetUrl('/favicon-32x32.svg'), sizes: '32x32' }, + { rel: 'icon', url: assetUrl('/favicon-16x16.svg'), sizes: '16x16' }, + { rel: 'apple-touch-icon', url: assetUrl('/apple-touch-icon.png') }, + { rel: 'apple-touch-icon', url: assetUrl('/apple-touch-icon-152x152.svg'), sizes: '152x152' }, + { rel: 'apple-touch-icon', url: assetUrl('/apple-touch-icon-180x180.svg'), sizes: '180x180' }, + { rel: 'apple-touch-icon', url: assetUrl('/apple-touch-icon-167x167.svg'), sizes: '167x167' }, ], } diff --git a/apps/docs/components/content/image.tsx b/apps/docs/components/content/image.tsx index ba65f711b583..e2687ef356c8 100644 --- a/apps/docs/components/content/image.tsx +++ b/apps/docs/components/content/image.tsx @@ -1,13 +1,16 @@ +import { assetUrl } from '@/utils/asset-url' import { TldrawLink } from '../common/tldraw-link' export function Image(props: any) { + const src = typeof props.src === 'string' ? assetUrl(props.src) : props.src + const href = props.href ?? src return ( - {props.title} + {props.title} {props.caption && ( diff --git a/apps/docs/next.config.js b/apps/docs/next.config.js index be9b4065c2d7..2c462df7a33a 100644 --- a/apps/docs/next.config.js +++ b/apps/docs/next.config.js @@ -5,6 +5,7 @@ const REWRITE_DOMAIN = 'tldrawdotdev.framer.website' const nextConfig = { reactStrictMode: true, + assetPrefix: process.env.ASSET_PREFIX || undefined, experimental: { scrollRestoration: true, }, diff --git a/apps/docs/utils/asset-url.test.ts b/apps/docs/utils/asset-url.test.ts new file mode 100644 index 000000000000..9d80817e0576 --- /dev/null +++ b/apps/docs/utils/asset-url.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest' +import { assetUrl } from './asset-url' + +describe('assetUrl', () => { + it('returns the original path when ASSET_PREFIX is unset', () => { + const prev = process.env.ASSET_PREFIX + try { + delete process.env.ASSET_PREFIX + expect(assetUrl('/favicon.svg')).toBe('/favicon.svg') + } finally { + process.env.ASSET_PREFIX = prev + } + }) + + it('returns the original path when ASSET_PREFIX is empty/whitespace', () => { + const prev = process.env.ASSET_PREFIX + try { + process.env.ASSET_PREFIX = ' ' + expect(assetUrl('/favicon.svg')).toBe('/favicon.svg') + } finally { + process.env.ASSET_PREFIX = prev + } + }) + + it('prefixes root-relative paths', () => { + const prev = process.env.ASSET_PREFIX + try { + process.env.ASSET_PREFIX = 'https://docs.example.com' + expect(assetUrl('/images/foo.png')).toBe('https://docs.example.com/images/foo.png') + } finally { + process.env.ASSET_PREFIX = prev + } + }) + + it('strips a trailing slash from the prefix', () => { + const prev = process.env.ASSET_PREFIX + try { + process.env.ASSET_PREFIX = 'https://docs.example.com/' + expect(assetUrl('/images/foo.png')).toBe('https://docs.example.com/images/foo.png') + } finally { + process.env.ASSET_PREFIX = prev + } + }) + + it('does not change non-root-relative paths', () => { + const prev = process.env.ASSET_PREFIX + try { + process.env.ASSET_PREFIX = 'https://docs.example.com' + expect(assetUrl('https://cdn.example.com/a.png')).toBe('https://cdn.example.com/a.png') + expect(assetUrl('relative.png')).toBe('relative.png') + } finally { + process.env.ASSET_PREFIX = prev + } + }) +}) diff --git a/apps/docs/utils/asset-url.ts b/apps/docs/utils/asset-url.ts new file mode 100644 index 000000000000..2b7bc8b2139f --- /dev/null +++ b/apps/docs/utils/asset-url.ts @@ -0,0 +1,6 @@ +export function assetUrl(path: string): string { + if (!path.startsWith('/')) return path + const prefix = process.env.ASSET_PREFIX?.trim() + if (!prefix) return path + return `${prefix.replace(/\/$/, '')}${path}` +} diff --git a/skills/tldraw-migrate/.gitignore b/skills/tldraw-migrate/.gitignore new file mode 100644 index 000000000000..6f49e841fe98 --- /dev/null +++ b/skills/tldraw-migrate/.gitignore @@ -0,0 +1 @@ +references/ diff --git a/skills/tldraw-migrate/SKILL.md b/skills/tldraw-migrate/SKILL.md new file mode 100644 index 000000000000..307fd4562682 --- /dev/null +++ b/skills/tldraw-migrate/SKILL.md @@ -0,0 +1,155 @@ +--- +name: tldraw-migrate +description: Migrate a project to a newer version of the tldraw SDK. Use when upgrading tldraw packages, fixing TypeScript errors after a tldraw upgrade, or when the user mentions tldraw migration. +argument-hint: '[previous-version]' +disable-model-invocation: true +user-invocable: true +--- + +# tldraw migration assistant + +You are helping migrate a project to a newer version of the tldraw SDK. Follow this process carefully. + +Previous version (auto-detected from git history; pass an explicit version as `/tldraw-migrate ` to override): !`node ${CLAUDE_SKILL_DIR}/detect-versions.mjs $ARGUMENTS` + +## Resources (auto-fetched on invocation) + +!`mkdir -p ${CLAUDE_SKILL_DIR}/references && CHANGELOG=${CLAUDE_SKILL_DIR}/references/tldraw-releases.txt && PREV=$(node ${CLAUDE_SKILL_DIR}/detect-versions.mjs $ARGUMENTS) && curl --fail -sS https://tldraw.dev/llms-releases.txt | node ${CLAUDE_SKILL_DIR}/filter-changelog.mjs "$PREV" > "$CHANGELOG" && echo "Saved changelog (from $PREV) to $CHANGELOG ($(wc -l < "$CHANGELOG") lines)"` + +!`DOCS=${CLAUDE_SKILL_DIR}/references/tldraw-full-docs.txt && if [ -s "$DOCS" ]; then echo "Using cached full docs at $DOCS ($(wc -l < "$DOCS") lines) — delete the file to refresh"; else curl --fail -sS https://tldraw.dev/llms-full.txt -o "$DOCS" && echo "Saved full docs to $DOCS ($(wc -l < "$DOCS") lines)"; fi` + +- **[Filtered changelog](references/tldraw-releases.txt)** — release notes for versions between the previous version and now. Read this first to understand what changed. +- **[Full docs](references/tldraw-full-docs.txt)** — complete tldraw SDK docs (~1.5MB). Do NOT read this upfront. Use Grep or Read with line ranges to search for specific topics as needed (e.g., custom shapes, TLTextOptions). + +## Step 1: Understand the environment + +Before making any changes, scan the project to understand what you're working with. Run these in parallel: + +- **Package manager**: Check for lock files (`yarn.lock`, `pnpm-lock.yaml`, `bun.lockb`, `package-lock.json`). Use the corresponding tool throughout. +- **tldraw packages**: `grep -E "tldraw|@tldraw" package.json` — which packages are installed, and at what versions? Note: not every project uses all tldraw packages. +- **Import style**: `grep -r "from '@tldraw/" src/ --include="*.ts" --include="*.tsx" -l | head -5` — does the project import from `'tldraw'`, `'@tldraw/editor'`, or both? This affects module augmentation targets. +- **TypeScript**: Check `package.json` for a `typecheck` or `tsc` script. Also check the TypeScript version — is it a direct dependency or does the project rely on a global install? +- **Build tool**: Check `package.json` scripts for the build command (vite, next, webpack, esbuild, etc.) +- **Linter**: Check for eslint/biome config files (`.eslintrc*`, `eslint.config.*`, `biome.json`). A linter may help catch deprecations later. +- **Monorepo**: Is `package.json` at the working directory root, or is this a nested package? Check for workspaces config. + +## Step 2: Upgrade packages + +Using the detected package manager, upgrade all tldraw packages that are already in the project's dependencies to the latest version. Don't add new packages the project doesn't already use. + +## Step 3: Identify all TypeScript errors + +Run the project's typecheck command (from Step 1) and categorize the errors: + +1. **Count errors by TS code** (e.g., TS2344, TS2786) to understand the distribution +2. **List unique files with errors** to understand the scope +3. **Identify error patterns** — common categories in tldraw upgrades: + - React types mismatch (TS2786 "cannot be used as JSX component") — usually means `@types/react` version doesn't match what tldraw bundles. To find the bundled version: with npm or yarn classic, check `node_modules/@tldraw/editor/node_modules/@types/react/package.json`; with pnpm or yarn berry, run the package manager's "why" command (e.g. `pnpm why @types/react`, `yarn why @types/react`) to see the resolved range. + - Custom shape type registration (TS2344 "does not satisfy constraint TLShape/TLBaseBoxShape") — tldraw v4.3+ requires module augmentation of `TLGlobalShapePropsMap` + - Custom binding type registration — same pattern with `TLGlobalBindingPropsMap` + - `BaseBoxShapeTool.shapeType` type mismatch (TS2416) — needs `as const` + - API renames/removals (TS2305, TS2724) — check changelog for specific migrations + - `createShapes`/`updateShapes` type widening (TS2345) — mapped arrays need `TLShapePartial` or `TLCreateShapePartial` casts + +## Step 4: Fix errors in order of impact + +Fix in this order (each fix eliminates many downstream errors): + +### 4a. Fix React types + +If you see TS2786 "bigint not assignable to ReactNode" errors, upgrade `@types/react` and `@types/react-dom` to match tldraw's bundled version. + +### 4b. Register custom shapes and bindings + +For every `TLBaseShape<'name', Props>` in the codebase, add module augmentation. Use the import style detected in Step 1 as the module target: + +```ts +declare module 'tldraw' { + interface TLGlobalShapePropsMap { + 'shape-name': { + /* props */ + } + } +} +type MyShape = TLShape<'shape-name'> +``` + +Same pattern for bindings with `TLGlobalBindingPropsMap` and `TLBinding<'name'>`. + +**IMPORTANT**: If multiple files use the same shape type name with different props, rename them to be unique — they all share the global type registry. + +Add `as const` to all `static override shapeType = '...'` properties. + +### 4c. Fix API renames and removals + +Cross-reference each TS2305/TS2724/TS2339 error with the changelog. Common patterns: + +- Removed exports: find replacement by grepping the tldraw type definitions +- Renamed properties: check changelog for the new name +- Changed method signatures: check the current type definitions + +To find tldraw type definitions, check which paths exist — the location varies by version: + +- `node_modules/tldraw/dist-cjs/index.d.ts` +- `node_modules/tldraw/dist/index.d.ts` +- `node_modules/@tldraw/editor/dist-cjs/index.d.ts` +- `node_modules/@tldraw/tlschema/dist-cjs/index.d.ts` + +### 4d. Fix TipTap imports if needed + +If the project uses TipTap (`@tiptap/*` in dependencies or imports), the tldraw upgrade may require upgrading TipTap as well. Starting with tldraw v4.2, tldraw bundles TipTap v3 as a transitive dependency. If the project pins TipTap v2 packages directly, this will cause version conflicts and broken imports. **Upgrade the project's direct `@tiptap/*` dependencies to v3** to match what tldraw expects — leaving them on v2 will not work. + +After upgrading, fix any breaking TipTap v2 → v3 changes: + +- **Default → named exports**: If a default import fails, switch to a named import: `import StarterKit from '@tiptap/starter-kit'` → `import { StarterKit } from '@tiptap/starter-kit'` +- **Renames**: If a named import fails, check the package's actual exports: `grep 'export' node_modules/@tiptap//dist/index.js | head` — some extensions were renamed in TipTap v3 (e.g., `TextStyle` → `TextStyleKit`). +- **Transaction handler types**: If TipTap event handler types break, import the proper types: `import { EditorEvents } from '@tiptap/core'` and type handlers as `(props: EditorEvents['transaction']) => void` rather than inline type annotations. + +### 4e. Fix remaining type errors + +- `createShapes`/`updateShapes` with `.map()`: The proper fix is to add `as const` to the `type` field in the mapped object literal so TypeScript narrows it to the string literal type. If that's not sufficient, use a `satisfies` annotation on the return value. Only as a last resort, cast the result to `TLCreateShapePartial` or `TLShapePartial`. +- TipTap extension commands not on `ChainedCommands`: use `declare module '@tiptap/core'` augmentation to register custom commands. +- **General rule**: every `as` cast you add is tech debt. Before adding one, exhaust these alternatives in order: + 1. `as const` on object literals to narrow string literal types + 2. `satisfies` annotations to check types without widening + 3. Proper generic type parameters on the call site + 4. Module augmentation to teach TypeScript about your types + 5. Only then, a targeted `as` cast with a comment explaining why it's needed + +## Step 5: Fix deprecations + +After all type errors are resolved, find and fix deprecated API usage. These still compile but should be migrated. + +1. **Find deprecated symbols** — grep the tldraw type definitions for `@deprecated` annotations. Also search the changelog for "deprecated" in `${CLAUDE_SKILL_DIR}/references/tldraw-releases.txt`. + +2. **Check if a linter is configured** — look for eslint, biome, or other linting config in the project. If available, run the linter — it may flag deprecated usage automatically (e.g., eslint's `deprecation/deprecation` rule). If no linter is configured, skip to step 3. + +3. **Search the project source** for each deprecated symbol found in step 1. Replace with the recommended alternative from the `@deprecated` JSDoc comment or changelog. + +## Step 6: Verify + +Run the project's typecheck and build commands (as discovered in Step 1). + +If errors remain, repeat the categorize-and-fix cycle. + +### Post-migration sanity check + +After all errors are resolved, do a quick audit: + +1. **Count `as` casts before vs after**: Run `grep -rn ' as ' --include='*.ts' --include='*.tsx' src/` and compare against the same grep on the pre-migration code (use `git stash` or `git show HEAD:path`). **The migration should add no more than a small handful of new casts** (ideally zero). If you added more than ~5 new `as` casts across the entire migration, go back and fix them — you are almost certainly using the new API incorrectly. +2. **Review every cast you added**: For each new `as` cast, verify it's truly necessary by checking whether `as const`, `satisfies`, generic type parameters, or module augmentation could replace it. Remove or replace any that have a cleaner alternative. +3. **Verify no stubs or dead code**: If you stubbed out removed APIs (e.g., replaced a removed function with a no-op), make sure the calling code doesn't depend on the return value. If it does, find the proper replacement in the changelog or docs. + +## Quality checks + +- **Type safety is paramount.** The goal is a migration that is as type-safe as the original code. Do NOT add `as any`, `as unknown`, or broad type casts. Do NOT add `@ts-ignore` or `@ts-expect-error`. These are never acceptable — if you can't make the types work, you don't understand the new API yet. Stop and read the changelog and type definitions before continuing. +- **Prefer TypeScript's narrowing features over casts.** Use `as const` for literal types, `satisfies` for type-checking without widening, generic parameters for call sites, and module augmentation for extending interfaces. These are the right tools for a migration — `as` casts are not. +- Use parallel agents for fixing large batches of files with the same pattern. +- **Don't just make errors go away — understand the new API.** When a method signature changes (e.g., new parameters added, property renamed to a richer type), read the changelog AND the current type definitions to understand _why_ it changed. A fix that compiles but passes hardcoded/dummy values where the new API expects real data is worse than a type error — it silently degrades behavior. For example, if a function gains new required parameters, check what shape props or editor state should feed those parameters rather than passing `0` or `1`. +- **When unsure about an API pattern**, grep the full docs (`${CLAUDE_SKILL_DIR}/references/tldraw-full-docs.txt`) for usage examples of that specific API. The docs contain code samples that show the canonical way to use each API. + +## Tips + +- Read `${CLAUDE_SKILL_DIR}/references/tldraw-releases.txt` for the filtered changelog — this is the primary source for what changed +- Grep `${CLAUDE_SKILL_DIR}/references/tldraw-full-docs.txt` when you need docs on a specific API +- When searching tldraw types, try multiple paths — the dist directory structure varies by version and package manager diff --git a/skills/tldraw-migrate/detect-versions.mjs b/skills/tldraw-migrate/detect-versions.mjs new file mode 100644 index 000000000000..a5b60c27a775 --- /dev/null +++ b/skills/tldraw-migrate/detect-versions.mjs @@ -0,0 +1,104 @@ +#!/usr/bin/env node +/** + * Detects the previous tldraw version and writes it to stdout. + * + * Resolution order: + * 1. An explicit version passed as argv[2] (e.g., "/tldraw-migrate 4.0.0"). + * 2. The version of any tldraw package on the main/master branch — but only + * if it differs from the working tree (otherwise the user is migrating + * directly on main and main already reflects the new version). + * 3. The version in HEAD~1. + * 4. The version in the current working tree (last resort: assumes the user + * hasn't bumped yet, so the working tree IS the previous version). + * + * Outputs just the version string (no trailing newline) so the SKILL.md can + * interpolate it into other commands. + */ + +import { execSync } from 'child_process' +import { readFileSync } from 'fs' + +function exec(cmd) { + try { + return execSync(cmd, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim() + } catch { + return null + } +} + +const TLDRAW_PACKAGES = [ + 'tldraw', + '@tldraw/editor', + '@tldraw/tldraw', + '@tldraw/store', + '@tldraw/tlschema', + '@tldraw/state', + '@tldraw/state-react', + '@tldraw/sync', + '@tldraw/sync-core', + '@tldraw/utils', + '@tldraw/validate', +] + +function extractTldrawVersion(packageJsonStr) { + try { + const pkg = JSON.parse(packageJsonStr) + const deps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) } + for (const name of TLDRAW_PACKAGES) { + const raw = deps[name] + if (!raw) continue + const cleaned = String(raw).replace(/[\^~>=<\s]/g, '') + const match = cleaned.match(/(\d+\.\d+\.\d+)/) + if (match) return match[1] + } + return null + } catch { + return null + } +} + +function readWorkingTreeVersion() { + try { + return extractTldrawVersion(readFileSync('package.json', 'utf8')) + } catch { + return null + } +} + +function emit(version) { + process.stdout.write(version) + process.exit(0) +} + +const explicit = process.argv[2]?.trim() +if (explicit) { + const cleaned = explicit.replace(/^v/, '') + const match = cleaned.match(/^(\d+\.\d+\.\d+)/) + if (match) emit(match[1]) +} + +const workingTreeVersion = readWorkingTreeVersion() + +for (const branch of ['main', 'master']) { + const content = exec(`git show ${branch}:package.json`) + if (!content) continue + const ver = extractTldrawVersion(content) + if (!ver) continue + // If the branch version matches the working tree, the user likely bumped + // directly on this branch — main isn't a useful "before" reference. + if (workingTreeVersion && ver === workingTreeVersion) continue + emit(ver) +} + +const parent = exec('git show HEAD~1:package.json') +if (parent) { + const ver = extractTldrawVersion(parent) + if (ver && ver !== workingTreeVersion) emit(ver) +} + +if (workingTreeVersion) emit(workingTreeVersion) + +console.error( + 'Could not detect previous tldraw version. Pass it as an argument: /tldraw-migrate 4.0.0' +) +process.exit(1) diff --git a/skills/tldraw-migrate/filter-changelog.mjs b/skills/tldraw-migrate/filter-changelog.mjs new file mode 100644 index 000000000000..fb2e2cdb832f --- /dev/null +++ b/skills/tldraw-migrate/filter-changelog.mjs @@ -0,0 +1,79 @@ +#!/usr/bin/env node +/** + * Filters tldraw changelog to only versions between `from` and `to` (inclusive of `to`). + * Usage: node filter-changelog.mjs [to-version] + * + * If to-version is omitted, includes everything newer than from-version. + * Versions should be semver like "4.0.0" or "v4.0.0" (the "v" prefix is optional). + * + * Reads changelog from stdin, writes filtered output to stdout. + */ + +const [, , fromArg, toArg] = process.argv + +if (!fromArg) { + console.error('Usage: node filter-changelog.mjs [to-version]') + console.error( + ' e.g.: curl -s https://tldraw.dev/llms-releases.txt | node filter-changelog.mjs 4.0.0 4.6.0' + ) + process.exit(1) +} + +function parseVersion(str) { + const m = str.replace(/^v/, '').match(/^(\d+)\.(\d+)\.(\d+)/) + if (!m) return null + return [+m[1], +m[2], +m[3]] +} + +function compareVersions(a, b) { + for (let i = 0; i < 3; i++) { + if (a[i] !== b[i]) return a[i] - b[i] + } + return 0 +} + +const fromVer = parseVersion(fromArg) +const toVer = toArg ? parseVersion(toArg) : null + +if (!fromVer) { + console.error(`Invalid from-version: ${fromArg}`) + process.exit(1) +} +if (toArg && !toVer) { + console.error(`Invalid to-version: ${toArg}`) + process.exit(1) +} + +let input = '' +process.stdin.setEncoding('utf8') +process.stdin.on('data', (chunk) => (input += chunk)) +process.stdin.on('end', () => { + // Split into sections by top-level headings (# vX.Y.Z) + const sections = [] + let current = null + + for (const line of input.split('\n')) { + const headingMatch = line.match(/^# (v?\d+\.\d+\.\d+)/) + if (headingMatch) { + if (current) sections.push(current) + current = { version: parseVersion(headingMatch[1]), lines: [line] } + } else if (current) { + current.lines.push(line) + } + } + if (current) sections.push(current) + + // Filter: include versions where from < version <= to + const filtered = sections.filter((s) => { + const afterFrom = compareVersions(s.version, fromVer) > 0 + const beforeTo = toVer ? compareVersions(s.version, toVer) <= 0 : true + return afterFrom && beforeTo + }) + + if (filtered.length === 0) { + console.error(`No versions found between ${fromArg} and ${toArg || 'latest'}`) + process.exit(0) + } + + process.stdout.write(filtered.map((s) => s.lines.join('\n')).join('\n\n')) +})