Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .bun-version
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
1.3.14
7 changes: 7 additions & 0 deletions .changeset/standalone-oxfmt.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@tanstack/create': minor
---

Add Oxfmt as a standalone formatter toolchain for React and Solid projects.

Show replaced package script commands before adding integrations to existing projects.
6 changes: 6 additions & 0 deletions .github/workflows/pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,12 @@ jobs:
uses: nrwl/nx-set-shas@3e9ad7370203c1e93d109be57f3b72eb0eb511b1 # v4.4.0
with:
main-branch-name: main
- name: Setup Bun for E2E
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version-file: .bun-version
no-cache: true
token: ''
- name: Install Playwright Chrome
run: pnpm --filter @tanstack/cli exec playwright install --with-deps chrome
- name: Test E2E Blocking
Expand Down
6 changes: 6 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,12 @@ jobs:
run: pnpm build
- name: Test Unit
run: pnpm test:unit
- name: Setup Bun for E2E
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version-file: .bun-version
no-cache: true
token: ''
- name: Install Playwright Chrome
run: pnpm --filter @tanstack/cli exec playwright install --with-deps chrome
- name: E2E Blocking
Expand Down
55 changes: 55 additions & 0 deletions packages/cli/tests-e2e/oxfmt-smoke.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { execFile } from 'node:child_process'
import { appendFile, readFile } from 'node:fs/promises'
import { join } from 'node:path'
import { promisify } from 'node:util'
import { expect, test } from '@playwright/test'
import { createAppFixture } from './helpers'

const runFile = promisify(execFile)
for (const framework of ['react', 'solid'] as const) {
for (const mode of ['standard', 'blank', 'existing'] as const) {
test(`@blocking formats a ${mode} ${framework} app with standalone Oxfmt`, async () => {
const packageManager =
mode === 'blank'
? 'npm'
: mode === 'existing' && framework === 'react'
? 'bun'
: 'pnpm'
const fixture = await createAppFixture({
appName: `${framework}-oxfmt-${mode}`,
framework,
bundler: mode === 'blank' ? 'rsbuild' : 'vite',
blank: mode !== 'standard',
...(mode === 'existing'
? { postCreateAddOns: ['oxfmt'] }
: { toolchain: 'oxfmt' }),
packageManager,
skipDevServer: true,
runQualityGatesChecks: true,
})
const run = (script: string) =>
runFile(packageManager, ['run', script], {
cwd: fixture.appDir,
timeout: 60_000,
})
try {
const manifest = JSON.parse(
await readFile(join(fixture.appDir, 'package.json'), 'utf8'),
)
expect(manifest.devDependencies.oxfmt).toBe('^0.77.0')
expect(manifest.devDependencies.eslint).toBeUndefined()
expect(manifest.devDependencies['@biomejs/biome']).toBeUndefined()
expect(manifest.scripts.lint).toBeUndefined()
await run('format')
await run('check')
await appendFile(join(fixture.appDir, 'src/routes/index.tsx'), '\n\n\n')
await expect(run('check')).rejects.toMatchObject({ code: 1 })
await run('format')
await run('check')
} finally {
await fixture.stop()
await fixture.cleanup()
}
})
}
}
17 changes: 17 additions & 0 deletions packages/create/src/add-to-app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -262,10 +262,20 @@ export async function writeFiles(

const overwrittenFiles: Array<string> = []
const changedFiles: Array<string> = []
const replacedScripts: Array<string> = []
for (const relativeFile of Object.keys(relativeOutputFiles)) {
if (Object.prototype.hasOwnProperty.call(currentFiles, relativeFile)) {
if (currentFiles[relativeFile] !== relativeOutputFiles[relativeFile]) {
overwrittenFiles.push(relativeFile)
if (basename(relativeFile) === 'package.json') {
const currentScripts = JSON.parse(currentFiles[relativeFile]).scripts ?? {}
const generatedScripts = JSON.parse(relativeOutputFiles[relativeFile]).scripts ?? {}
for (const [name, command] of Object.entries(generatedScripts)) {
if (Object.prototype.hasOwnProperty.call(currentScripts, name) && currentScripts[name] !== command) {
replacedScripts.push(`${relativeFile} scripts[${JSON.stringify(name)}]: ${JSON.stringify(currentScripts[name])} -> ${JSON.stringify(command)}`)
}
}
}
}
} else {
changedFiles.push(relativeFile)
Expand All @@ -276,6 +286,13 @@ export async function writeFiles(
.map(toRelativePath)
.filter((file) => environment.exists(resolve(cwd, file)))

if (replacedScripts.length) {
environment.warn(
'The following package scripts will be replaced',
replacedScripts.join('\n'),
)
}

if (!forced && (overwrittenFiles.length || deletedFiles.length)) {
environment.warn(
'The following files will be changed or deleted',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
## Formatting

Run `<%= getPackageManagerRunScript('format') %>` to format files and
`<%= getPackageManagerRunScript('check') %>` to check formatting without changing them.

Oxfmt is configured in `.oxfmtrc.json`. This toolchain does not add a linter.
Oxfmt requires Node 20.19 or newer on the Node 20 line, or Node 22.12 or newer.
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{
"semi": false,
"singleQuote": true,
"trailingComma": "all",
"printWidth": 80,
"sortPackageJson": false,
"ignorePatterns": [
"node_modules",
"dist",
"build",
".output",
".tanstack",
"coverage",
"src/routeTree.gen.ts",
"package-lock.json",
"pnpm-lock.yaml",
"yarn.lock",
"bun.lock",
"bun.lockb",
"deno.lock"
]
}
15 changes: 15 additions & 0 deletions packages/create/src/frameworks/react/toolchains/oxfmt/info.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"name": "Oxfmt",
"description": "Format code without adding a linter.",
"phase": "setup",
"type": "toolchain",
"category": "tooling",
"exclusive": [
"linter"
],
"modes": [
"code-router",
"file-router"
],
"link": "https://oxc.rs/docs/guide/usage/formatter"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"scripts": {
"format": "oxfmt",
"check": "oxfmt --check"
},
"devDependencies": {
"oxfmt": "^0.77.0"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
## Formatting

Run `<%= getPackageManagerRunScript('format') %>` to format files and
`<%= getPackageManagerRunScript('check') %>` to check formatting without changing them.

Oxfmt is configured in `.oxfmtrc.json`. This toolchain does not add a linter.
Oxfmt requires Node 20.19 or newer on the Node 20 line, or Node 22.12 or newer.
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{
"semi": false,
"singleQuote": true,
"trailingComma": "all",
"printWidth": 80,
"sortPackageJson": false,
"ignorePatterns": [
"node_modules",
"dist",
"build",
".output",
".tanstack",
"coverage",
"src/routeTree.gen.ts",
"package-lock.json",
"pnpm-lock.yaml",
"yarn.lock",
"bun.lock",
"bun.lockb",
"deno.lock"
]
}
15 changes: 15 additions & 0 deletions packages/create/src/frameworks/solid/toolchains/oxfmt/info.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"name": "Oxfmt",
"description": "Format code without adding a linter.",
"phase": "setup",
"type": "toolchain",
"category": "tooling",
"exclusive": [
"linter"
],
"modes": [
"code-router",
"file-router"
],
"link": "https://oxc.rs/docs/guide/usage/formatter"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"scripts": {
"format": "oxfmt",
"check": "oxfmt --check"
},
"devDependencies": {
"oxfmt": "^0.77.0"
}
}
105 changes: 105 additions & 0 deletions packages/create/tests/script-replacements.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'

import { createDefaultEnvironment } from '../src/environment.js'
import { writeFiles } from '../src/add-to-app.js'

let directory: string | undefined

async function fixture() {
directory = await mkdtemp(join(tmpdir(), 'tanstack-script-replacement-'))
const manifest = {
name: 'fixture',
scripts: {
check: 'tsc --noEmit && vitest run',
format: 'prettier --write .',
build: 'vite build',
},
}
const original = JSON.stringify(manifest, null, 2)
await writeFile(join(directory, 'package.json'), original)
const environment = createDefaultEnvironment()
environment.warn = vi.fn()
environment.confirm = vi.fn(async () => false)
const output = {
files: {
[join(directory, 'package.json')]: JSON.stringify({
scripts: { check: 'oxfmt --check', format: 'oxfmt' },
}),
},
deletedFiles: [],
}
return { cwd: directory, environment, output, original }
}

afterEach(async () => {
if (directory) await rm(directory, { recursive: true, force: true })
directory = undefined
})

describe('existing package script replacements', () => {
it('shows each old and new command before confirmation and preserves the file on cancellation', async () => {
const { cwd, environment, output, original } = await fixture()
environment.confirm = vi.fn(async () => {
expect(environment.warn).toHaveBeenCalledWith(
'The following package scripts will be replaced',
'package.json scripts["check"]: "tsc --noEmit && vitest run" -> "oxfmt --check"\npackage.json scripts["format"]: "prettier --write ." -> "oxfmt"',
)
expect(await readFile(join(cwd, 'package.json'), 'utf8')).toBe(original)
return false
})
await expect(writeFiles(environment, cwd, output, false)).rejects.toThrow(
'User cancelled',
)
expect(await readFile(join(cwd, 'package.json'), 'utf8')).toBe(original)
})

it('replaces approved commands and retains unrelated scripts', async () => {
const { cwd, environment, output } = await fixture()
environment.confirm = vi.fn(async () => true)
await writeFiles(environment, cwd, output, false)
expect(
JSON.parse(await readFile(join(cwd, 'package.json'), 'utf8')).scripts,
).toEqual({
check: 'oxfmt --check',
format: 'oxfmt',
build: 'vite build',
})
})

it('does not call an identical command or a new script a replacement', async () => {
const { cwd, environment, output } = await fixture()
output.files[join(cwd, 'package.json')] = JSON.stringify({
scripts: { check: 'tsc --noEmit && vitest run', test: 'vitest run' },
})
environment.confirm = vi.fn(async () => true)
await writeFiles(environment, cwd, output, false)
expect(
vi.mocked(environment.warn).mock.calls.map(([message]) => message),
).not.toContain('The following package scripts will be replaced')
expect(
JSON.parse(await readFile(join(cwd, 'package.json'), 'utf8')).scripts,
).toEqual({
check: 'tsc --noEmit && vitest run',
format: 'prettier --write .',
build: 'vite build',
test: 'vitest run',
})
})

it('still reports replacements when force skips confirmation', async () => {
const { cwd, environment, output } = await fixture()
await writeFiles(environment, cwd, output, true)
expect(environment.confirm).not.toHaveBeenCalled()
expect(environment.warn).toHaveBeenCalledWith(
'The following package scripts will be replaced',
expect.stringContaining('tsc --noEmit && vitest run'),
)
expect(
JSON.parse(await readFile(join(cwd, 'package.json'), 'utf8')).scripts
.build,
).toBe('vite build')
})
})
Loading