diff --git a/.changeset/create-plugin-runnable-test-stack.md b/.changeset/create-plugin-runnable-test-stack.md new file mode 100644 index 000000000..606141063 --- /dev/null +++ b/.changeset/create-plugin-runnable-test-stack.md @@ -0,0 +1,19 @@ +--- +'@object-ui/create-plugin': patch +--- + +create-plugin: make the scaffolded plugin's own test suite runnable + +The generator wrote an example test importing `@testing-library/react` and +asserting with `toBeInTheDocument()`, plus a `test: 'vitest run'` script, while +declaring neither library and giving Vitest no DOM environment — so `pnpm test` +in a freshly scaffolded plugin failed on the very first run, at import +resolution. + +The generated `package.json` now declares `@testing-library/react`, +`@testing-library/jest-dom` and `jsdom` (each range copied from this +monorepo's own manifest), the generated `vite.config.ts` gains a `test` block +with `environment: 'jsdom'`, `globals: true` and `setupFiles`, and a +`vitest.setup.ts` registering the jest-dom matchers is written alongside it. +The templates moved to `src/templates.ts` so the generated artifacts can be +pinned by unit tests without executing the CLI. diff --git a/packages/create-plugin/README.md b/packages/create-plugin/README.md index 0d72adafe..ee423f372 100644 --- a/packages/create-plugin/README.md +++ b/packages/create-plugin/README.md @@ -29,6 +29,7 @@ packages/plugin-my-plugin/ ├── package.json ├── tsconfig.json ├── vite.config.ts +├── vitest.setup.ts # Registers the jest-dom matchers └── README.md ``` @@ -37,7 +38,8 @@ packages/plugin-my-plugin/ - ✅ TypeScript support out of the box - ✅ Vite build configuration - ✅ Component registration with ComponentRegistry -- ✅ Test setup with Vitest +- ✅ Runnable Vitest setup — jsdom environment, Testing Library and the jest-dom + matchers are all declared, so `pnpm test` is green on the first run - ✅ Proper package.json with workspace dependencies - ✅ README template - ✅ Type definitions diff --git a/packages/create-plugin/package.json b/packages/create-plugin/package.json index 1162139f8..2590c9299 100644 --- a/packages/create-plugin/package.json +++ b/packages/create-plugin/package.json @@ -15,7 +15,7 @@ ], "scripts": { "build": "tsup", - "type-check": "tsc --noEmit", + "type-check": "tsc --noEmit && tsc -p tsconfig.test.json", "dev": "tsup --watch", "test": "vitest run", "lint": "eslint ." diff --git a/packages/create-plugin/src/__tests__/templates.test.ts b/packages/create-plugin/src/__tests__/templates.test.ts new file mode 100644 index 000000000..51f708e28 --- /dev/null +++ b/packages/create-plugin/src/__tests__/templates.test.ts @@ -0,0 +1,193 @@ +/** + * Pins the scaffold's test stack (objectui#3716). + * + * The generator used to write an example test that imported + * `@testing-library/react` and asserted with `toBeInTheDocument()`, plus a + * `test: 'vitest run'` script — while declaring neither library and giving + * Vitest no DOM environment. `npm test` in a freshly scaffolded plugin was + * therefore red on the very first run. + * + * These tests assert over the SAME file map the CLI writes + * (`buildPluginFiles`), not over this repo's source text, so they cannot go + * green on a template that no longer produces a runnable artifact. The two + * that matter most are structural rather than string-matching: + * + * - every bare import in every generated source file must be a declared + * dependency of the generated package (the exact defect, generalised); + * - the `setupFiles` path in the generated Vitest config must name a file the + * generator actually writes. + */ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { isBuiltin } from 'node:module'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { + VITEST_SETUP_FILE, + buildPackageJson, + buildPluginFiles, + buildTestFile, + buildViteConfig, + buildVitestSetup, + type PluginTemplateVars +} from '../templates'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +/** packages/create-plugin/src/__tests__ -> repo root */ +const REPO_ROOT = resolve(__dirname, '../../../..'); + +const VARS: PluginTemplateVars = { + packageName: '@object-ui/plugin-heatmap', + pluginName: 'heatmap', + pascalName: 'Heatmap', + description: 'Heatmap plugin for ObjectUI', + author: 'ObjectStack Team', + version: '0.1.0', + year: 2026 +}; + +/** + * Package names imported by a generated source file, excluding relative imports. + * + * Covers both `import x from 'pkg'` and the side-effect form `import 'pkg'` + * (which is how the generated Vitest setup file pulls the matchers in), and + * folds a subpath specifier back onto its package name so + * `@testing-library/jest-dom/vitest` is checked against + * `@testing-library/jest-dom`. Node builtins are dropped — the generated Vite + * config imports `path`, which nothing declares because nothing has to. + */ +function importedPackagesOf(source: string): string[] { + const packages = new Set(); + for (const match of source.matchAll(/(?:^|\n)\s*import\s+(?:[^;'"]*?from\s+)?'([^']+)'/g)) { + const specifier = match[1]; + if (specifier.startsWith('.') || specifier.startsWith('/')) continue; + if (isBuiltin(specifier)) continue; + const segments = specifier.split('/'); + packages.add(specifier.startsWith('@') ? segments.slice(0, 2).join('/') : segments[0]); + } + return [...packages].sort(); +} + +function declaredDependencies(vars: PluginTemplateVars): Record { + const pkg = buildPackageJson(vars) as { + dependencies: Record; + devDependencies: Record; + peerDependencies: Record; + }; + return { ...pkg.dependencies, ...pkg.devDependencies, ...pkg.peerDependencies }; +} + +describe('generated package.json', () => { + it('declares the whole test stack the template ships', () => { + const pkg = buildPackageJson(VARS) as { + scripts: Record; + devDependencies: Record; + }; + + // The script is what makes the three declarations mandatory: ship one and + // the author's first command has to work. + expect(pkg.scripts.test).toBe('vitest run'); + + expect(Object.keys(pkg.devDependencies)).toEqual( + expect.arrayContaining(['@testing-library/react', '@testing-library/jest-dom', 'jsdom']) + ); + }); + + it('sources the testing ranges from this repo instead of inventing them', () => { + // These literals live in `.ts` source, outside the objectui#3711 + // version-claims gate's scan face, so this is the gate for them: the + // template must quote the monorepo's own range for the same package. + // Bumping the root manifest and leaving the template behind is the drift + // this test exists to catch — update `src/templates.ts` in the same PR. + const rootPkg = JSON.parse(readFileSync(resolve(REPO_ROOT, 'package.json'), 'utf-8')) as { + devDependencies: Record; + }; + const generated = ( + buildPackageJson(VARS) as { devDependencies: Record } + ).devDependencies; + + for (const name of ['@testing-library/react', '@testing-library/jest-dom', 'jsdom']) { + expect(rootPkg.devDependencies[name], `${name} must exist in the root manifest`).toBeTruthy(); + expect(generated[name], `${name} range must match the repo root`).toBe( + rootPkg.devDependencies[name] + ); + } + }); +}); + +describe('generated sources', () => { + it('import nothing the generated package.json does not declare', () => { + // The defect behind objectui#3716, generalised over every generated file: + // the example test imported `@testing-library/react` and the manifest never + // declared it. Add an undeclared import to any template and this goes red. + const declared = declaredDependencies(VARS); + const files = buildPluginFiles(VARS); + for (const [relativePath, contents] of Object.entries(files)) { + if (!/\.tsx?$/.test(relativePath)) continue; + for (const pkg of importedPackagesOf(contents)) { + expect(declared[pkg], `${relativePath} imports ${pkg}, which is not declared`).toBeTruthy(); + } + } + }); + + it('has its jest-dom matchers registered by the setup file', () => { + expect(buildTestFile(VARS)).toContain('toBeInTheDocument'); + expect(buildVitestSetup()).toContain('@testing-library/jest-dom'); + }); +}); + +describe('generated vite.config.ts', () => { + const viteConfig = buildViteConfig(VARS); + + it('gives Vitest a DOM environment', () => { + // Without this the example test's `render()` runs in Vitest's default + // `node` environment, where React has no `document` to mount into. + expect(viteConfig).toMatch(/test:\s*\{/); + expect(viteConfig).toContain(`environment: 'jsdom'`); + }); + + it('enables globals so @testing-library/react registers its auto cleanup', () => { + // RTL only hooks cleanup when `afterEach` exists as a global + // (`@testing-library/react/dist/index.js`: `if (typeof afterEach === 'function')`). + expect(viteConfig).toContain('globals: true'); + }); + + it('points setupFiles at a file the generator actually writes', () => { + expect(viteConfig).toContain(`setupFiles: ['./${VITEST_SETUP_FILE}']`); + + const setupPaths = [...viteConfig.matchAll(/setupFiles:\s*\['([^']+)'\]/g)].map((m) => m[1]); + expect(setupPaths).not.toHaveLength(0); + const files = buildPluginFiles(VARS); + for (const setupPath of setupPaths) { + expect(files[setupPath.replace(/^\.\//, '')], `${setupPath} is not generated`).toBeTruthy(); + } + }); +}); + +describe('generated file map', () => { + it('writes the Vitest setup file next to the config that names it', () => { + const files = buildPluginFiles(VARS); + expect(Object.keys(files).sort()).toEqual([ + 'README.md', + 'package.json', + 'src/HeatmapImpl.test.tsx', + 'src/HeatmapImpl.tsx', + 'src/index.tsx', + 'src/types.ts', + 'tsconfig.json', + 'vite.config.ts', + 'vitest.setup.ts' + ]); + expect(files[VITEST_SETUP_FILE]).toContain(`import '@testing-library/jest-dom/vitest';`); + }); + + it('keeps every path inside the generated plugin directory', () => { + // The CLI joins these onto the target dir, so a `..` segment here would + // escape it — the same traversal the plugin-name validation guards against. + for (const relativePath of Object.keys(buildPluginFiles(VARS))) { + expect(relativePath).not.toMatch(/(^|\/)\.\.(\/|$)/); + expect(relativePath.startsWith('/')).toBe(false); + } + }); +}); diff --git a/packages/create-plugin/src/index.ts b/packages/create-plugin/src/index.ts index 3f532d9d4..99633bc2f 100644 --- a/packages/create-plugin/src/index.ts +++ b/packages/create-plugin/src/index.ts @@ -12,6 +12,7 @@ import chalk from 'chalk'; import prompts from 'prompts'; import * as path from 'path'; import fs from 'fs-extra'; +import { buildPluginFiles, type PluginTemplateVars } from './templates'; const program = new Command(); @@ -105,276 +106,25 @@ async function createPlugin(pluginName?: string, options: PluginOptions = {}) { // Create directory structure fs.mkdirpSync(targetDir); - fs.mkdirpSync(path.join(targetDir, 'src')); // Template variables - const vars = { - PACKAGE_NAME: `@object-ui/${fullPackageName}`, - PLUGIN_NAME: cleanName, - PASCAL_NAME: pascalCaseName, - DESCRIPTION: answers.description, - AUTHOR: answers.author, - VERSION: '0.1.0', - YEAR: new Date().getFullYear() + const vars: PluginTemplateVars = { + packageName: `@object-ui/${fullPackageName}`, + pluginName: cleanName, + pascalName: pascalCaseName, + description: answers.description, + author: answers.author, + version: '0.1.0', + year: new Date().getFullYear() }; - // Create package.json - const packageJson = { - name: vars.PACKAGE_NAME, - version: vars.VERSION, - type: 'module', - license: 'MIT', - description: vars.DESCRIPTION, - main: 'dist/index.umd.cjs', - module: 'dist/index.js', - types: 'dist/index.d.ts', - exports: { - '.': { - types: './dist/index.d.ts', - import: './dist/index.js', - require: './dist/index.umd.cjs' - } - }, - scripts: { - build: 'vite build', - test: 'vitest run', - lint: 'eslint .' - }, - dependencies: { - '@object-ui/components': 'workspace:*', - '@object-ui/core': 'workspace:*', - '@object-ui/react': 'workspace:*', - '@object-ui/types': 'workspace:*', - 'lucide-react': '^0.563.0' - }, - peerDependencies: { - react: '^18.0.0 || ^19.0.0', - 'react-dom': '^18.0.0 || ^19.0.0' - }, - devDependencies: { - '@vitejs/plugin-react': '^4.2.1', - typescript: '^5.9.3', - vite: '^7.3.1', - 'vite-plugin-dts': '^4.5.4', - vitest: '^4.0.18' - } - }; - - fs.writeFileSync( - path.join(targetDir, 'package.json'), - JSON.stringify(packageJson, null, 2) - ); - - // Create tsconfig.json - const tsconfig = { - extends: '../../tsconfig.json', - compilerOptions: { - outDir: './dist', - rootDir: './src', - declaration: true, - declarationMap: true - }, - include: ['src/**/*'], - exclude: ['node_modules', 'dist', '**/*.test.ts', '**/*.test.tsx'] - }; - - fs.writeFileSync( - path.join(targetDir, 'tsconfig.json'), - JSON.stringify(tsconfig, null, 2) - ); - - // Create vite.config.ts - const viteConfig = `import { defineConfig } from 'vite'; -import react from '@vitejs/plugin-react'; -import dts from 'vite-plugin-dts'; -import * as path from 'path'; - -export default defineConfig({ - plugins: [ - react(), - dts({ - insertTypesEntry: true, - }), - ], - build: { - lib: { - entry: path.resolve(__dirname, 'src/index.tsx'), - name: '${pascalCaseName}', - formats: ['es', 'umd'], - fileName: (format) => \`index.\${format === 'es' ? 'js' : 'umd.cjs'}\`, - }, - rollupOptions: { - external: ['react', 'react-dom', 'react/jsx-runtime'], - output: { - globals: { - react: 'React', - 'react-dom': 'ReactDOM', - }, - }, - }, - }, -}); -`; - - fs.writeFileSync(path.join(targetDir, 'vite.config.ts'), viteConfig); - - // Create README.md - const readme = `# ${vars.PACKAGE_NAME} - -${vars.DESCRIPTION} - -## Installation - -\`\`\`bash -pnpm add ${vars.PACKAGE_NAME} -\`\`\` - -## Usage - -\`\`\`tsx -import { ${pascalCaseName} } from '${vars.PACKAGE_NAME}'; - -// Use the component -<${pascalCaseName} /> -\`\`\` - -## Development - -\`\`\`bash -# Build the plugin -pnpm build - -# Run tests -pnpm test - -# Lint code -pnpm lint -\`\`\` - -## License - -MIT © ${vars.AUTHOR} -`; - - fs.writeFileSync(path.join(targetDir, 'README.md'), readme); - - // Create src/index.tsx - const indexFile = `/** - * ObjectUI - * Copyright (c) ${vars.YEAR}-present ObjectStack Inc. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ - -import React from 'react'; -import { ComponentRegistry } from '@object-ui/core'; -import { ${pascalCaseName} } from './${pascalCaseName}Impl'; - -export { ${pascalCaseName} }; -export type { ${pascalCaseName}Props } from './${pascalCaseName}Impl'; - -// Register component with ComponentRegistry -const ${pascalCaseName}Renderer: React.FC<{ schema: any }> = ({ schema }) => { - return <${pascalCaseName} {...schema} />; -}; - -ComponentRegistry.register('${cleanName}', ${pascalCaseName}Renderer, { - label: '${pascalCaseName}', - category: 'plugin', - inputs: [ - // Define your component inputs here - ], - defaultProps: { - // Define default props here + // Write every templated file. The templates themselves live in + // `./templates` so they can be unit-tested without executing this CLI. + for (const [relativePath, contents] of Object.entries(buildPluginFiles(vars))) { + const filePath = path.join(targetDir, relativePath); + fs.mkdirpSync(path.dirname(filePath)); + fs.writeFileSync(filePath, contents); } -}); -`; - - fs.writeFileSync(path.join(targetDir, 'src', 'index.tsx'), indexFile); - - // Create src/[Name]Impl.tsx - const implFile = `/** - * ObjectUI - * Copyright (c) ${vars.YEAR}-present ObjectStack Inc. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ - -import React from 'react'; - -export interface ${pascalCaseName}Props { - // Define your props here - className?: string; -} - -/** - * ${pascalCaseName} component - */ -export const ${pascalCaseName}: React.FC<${pascalCaseName}Props> = ({ className }) => { - return ( -
-

${pascalCaseName} Plugin

-

Implement your plugin logic here.

-
- ); -}; -`; - - fs.writeFileSync( - path.join(targetDir, 'src', `${pascalCaseName}Impl.tsx`), - implFile - ); - - // Create src/types.ts - const typesFile = `/** - * ObjectUI - * Copyright (c) ${vars.YEAR}-present ObjectStack Inc. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ - -/** - * Schema definition for ${pascalCaseName} - */ -export interface ${pascalCaseName}Schema { - type: '${cleanName}'; - id?: string; - className?: string; - // Add schema properties here -} -`; - - fs.writeFileSync(path.join(targetDir, 'src', 'types.ts'), typesFile); - - // Create src/[Name]Impl.test.tsx - const testFile = `/** - * ObjectUI - * Copyright (c) ${vars.YEAR}-present ObjectStack Inc. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ - -import { describe, it, expect } from 'vitest'; -import { render, screen } from '@testing-library/react'; -import React from 'react'; -import { ${pascalCaseName} } from './${pascalCaseName}Impl'; - -describe('${pascalCaseName}', () => { - it('should render', () => { - render(<${pascalCaseName} />); - expect(screen.getByText('${pascalCaseName} Plugin')).toBeInTheDocument(); - }); -}); -`; - - fs.writeFileSync( - path.join(targetDir, 'src', `${pascalCaseName}Impl.test.tsx`), - testFile - ); console.log(chalk.green('✅ Plugin created successfully!\n')); console.log(chalk.blue('Next steps:\n')); @@ -382,7 +132,7 @@ describe('${pascalCaseName}', () => { console.log(chalk.gray(' pnpm install')); console.log(chalk.gray(' pnpm build\n')); console.log(chalk.blue('To use the plugin:\n')); - console.log(chalk.gray(` import { ${pascalCaseName} } from '${vars.PACKAGE_NAME}';\n`)); + console.log(chalk.gray(` import { ${pascalCaseName} } from '${vars.packageName}';\n`)); } program diff --git a/packages/create-plugin/src/templates.ts b/packages/create-plugin/src/templates.ts new file mode 100644 index 000000000..e0d8a3e31 --- /dev/null +++ b/packages/create-plugin/src/templates.ts @@ -0,0 +1,367 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * Every file `create-plugin` writes, as pure data. + * + * These builders live outside `index.ts` on purpose: `index.ts` calls + * `program.parse()` at import time, so nothing can import it to inspect what + * the generator produces. Moving the templates here makes the ARTIFACTS + * testable — `src/__tests__/templates.test.ts` asserts the generated + * `package.json`, `vite.config.ts`, `vitest.setup.ts` and example test are + * mutually consistent, instead of grepping this source for strings. + * + * objectui#3716 is why that matters: the template shipped a `test` script and + * an example test importing `@testing-library/react` + `toBeInTheDocument()` + * while declaring neither library, and with no DOM test environment — so + * `npm test` in a freshly scaffolded plugin was red on the very first run. + */ + +/** Values interpolated into the templates for one generated plugin. */ +export interface PluginTemplateVars { + /** Full package name, e.g. `@object-ui/plugin-heatmap`. */ + packageName: string; + /** Plugin name without the `plugin-` prefix, e.g. `heatmap`. Also the registry key. */ + pluginName: string; + /** PascalCase component name, e.g. `Heatmap`. */ + pascalName: string; + description: string; + author: string; + version: string; + year: number; +} + +/** + * The Vitest setup file the generated `vite.config.ts` points `setupFiles` at. + * Named as a constant because two templates have to agree on it. + */ +export const VITEST_SETUP_FILE = 'vitest.setup.ts'; + +/** + * devDependencies written into the generated plugin. + * + * The three testing entries are SOURCED, not invented — each copies this + * monorepo's own range for the same package verbatim, so the repo has one + * range per dependency rather than one per file (objectui#3716; these literals + * sit in `.ts` source, outside the objectui#3711 version-claims gate's scan + * face, so `templates.test.ts` pins the parity instead): + * + * - `@testing-library/jest-dom` `^7.0.0` — repo root package.json (also apps/console) + * - `@testing-library/react` `^16.3.2` — repo root package.json (also apps/console) + * - `jsdom` `^30.0.1` — repo root package.json + * + * `@testing-library/dom` is deliberately absent: it is a peer of + * `@testing-library/react` 16 and is installed by the workspace's + * `auto-install-peers=true`, which is also why `apps/console` declares the + * same three and not four. + * + * The five build-side entries below keep the ranges they have shipped with; + * re-anchoring those is a separate change, not part of this fix. + */ +const DEV_DEPENDENCIES: Record = { + '@testing-library/jest-dom': '^7.0.0', + '@testing-library/react': '^16.3.2', + '@vitejs/plugin-react': '^4.2.1', + jsdom: '^30.0.1', + typescript: '^5.9.3', + vite: '^7.3.1', + 'vite-plugin-dts': '^4.5.4', + vitest: '^4.0.18' +}; + +/** The generated plugin's `package.json`, as an object (not yet serialised). */ +export function buildPackageJson(vars: PluginTemplateVars): Record { + return { + name: vars.packageName, + version: vars.version, + type: 'module', + license: 'MIT', + description: vars.description, + main: 'dist/index.umd.cjs', + module: 'dist/index.js', + types: 'dist/index.d.ts', + exports: { + '.': { + types: './dist/index.d.ts', + import: './dist/index.js', + require: './dist/index.umd.cjs' + } + }, + scripts: { + build: 'vite build', + test: 'vitest run', + lint: 'eslint .' + }, + dependencies: { + '@object-ui/components': 'workspace:*', + '@object-ui/core': 'workspace:*', + '@object-ui/react': 'workspace:*', + '@object-ui/types': 'workspace:*', + 'lucide-react': '^0.563.0' + }, + peerDependencies: { + react: '^18.0.0 || ^19.0.0', + 'react-dom': '^18.0.0 || ^19.0.0' + }, + devDependencies: { ...DEV_DEPENDENCIES } + }; +} + +/** The generated plugin's `tsconfig.json`, as an object (not yet serialised). */ +export function buildTsconfig(): Record { + return { + extends: '../../tsconfig.json', + compilerOptions: { + outDir: './dist', + rootDir: './src', + declaration: true, + declarationMap: true + }, + include: ['src/**/*'], + exclude: ['node_modules', 'dist', '**/*.test.ts', '**/*.test.tsx'] + }; +} + +/** + * The generated plugin's `vite.config.ts`. + * + * The `test` block is load-bearing, not decoration: + * - `environment: 'jsdom'` — the example test calls `render()`; the Vitest + * default (`node`) has no `document` for React to mount into. + * - `globals: true` — `@testing-library/react` only registers its automatic + * `cleanup()` when `afterEach` exists as a GLOBAL (`dist/index.js`: + * `if (typeof afterEach === 'function')`). Without it the DOM leaks between + * tests, silently, as soon as the author writes a second one. + * - `setupFiles` — where the jest-dom matchers get registered; see + * {@link buildVitestSetup}. + */ +export function buildViteConfig(vars: PluginTemplateVars): string { + return `import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import dts from 'vite-plugin-dts'; +import * as path from 'path'; + +export default defineConfig({ + plugins: [ + react(), + dts({ + insertTypesEntry: true, + }), + ], + build: { + lib: { + entry: path.resolve(__dirname, 'src/index.tsx'), + name: '${vars.pascalName}', + formats: ['es', 'umd'], + fileName: (format) => \`index.\${format === 'es' ? 'js' : 'umd.cjs'}\`, + }, + rollupOptions: { + external: ['react', 'react-dom', 'react/jsx-runtime'], + output: { + globals: { + react: 'React', + 'react-dom': 'ReactDOM', + }, + }, + }, + }, + test: { + globals: true, + environment: 'jsdom', + setupFiles: ['./${VITEST_SETUP_FILE}'], + }, +}); +`; +} + +/** + * The generated plugin's Vitest setup file. + * + * Uses the `/vitest` entry point rather than the bare package: that one takes + * `expect` from `vitest` explicitly, while the bare entry extends a GLOBAL + * `expect` and therefore breaks the moment an author turns `globals` off. + */ +export function buildVitestSetup(): string { + return `// Registers @testing-library/jest-dom's matchers (\`toBeInTheDocument\` and +// friends) on Vitest's \`expect\`, which the example test in src/ relies on. +import '@testing-library/jest-dom/vitest'; +`; +} + +/** The generated plugin's `README.md`. */ +export function buildReadme(vars: PluginTemplateVars): string { + return `# ${vars.packageName} + +${vars.description} + +## Installation + +\`\`\`bash +pnpm add ${vars.packageName} +\`\`\` + +## Usage + +\`\`\`tsx +import { ${vars.pascalName} } from '${vars.packageName}'; + +// Use the component +<${vars.pascalName} /> +\`\`\` + +## Development + +\`\`\`bash +# Build the plugin +pnpm build + +# Run tests +pnpm test + +# Lint code +pnpm lint +\`\`\` + +## License + +MIT © ${vars.author} +`; +} + +/** The generated plugin's `src/index.tsx` (entry point + registry registration). */ +export function buildIndexFile(vars: PluginTemplateVars): string { + return `/** + * ObjectUI + * Copyright (c) ${vars.year}-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import React from 'react'; +import { ComponentRegistry } from '@object-ui/core'; +import { ${vars.pascalName} } from './${vars.pascalName}Impl'; + +export { ${vars.pascalName} }; +export type { ${vars.pascalName}Props } from './${vars.pascalName}Impl'; + +// Register component with ComponentRegistry +const ${vars.pascalName}Renderer: React.FC<{ schema: any }> = ({ schema }) => { + return <${vars.pascalName} {...schema} />; +}; + +ComponentRegistry.register('${vars.pluginName}', ${vars.pascalName}Renderer, { + label: '${vars.pascalName}', + category: 'plugin', + inputs: [ + // Define your component inputs here + ], + defaultProps: { + // Define default props here + } +}); +`; +} + +/** The generated plugin's `src/Impl.tsx`. */ +export function buildImplFile(vars: PluginTemplateVars): string { + return `/** + * ObjectUI + * Copyright (c) ${vars.year}-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import React from 'react'; + +export interface ${vars.pascalName}Props { + // Define your props here + className?: string; +} + +/** + * ${vars.pascalName} component + */ +export const ${vars.pascalName}: React.FC<${vars.pascalName}Props> = ({ className }) => { + return ( +
+

${vars.pascalName} Plugin

+

Implement your plugin logic here.

+
+ ); +}; +`; +} + +/** The generated plugin's `src/types.ts`. */ +export function buildTypesFile(vars: PluginTemplateVars): string { + return `/** + * ObjectUI + * Copyright (c) ${vars.year}-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * Schema definition for ${vars.pascalName} + */ +export interface ${vars.pascalName}Schema { + type: '${vars.pluginName}'; + id?: string; + className?: string; + // Add schema properties here +} +`; +} + +/** The generated plugin's example test, `src/Impl.test.tsx`. */ +export function buildTestFile(vars: PluginTemplateVars): string { + return `/** + * ObjectUI + * Copyright (c) ${vars.year}-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import React from 'react'; +import { ${vars.pascalName} } from './${vars.pascalName}Impl'; + +describe('${vars.pascalName}', () => { + it('should render', () => { + render(<${vars.pascalName} />); + expect(screen.getByText('${vars.pascalName} Plugin')).toBeInTheDocument(); + }); +}); +`; +} + +/** + * The whole generated plugin as `relative path -> file contents`. + * + * Single source of truth for what a scaffolded plugin contains, so the writer + * in `index.ts` stays a loop and the pin test can assert over the same map the + * CLI writes. + */ +export function buildPluginFiles(vars: PluginTemplateVars): Record { + return { + 'package.json': `${JSON.stringify(buildPackageJson(vars), null, 2)}`, + 'tsconfig.json': `${JSON.stringify(buildTsconfig(), null, 2)}`, + 'vite.config.ts': buildViteConfig(vars), + [VITEST_SETUP_FILE]: buildVitestSetup(), + 'README.md': buildReadme(vars), + 'src/index.tsx': buildIndexFile(vars), + [`src/${vars.pascalName}Impl.tsx`]: buildImplFile(vars), + 'src/types.ts': buildTypesFile(vars), + [`src/${vars.pascalName}Impl.test.tsx`]: buildTestFile(vars) + }; +} diff --git a/packages/create-plugin/tsconfig.test.json b/packages/create-plugin/tsconfig.test.json new file mode 100644 index 000000000..543e8981f --- /dev/null +++ b/packages/create-plugin/tsconfig.test.json @@ -0,0 +1,21 @@ +{ + // Type-checks this package's TESTS, which `tsconfig.json` excludes. + // See `packages/types/tsconfig.test.json` for why that exclusion was a hole: + // the build config correctly keeps tests out of the emit, but nothing else + // compiled them, so a test could assert a contract the compiler never + // checked. `scripts/check-type-check-coverage.mjs` is the guard, and it wants + // this project CHAINED from `type-check` — the script CI actually runs. + "extends": "../../tsconfig.json", + "compilerOptions": { + "noEmit": true, + // `templates.test.ts` reads the repo-root manifest off disk (readFileSync, + // fileURLToPath, isBuiltin) to assert the generated dependency ranges are + // quoted from this monorepo rather than invented. + "types": ["node"], + // Drop the root tsconfig's source-tree `paths`: this package depends on no + // workspace package, and inheriting them would pull sibling sources in as + // program inputs. + "paths": {} + }, + "include": ["src/**/*.test.ts", "src/**/*.test.tsx"] +}