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

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

View File

@@ -0,0 +1 @@
This code is licensed under the [tldraw license](https://github.com/tldraw/tldraw/blob/main/LICENSE.md)

View 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
View File

@@ -0,0 +1,3 @@
#!/usr/bin/env node
require('./dist-cjs/main.cjs')

View 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"
}
}

View 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

View 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 "$@"

View 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>
}

View 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)
})

View 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',
},
]

View 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: '.',
})
})
})

View 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}`
}
}

View 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)
})
})
})
})

View 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')
}

View File

@@ -0,0 +1,7 @@
{
"extends": "../../internal/config/tsconfig.base.json",
"include": ["src"],
"exclude": ["node_modules", "dist", ".tsbuild*"],
"compilerOptions": { "outDir": "./.tsbuild" },
"references": [{ "path": "../utils" }]
}

View 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'],
},
},
})