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
packages/create-tldraw/LICENSE.md
Normal file
1
packages/create-tldraw/LICENSE.md
Normal file
@@ -0,0 +1 @@
|
||||
This code is licensed under the [tldraw license](https://github.com/tldraw/tldraw/blob/main/LICENSE.md)
|
||||
94
packages/create-tldraw/README.md
Normal file
94
packages/create-tldraw/README.md
Normal file
@@ -0,0 +1,94 @@
|
||||
# npm create tldraw
|
||||
|
||||
A CLI tool for quickly scaffolding a new tldraw project. Inspired by `npm create vite`.
|
||||
|
||||
## Usage:
|
||||
|
||||
```
|
||||
$ npm create tldraw -- --help
|
||||
Usage: create-tldraw [OPTION]... [DIRECTORY]
|
||||
|
||||
Create a new tldraw project from a starter kit.
|
||||
With no arguments, you'll be guided through an interactive setup.
|
||||
Pass . as the directory to create the project in the current directory.
|
||||
|
||||
Options:
|
||||
-h, --help Display this help message.
|
||||
-t, --template NAME Use a specific template.
|
||||
--no-telemetry Disable anonymous usage tracking.
|
||||
|
||||
Available starter kits:
|
||||
• basic A minimal tldraw template with Vite, React, and TypeScript.
|
||||
• agent An AI-powered agent.
|
||||
• branching-chat A branching chat interface.
|
||||
• chat A chat UI with sketches and images as context for AI.
|
||||
• multiplayer Real-time multiplayer for tldraw, built with Cloudflare Durable Objects.
|
||||
• workflow Visual node-based builder for workflows.
|
||||
```
|
||||
|
||||
To scaffold into the current directory instead of a new folder, pass `.` as the directory:
|
||||
|
||||
```
|
||||
$ npm create tldraw@latest .
|
||||
```
|
||||
|
||||
If the directory already has files in it, you'll be asked whether to keep them, remove them (`.git` is always kept), or cancel.
|
||||
|
||||
## Development
|
||||
|
||||
Run in development mode with `yarn dev` from this folder.
|
||||
You can also run `alias create-tldraw=~/<path to this repo>/packages/create-tldraw/scripts/dev.sh`, and then use `create-tldraw` to run the development version of this script.
|
||||
|
||||
## Adding more templates
|
||||
|
||||
Templates are automatically pulled into this tool by `/internal/scripts/refresh-create-templates.ts`.
|
||||
This will add a template entry for any workspace in the repo which has a `package.json` with a `tldraw_template` key that looks like this:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
// indicates that this is a template app and should be published:
|
||||
"tldraw_template": {
|
||||
// the github repo to publish this template to
|
||||
"repo": "tldraw/vite-template",
|
||||
// if `cli` is present, we'll include this template in `npm create tldraw`
|
||||
"cli": {
|
||||
// the name of this template, as it appears in the CLI tool
|
||||
"name": "Vite + tldraw",
|
||||
// the description of this template as it appears in the CLI tool
|
||||
"description": "The easiest way to get started with tldraw. Built with Vite, React, and TypeScript.",
|
||||
// (optional) a shorter description of the template, shown in `--help`
|
||||
"shortDescription": "The easiest way to get started with tldraw.",
|
||||
// (optional) where in its category should this template appear? lower numbers appear first
|
||||
"order": 1,
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## 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).
|
||||
|
||||
## License
|
||||
|
||||
This project is part of the tldraw SDK. It is provided under the [tldraw SDK license](https://github.com/tldraw/tldraw/blob/main/LICENSE.md).
|
||||
|
||||
You can use the tldraw SDK in commercial or non-commercial projects so long as you preserve the "Made with tldraw" watermark on the canvas. To remove the watermark, you can purchase a [business license](https://tldraw.dev/pricing). Visit [tldraw.dev](https://tldraw.dev) to learn more.
|
||||
|
||||
## Trademarks
|
||||
|
||||
Copyright (c) 2025-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.
|
||||
|
||||
## Contribution
|
||||
|
||||
Found a bug? Please [submit an issue](https://github.com/tldraw/tldraw/issues/new).
|
||||
|
||||
## Community
|
||||
|
||||
Have questions, comments or feedback? [Join our discord](https://discord.gg/rhsyWMUJxd). For the latest news and release notes, visit [tldraw.dev](https://tldraw.dev).
|
||||
|
||||
## Contact
|
||||
|
||||
Find us on Twitter/X at [@tldraw](https://twitter.com/tldraw) or email us at [hello@tldraw.com](mailto:hello@tldraw.com).
|
||||
3
packages/create-tldraw/cli.cjs
Executable file
3
packages/create-tldraw/cli.cjs
Executable file
@@ -0,0 +1,3 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
require('./dist-cjs/main.cjs')
|
||||
68
packages/create-tldraw/package.json
Normal file
68
packages/create-tldraw/package.json
Normal file
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"name": "create-tldraw",
|
||||
"description": "tldraw infinite canvas SDK (create cli).",
|
||||
"version": "5.4.2",
|
||||
"author": {
|
||||
"name": "tldraw Inc.",
|
||||
"email": "hello@tldraw.com"
|
||||
},
|
||||
"homepage": "https://tldraw.dev",
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"tldraw_product": {
|
||||
"stableId": "tldraw:create-tldraw",
|
||||
"name": "Create tldraw CLI",
|
||||
"type": "feature",
|
||||
"parent": "tldraw:sdk-core",
|
||||
"premium": false
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/tldraw/tldraw"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/tldraw/tldraw/issues"
|
||||
},
|
||||
"keywords": [
|
||||
"tldraw",
|
||||
"sdk",
|
||||
"drawing",
|
||||
"app",
|
||||
"development",
|
||||
"whiteboard",
|
||||
"canvas",
|
||||
"infinite"
|
||||
],
|
||||
"bin": "./cli.cjs",
|
||||
"files": [
|
||||
"dist-cjs",
|
||||
"./cli.cjs"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "./scripts/dev.sh",
|
||||
"test-ci": "yarn run -T vitest run --passWithNoTests",
|
||||
"test": "yarn run -T vitest --passWithNoTests",
|
||||
"test-coverage": "yarn run -T vitest run --coverage --passWithNoTests",
|
||||
"build": "./scripts/build.sh",
|
||||
"prepack": "yarn build",
|
||||
"pack-tarball": "yarn pack",
|
||||
"lint": "yarn run -T tsx ../../internal/scripts/lint.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@bomb.sh/args": "^0.3.1",
|
||||
"@clack/core": "^1.0.0-alpha.1",
|
||||
"@clack/prompts": "^1.0.0-alpha.1",
|
||||
"@tldraw/utils": "workspace:*",
|
||||
"ansi-regex": "^6.1.0",
|
||||
"ansi-styles": "^6.2.1",
|
||||
"esbuild": "^0.28.0",
|
||||
"lazyrepo": "0.0.0-alpha.27",
|
||||
"picocolors": "^1.1.1",
|
||||
"string-width": "^7.2.0",
|
||||
"strip-ansi": "^7.1.0",
|
||||
"tar": "^7.5.11"
|
||||
},
|
||||
"// because this is bundled, all dependencies should be dev dependencies.": null,
|
||||
"engines": {
|
||||
"node": ">=22.12.0"
|
||||
}
|
||||
}
|
||||
12
packages/create-tldraw/scripts/build.sh
Executable file
12
packages/create-tldraw/scripts/build.sh
Executable file
@@ -0,0 +1,12 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -eux
|
||||
|
||||
yarn run -T tsx ../../internal/scripts/refresh-create-templates.ts
|
||||
|
||||
esbuild src/main.ts \
|
||||
--bundle \
|
||||
--outfile=dist-cjs/main.cjs \
|
||||
--platform=node \
|
||||
--target=node20 \
|
||||
--sourcemap
|
||||
10
packages/create-tldraw/scripts/dev.sh
Executable file
10
packages/create-tldraw/scripts/dev.sh
Executable file
@@ -0,0 +1,10 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -eux
|
||||
|
||||
yarn build
|
||||
|
||||
# add the current directory in case the command deletes it
|
||||
git add .
|
||||
|
||||
./cli.cjs "$@"
|
||||
143
packages/create-tldraw/src/group-select.ts
Normal file
143
packages/create-tldraw/src/group-select.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
/*!
|
||||
* This is a fork of @clack/prompts's select prompt, but with the ability to group options by a string.
|
||||
* MIT License: https://github.com/bombshell-dev/clack/blob/1adb270af5509ead3d22c4fa602b465f39c7a8c2/packages/prompts/LICENSE
|
||||
* Copyright (c) Nate Moore
|
||||
* https://github.com/bombshell-dev/clack/blob/1adb270af5509ead3d22c4fa602b465f39c7a8c2/packages/prompts/src/select.ts
|
||||
*/
|
||||
|
||||
import process from 'node:process'
|
||||
import { WriteStream } from 'node:tty'
|
||||
import { SelectPrompt } from '@clack/core'
|
||||
import {
|
||||
Option,
|
||||
S_BAR,
|
||||
S_RADIO_ACTIVE,
|
||||
S_RADIO_INACTIVE,
|
||||
SelectOptions,
|
||||
symbol,
|
||||
} from '@clack/prompts'
|
||||
import picocolors from 'picocolors'
|
||||
import { wrapAnsi } from './wrap-ansi'
|
||||
|
||||
export type GroupSelectOption<Value> = Option<Value> & {
|
||||
group?: string
|
||||
hint: string
|
||||
}
|
||||
|
||||
export interface GroupSelectOptions<Value> extends SelectOptions<Value> {
|
||||
options: GroupSelectOption<Value>[]
|
||||
}
|
||||
|
||||
function limit(
|
||||
contents: string,
|
||||
rows: number,
|
||||
focusCharacter: string,
|
||||
truncateLine = picocolors.dim('...')
|
||||
) {
|
||||
const lines = contents.split('\n')
|
||||
const focusLine = Math.max(
|
||||
0,
|
||||
lines.findIndex((line) => line.includes(focusCharacter))
|
||||
)
|
||||
|
||||
if (lines.length <= rows) {
|
||||
return contents
|
||||
}
|
||||
|
||||
const outputLines = []
|
||||
// if we're right at the top, we don't truncate above:
|
||||
if (focusLine < 2) {
|
||||
outputLines.push(...lines.slice(0, rows - 1), truncateLine)
|
||||
} else if (focusLine + rows - 2 > lines.length) {
|
||||
// if we're right at the bottom, we don't truncate below:
|
||||
outputLines.push(truncateLine, ...lines.slice(lines.length - rows + 1))
|
||||
} else {
|
||||
// otherwise, we truncate the middle:
|
||||
outputLines.push(
|
||||
truncateLine,
|
||||
...lines.slice(focusLine - 1, focusLine + rows - 2),
|
||||
truncateLine
|
||||
)
|
||||
}
|
||||
|
||||
return outputLines.join('\n')
|
||||
}
|
||||
|
||||
export function groupSelect<Value>(opts: GroupSelectOptions<Value>) {
|
||||
const opt = (option: GroupSelectOption<Value>, state: 'inactive' | 'active') => {
|
||||
const label = option.label ?? String(option.value)
|
||||
|
||||
if (state !== 'active') {
|
||||
return [picocolors.dim(S_RADIO_INACTIVE), ' ', picocolors.dim(label)].join('')
|
||||
}
|
||||
|
||||
return [picocolors.green(S_RADIO_ACTIVE), ' ', picocolors.bold(label)].join('')
|
||||
}
|
||||
|
||||
return new SelectPrompt({
|
||||
options: opts.options,
|
||||
signal: opts.signal,
|
||||
input: opts.input,
|
||||
output: opts.output,
|
||||
initialValue: opts.initialValue,
|
||||
render() {
|
||||
const title = `${picocolors.gray(S_BAR)}\n${symbol(this.state)} ${picocolors.bold(opts.message)}\n`
|
||||
|
||||
if (this.state === 'submit') {
|
||||
return [
|
||||
title,
|
||||
picocolors.gray(S_BAR),
|
||||
' ',
|
||||
picocolors.dim(this.options[this.cursor].label),
|
||||
].join('')
|
||||
} else if (this.state === 'cancel') {
|
||||
return [
|
||||
title,
|
||||
picocolors.gray(S_BAR),
|
||||
' ',
|
||||
picocolors.strikethrough(picocolors.dim(this.options[this.cursor].label)),
|
||||
'\n',
|
||||
picocolors.gray(S_BAR),
|
||||
].join('')
|
||||
}
|
||||
|
||||
const selectedOption = this.options[this.cursor]
|
||||
let previousGroup = undefined
|
||||
const body = []
|
||||
|
||||
for (const option of this.options) {
|
||||
if (option.group !== previousGroup) {
|
||||
if (previousGroup) body.push(picocolors.cyan(S_BAR), '\n')
|
||||
body.push(picocolors.cyan(S_BAR), ' ', option.group, '\n')
|
||||
}
|
||||
body.push(
|
||||
picocolors.cyan(S_BAR),
|
||||
' ',
|
||||
opt(option, option === selectedOption ? 'active' : 'inactive'),
|
||||
'\n'
|
||||
)
|
||||
previousGroup = option.group
|
||||
}
|
||||
|
||||
// Add the description as the last line
|
||||
body.push(picocolors.cyan(S_BAR), '\n', picocolors.cyan(S_BAR), ' ', selectedOption.hint)
|
||||
|
||||
const output = opts.output ?? process.stdout
|
||||
const columns = output instanceof WriteStream ? output.columns : 80
|
||||
const rows = output instanceof WriteStream ? output.rows : 10
|
||||
|
||||
const wrapped = wrapAnsi(body.join(''), columns, {
|
||||
indent: [picocolors.cyan(S_BAR), ' '].join(''),
|
||||
})
|
||||
|
||||
const limited = limit(
|
||||
wrapped,
|
||||
rows - 3,
|
||||
S_RADIO_ACTIVE,
|
||||
[picocolors.cyan(S_BAR), picocolors.dim(' ...')].join('')
|
||||
)
|
||||
|
||||
return [title, picocolors.cyan(S_BAR), '\n', limited].join('')
|
||||
},
|
||||
}).prompt() as Promise<Value | symbol>
|
||||
}
|
||||
310
packages/create-tldraw/src/main.ts
Normal file
310
packages/create-tldraw/src/main.ts
Normal file
@@ -0,0 +1,310 @@
|
||||
import { mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'
|
||||
import { relative, resolve } from 'node:path'
|
||||
import process from 'node:process'
|
||||
import { Readable } from 'node:stream'
|
||||
import { outro, select, spinner, text } from '@clack/prompts'
|
||||
import picocolors from 'picocolors'
|
||||
import * as tar from 'tar'
|
||||
import { groupSelect, GroupSelectOption } from './group-select'
|
||||
import { Template, TEMPLATES } from './templates'
|
||||
import {
|
||||
cancel,
|
||||
CliArgs,
|
||||
emptyDir,
|
||||
formatTargetDir,
|
||||
getInstallCommand,
|
||||
getPackageManager,
|
||||
getRunCommand,
|
||||
uncancel as handleCancel,
|
||||
isDirEmpty,
|
||||
isValidPackageName,
|
||||
nicelog,
|
||||
parseCliArgs,
|
||||
pathToName,
|
||||
} from './utils'
|
||||
import { wrapAnsi } from './wrap-ansi'
|
||||
|
||||
const DEBUG = !!process.env.DEBUG
|
||||
|
||||
const TELEMETRY_URLS = [
|
||||
'https://dashboard.tldraw.pro/api/starter-kit-choice',
|
||||
'https://teamldraw.com/api/starter-kit-choice',
|
||||
]
|
||||
|
||||
async function main() {
|
||||
const args = parseCliArgs(process.argv.slice(2))
|
||||
|
||||
if (args.help) {
|
||||
nicelog(getHelp())
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const maybeTargetDir = args.targetDir ? formatTargetDir(resolve(args.targetDir)) : undefined
|
||||
|
||||
// Settle the directory before anything else so a cancel here doesn't waste a template pick.
|
||||
const dirAction = maybeTargetDir ? await prepareRequestedDir(maybeTargetDir) : undefined
|
||||
|
||||
const template = await templatePicker(args)
|
||||
const name = await namePicker(maybeTargetDir)
|
||||
|
||||
const targetDir = maybeTargetDir ?? findAvailableDir(resolve(process.cwd(), name))
|
||||
mkdirSync(targetDir, { recursive: true })
|
||||
|
||||
// Only destroy existing files once every prompt has passed; a cancel or bad -t after the
|
||||
// "remove" choice must leave the directory untouched.
|
||||
if (dirAction === 'empty') emptyDir(targetDir)
|
||||
|
||||
await downloadTemplate(template, targetDir)
|
||||
await renameTemplate(name, targetDir)
|
||||
|
||||
const manager = getPackageManager()
|
||||
const doneMessage = ['Done! Now run:', '']
|
||||
if (targetDir !== process.cwd()) {
|
||||
doneMessage.push(` cd ${relative(process.cwd(), targetDir)}`)
|
||||
}
|
||||
doneMessage.push(` ${getInstallCommand(manager)}`)
|
||||
doneMessage.push(` ${getRunCommand(manager, 'dev')}`)
|
||||
doneMessage.push('')
|
||||
doneMessage.push(' Happy building! Visit https://tldraw.dev/docs to learn more.')
|
||||
|
||||
outro(doneMessage.join('\n'))
|
||||
}
|
||||
|
||||
async function templatePicker(args: CliArgs) {
|
||||
let template: Template
|
||||
if (args.template) {
|
||||
const templateId = args.template.toLowerCase().trim()
|
||||
const found = TEMPLATES.find((t) => formatTemplateId(t) === templateId)
|
||||
if (!found) {
|
||||
outro(`Template ${args.template} not found`)
|
||||
process.exit(1)
|
||||
}
|
||||
template = found
|
||||
} else {
|
||||
template = await handleCancel(
|
||||
groupSelect({
|
||||
message: 'Select a tldraw starter kit:',
|
||||
options: TEMPLATES.map(
|
||||
(template): GroupSelectOption<Template> => ({
|
||||
label: template.name,
|
||||
hint: template.description,
|
||||
value: template,
|
||||
})
|
||||
),
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
trackStarterKitChoice(template.name, args.telemetry)
|
||||
return template
|
||||
}
|
||||
|
||||
function trackStarterKitChoice(templateId: string, telemetry: boolean) {
|
||||
if (!telemetry) return
|
||||
|
||||
for (const url of TELEMETRY_URLS) {
|
||||
// Fire and forget - don't block on this request
|
||||
fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id: templateId }),
|
||||
}).catch(() => {
|
||||
// Silently ignore errors
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function namePicker(argOption?: string) {
|
||||
if (argOption) {
|
||||
return pathToName(argOption)
|
||||
}
|
||||
|
||||
const defaultName = pathToName(process.cwd())
|
||||
|
||||
const name = await handleCancel(
|
||||
text({
|
||||
message: picocolors.bold('Name your app'),
|
||||
placeholder: defaultName,
|
||||
validate: (value) => {
|
||||
if (value && !isValidPackageName(value)) {
|
||||
return `Invalid name: ${value}`
|
||||
}
|
||||
|
||||
return undefined
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
if (!name.trim()) return defaultName
|
||||
return pathToName(name)
|
||||
}
|
||||
|
||||
type RequestedDirAction = 'ignore' | 'empty'
|
||||
|
||||
// A directory the user named explicitly (e.g. `.`) must not be swapped for a suffixed sibling, so ask
|
||||
// what to do with its existing contents instead. Returns the choice rather than acting on it so the
|
||||
// caller can defer any deletion until the rest of the setup has succeeded.
|
||||
async function prepareRequestedDir(targetDir: string): Promise<RequestedDirAction | undefined> {
|
||||
if (isDirEmpty(targetDir)) return undefined
|
||||
|
||||
// Show the full path for anything outside the cwd so "remove existing files" on `..` isn't a surprise.
|
||||
const relativeName = relative(process.cwd(), targetDir)
|
||||
const displayName = !relativeName ? '.' : relativeName.startsWith('..') ? targetDir : relativeName
|
||||
if (!statSync(targetDir).isDirectory()) {
|
||||
outro(`${displayName} exists and is not a directory.`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const action = await handleCancel(
|
||||
select<RequestedDirAction | 'cancel'>({
|
||||
message: picocolors.bold(
|
||||
`${displayName === '.' ? 'The current directory' : displayName} is not empty. How would you like to proceed?`
|
||||
),
|
||||
options: [
|
||||
{
|
||||
value: 'ignore',
|
||||
label: 'Ignore existing files and continue',
|
||||
hint: 'template files overwrite any that conflict',
|
||||
},
|
||||
{
|
||||
value: 'empty',
|
||||
label: 'Remove existing files and continue',
|
||||
hint: 'keeps .git',
|
||||
},
|
||||
{ value: 'cancel', label: 'Cancel' },
|
||||
],
|
||||
})
|
||||
)
|
||||
|
||||
if (action === 'cancel') cancel()
|
||||
return action
|
||||
}
|
||||
|
||||
function findAvailableDir(targetDir: string): string {
|
||||
if (isDirEmpty(targetDir)) {
|
||||
return targetDir
|
||||
}
|
||||
|
||||
// Keep the user's chosen package name, but pick a suffixed directory if the target is unavailable.
|
||||
for (let i = 1; ; i++) {
|
||||
const candidate = `${targetDir}-${i}`
|
||||
if (isDirEmpty(candidate)) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadTemplate(template: Template, targetDir: string) {
|
||||
const s = spinner()
|
||||
s.start(`Downloading github.com/${template.repo}...`)
|
||||
try {
|
||||
const url = `https://github.com/${template.repo}/archive/refs/heads/main.tar.gz`
|
||||
const tarResponse = await fetch(url)
|
||||
if (!tarResponse.ok) {
|
||||
throw new Error(`${url}: ${tarResponse.status} ${tarResponse.statusText}`)
|
||||
}
|
||||
|
||||
if (!tarResponse.body) {
|
||||
throw new Error(`${url}: no body`)
|
||||
}
|
||||
|
||||
const extractor = tar.extract({
|
||||
cwd: targetDir,
|
||||
strip: 1,
|
||||
})
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
Readable.fromWeb(tarResponse.body as any)
|
||||
.pipe(extractor)
|
||||
.on('end', resolve)
|
||||
.on('error', reject)
|
||||
})
|
||||
|
||||
s.stop(`Downloaded github.com/${template.repo}`)
|
||||
} catch (err) {
|
||||
s.stop(`Failed to download github.com/${template.repo}`)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
async function renameTemplate(name: string, targetDir: string) {
|
||||
const packageJson = JSON.parse(readFileSync(resolve(targetDir, 'package.json'), 'utf-8'))
|
||||
|
||||
packageJson.name = name
|
||||
delete packageJson.author
|
||||
delete packageJson.homepage
|
||||
delete packageJson.license
|
||||
|
||||
writeFileSync(resolve(targetDir, 'package.json'), JSON.stringify(packageJson, null, '\t') + '\n')
|
||||
}
|
||||
|
||||
function formatTemplateId(template: Template) {
|
||||
return template.name.trim().toLowerCase().replace(/\s+/g, '-')
|
||||
}
|
||||
|
||||
function getHelp() {
|
||||
const options = [
|
||||
{ flags: '-h, --help', description: 'Display this help message.' },
|
||||
{ flags: '-t, --template NAME', description: 'Use a specific template.' },
|
||||
{ flags: '--no-telemetry', description: 'Disable anonymous usage tracking.' },
|
||||
]
|
||||
const templates = TEMPLATES.map((t) => ({
|
||||
name: formatTemplateId(t),
|
||||
description: t.shortDescription ?? t.description,
|
||||
}))
|
||||
|
||||
const GAP_SIZE = 2
|
||||
const optionPrefix = ' '
|
||||
const templatePrefix = ' • '
|
||||
|
||||
const idealIndentSize =
|
||||
Math.max(...options.map((o) => o.flags.length), ...templates.map((t) => t.name.length)) +
|
||||
GAP_SIZE +
|
||||
templatePrefix.length
|
||||
|
||||
const columns = process.stdout.columns
|
||||
const isNarrow = columns < idealIndentSize + 50
|
||||
|
||||
// Wide terminals get a two-column table; narrow ones put each description on its own wrapped line.
|
||||
function formatRows(prefix: string, rows: { name: string; description: string }[]) {
|
||||
if (isNarrow) {
|
||||
const indent = ' '.repeat(prefix.length + GAP_SIZE)
|
||||
return rows.flatMap((row) => [
|
||||
`${prefix}${row.name}`,
|
||||
wrapAnsi(`${indent}${row.description}`, columns, { indent }),
|
||||
])
|
||||
}
|
||||
const indent = ' '.repeat(idealIndentSize)
|
||||
return rows.map((row) => {
|
||||
const start = `${prefix}${row.name}`.padEnd(idealIndentSize, ' ')
|
||||
return wrapAnsi(`${start}${row.description}`, columns, { indent })
|
||||
})
|
||||
}
|
||||
|
||||
return [
|
||||
picocolors.bold('Usage: create-tldraw [OPTION]... [DIRECTORY]'),
|
||||
'',
|
||||
'Create a new tldraw project from a starter kit.',
|
||||
"With no arguments, you'll be guided through an interactive setup.",
|
||||
'Pass . as the directory to create the project in the current directory.',
|
||||
'',
|
||||
picocolors.bold('Options:'),
|
||||
...formatRows(
|
||||
optionPrefix,
|
||||
options.map((o) => ({ name: o.flags, description: o.description }))
|
||||
),
|
||||
'',
|
||||
picocolors.bold('Available starter kits:'),
|
||||
...formatRows(templatePrefix, templates),
|
||||
'',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
// Keep this last, and keep module-level constants above main(). With -t, main() runs all the way to
|
||||
// trackStarterKitChoice without ever awaiting, so a constant declared below it is still in its
|
||||
// temporal dead zone when it's read: that shipped as "TELEMETRY_URLS is not iterable" (#10745).
|
||||
main().catch((err) => {
|
||||
if (DEBUG) console.error(err)
|
||||
outro(`it's bad`)
|
||||
process.exit(1)
|
||||
})
|
||||
68
packages/create-tldraw/src/templates.ts
Normal file
68
packages/create-tldraw/src/templates.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
// This file is automatically generated by refresh-create-templates.ts.
|
||||
// Do not edit manually. Or do, I'm a comment, not a cop.
|
||||
|
||||
export interface Template {
|
||||
repo: string
|
||||
name: string
|
||||
description: string
|
||||
shortDescription?: string
|
||||
order?: number
|
||||
}
|
||||
|
||||
export const TEMPLATES: Template[] = [
|
||||
{
|
||||
name: 'Basic',
|
||||
description: 'A minimal tldraw template with Vite, React, and TypeScript.',
|
||||
order: 1,
|
||||
repo: 'tldraw/vite-template',
|
||||
},
|
||||
{
|
||||
name: 'Multiplayer',
|
||||
description: 'Real-time multiplayer with a self-hosted backend.',
|
||||
shortDescription: 'Real-time multiplayer.',
|
||||
order: 2,
|
||||
repo: 'tldraw/tldraw-sync-cloudflare',
|
||||
},
|
||||
{
|
||||
name: 'Agent',
|
||||
description: 'Work on the canvas with an AI agent.',
|
||||
shortDescription: 'An AI-powered agent.',
|
||||
order: 3,
|
||||
repo: 'tldraw/agent-template',
|
||||
},
|
||||
{
|
||||
name: 'Workflow',
|
||||
description: 'Create a node-based builder for visual workflows.',
|
||||
shortDescription: 'Visual node-based builder for workflows.',
|
||||
order: 4,
|
||||
repo: 'tldraw/workflow-template',
|
||||
},
|
||||
{
|
||||
name: 'Chat',
|
||||
description: 'Create an AI chat that uses tldraw for sketches and annotations.',
|
||||
shortDescription: 'A chat UI with sketches and images as context for AI.',
|
||||
order: 5,
|
||||
repo: 'tldraw/chat-template',
|
||||
},
|
||||
{
|
||||
name: 'Image pipeline',
|
||||
description: 'Create a node-based builder for image generation pipelines.',
|
||||
shortDescription: 'Visual node-based builder for image pipelines.',
|
||||
order: 6,
|
||||
repo: 'tldraw/image-pipeline-template',
|
||||
},
|
||||
{
|
||||
name: 'Branching chat',
|
||||
description: 'Chat with an AI using a branching conversation interface.',
|
||||
shortDescription: 'A branching chat interface.',
|
||||
order: 7,
|
||||
repo: 'tldraw/branching-chat-template',
|
||||
},
|
||||
{
|
||||
name: 'Shader',
|
||||
description: 'Create WebGL shaders that respond to shapes on the canvas.',
|
||||
shortDescription: 'Shaders on the canvas.',
|
||||
order: 8,
|
||||
repo: 'tldraw/shader-starter-kit',
|
||||
},
|
||||
]
|
||||
128
packages/create-tldraw/src/utils.test.ts
Normal file
128
packages/create-tldraw/src/utils.test.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readdirSync,
|
||||
rmSync,
|
||||
symlinkSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { emptyDir, isDirEmpty, parseCliArgs } from './utils'
|
||||
|
||||
describe('isDirEmpty', () => {
|
||||
let tempDir: string
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = mkdtempSync(join(tmpdir(), 'create-tldraw-'))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tempDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('returns false for a regular file', () => {
|
||||
const filePath = join(tempDir, 'my-app')
|
||||
writeFileSync(filePath, 'x')
|
||||
|
||||
expect(isDirEmpty(filePath)).toBe(false)
|
||||
})
|
||||
|
||||
it('follows a symlink to an empty directory', () => {
|
||||
const dirPath = join(tempDir, 'real')
|
||||
mkdirSync(dirPath)
|
||||
symlinkSync(dirPath, join(tempDir, 'link'))
|
||||
|
||||
expect(isDirEmpty(join(tempDir, 'link'))).toBe(true)
|
||||
})
|
||||
|
||||
it('treats a directory containing only .git as empty', () => {
|
||||
const dirPath = join(tempDir, 'my-app')
|
||||
mkdirSync(dirPath)
|
||||
mkdirSync(join(dirPath, '.git'))
|
||||
|
||||
expect(isDirEmpty(dirPath)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('emptyDir', () => {
|
||||
let tempDir: string
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = mkdtempSync(join(tmpdir(), 'create-tldraw-'))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tempDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('removes everything except .git', () => {
|
||||
mkdirSync(join(tempDir, '.git'))
|
||||
writeFileSync(join(tempDir, '.git', 'HEAD'), 'ref: refs/heads/main')
|
||||
mkdirSync(join(tempDir, 'src'))
|
||||
writeFileSync(join(tempDir, 'src', 'index.ts'), 'x')
|
||||
writeFileSync(join(tempDir, 'README.md'), 'x')
|
||||
|
||||
emptyDir(tempDir)
|
||||
|
||||
expect(readdirSync(tempDir)).toEqual(['.git'])
|
||||
expect(existsSync(join(tempDir, '.git', 'HEAD'))).toBe(true)
|
||||
expect(isDirEmpty(tempDir)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseCliArgs', () => {
|
||||
it('leaves telemetry on when the flag is absent', () => {
|
||||
expect(parseCliArgs([])).toEqual({
|
||||
help: false,
|
||||
template: undefined,
|
||||
telemetry: true,
|
||||
targetDir: undefined,
|
||||
})
|
||||
})
|
||||
|
||||
it('turns telemetry off for --no-telemetry', () => {
|
||||
expect(parseCliArgs(['--no-telemetry'])).toEqual({
|
||||
help: false,
|
||||
template: undefined,
|
||||
telemetry: false,
|
||||
targetDir: undefined,
|
||||
})
|
||||
})
|
||||
|
||||
// Without `no-telemetry` declared as a boolean the parser reads the directory as the flag's value.
|
||||
it('keeps the directory when it follows --no-telemetry', () => {
|
||||
expect(parseCliArgs(['--no-telemetry', 'my-app'])).toEqual({
|
||||
help: false,
|
||||
template: undefined,
|
||||
telemetry: false,
|
||||
targetDir: 'my-app',
|
||||
})
|
||||
})
|
||||
|
||||
it('reads the template from -t, --template and --template=', () => {
|
||||
expect(parseCliArgs(['-t', 'agent']).template).toBe('agent')
|
||||
expect(parseCliArgs(['--template', 'agent']).template).toBe('agent')
|
||||
expect(parseCliArgs(['--template=agent']).template).toBe('agent')
|
||||
})
|
||||
|
||||
it('reads help from -h and --help', () => {
|
||||
expect(parseCliArgs(['-h']).help).toBe(true)
|
||||
expect(parseCliArgs(['--help']).help).toBe(true)
|
||||
})
|
||||
|
||||
// Bare arguments are coerced by the parser, so a numeric directory comes back as a number.
|
||||
it('keeps an all-digit directory as a string', () => {
|
||||
expect(parseCliArgs(['2026']).targetDir).toBe('2026')
|
||||
})
|
||||
|
||||
it('accepts a directory, a template and --no-telemetry together', () => {
|
||||
expect(parseCliArgs(['.', '-t', 'basic', '--no-telemetry'])).toEqual({
|
||||
help: false,
|
||||
template: 'basic',
|
||||
telemetry: false,
|
||||
targetDir: '.',
|
||||
})
|
||||
})
|
||||
})
|
||||
129
packages/create-tldraw/src/utils.ts
Normal file
129
packages/create-tldraw/src/utils.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { existsSync, readdirSync, rmSync, statSync } from 'node:fs'
|
||||
import { basename, join, resolve } from 'node:path'
|
||||
import { parse as parseArgs } from '@bomb.sh/args'
|
||||
import { isCancel, outro } from '@clack/prompts'
|
||||
|
||||
export interface CliArgs {
|
||||
help: boolean
|
||||
template?: string
|
||||
telemetry: boolean
|
||||
targetDir?: string
|
||||
}
|
||||
|
||||
export function parseCliArgs(argv: string[]): CliArgs {
|
||||
const args = parseArgs(argv, {
|
||||
alias: {
|
||||
h: 'help',
|
||||
t: 'template',
|
||||
},
|
||||
// The parser reports `--no-telemetry` as `telemetry: false`, never as a `no-telemetry` key.
|
||||
// It still has to be listed as a boolean: otherwise `--no-telemetry my-app` treats the
|
||||
// directory as the flag's value and swallows it.
|
||||
boolean: ['help', 'telemetry', 'no-telemetry'],
|
||||
string: ['template'],
|
||||
default: { telemetry: true },
|
||||
})
|
||||
|
||||
return {
|
||||
help: !!args.help,
|
||||
template: args.template ? String(args.template) : undefined,
|
||||
telemetry: args.telemetry !== false,
|
||||
// Bare arguments are coerced, so a directory like `2026` arrives as a number.
|
||||
targetDir: args._[0] === undefined ? undefined : String(args._[0]),
|
||||
}
|
||||
}
|
||||
|
||||
export function nicelog(...args: unknown[]) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(...args)
|
||||
}
|
||||
|
||||
export function isDirEmpty(path: string) {
|
||||
if (!existsSync(path)) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Existing files block the target path, so only directories should be inspected with readdirSync.
|
||||
if (!statSync(path).isDirectory()) {
|
||||
return false
|
||||
}
|
||||
|
||||
const files = readdirSync(path)
|
||||
return files.length === 0 || (files.length === 1 && files[0] === '.git')
|
||||
}
|
||||
|
||||
// Keeps .git so scaffolding into a freshly initialised repo doesn't destroy its history.
|
||||
export function emptyDir(path: string) {
|
||||
for (const file of readdirSync(path)) {
|
||||
if (file === '.git') continue
|
||||
rmSync(join(path, file), { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
export function pathToName(path: string) {
|
||||
return toValidPackageName(basename(formatTargetDir(resolve(path))))
|
||||
}
|
||||
export function formatTargetDir(targetDir: string) {
|
||||
return targetDir.trim().replace(/[/\\]+$/g, '')
|
||||
}
|
||||
|
||||
export function isValidPackageName(projectName: string) {
|
||||
return /^(?:@[a-z\d\-*~][a-z\d\-*._~]*\/)?[a-z\d\-~][a-z\d\-._~]*$/.test(projectName)
|
||||
}
|
||||
|
||||
function toValidPackageName(projectName: string) {
|
||||
return projectName
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/^[._]/, '')
|
||||
.replace(/[^a-z\d\-~]+/g, '-')
|
||||
}
|
||||
|
||||
export function cancel(): never {
|
||||
outro('Setup cancelled.\n Try again or visit https://tldraw.dev/docs to learn more.')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
export async function uncancel<T>(promise: Promise<T | symbol>): Promise<T> {
|
||||
const result = await promise
|
||||
if (isCancel(result)) {
|
||||
cancel()
|
||||
}
|
||||
|
||||
return result as T
|
||||
}
|
||||
|
||||
export type PackageManager = 'npm' | 'pnpm' | 'yarn'
|
||||
export function getPackageManager(): PackageManager {
|
||||
const userAgent = process.env.npm_config_user_agent
|
||||
if (!userAgent) return 'npm'
|
||||
|
||||
const manager = userAgent.split(' ')[0].split('/')[0]
|
||||
if (manager === 'pnpm') return 'pnpm'
|
||||
if (manager === 'yarn') return 'yarn'
|
||||
|
||||
return 'npm'
|
||||
}
|
||||
|
||||
export function getInstallCommand(manager: PackageManager): string {
|
||||
switch (manager) {
|
||||
case 'pnpm':
|
||||
return 'pnpm install'
|
||||
case 'yarn':
|
||||
return 'yarn'
|
||||
case 'npm':
|
||||
return 'npm install'
|
||||
}
|
||||
}
|
||||
|
||||
export function getRunCommand(manager: PackageManager, command: string): string {
|
||||
switch (manager) {
|
||||
case 'pnpm':
|
||||
return `pnpm run ${command}`
|
||||
case 'yarn':
|
||||
return `yarn ${command}`
|
||||
case 'npm':
|
||||
return `npm run ${command}`
|
||||
}
|
||||
}
|
||||
387
packages/create-tldraw/src/wrap-ansi.test.ts
Normal file
387
packages/create-tldraw/src/wrap-ansi.test.ts
Normal file
@@ -0,0 +1,387 @@
|
||||
import { wrapAnsi } from './wrap-ansi'
|
||||
|
||||
describe('wrapAnsi', () => {
|
||||
describe('basic wrapping', () => {
|
||||
it('should wrap text at specified column width', () => {
|
||||
const input = 'This is a long string that should be wrapped at 20 characters'
|
||||
const result = wrapAnsi(input, 20, {})
|
||||
const lines = result.split('\n')
|
||||
|
||||
expect(lines).toHaveLength(4)
|
||||
expect(lines[0].length).toBeLessThanOrEqual(20)
|
||||
expect(lines[1].length).toBeLessThanOrEqual(20)
|
||||
expect(lines[2].length).toBeLessThanOrEqual(20)
|
||||
expect(lines[3].length).toBeLessThanOrEqual(20)
|
||||
})
|
||||
|
||||
it('should handle empty string', () => {
|
||||
const result = wrapAnsi('', 10, {})
|
||||
expect(result).toBe('')
|
||||
})
|
||||
|
||||
it('should handle whitespace-only string', () => {
|
||||
const result = wrapAnsi(' ', 10, {})
|
||||
expect(result).toBe('')
|
||||
})
|
||||
|
||||
it('should handle single word shorter than column width', () => {
|
||||
const result = wrapAnsi('hello', 10, {})
|
||||
expect(result).toBe('hello')
|
||||
})
|
||||
|
||||
it('should handle single word longer than column width', () => {
|
||||
const result = wrapAnsi('supercalifragilisticexpialidocious', 10, {})
|
||||
const lines = result.split('\n')
|
||||
// The word is longer than 10 chars, so it should be wrapped
|
||||
expect(lines.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ANSI escape codes', () => {
|
||||
it('should preserve ANSI color codes across line breaks', () => {
|
||||
const input = '\u001B[31mThis is red text that should wrap\u001B[39m'
|
||||
const result = wrapAnsi(input, 15, {})
|
||||
|
||||
// Should contain the color codes
|
||||
expect(result).toContain('\u001B[31m')
|
||||
expect(result).toContain('\u001B[39m')
|
||||
|
||||
// Should have wrapped the text
|
||||
const lines = result.split('\n')
|
||||
expect(lines.length).toBeGreaterThan(1)
|
||||
})
|
||||
|
||||
it('should handle multiple ANSI codes', () => {
|
||||
const input = '\u001B[31m\u001B[1mBold red text\u001B[39m\u001B[22m'
|
||||
const result = wrapAnsi(input, 10, {})
|
||||
|
||||
expect(result).toContain('\u001B[31m')
|
||||
expect(result).toContain('\u001B[1m')
|
||||
expect(result).toContain('\u001B[39m')
|
||||
expect(result).toContain('\u001B[22m')
|
||||
})
|
||||
|
||||
it('should handle ANSI hyperlink codes', () => {
|
||||
const input = '\u001B]8;;https://example.com\u0007link text\u001B]8;;\u0007'
|
||||
const result = wrapAnsi(input, 10, {})
|
||||
|
||||
expect(result).toContain('\u001B]8;;https://example.com\u0007')
|
||||
expect(result).toContain('\u001B]8;;\u0007')
|
||||
})
|
||||
|
||||
it('should not count ANSI codes towards line length', () => {
|
||||
const input = '\u001B[31mThis text should wrap at exactly 20 characters\u001B[39m'
|
||||
const result = wrapAnsi(input, 20, {})
|
||||
const lines = result.split('\n')
|
||||
|
||||
// Each line should be at most 20 characters (excluding ANSI codes)
|
||||
lines.forEach((line) => {
|
||||
// Remove ANSI escape sequences for length calculation
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const cleanLine = line.replace(/\u001B\[[0-9;]*m/g, '')
|
||||
expect(cleanLine.length).toBeLessThanOrEqual(20)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('indentation', () => {
|
||||
it('should apply indentation to wrapped lines', () => {
|
||||
const input = 'This is a long string that should be wrapped with indentation'
|
||||
const result = wrapAnsi(input, 20, { indent: ' ' })
|
||||
const lines = result.split('\n')
|
||||
|
||||
// First line should not be indented (no original leading whitespace)
|
||||
expect(lines[0]).not.toMatch(/^ {2}/)
|
||||
|
||||
// All wrapped lines should be indented
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
expect(lines[i]).toMatch(/^ {2}/)
|
||||
}
|
||||
})
|
||||
|
||||
it('should handle empty indentation', () => {
|
||||
const input = 'This is a long string'
|
||||
const result = wrapAnsi(input, 10, { indent: '' })
|
||||
const lines = result.split('\n')
|
||||
|
||||
// All lines should start without indentation
|
||||
lines.forEach((line) => {
|
||||
expect(line).not.toMatch(/^ /)
|
||||
})
|
||||
})
|
||||
|
||||
it('should account for indentation in line length calculations', () => {
|
||||
const input = 'This is a long string that should be wrapped'
|
||||
const result = wrapAnsi(input, 15, { indent: ' ' })
|
||||
const lines = result.split('\n')
|
||||
|
||||
lines.forEach((line) => {
|
||||
const visibleLength = line.length
|
||||
expect(visibleLength).toBeLessThanOrEqual(15)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('word wrapping', () => {
|
||||
it('should wrap at word boundaries by default', () => {
|
||||
const input = 'This is a sentence with multiple words'
|
||||
const result = wrapAnsi(input, 15, {})
|
||||
const lines = result.split('\n')
|
||||
|
||||
// Should not break words in the middle
|
||||
lines.forEach((line) => {
|
||||
const words = line.trim().split(' ')
|
||||
words.forEach((word) => {
|
||||
expect(word.length).toBeLessThanOrEqual(15)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle wordWrap: false option', () => {
|
||||
const input = 'supercalifragilisticexpialidocious'
|
||||
const result = wrapAnsi(input, 10, { wordWrap: false })
|
||||
const lines = result.split('\n')
|
||||
|
||||
// Should break the word at character boundaries
|
||||
expect(lines.length).toBeGreaterThan(1)
|
||||
})
|
||||
|
||||
it('should handle mixed word lengths', () => {
|
||||
const input = 'a very long word supercalifragilisticexpialidocious and short words'
|
||||
const result = wrapAnsi(input, 15, {})
|
||||
const lines = result.split('\n')
|
||||
|
||||
expect(lines.length).toBeGreaterThan(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('hard wrapping', () => {
|
||||
it('should never exceed column width in hard mode', () => {
|
||||
const input = 'This is a very long word that should be hard wrapped'
|
||||
const result = wrapAnsi(input, 10, { hard: true })
|
||||
const lines = result.split('\n')
|
||||
|
||||
lines.forEach((line) => {
|
||||
expect(line.length).toBeLessThanOrEqual(10)
|
||||
})
|
||||
})
|
||||
|
||||
it('should break long words in hard mode', () => {
|
||||
const input = 'supercalifragilisticexpialidocious'
|
||||
const result = wrapAnsi(input, 5, { hard: true })
|
||||
const lines = result.split('\n')
|
||||
|
||||
expect(lines.length).toBeGreaterThan(1)
|
||||
lines.forEach((line) => {
|
||||
expect(line.length).toBeLessThanOrEqual(5)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('trimming', () => {
|
||||
it('should trim whitespace by default', () => {
|
||||
const input = ' This has leading and trailing spaces '
|
||||
const result = wrapAnsi(input, 20, {})
|
||||
const lines = result.split('\n')
|
||||
|
||||
lines.forEach((line) => {
|
||||
expect(line).not.toMatch(/^ /)
|
||||
expect(line).not.toMatch(/ $/)
|
||||
})
|
||||
})
|
||||
|
||||
it('should preserve whitespace when trim: false', () => {
|
||||
const input = ' This has leading spaces '
|
||||
const result = wrapAnsi(input, 20, { trim: false })
|
||||
const lines = result.split('\n')
|
||||
|
||||
expect(lines[0]).toMatch(/^ {2}/)
|
||||
})
|
||||
|
||||
it('should preserve original leading whitespace on first line and apply indent to wrapped lines', () => {
|
||||
const input = ' Display this help message that is long enough to wrap around'
|
||||
const result = wrapAnsi(input, 30, { indent: ' ' })
|
||||
const lines = result.split('\n')
|
||||
|
||||
// Should have multiple lines due to wrapping
|
||||
expect(lines.length).toBeGreaterThan(1)
|
||||
|
||||
// First line should preserve original leading whitespace
|
||||
expect(lines[0]).toMatch(/^ {5}/)
|
||||
|
||||
// Wrapped lines should use the indent option
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
expect(lines[i]).toMatch(/^ {5}/)
|
||||
}
|
||||
})
|
||||
|
||||
it('should preserve original leading whitespace only on first line when no indent specified', () => {
|
||||
const input = ' Display this help message that is long enough to wrap around'
|
||||
const result = wrapAnsi(input, 30, {})
|
||||
const lines = result.split('\n')
|
||||
|
||||
// Should have multiple lines due to wrapping
|
||||
expect(lines.length).toBeGreaterThan(1)
|
||||
|
||||
// First line should NOT preserve original leading whitespace (default trim: true)
|
||||
expect(lines[0]).not.toMatch(/^ {5}/)
|
||||
|
||||
// Wrapped lines should not have any leading whitespace
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
expect(lines[i]).not.toMatch(/^ /)
|
||||
}
|
||||
})
|
||||
|
||||
it('should respect column limit when first line has original leading whitespace', () => {
|
||||
const input = ' Real-time multiplayer for tldraw, built with Cloudflare Durable Objects.'
|
||||
const result = wrapAnsi(input, 56, { indent: ' ' })
|
||||
const lines = result.split('\n')
|
||||
|
||||
// Should have multiple lines due to wrapping
|
||||
expect(lines.length).toBeGreaterThan(1)
|
||||
|
||||
// First line should not exceed column limit
|
||||
expect(lines[0].length).toBeLessThanOrEqual(56)
|
||||
|
||||
// All lines should respect column limit
|
||||
lines.forEach((line) => {
|
||||
expect(line.length).toBeLessThanOrEqual(56)
|
||||
})
|
||||
|
||||
// First line should preserve original leading whitespace
|
||||
expect(lines[0]).toMatch(/^ {5}/)
|
||||
|
||||
// Wrapped lines should use indent
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
expect(lines[i]).toMatch(/^ {5}/)
|
||||
}
|
||||
})
|
||||
|
||||
it('should handle multiple spaces between words', () => {
|
||||
const input = 'word1 word2 word3'
|
||||
const result = wrapAnsi(input, 10, {})
|
||||
const lines = result.split('\n')
|
||||
|
||||
// Should preserve single spaces between words
|
||||
lines.forEach((line) => {
|
||||
expect(line).not.toMatch(/ {2}/) // No double spaces
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('newline handling', () => {
|
||||
it('should preserve existing newlines', () => {
|
||||
const input = 'Line 1\nLine 2\nLine 3'
|
||||
const result = wrapAnsi(input, 10, {})
|
||||
const lines = result.split('\n')
|
||||
|
||||
expect(lines.length).toBeGreaterThanOrEqual(3)
|
||||
})
|
||||
|
||||
it('should handle carriage return line feeds', () => {
|
||||
const input = 'Line 1\r\nLine 2\r\nLine 3'
|
||||
const result = wrapAnsi(input, 10, {})
|
||||
const lines = result.split('\n')
|
||||
|
||||
expect(lines.length).toBeGreaterThanOrEqual(3)
|
||||
})
|
||||
|
||||
it('should wrap each line separately', () => {
|
||||
const input = 'This is a long line\nThis is another long line'
|
||||
const result = wrapAnsi(input, 15, {})
|
||||
const lines = result.split('\n')
|
||||
|
||||
// Should have more than 2 lines due to wrapping
|
||||
expect(lines.length).toBeGreaterThan(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle strings with only ANSI codes', () => {
|
||||
const input = '\u001B[31m\u001B[39m'
|
||||
const result = wrapAnsi(input, 10, {})
|
||||
expect(result).toBe(input)
|
||||
})
|
||||
|
||||
it('should handle strings with unicode characters', () => {
|
||||
const input = 'café résumé naïve'
|
||||
const result = wrapAnsi(input, 10, {})
|
||||
const lines = result.split('\n')
|
||||
|
||||
expect(lines.length).toBeGreaterThan(1)
|
||||
})
|
||||
|
||||
it('should handle very long words', () => {
|
||||
const input = 'a'.repeat(100)
|
||||
const result = wrapAnsi(input, 10, {})
|
||||
const lines = result.split('\n')
|
||||
|
||||
// The function doesn't wrap very long words by default (not in hard mode)
|
||||
// So it should just return the original word
|
||||
expect(lines.length).toBe(1)
|
||||
expect(lines[0]).toBe(input)
|
||||
})
|
||||
|
||||
it('should handle zero column width', () => {
|
||||
const input = 'This should be wrapped'
|
||||
const result = wrapAnsi(input, 0, {})
|
||||
const lines = result.split('\n')
|
||||
|
||||
expect(lines.length).toBeGreaterThan(1)
|
||||
})
|
||||
|
||||
it('should handle negative column width', () => {
|
||||
const input = 'This should be wrapped'
|
||||
const result = wrapAnsi(input, -5, {})
|
||||
const lines = result.split('\n')
|
||||
|
||||
expect(lines.length).toBeGreaterThan(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('complex scenarios', () => {
|
||||
it('should handle mixed ANSI codes and long text', () => {
|
||||
const input =
|
||||
'\u001B[31m\u001B[1mThis is bold red text that should wrap at the specified column width\u001B[39m\u001B[22m'
|
||||
const result = wrapAnsi(input, 20, {})
|
||||
const lines = result.split('\n')
|
||||
|
||||
expect(lines.length).toBeGreaterThan(1)
|
||||
expect(result).toContain('\u001B[31m')
|
||||
expect(result).toContain('\u001B[1m')
|
||||
expect(result).toContain('\u001B[39m')
|
||||
expect(result).toContain('\u001B[22m')
|
||||
})
|
||||
|
||||
it('should handle indentation with ANSI codes', () => {
|
||||
const input = '\u001B[32mThis is green text that should be indented when wrapped\u001B[39m'
|
||||
const result = wrapAnsi(input, 20, { indent: ' ' })
|
||||
const lines = result.split('\n')
|
||||
|
||||
expect(lines.length).toBeGreaterThan(1)
|
||||
// First line should not be indented
|
||||
expect(lines[0]).not.toMatch(/^ {2}/)
|
||||
// Only the last line should be indented (after ANSI codes)
|
||||
const lastLine = lines[lines.length - 1]
|
||||
expect(lastLine).toContain(' ')
|
||||
// Middle lines should not be indented
|
||||
for (let i = 1; i < lines.length - 1; i++) {
|
||||
expect(lines[i]).not.toMatch(/^ {2}/)
|
||||
}
|
||||
})
|
||||
|
||||
it('should handle hard wrapping with ANSI codes', () => {
|
||||
const input =
|
||||
'\u001B[33m\u001B[1mThis is bold yellow text that should be hard wrapped\u001B[39m\u001B[22m'
|
||||
const result = wrapAnsi(input, 15, { hard: true })
|
||||
const lines = result.split('\n')
|
||||
|
||||
lines.forEach((line) => {
|
||||
// Remove ANSI codes for length calculation
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const cleanLine = line.replace(/\u001B\[[0-9;]*m/g, '')
|
||||
expect(cleanLine.length).toBeLessThanOrEqual(15)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
279
packages/create-tldraw/src/wrap-ansi.ts
Normal file
279
packages/create-tldraw/src/wrap-ansi.ts
Normal file
@@ -0,0 +1,279 @@
|
||||
/*!
|
||||
* This is a fork of wrap-ansi, adding the ability to customize the string inserted at the start of each wrapped line.
|
||||
* MIT License: https://github.com/chalk/wrap-ansi/blob/163b878a6eb5d8c32b7bbea65036eeadc0e4def9/license
|
||||
* Copyright (c) Sindre Sorhus <sindresorhus@gmail.com> (https://sindresorhus.com)
|
||||
* https://github.com/chalk/wrap-ansi/blob/163b878a6eb5d8c32b7bbea65036eeadc0e4def9/index.js
|
||||
*/
|
||||
|
||||
import ansiStyles from 'ansi-styles'
|
||||
import stringWidth from 'string-width'
|
||||
import stripAnsi from 'strip-ansi'
|
||||
|
||||
interface WrapAnsiOptions {
|
||||
trim?: boolean
|
||||
wordWrap?: boolean
|
||||
hard?: boolean
|
||||
indent?: string
|
||||
}
|
||||
|
||||
const ESCAPES = new Set(['\u001B', '\u009B'])
|
||||
|
||||
const END_CODE = 39
|
||||
const ANSI_ESCAPE_BELL = '\u0007'
|
||||
const ANSI_CSI = '['
|
||||
const ANSI_OSC = ']'
|
||||
const ANSI_SGR_TERMINATOR = 'm'
|
||||
const ANSI_ESCAPE_LINK = `${ANSI_OSC}8;;`
|
||||
|
||||
function wrapAnsiCode(code: number) {
|
||||
return `${ESCAPES.values().next().value}${ANSI_CSI}${code}${ANSI_SGR_TERMINATOR}`
|
||||
}
|
||||
function wrapAnsiHyperlink(url: string) {
|
||||
return `${ESCAPES.values().next().value}${ANSI_ESCAPE_LINK}${url}${ANSI_ESCAPE_BELL}`
|
||||
}
|
||||
|
||||
// Calculate the length of words split on ' ', ignoring
|
||||
// the extra characters added by ansi escape codes
|
||||
function wordLengths(string: string) {
|
||||
return string.split(' ').map((character) => stringWidth(character))
|
||||
}
|
||||
|
||||
// Wrap a long word across multiple rows
|
||||
// Ansi escape codes do not count towards length
|
||||
function wrapWord(rows: string[], word: string, columns: number, indent: string) {
|
||||
const characters = [...word]
|
||||
|
||||
let isInsideEscape = false
|
||||
let isInsideLinkEscape = false
|
||||
let visible = stringWidth(stripAnsi(rows.at(-1)!))
|
||||
|
||||
const indentLength = stringWidth(indent)
|
||||
|
||||
for (const [index, character] of characters.entries()) {
|
||||
const characterLength = stringWidth(character)
|
||||
|
||||
if (visible + characterLength <= columns) {
|
||||
rows[rows.length - 1] += character
|
||||
} else {
|
||||
rows.push(`${indent}${character}`)
|
||||
visible = indentLength
|
||||
}
|
||||
|
||||
if (ESCAPES.has(character)) {
|
||||
isInsideEscape = true
|
||||
|
||||
const ansiEscapeLinkCandidate = characters
|
||||
.slice(index + 1, index + 1 + ANSI_ESCAPE_LINK.length)
|
||||
.join('')
|
||||
isInsideLinkEscape = ansiEscapeLinkCandidate === ANSI_ESCAPE_LINK
|
||||
}
|
||||
|
||||
if (isInsideEscape) {
|
||||
if (isInsideLinkEscape) {
|
||||
if (character === ANSI_ESCAPE_BELL) {
|
||||
isInsideEscape = false
|
||||
isInsideLinkEscape = false
|
||||
}
|
||||
} else if (character === ANSI_SGR_TERMINATOR) {
|
||||
isInsideEscape = false
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
visible += characterLength
|
||||
|
||||
if (visible === columns && index < characters.length - 1) {
|
||||
rows.push(indent)
|
||||
visible = indentLength
|
||||
}
|
||||
}
|
||||
|
||||
// It's possible that the last row we copy over is only
|
||||
// ansi escape characters, handle this edge-case
|
||||
if (!visible && rows.at(-1)!.length > 0 && rows.length > 1) {
|
||||
rows[rows.length - 2] += rows.pop()
|
||||
}
|
||||
}
|
||||
|
||||
// Trims spaces from a string ignoring invisible sequences
|
||||
function stringVisibleTrimSpacesRight(string: string) {
|
||||
const words = string.split(' ')
|
||||
let last = words.length
|
||||
|
||||
while (last > 0) {
|
||||
if (stringWidth(words[last - 1]) > 0) {
|
||||
break
|
||||
}
|
||||
|
||||
last--
|
||||
}
|
||||
|
||||
if (last === words.length) {
|
||||
return string
|
||||
}
|
||||
|
||||
return words.slice(0, last).join(' ') + words.slice(last).join('')
|
||||
}
|
||||
|
||||
// The wrap-ansi module can be invoked in either 'hard' or 'soft' wrap mode.
|
||||
//
|
||||
// 'hard' will never allow a string to take up more than columns characters.
|
||||
//
|
||||
// 'soft' allows long words to expand past the column length.
|
||||
function exec(string: string, columns: number, options: WrapAnsiOptions = {}) {
|
||||
if (options.trim !== false && string.trim() === '') {
|
||||
return ''
|
||||
}
|
||||
|
||||
const indent = options.indent ?? ''
|
||||
const indentLength = stringWidth(indent)
|
||||
|
||||
// Extract original leading whitespace if we want to preserve it
|
||||
const originalLeadingWhitespace = string.match(/^(\s*)/)?.[1] || ''
|
||||
const trimmedString = string.trimStart()
|
||||
|
||||
// Adjust column width to account for original leading whitespace on first row
|
||||
const shouldPreserveLeadingWhitespace =
|
||||
(options.trim === false || options.indent) && originalLeadingWhitespace
|
||||
const adjustedColumns = shouldPreserveLeadingWhitespace
|
||||
? columns - stringWidth(originalLeadingWhitespace)
|
||||
: columns
|
||||
|
||||
let returnValue = ''
|
||||
let escapeCode
|
||||
let escapeUrl
|
||||
|
||||
const lengths = wordLengths(trimmedString)
|
||||
let rows = ['']
|
||||
let isFirstRow = true
|
||||
const rowsWithIndent = new Set<number>()
|
||||
|
||||
for (const [index, word] of trimmedString.split(' ').entries()) {
|
||||
if (options.trim !== false && !isFirstRow && !rowsWithIndent.has(rows.length - 1)) {
|
||||
rows[rows.length - 1] = rows.at(-1)!.trimStart()
|
||||
}
|
||||
|
||||
let rowLength = stringWidth(rows.at(-1)!)
|
||||
|
||||
if (index !== 0) {
|
||||
const currentColumns = isFirstRow ? adjustedColumns : columns
|
||||
if (rowLength >= currentColumns && (options.wordWrap === false || options.trim === false)) {
|
||||
// If we start with a new word but the current row length equals the length of the columns, add a new row
|
||||
rows.push(indent)
|
||||
rowsWithIndent.add(rows.length - 1)
|
||||
rowLength = indentLength
|
||||
isFirstRow = false
|
||||
}
|
||||
|
||||
if (rowLength > 0 || options.trim === false) {
|
||||
rows[rows.length - 1] += ' '
|
||||
rowLength++
|
||||
}
|
||||
}
|
||||
|
||||
// In 'hard' wrap mode, the length of a line is never allowed to extend past 'columns'
|
||||
const currentColumns = isFirstRow ? adjustedColumns : columns
|
||||
if (options.hard && lengths[index] > currentColumns) {
|
||||
const remainingColumns = currentColumns - rowLength
|
||||
const breaksStartingThisLine =
|
||||
1 + Math.floor((lengths[index] - remainingColumns - 1) / currentColumns)
|
||||
const breaksStartingNextLine = Math.floor((lengths[index] - 1) / currentColumns)
|
||||
if (breaksStartingNextLine < breaksStartingThisLine) {
|
||||
rows.push(indent)
|
||||
rowsWithIndent.add(rows.length - 1)
|
||||
isFirstRow = false
|
||||
}
|
||||
|
||||
wrapWord(rows, word, currentColumns, indent)
|
||||
continue
|
||||
}
|
||||
|
||||
const currentColumnsForWrap = isFirstRow ? adjustedColumns : columns
|
||||
if (rowLength + lengths[index] > currentColumnsForWrap && rowLength > 0 && lengths[index] > 0) {
|
||||
if (options.wordWrap === false && rowLength < currentColumnsForWrap) {
|
||||
wrapWord(rows, word, currentColumnsForWrap, indent)
|
||||
continue
|
||||
}
|
||||
|
||||
rows.push(indent)
|
||||
rowsWithIndent.add(rows.length - 1)
|
||||
isFirstRow = false
|
||||
}
|
||||
|
||||
const newCurrentColumns = isFirstRow ? adjustedColumns : columns
|
||||
if (rowLength + lengths[index] > newCurrentColumns && options.wordWrap === false) {
|
||||
wrapWord(rows, word, newCurrentColumns, indent)
|
||||
continue
|
||||
}
|
||||
|
||||
rows[rows.length - 1] += word
|
||||
}
|
||||
|
||||
if (options.trim !== false) {
|
||||
rows = rows.map((row) => stringVisibleTrimSpacesRight(row))
|
||||
}
|
||||
|
||||
// Add original leading whitespace to the first row when needed
|
||||
if (originalLeadingWhitespace && rows.length > 0) {
|
||||
if (options.trim === false || options.indent) {
|
||||
rows[0] = originalLeadingWhitespace + rows[0]
|
||||
}
|
||||
}
|
||||
|
||||
const preString = rows.join('\n')
|
||||
const pre = [...preString]
|
||||
|
||||
// We need to keep a separate index as `String#slice()` works on Unicode code units, while `pre` is an array of codepoints.
|
||||
let preStringIndex = 0
|
||||
|
||||
for (const [index, character] of pre.entries()) {
|
||||
returnValue += character
|
||||
|
||||
if (ESCAPES.has(character)) {
|
||||
const { groups } = new RegExp(
|
||||
`(?:\\${ANSI_CSI}(?<code>\\d+)m|\\${ANSI_ESCAPE_LINK}(?<uri>.*)${ANSI_ESCAPE_BELL})`
|
||||
).exec(preString.slice(preStringIndex)) || { groups: { code: undefined, uri: undefined } }
|
||||
if (groups!.code !== undefined) {
|
||||
const code = Number.parseFloat(groups!.code)
|
||||
escapeCode = code === END_CODE ? undefined : code
|
||||
} else if (groups!.uri !== undefined) {
|
||||
escapeUrl = groups!.uri.length === 0 ? undefined : groups!.uri
|
||||
}
|
||||
}
|
||||
|
||||
const code = ansiStyles.codes.get(Number(escapeCode))
|
||||
|
||||
if (pre[index + 1] === '\n') {
|
||||
if (escapeUrl) {
|
||||
returnValue += wrapAnsiHyperlink('')
|
||||
}
|
||||
|
||||
if (escapeCode && code) {
|
||||
returnValue += wrapAnsiCode(code)
|
||||
}
|
||||
} else if (character === '\n') {
|
||||
if (escapeCode && code) {
|
||||
returnValue += wrapAnsiCode(escapeCode)
|
||||
}
|
||||
|
||||
if (escapeUrl) {
|
||||
returnValue += wrapAnsiHyperlink(escapeUrl)
|
||||
}
|
||||
}
|
||||
|
||||
preStringIndex += character.length
|
||||
}
|
||||
|
||||
return returnValue
|
||||
}
|
||||
|
||||
// For each newline, invoke the method separately
|
||||
export function wrapAnsi(string: string, columns: number, options: WrapAnsiOptions) {
|
||||
return String(string)
|
||||
.normalize()
|
||||
.replaceAll('\r\n', '\n')
|
||||
.split('\n')
|
||||
.map((line) => exec(line, columns, options))
|
||||
.join('\n')
|
||||
}
|
||||
7
packages/create-tldraw/tsconfig.json
Normal file
7
packages/create-tldraw/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "../../internal/config/tsconfig.base.json",
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "dist", ".tsbuild*"],
|
||||
"compilerOptions": { "outDir": "./.tsbuild" },
|
||||
"references": [{ "path": "../utils" }]
|
||||
}
|
||||
14
packages/create-tldraw/vitest.config.ts
Normal file
14
packages/create-tldraw/vitest.config.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
/// <reference types="vitest" />
|
||||
import { mergeConfig } from 'vitest/config'
|
||||
import baseConfig from '../../internal/config/vitest/node-preset'
|
||||
|
||||
// create-tldraw package uses the standard node preset with minimal overrides
|
||||
export default mergeConfig(baseConfig, {
|
||||
test: {
|
||||
// Handle ESM modules that need to be transformed
|
||||
transformMode: {
|
||||
// Transform these ESM modules during testing
|
||||
ssr: ['ansi-styles', 'string-width', 'strip-ansi', 'ansi-regex', 'get-east-asian-width'],
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user