diff --git a/packages/turbo-generators/package.json b/packages/turbo-generators/package.json index 9f59243c..3c2914cd 100644 --- a/packages/turbo-generators/package.json +++ b/packages/turbo-generators/package.json @@ -13,6 +13,7 @@ "dependencies": {}, "devDependencies": { "@types/node": "catalog:", + "oxc-parser": "catalog:", "tsx": "catalog:", "typescript": "catalog:", "vite-plus": "catalog:", diff --git a/packages/turbo-generators/src/apply-component-creation-plan.test.ts b/packages/turbo-generators/src/apply-component-creation-plan.test.ts index 0c6b2cd4..1c5bb063 100644 --- a/packages/turbo-generators/src/apply-component-creation-plan.test.ts +++ b/packages/turbo-generators/src/apply-component-creation-plan.test.ts @@ -170,6 +170,21 @@ describe('createComponent', () => { createComponent(root, { docsGroup: 'feedback', name: 'StatusBadge' }), ).rejects.toBeInstanceOf(z.ZodError); }); + + it('writes docs navigation JSON that is already formatter-clean', async () => { + const root = await createRepositoryFixture(); + + await createComponent(root, { docsGroup: 'feedback', name: 'StatusBadge' }); + + // oxfmt collapses short pages arrays onto one line; raw JSON.stringify leaves them + // multi-line and fails check:format. + await expect( + readFile(join(root, 'apps/docs/content/docs/components/feedback/meta.json'), 'utf8'), + ).resolves.toBe('{\n\t"pages": ["status-badge"],\n\t"title": "Feedback"\n}\n'); + await expect( + readFile(join(root, 'apps/docs/content/docs/components/meta.json'), 'utf8'), + ).resolves.toBe('{\n\t"pages": ["actions", "feedback"],\n\t"title": "Components"\n}\n'); + }); }); const modulesRegistryPath = 'packages/@luke-ui/react/src/styles/modules.css.ts'; diff --git a/packages/turbo-generators/src/apply-component-creation-plan.ts b/packages/turbo-generators/src/apply-component-creation-plan.ts index 29066df0..4a4c4cf1 100644 --- a/packages/turbo-generators/src/apply-component-creation-plan.ts +++ b/packages/turbo-generators/src/apply-component-creation-plan.ts @@ -1,9 +1,75 @@ import { dirname, join } from 'node:path'; -import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { pathToFileURL } from 'node:url'; +import { access, mkdir, readFile, writeFile } from 'node:fs/promises'; +import type * as ViteFmt from 'vite-plus/fmt'; import * as z from 'zod'; import type { ComponentCreationPlan, PlanFile } from './component-creation-plan.js'; import { createComponentWork, parseComponentAnswers } from './component-creation-plan.js'; +// `config.ts` (this module's ultimate importer) is loaded by `@turbo/gen` through an esbuild +// bundle (bundle:true, format:'cjs'). Two things break once that bundler actually traces into +// real module content: +// - oxfmt's `format()` implementation contains its own dynamic `import()` calls for optional, +// uninstalled prettier plugins (Astro, Marko, Twig, ...). A statically analyzable +// `import('vite-plus/fmt')` lets esbuild trace into that file and fail the whole bundle +// trying to resolve those plugins. +// - `vite-plus`'s own published output reads `import.meta.url` at module scope. esbuild's +// node+cjs target replaces `import.meta` with `{}` instead of shimming it, so a statically +// bundled `import rootConfig from '../../../vite.config.js'` (which pulls in `defineConfig` +// from `vite-plus`) throws `createRequire(undefined)` at load time. +// Routing both specifiers through a variable/expression (rather than a literal esbuild can trace) +// hides them from static analysis, so they're left as real dynamic imports resolved by Node at +// runtime, where both work correctly (genuine ESM, real `import.meta`, real installed packages). +async function loadFormat(): Promise { + const specifier = 'vite-plus/fmt'; + return import(specifier); +} + +async function findRepoRoot(startDir: string): Promise { + async function walk(dir: string): Promise { + try { + await access(join(dir, 'pnpm-workspace.yaml')); + return dir; + } catch { + const parent = dirname(dir); + if (parent === dir) { + throw new Error( + `Could not locate repository root (no pnpm-workspace.yaml) above ${startDir}`, + ); + } + return walk(parent); + } + } + return walk(startDir); +} + +let cachedFmtConfig: Promise | undefined; + +async function loadRootFmtConfig(): Promise { + cachedFmtConfig ??= (async () => { + const repoRoot = await findRepoRoot(process.cwd()); + const configPath = join(repoRoot, 'vite.config.ts'); + const mod: { default?: { fmt?: ViteFmt.FormatConfig } } = await import( + pathToFileURL(configPath).href + ); + return mod.default?.fmt; + })(); + return cachedFmtConfig; +} + +// Runs generated content through the repo's real formatter before it hits disk, so a freshly +// scaffolded file never needs a hand edit to satisfy `check:format`/`check:format-root`. +async function formatGeneratedContent(target: string, raw: string): Promise { + const [{ format }, fmtConfig] = await Promise.all([loadFormat(), loadRootFmtConfig()]); + const result = await format(target, raw, fmtConfig); + if (result.errors.length > 0) { + throw new Error( + `Failed to format ${target}: ${result.errors.map((error) => error.message).join(', ')}`, + ); + } + return result.code; +} + const docsMetaSchema = z.record(z.string(), z.unknown()); type ComponentCreationWork = ReturnType; @@ -29,7 +95,8 @@ async function applyComponentCreationPlan( async function writePlanFile(root: string, file: PlanFile): Promise { const target = join(root, file.path); await mkdir(dirname(target), { recursive: true }); - await writeFile(target, file.contents, 'utf8'); + const formatted = await formatGeneratedContent(target, file.contents); + await writeFile(target, formatted, 'utf8'); } async function applyJsonEdit( @@ -43,7 +110,9 @@ async function applyJsonEdit( const currentPages = Array.isArray(current) ? current.filter(isString) : []; const pages = [...new Set([...currentPages, edit.value])].sort((a, b) => a.localeCompare(b)); data[edit.key] = pages; - await writeFile(target, `${JSON.stringify(data, null, '\t')}\n`, 'utf8'); + const raw = `${JSON.stringify(data, null, '\t')}\n`; + const formatted = await formatGeneratedContent(target, raw); + await writeFile(target, formatted, 'utf8'); } async function readJson(path: string, title: string): Promise> { diff --git a/packages/turbo-generators/src/component-creation-plan.test.ts b/packages/turbo-generators/src/component-creation-plan.test.ts index 93877297..e018abf6 100644 --- a/packages/turbo-generators/src/component-creation-plan.test.ts +++ b/packages/turbo-generators/src/component-creation-plan.test.ts @@ -1,3 +1,4 @@ +import { parseSync } from 'oxc-parser'; import { describe, expect, it } from 'vite-plus/test'; import { ZodError } from 'zod'; import { @@ -105,4 +106,23 @@ describe('createComponentPlan', () => { '', ); }); + + it('generates a browser test file that parses as valid TSX', () => { + const plan = createComponentPlan(validAnswers); + + const testFile = plan.files.find((file) => file.path.endsWith('.browser.test.tsx')); + if (testFile === undefined) throw new Error('Expected the scaffold to write a browser test.'); + + const parsed = parseSync(testFile.path, testFile.contents, { lang: 'tsx' }); + expect(parsed.errors).toEqual([]); + }); + + it('does not import unused ComponentProps in the browser test', () => { + const plan = createComponentPlan(validAnswers); + + const testFile = plan.files.find((file) => file.path.endsWith('.browser.test.tsx')); + if (testFile === undefined) throw new Error('Expected the scaffold to write a browser test.'); + + expect(testFile.contents).not.toContain('ComponentProps'); + }); }); diff --git a/packages/turbo-generators/src/component-creation-plan.ts b/packages/turbo-generators/src/component-creation-plan.ts index ee93b241..3ccd6501 100644 --- a/packages/turbo-generators/src/component-creation-plan.ts +++ b/packages/turbo-generators/src/component-creation-plan.ts @@ -307,7 +307,6 @@ function renderComponentTest(input: { input.integrationTripwire === 'required' ? 'testIntegration' : undefined, ].filter((value): value is string => value != null); const imports = [ - ...(input.conformanceTier !== 'none' ? ["import type { ComponentProps } from 'react';"] : []), ...(input.integrationTripwire === 'required' ? ["import { expect } from 'vite-plus/test';"] : input.conformanceTier === 'none' @@ -320,7 +319,7 @@ function renderComponentTest(input: { `import { ${input.pascalName} } from './index.js';`, ]; - const renderComponent = `render(<${input.pascalName} {...(props}>Content)`; + const renderComponent = `render(<${input.pascalName} {...props}>Content)`; const contract = input.conformanceTier === 'universal' ? `testUniversalConformance({ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d724232b..d3eabfa3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -552,6 +552,9 @@ importers: '@types/node': specifier: 'catalog:' version: 26.2.0 + oxc-parser: + specifier: 'catalog:' + version: 0.143.0 tsx: specifier: 'catalog:' version: 4.23.12