diff --git a/.bun-version b/.bun-version new file mode 100644 index 00000000..085c0f26 --- /dev/null +++ b/.bun-version @@ -0,0 +1 @@ +1.3.14 diff --git a/.changeset/standalone-oxfmt.md b/.changeset/standalone-oxfmt.md new file mode 100644 index 00000000..19a70187 --- /dev/null +++ b/.changeset/standalone-oxfmt.md @@ -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. diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index ca506976..5b54665f 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 074157f1..3837c5a3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 diff --git a/packages/cli/tests-e2e/oxfmt-smoke.spec.ts b/packages/cli/tests-e2e/oxfmt-smoke.spec.ts new file mode 100644 index 00000000..cb87ca3c --- /dev/null +++ b/packages/cli/tests-e2e/oxfmt-smoke.spec.ts @@ -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() + } + }) + } +} diff --git a/packages/create/src/add-to-app.ts b/packages/create/src/add-to-app.ts index 10feaf91..f7ea2eb5 100644 --- a/packages/create/src/add-to-app.ts +++ b/packages/create/src/add-to-app.ts @@ -262,10 +262,20 @@ export async function writeFiles( const overwrittenFiles: Array = [] const changedFiles: Array = [] + const replacedScripts: Array = [] 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) @@ -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', diff --git a/packages/create/src/frameworks/react/toolchains/oxfmt/README.md.ejs b/packages/create/src/frameworks/react/toolchains/oxfmt/README.md.ejs new file mode 100644 index 00000000..bf6c5e26 --- /dev/null +++ b/packages/create/src/frameworks/react/toolchains/oxfmt/README.md.ejs @@ -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. diff --git a/packages/create/src/frameworks/react/toolchains/oxfmt/assets/_dot_oxfmtrc.json b/packages/create/src/frameworks/react/toolchains/oxfmt/assets/_dot_oxfmtrc.json new file mode 100644 index 00000000..2de56be0 --- /dev/null +++ b/packages/create/src/frameworks/react/toolchains/oxfmt/assets/_dot_oxfmtrc.json @@ -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" + ] +} diff --git a/packages/create/src/frameworks/react/toolchains/oxfmt/info.json b/packages/create/src/frameworks/react/toolchains/oxfmt/info.json new file mode 100644 index 00000000..a72b7f9c --- /dev/null +++ b/packages/create/src/frameworks/react/toolchains/oxfmt/info.json @@ -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" +} diff --git a/packages/create/src/frameworks/react/toolchains/oxfmt/package.json b/packages/create/src/frameworks/react/toolchains/oxfmt/package.json new file mode 100644 index 00000000..41deb0d6 --- /dev/null +++ b/packages/create/src/frameworks/react/toolchains/oxfmt/package.json @@ -0,0 +1,9 @@ +{ + "scripts": { + "format": "oxfmt", + "check": "oxfmt --check" + }, + "devDependencies": { + "oxfmt": "^0.77.0" + } +} diff --git a/packages/create/src/frameworks/solid/toolchains/oxfmt/README.md.ejs b/packages/create/src/frameworks/solid/toolchains/oxfmt/README.md.ejs new file mode 100644 index 00000000..bf6c5e26 --- /dev/null +++ b/packages/create/src/frameworks/solid/toolchains/oxfmt/README.md.ejs @@ -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. diff --git a/packages/create/src/frameworks/solid/toolchains/oxfmt/assets/_dot_oxfmtrc.json b/packages/create/src/frameworks/solid/toolchains/oxfmt/assets/_dot_oxfmtrc.json new file mode 100644 index 00000000..2de56be0 --- /dev/null +++ b/packages/create/src/frameworks/solid/toolchains/oxfmt/assets/_dot_oxfmtrc.json @@ -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" + ] +} diff --git a/packages/create/src/frameworks/solid/toolchains/oxfmt/info.json b/packages/create/src/frameworks/solid/toolchains/oxfmt/info.json new file mode 100644 index 00000000..a72b7f9c --- /dev/null +++ b/packages/create/src/frameworks/solid/toolchains/oxfmt/info.json @@ -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" +} diff --git a/packages/create/src/frameworks/solid/toolchains/oxfmt/package.json b/packages/create/src/frameworks/solid/toolchains/oxfmt/package.json new file mode 100644 index 00000000..41deb0d6 --- /dev/null +++ b/packages/create/src/frameworks/solid/toolchains/oxfmt/package.json @@ -0,0 +1,9 @@ +{ + "scripts": { + "format": "oxfmt", + "check": "oxfmt --check" + }, + "devDependencies": { + "oxfmt": "^0.77.0" + } +} diff --git a/packages/create/tests/script-replacements.test.ts b/packages/create/tests/script-replacements.test.ts new file mode 100644 index 00000000..8407fae6 --- /dev/null +++ b/packages/create/tests/script-replacements.test.ts @@ -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') + }) +})