diff --git a/packages/cli/README.md b/packages/cli/README.md index 91843600..79504e77 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -34,9 +34,59 @@ objectui init . --template dashboard - **form**: A contact form with validation - **dashboard**: A full dashboard with metrics and charts +### `objectui dev [schema]` + +Start a development server with hot reload. Opens browser automatically. + +```bash +objectui dev app.json +objectui dev my-schema.json --port 8080 +objectui dev --no-open +``` + +**Arguments:** +- `[schema]` - Path to JSON/YAML schema file (default: `app.json`) + +**Options:** +- `-p, --port ` - Port to run the server on (default: `3000`) +- `-h, --host ` - Host to bind the server to (default: `localhost`) +- `--no-open` - Do not open browser automatically + +### `objectui build [schema]` + +Build your application for production deployment. + +```bash +objectui build app.json +objectui build --out-dir build +objectui build --clean +``` + +**Arguments:** +- `[schema]` - Path to JSON/YAML schema file (default: `app.json`) + +**Options:** +- `-o, --out-dir ` - Output directory (default: `dist`) +- `--clean` - Clean output directory before build + +### `objectui start` + +Serve the production build locally. + +```bash +objectui start +objectui start --port 8080 +objectui start --dir build +``` + +**Options:** +- `-p, --port ` - Port to run the server on (default: `3000`) +- `-h, --host ` - Host to bind the server to (default: `0.0.0.0`) +- `-d, --dir ` - Directory to serve (default: `dist`) + ### `objectui serve [schema]` -Start a development server to render your JSON schema. +Start a development server (legacy command, use `dev` instead). ```bash objectui serve app.json @@ -50,6 +100,38 @@ objectui serve my-schema.json --port 8080 - `-p, --port ` - Port to run the server on (default: `3000`) - `-h, --host ` - Host to bind the server to (default: `localhost`) +### `objectui lint` + +Lint the generated application code using ESLint. + +```bash +objectui lint +objectui lint --fix +``` + +**Options:** +- `--fix` - Automatically fix linting issues + +**Note:** Run `objectui dev` first to generate the application before linting. + +### `objectui test` + +Run tests for the application using Vitest. + +```bash +objectui test +objectui test --watch +objectui test --coverage +objectui test --ui +``` + +**Options:** +- `-w, --watch` - Run tests in watch mode +- `-c, --coverage` - Generate test coverage report +- `--ui` - Run tests with Vitest UI + +**Note:** Run `objectui dev` first to generate the application before testing. + ## Quick Start 1. Create a new application: @@ -60,12 +142,30 @@ objectui serve my-schema.json --port 8080 2. Start the development server: ```bash - objectui serve app.json + objectui dev app.json ``` -3. Open http://localhost:3000 in your browser +3. Lint your code (optional): + ```bash + objectui lint + ``` + +4. Run tests (optional): + ```bash + objectui test + ``` + +5. Build for production: + ```bash + objectui build app.json + ``` + +6. Serve the production build: + ```bash + objectui start + ``` -4. Edit `app.json` to customize your application +Your app will be running at http://localhost:3000! ## Example Schema diff --git a/packages/cli/package.json b/packages/cli/package.json index 94d84fd7..a6b397b9 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -36,6 +36,7 @@ "vite": "^5.0.0", "@vitejs/plugin-react": "^4.2.1", "express": "^4.21.2", + "express-rate-limit": "^7.4.1", "js-yaml": "^4.1.0", "@object-ui/react": "workspace:*", "@object-ui/components": "workspace:*" diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 6b758df7..69a1e7bc 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -3,6 +3,11 @@ import { Command } from 'commander'; import chalk from 'chalk'; import { serve } from './commands/serve.js'; import { init } from './commands/init.js'; +import { dev } from './commands/dev.js'; +import { buildApp } from './commands/build.js'; +import { start } from './commands/start.js'; +import { lint } from './commands/lint.js'; +import { test } from './commands/test.js'; import { readFileSync } from 'fs'; import { fileURLToPath } from 'url'; import { dirname, join } from 'path'; @@ -37,6 +42,52 @@ program } }); +program + .command('dev') + .description('Start development server (alias for serve)') + .argument('[schema]', 'Path to JSON/YAML schema file', 'app.json') + .option('-p, --port ', 'Port to run the server on', '3000') + .option('-h, --host ', 'Host to bind the server to', 'localhost') + .option('--no-open', 'Do not open browser automatically') + .action(async (schema, options) => { + try { + await dev(schema, options); + } catch (error) { + console.error(chalk.red('Error:'), error instanceof Error ? error.message : error); + process.exit(1); + } + }); + +program + .command('build') + .description('Build application for production') + .argument('[schema]', 'Path to JSON/YAML schema file', 'app.json') + .option('-o, --out-dir ', 'Output directory', 'dist') + .option('--clean', 'Clean output directory before build', false) + .action(async (schema, options) => { + try { + await buildApp(schema, options); + } catch (error) { + console.error(chalk.red('Error:'), error instanceof Error ? error.message : error); + process.exit(1); + } + }); + +program + .command('start') + .description('Start production server') + .option('-p, --port ', 'Port to run the server on', '3000') + .option('-h, --host ', 'Host to bind the server to', '0.0.0.0') + .option('-d, --dir ', 'Directory to serve', 'dist') + .action(async (options) => { + try { + await start(options); + } catch (error) { + console.error(chalk.red('Error:'), error instanceof Error ? error.message : error); + process.exit(1); + } + }); + program .command('init') .description('初始化新的Object UI应用 / Initialize a new Object UI application with sample schema') @@ -51,4 +102,32 @@ program } }); +program + .command('lint') + .description('Lint the generated application code') + .option('--fix', 'Automatically fix linting issues') + .action(async (options) => { + try { + await lint(options); + } catch (error) { + console.error(chalk.red('Error:'), error instanceof Error ? error.message : error); + process.exit(1); + } + }); + +program + .command('test') + .description('Run tests for the application') + .option('-w, --watch', 'Run tests in watch mode') + .option('-c, --coverage', 'Generate test coverage report') + .option('--ui', 'Run tests with Vitest UI') + .action(async (options) => { + try { + await test(options); + } catch (error) { + console.error(chalk.red('Error:'), error instanceof Error ? error.message : error); + process.exit(1); + } + }); + program.parse(); diff --git a/packages/cli/src/commands/build.ts b/packages/cli/src/commands/build.ts new file mode 100644 index 00000000..08b8326f --- /dev/null +++ b/packages/cli/src/commands/build.ts @@ -0,0 +1,120 @@ +import { build as viteBuild } from 'vite'; +import react from '@vitejs/plugin-react'; +import { existsSync, mkdirSync, cpSync, rmSync } from 'fs'; +import { join, resolve } from 'path'; +import chalk from 'chalk'; +import { execSync } from 'child_process'; +import { scanPagesDirectory, createTempAppWithRouting, createTempApp, parseSchemaFile, type RouteInfo } from '../utils/app-generator.js'; + +interface BuildOptions { + outDir?: string; + clean?: boolean; +} + +export async function buildApp(schemaPath: string, options: BuildOptions) { + const cwd = process.cwd(); + const outDir = options.outDir || 'dist'; + const outputPath = resolve(cwd, outDir); + + console.log(chalk.blue('🔨 Building application for production...')); + console.log(); + + // Check if pages directory exists for file-system routing + const pagesDir = join(cwd, 'pages'); + const hasPagesDir = existsSync(pagesDir); + + let routes: RouteInfo[] = []; + let schema: unknown = null; + let useFileSystemRouting = false; + + if (hasPagesDir) { + // File-system based routing + console.log(chalk.blue('📁 Using file-system routing')); + routes = scanPagesDirectory(pagesDir); + useFileSystemRouting = true; + + if (routes.length === 0) { + throw new Error('No schema files found in pages/ directory'); + } + + console.log(chalk.green(`✓ Found ${routes.length} route(s)`)); + } else { + // Single schema file mode + const fullSchemaPath = resolve(cwd, schemaPath); + + // Check if schema file exists + if (!existsSync(fullSchemaPath)) { + throw new Error(`Schema file not found: ${schemaPath}\nRun 'objectui init' to create a sample schema.`); + } + + console.log(chalk.blue('📋 Loading schema:'), chalk.cyan(schemaPath)); + + // Read and validate schema + try { + schema = parseSchemaFile(fullSchemaPath); + } catch (error) { + throw new Error(`Invalid schema file: ${error instanceof Error ? error.message : error}`); + } + } + + // Create temporary app directory + const tmpDir = join(cwd, '.objectui-tmp'); + mkdirSync(tmpDir, { recursive: true }); + + // Create temporary app files + if (useFileSystemRouting) { + createTempAppWithRouting(tmpDir, routes); + } else { + createTempApp(tmpDir, schema); + } + + // Install dependencies + console.log(chalk.blue('📦 Installing dependencies...')); + try { + execSync('npm install --silent --prefer-offline', { + cwd: tmpDir, + stdio: 'pipe', + }); + console.log(chalk.green('✓ Dependencies installed')); + } catch { + throw new Error('Failed to install dependencies. Please check your internet connection and try again.'); + } + + console.log(chalk.blue('⚙️ Building with Vite...')); + console.log(); + + // Clean output directory if requested + if (options.clean && existsSync(outputPath)) { + console.log(chalk.dim(` Cleaning ${outDir}/ directory...`)); + rmSync(outputPath, { recursive: true, force: true }); + } + + // Build with Vite + try { + await viteBuild({ + root: tmpDir, + build: { + outDir: join(tmpDir, 'dist'), + emptyOutDir: true, + reportCompressedSize: true, + }, + plugins: [react()], + logLevel: 'info', + }); + + // Copy built files to output directory + mkdirSync(outputPath, { recursive: true }); + cpSync(join(tmpDir, 'dist'), outputPath, { recursive: true }); + + console.log(); + console.log(chalk.green('✓ Build completed successfully!')); + console.log(); + console.log(chalk.bold(' Output: ') + chalk.cyan(outDir + '/')); + console.log(); + console.log(chalk.dim(' To serve the production build, run:')); + console.log(chalk.cyan(` objectui start --dir ${outDir}`)); + console.log(); + } catch (error) { + throw new Error(`Build failed: ${error instanceof Error ? error.message : error}`); + } +} diff --git a/packages/cli/src/commands/dev.ts b/packages/cli/src/commands/dev.ts new file mode 100644 index 00000000..97f27082 --- /dev/null +++ b/packages/cli/src/commands/dev.ts @@ -0,0 +1,113 @@ +import { createServer } from 'vite'; +import react from '@vitejs/plugin-react'; +import { existsSync, mkdirSync } from 'fs'; +import { join, resolve } from 'path'; +import chalk from 'chalk'; +import { execSync } from 'child_process'; +import { scanPagesDirectory, createTempAppWithRouting, createTempApp, parseSchemaFile, type RouteInfo } from '../utils/app-generator.js'; + +interface DevOptions { + port: string; + host: string; + open?: boolean; +} + +export async function dev(schemaPath: string, options: DevOptions) { + const cwd = process.cwd(); + + // Check if pages directory exists for file-system routing + const pagesDir = join(cwd, 'pages'); + const hasPagesDir = existsSync(pagesDir); + + let routes: RouteInfo[] = []; + let schema: unknown = null; + let useFileSystemRouting = false; + + if (hasPagesDir) { + // File-system based routing + console.log(chalk.blue('📁 Detected pages/ directory - using file-system routing')); + routes = scanPagesDirectory(pagesDir); + useFileSystemRouting = true; + + if (routes.length === 0) { + throw new Error('No schema files found in pages/ directory'); + } + + console.log(chalk.green(`✓ Found ${routes.length} route(s)`)); + routes.forEach(route => { + console.log(chalk.dim(` ${route.path} → ${route.filePath.replace(cwd, '.')}`)); + }); + } else { + // Single schema file mode + const fullSchemaPath = resolve(cwd, schemaPath); + + // Check if schema file exists + if (!existsSync(fullSchemaPath)) { + throw new Error(`Schema file not found: ${schemaPath}\nRun 'objectui init' to create a sample schema.`); + } + + console.log(chalk.blue('📋 Loading schema:'), chalk.cyan(schemaPath)); + + // Read and validate schema + try { + schema = parseSchemaFile(fullSchemaPath); + } catch (error) { + throw new Error(`Invalid schema file: ${error instanceof Error ? error.message : error}`); + } + } + + // Create temporary app directory + const tmpDir = join(cwd, '.objectui-tmp'); + mkdirSync(tmpDir, { recursive: true }); + + // Create temporary app files + if (useFileSystemRouting) { + createTempAppWithRouting(tmpDir, routes); + } else { + createTempApp(tmpDir, schema); + } + + // Install dependencies + console.log(chalk.blue('📦 Installing dependencies...')); + console.log(chalk.dim(' This may take a moment on first run...')); + try { + execSync('npm install --silent --prefer-offline', { + cwd: tmpDir, + stdio: 'inherit', + }); + console.log(chalk.green('✓ Dependencies installed')); + } catch { + throw new Error('Failed to install dependencies. Please check your internet connection and try again.'); + } + + console.log(chalk.green('✓ Schema loaded successfully')); + console.log(chalk.blue('🚀 Starting development server...\n')); + + // Create Vite config + const viteConfig = { + root: tmpDir, + server: { + port: parseInt(options.port), + host: options.host, + open: options.open !== false, + }, + plugins: [react()], + }; + + // Create Vite server + const server = await createServer(viteConfig); + + await server.listen(); + + const { port, host } = server.config.server; + const protocol = server.config.server.https ? 'https' : 'http'; + const displayHost = host === '0.0.0.0' ? 'localhost' : host; + + console.log(); + console.log(chalk.green('✓ Development server started successfully!')); + console.log(); + console.log(chalk.bold(' Local: ') + chalk.cyan(`${protocol}://${displayHost}:${port}`)); + console.log(); + console.log(chalk.dim(' Press Ctrl+C to stop the server')); + console.log(); +} diff --git a/packages/cli/src/commands/lint.ts b/packages/cli/src/commands/lint.ts new file mode 100644 index 00000000..06262ea4 --- /dev/null +++ b/packages/cli/src/commands/lint.ts @@ -0,0 +1,59 @@ +import { execSync } from 'child_process'; +import { existsSync } from 'fs'; +import { join } from 'path'; +import chalk from 'chalk'; + +interface LintOptions { + fix?: boolean; +} + +export async function lint(options: LintOptions) { + const cwd = process.cwd(); + + console.log(chalk.blue('🔍 Running linter...\n')); + + // Check if the generated temp app exists + const tmpDir = join(cwd, '.objectui-tmp'); + const hasTempApp = existsSync(tmpDir); + + if (!hasTempApp) { + throw new Error( + 'No Object UI application found. Run \'objectui dev\' first to generate the application.' + ); + } + + // Check if package.json and node_modules exist + const packageJsonPath = join(tmpDir, 'package.json'); + const nodeModulesPath = join(tmpDir, 'node_modules'); + + if (!existsSync(packageJsonPath) || !existsSync(nodeModulesPath)) { + throw new Error( + 'Dependencies not installed. Run \'objectui dev\' first to set up the application.' + ); + } + + try { + const fixFlag = options.fix ? '--fix' : ''; + const command = `npx eslint "src/**/*.{js,jsx,ts,tsx}" ${fixFlag}`.trim(); + + console.log(chalk.dim(` Running: ${command}\n`)); + + execSync(command, { + cwd: tmpDir, + stdio: 'inherit', + }); + + console.log(); + console.log(chalk.green('✓ Linting completed successfully!')); + console.log(); + } catch { + // ESLint returns non-zero exit code when there are linting errors + console.log(); + console.log(chalk.yellow('⚠ Linting found issues.')); + if (!options.fix) { + console.log(chalk.dim(' Run \'objectui lint --fix\' to automatically fix some issues.')); + } + console.log(); + process.exit(1); + } +} diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index a380d964..83633503 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -1,23 +1,16 @@ import { createServer } from 'vite'; import react from '@vitejs/plugin-react'; -import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, statSync } from 'fs'; -import { join, resolve, relative, basename, dirname } from 'path'; +import { existsSync, mkdirSync } from 'fs'; +import { join, resolve, relative } from 'path'; import chalk from 'chalk'; -import * as yaml from 'js-yaml'; +import { execSync } from 'child_process'; +import { scanPagesDirectory, createTempAppWithRouting, createTempApp, parseSchemaFile, type RouteInfo } from '../utils/app-generator.js'; interface ServeOptions { port: string; host: string; } -interface RouteInfo { - path: string; - filePath: string; - schema: any; - isDynamic: boolean; - paramName?: string; -} - export async function serve(schemaPath: string, options: ServeOptions) { const cwd = process.cwd(); @@ -26,7 +19,7 @@ export async function serve(schemaPath: string, options: ServeOptions) { const hasPagesDir = existsSync(pagesDir); let routes: RouteInfo[] = []; - let schema: any = null; + let schema: unknown = null; let useFileSystemRouting = false; if (hasPagesDir) { @@ -76,14 +69,13 @@ export async function serve(schemaPath: string, options: ServeOptions) { // Install dependencies console.log(chalk.blue('📦 Installing dependencies...')); console.log(chalk.dim(' This may take a moment on first run...')); - const { execSync } = await import('child_process'); try { execSync('npm install --silent --prefer-offline', { cwd: tmpDir, stdio: 'inherit', }); console.log(chalk.green('✓ Dependencies installed')); - } catch (error) { + } catch { throw new Error('Failed to install dependencies. Please check your internet connection and try again.'); } @@ -91,7 +83,7 @@ export async function serve(schemaPath: string, options: ServeOptions) { console.log(chalk.blue('🚀 Starting development server...\n')); // Create Vite config - const viteConfig: any = { + const viteConfig = { root: tmpDir, server: { port: parseInt(options.port), @@ -118,611 +110,3 @@ export async function serve(schemaPath: string, options: ServeOptions) { console.log(chalk.dim(' Press Ctrl+C to stop the server')); console.log(); } - -function createTempApp(tmpDir: string, schema: any) { - // Create index.html - const html = ` - - - - - Object UI App - - -
- - -`; - - writeFileSync(join(tmpDir, 'index.html'), html); - - // Create src directory - const srcDir = join(tmpDir, 'src'); - mkdirSync(srcDir, { recursive: true }); - - // Create main.tsx - const mainTsx = `import React from 'react'; -import ReactDOM from 'react-dom/client'; -import App from './App'; -import './index.css'; - -ReactDOM.createRoot(document.getElementById('root')!).render( - - - -);`; - - writeFileSync(join(srcDir, 'main.tsx'), mainTsx); - - // Create App.tsx - const appTsx = `import { SchemaRenderer } from '@object-ui/react'; -import '@object-ui/components'; - -const schema = ${JSON.stringify(schema, null, 2)}; - -function App() { - return ; -} - -export default App;`; - - writeFileSync(join(srcDir, 'App.tsx'), appTsx); - - // Create index.css - const indexCss = `@tailwind base; -@tailwind components; -@tailwind utilities; - -@layer base { - :root { - --background: 0 0% 100%; - --foreground: 222.2 84% 4.9%; - --card: 0 0% 100%; - --card-foreground: 222.2 84% 4.9%; - --popover: 0 0% 100%; - --popover-foreground: 222.2 84% 4.9%; - --primary: 222.2 47.4% 11.2%; - --primary-foreground: 210 40% 98%; - --secondary: 210 40% 96.1%; - --secondary-foreground: 222.2 47.4% 11.2%; - --muted: 210 40% 96.1%; - --muted-foreground: 215.4 16.3% 46.9%; - --accent: 210 40% 96.1%; - --accent-foreground: 222.2 47.4% 11.2%; - --destructive: 0 84.2% 60.2%; - --destructive-foreground: 210 40% 98%; - --border: 214.3 31.8% 91.4%; - --input: 214.3 31.8% 91.4%; - --ring: 222.2 84% 4.9%; - --radius: 0.5rem; - --chart-1: 12 76% 61%; - --chart-2: 173 58% 39%; - --chart-3: 197 37% 24%; - --chart-4: 43 74% 66%; - --chart-5: 27 87% 67%; - } - - .dark { - --background: 222.2 84% 4.9%; - --foreground: 210 40% 98%; - --card: 222.2 84% 4.9%; - --card-foreground: 210 40% 98%; - --popover: 222.2 84% 4.9%; - --popover-foreground: 210 40% 98%; - --primary: 210 40% 98%; - --primary-foreground: 222.2 47.4% 11.2%; - --secondary: 217.2 32.6% 17.5%; - --secondary-foreground: 210 40% 98%; - --muted: 217.2 32.6% 17.5%; - --muted-foreground: 215 20.2% 65.1%; - --accent: 217.2 32.6% 17.5%; - --accent-foreground: 210 40% 98%; - --destructive: 0 62.8% 30.6%; - --destructive-foreground: 210 40% 98%; - --border: 217.2 32.6% 17.5%; - --input: 217.2 32.6% 17.5%; - --ring: 212.7 26.8% 83.9%; - --chart-1: 220 70% 50%; - --chart-2: 160 60% 45%; - --chart-3: 30 80% 55%; - --chart-4: 280 65% 60%; - --chart-5: 340 75% 55%; - } -} - -@layer base { - * { - @apply border-border; - } - body { - @apply bg-background text-foreground; - } -}`; - - writeFileSync(join(srcDir, 'index.css'), indexCss); - - // Create tailwind.config.js - const tailwindConfig = `/** @type {import('tailwindcss').Config} */ -export default { - darkMode: ['class'], - content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'], - theme: { - extend: { - borderRadius: { - lg: 'var(--radius)', - md: 'calc(var(--radius) - 2px)', - sm: 'calc(var(--radius) - 4px)', - }, - colors: { - background: 'hsl(var(--background))', - foreground: 'hsl(var(--foreground))', - card: { - DEFAULT: 'hsl(var(--card))', - foreground: 'hsl(var(--card-foreground))', - }, - popover: { - DEFAULT: 'hsl(var(--popover))', - foreground: 'hsl(var(--popover-foreground))', - }, - primary: { - DEFAULT: 'hsl(var(--primary))', - foreground: 'hsl(var(--primary-foreground))', - }, - secondary: { - DEFAULT: 'hsl(var(--secondary))', - foreground: 'hsl(var(--secondary-foreground))', - }, - muted: { - DEFAULT: 'hsl(var(--muted))', - foreground: 'hsl(var(--muted-foreground))', - }, - accent: { - DEFAULT: 'hsl(var(--accent))', - foreground: 'hsl(var(--accent-foreground))', - }, - destructive: { - DEFAULT: 'hsl(var(--destructive))', - foreground: 'hsl(var(--destructive-foreground))', - }, - border: 'hsl(var(--border))', - input: 'hsl(var(--input))', - ring: 'hsl(var(--ring))', - chart: { - 1: 'hsl(var(--chart-1))', - 2: 'hsl(var(--chart-2))', - 3: 'hsl(var(--chart-3))', - 4: 'hsl(var(--chart-4))', - 5: 'hsl(var(--chart-5))', - }, - }, - }, - }, - plugins: [], -};`; - - writeFileSync(join(tmpDir, 'tailwind.config.js'), tailwindConfig); - - // Create postcss.config.js - const postcssConfig = `export default { - plugins: { - tailwindcss: {}, - autoprefixer: {}, - }, -};`; - - writeFileSync(join(tmpDir, 'postcss.config.js'), postcssConfig); - - // Create package.json - const packageJson = { - name: 'objectui-temp-app', - private: true, - type: 'module', - dependencies: { - react: '^18.3.1', - 'react-dom': '^18.3.1', - '@object-ui/react': '^0.1.0', - '@object-ui/components': '^0.1.0', - }, - devDependencies: { - '@types/react': '^18.3.12', - '@types/react-dom': '^18.3.1', - '@vitejs/plugin-react': '^4.2.1', - autoprefixer: '^10.4.23', - postcss: '^8.5.6', - tailwindcss: '^3.4.19', - typescript: '~5.7.3', - vite: '^5.0.0', - }, - }; - - writeFileSync(join(tmpDir, 'package.json'), JSON.stringify(packageJson, null, 2)); - - // Create tsconfig.json - const tsconfig = { - compilerOptions: { - target: 'ES2020', - useDefineForClassFields: true, - lib: ['ES2020', 'DOM', 'DOM.Iterable'], - module: 'ESNext', - skipLibCheck: true, - moduleResolution: 'bundler', - allowImportingTsExtensions: true, - resolveJsonModule: true, - isolatedModules: true, - noEmit: true, - jsx: 'react-jsx', - strict: true, - noUnusedLocals: true, - noUnusedParameters: true, - noFallthroughCasesInSwitch: true, - }, - include: ['src'], - }; - - writeFileSync(join(tmpDir, 'tsconfig.json'), JSON.stringify(tsconfig, null, 2)); -} - -// Helper function to check if a file is a supported schema file -function isSupportedSchemaFile(filename: string): boolean { - return filename.endsWith('.json') || - filename.endsWith('.yml') || - filename.endsWith('.yaml'); -} - -// Helper function to extract the base filename without extension -function getBaseFileName(filename: string): string { - // Remove supported extensions - return filename - .replace(/\.(json|yml|yaml)$/, ''); -} - -// Helper function to parse schema file (JSON or YAML) -function parseSchemaFile(filePath: string): any { - const content = readFileSync(filePath, 'utf-8'); - - if (filePath.endsWith('.json')) { - return JSON.parse(content); - } else if (filePath.endsWith('.yml') || filePath.endsWith('.yaml')) { - return yaml.load(content); - } - - throw new Error(`Unsupported file format: ${filePath}`); -} - -function scanPagesDirectory(pagesDir: string): RouteInfo[] { - const routes: RouteInfo[] = []; - - const scanDir = (dir: string, routePrefix: string = '') => { - const entries = readdirSync(dir); - - for (const entry of entries) { - const fullPath = join(dir, entry); - const stat = statSync(fullPath); - - if (stat.isDirectory()) { - // Recursively scan subdirectories - const newPrefix = routePrefix + '/' + entry; - scanDir(fullPath, newPrefix); - } else if (isSupportedSchemaFile(entry)) { - // Process schema file - const fileName = getBaseFileName(entry); - let routePath: string; - let isDynamic = false; - let paramName: string | undefined; - - if (fileName === 'index') { - // index.schema.json or index.page.json maps to the directory path - routePath = routePrefix || '/'; - } else if (fileName.startsWith('[') && fileName.endsWith(']')) { - // Dynamic route: [id].schema.json -> /:id - paramName = fileName.slice(1, -1); - routePath = routePrefix + '/:' + paramName; - isDynamic = true; - } else { - // Regular file: about.schema.json -> /about - routePath = routePrefix + '/' + fileName; - } - - // Read and parse schema - try { - const schema = parseSchemaFile(fullPath); - - routes.push({ - path: routePath, - filePath: fullPath, - schema, - isDynamic, - paramName, - }); - } catch (error) { - console.warn(chalk.yellow(`⚠ Warning: Failed to parse ${fullPath}: ${error instanceof Error ? error.message : error}`)); - } - } - } - }; - - scanDir(pagesDir); - - // Sort routes: exact routes first, then dynamic routes - routes.sort((a, b) => { - if (a.isDynamic && !b.isDynamic) return 1; - if (!a.isDynamic && b.isDynamic) return -1; - return a.path.localeCompare(b.path); - }); - - return routes; -} - -function createTempAppWithRouting(tmpDir: string, routes: RouteInfo[]) { - // Create index.html - const html = ` - - - - - Object UI App - - -
- - -`; - - writeFileSync(join(tmpDir, 'index.html'), html); - - // Create src directory - const srcDir = join(tmpDir, 'src'); - mkdirSync(srcDir, { recursive: true }); - - // Create schemas directory and copy all schemas - const schemasDir = join(srcDir, 'schemas'); - mkdirSync(schemasDir, { recursive: true }); - - const schemaImports: string[] = []; - const routeComponents: string[] = []; - - routes.forEach((route, index) => { - const schemaVarName = `schema${index}`; - const schemaFileName = `page${index}.json`; - - // Write schema to schemas directory - writeFileSync( - join(schemasDir, schemaFileName), - JSON.stringify(route.schema, null, 2) - ); - - // Add import statement - schemaImports.push(`import ${schemaVarName} from './schemas/${schemaFileName}';`); - - // Add route component - routeComponents.push(` } />`); - }); - - // Create main.tsx - const mainTsx = `import React from 'react'; -import ReactDOM from 'react-dom/client'; -import App from './App'; -import './index.css'; - -ReactDOM.createRoot(document.getElementById('root')!).render( - - - -);`; - - writeFileSync(join(srcDir, 'main.tsx'), mainTsx); - - // Create App.tsx with routing - const appTsx = `import { BrowserRouter, Routes, Route, Link } from 'react-router-dom'; -import { SchemaRenderer } from '@object-ui/react'; -import '@object-ui/components'; -${schemaImports.join('\n')} - -function App() { - return ( - - -${routeComponents.join('\n')} - - - ); -} - -export default App;`; - - writeFileSync(join(srcDir, 'App.tsx'), appTsx); - - // Create index.css with Tailwind - const indexCss = `@tailwind base; -@tailwind components; -@tailwind utilities; - -@layer base { - :root { - --background: 0 0% 100%; - --foreground: 222.2 84% 4.9%; - --card: 0 0% 100%; - --card-foreground: 222.2 84% 4.9%; - --popover: 0 0% 100%; - --popover-foreground: 222.2 84% 4.9%; - --primary: 222.2 47.4% 11.2%; - --primary-foreground: 210 40% 98%; - --secondary: 210 40% 96.1%; - --secondary-foreground: 222.2 47.4% 11.2%; - --muted: 210 40% 96.1%; - --muted-foreground: 215.4 16.3% 46.9%; - --accent: 210 40% 96.1%; - --accent-foreground: 222.2 47.4% 11.2%; - --destructive: 0 84.2% 60.2%; - --destructive-foreground: 210 40% 98%; - --border: 214.3 31.8% 91.4%; - --input: 214.3 31.8% 91.4%; - --ring: 222.2 84% 4.9%; - --radius: 0.5rem; - --chart-1: 12 76% 61%; - --chart-2: 173 58% 39%; - --chart-3: 197 37% 24%; - --chart-4: 43 74% 66%; - --chart-5: 27 87% 67%; - } - - .dark { - --background: 222.2 84% 4.9%; - --foreground: 210 40% 98%; - --card: 222.2 84% 4.9%; - --card-foreground: 210 40% 98%; - --popover: 222.2 84% 4.9%; - --popover-foreground: 210 40% 98%; - --primary: 210 40% 98%; - --primary-foreground: 222.2 47.4% 11.2%; - --secondary: 217.2 32.6% 17.5%; - --secondary-foreground: 210 40% 98%; - --muted: 217.2 32.6% 17.5%; - --muted-foreground: 215 20.2% 65.1%; - --accent: 217.2 32.6% 17.5%; - --accent-foreground: 210 40% 98%; - --destructive: 0 62.8% 30.6%; - --destructive-foreground: 210 40% 98%; - --border: 217.2 32.6% 17.5%; - --input: 217.2 32.6% 17.5%; - --ring: 212.7 26.8% 83.9%; - --chart-1: 220 70% 50%; - --chart-2: 160 60% 45%; - --chart-3: 30 80% 55%; - --chart-4: 280 65% 60%; - --chart-5: 340 75% 55%; - } -} - -@layer base { - * { - @apply border-border; - } - body { - @apply bg-background text-foreground; - } -}`; - - writeFileSync(join(srcDir, 'index.css'), indexCss); - - // Create tailwind.config.js - const tailwindConfig = `/** @type {import('tailwindcss').Config} */ -export default { - darkMode: ['class'], - content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'], - theme: { - extend: { - borderRadius: { - lg: 'var(--radius)', - md: 'calc(var(--radius) - 2px)', - sm: 'calc(var(--radius) - 4px)', - }, - colors: { - background: 'hsl(var(--background))', - foreground: 'hsl(var(--foreground))', - card: { - DEFAULT: 'hsl(var(--card))', - foreground: 'hsl(var(--card-foreground))', - }, - popover: { - DEFAULT: 'hsl(var(--popover))', - foreground: 'hsl(var(--popover-foreground))', - }, - primary: { - DEFAULT: 'hsl(var(--primary))', - foreground: 'hsl(var(--primary-foreground))', - }, - secondary: { - DEFAULT: 'hsl(var(--secondary))', - foreground: 'hsl(var(--secondary-foreground))', - }, - muted: { - DEFAULT: 'hsl(var(--muted))', - foreground: 'hsl(var(--muted-foreground))', - }, - accent: { - DEFAULT: 'hsl(var(--accent))', - foreground: 'hsl(var(--accent-foreground))', - }, - destructive: { - DEFAULT: 'hsl(var(--destructive))', - foreground: 'hsl(var(--destructive-foreground))', - }, - border: 'hsl(var(--border))', - input: 'hsl(var(--input))', - ring: 'hsl(var(--ring))', - chart: { - 1: 'hsl(var(--chart-1))', - 2: 'hsl(var(--chart-2))', - 3: 'hsl(var(--chart-3))', - 4: 'hsl(var(--chart-4))', - 5: 'hsl(var(--chart-5))', - }, - }, - }, - }, - plugins: [], -};`; - - writeFileSync(join(tmpDir, 'tailwind.config.js'), tailwindConfig); - - // Create postcss.config.js - const postcssConfig = `export default { - plugins: { - tailwindcss: {}, - autoprefixer: {}, - }, -};`; - - writeFileSync(join(tmpDir, 'postcss.config.js'), postcssConfig); - - // Create package.json with react-router-dom - const packageJson = { - name: 'objectui-temp-app', - private: true, - type: 'module', - dependencies: { - react: '^18.3.1', - 'react-dom': '^18.3.1', - 'react-router-dom': '^7.12.0', - '@object-ui/react': '^0.1.0', - '@object-ui/components': '^0.1.0', - }, - devDependencies: { - '@types/react': '^18.3.12', - '@types/react-dom': '^18.3.1', - '@vitejs/plugin-react': '^4.2.1', - autoprefixer: '^10.4.23', - postcss: '^8.5.6', - tailwindcss: '^3.4.19', - typescript: '~5.7.3', - vite: '^5.0.0', - }, - }; - - writeFileSync(join(tmpDir, 'package.json'), JSON.stringify(packageJson, null, 2)); - - // Create tsconfig.json - const tsconfig = { - compilerOptions: { - target: 'ES2020', - useDefineForClassFields: true, - lib: ['ES2020', 'DOM', 'DOM.Iterable'], - module: 'ESNext', - skipLibCheck: true, - moduleResolution: 'bundler', - allowImportingTsExtensions: true, - resolveJsonModule: true, - isolatedModules: true, - noEmit: true, - jsx: 'react-jsx', - strict: true, - noUnusedLocals: true, - noUnusedParameters: true, - noFallthroughCasesInSwitch: true, - }, - include: ['src'], - }; - - writeFileSync(join(tmpDir, 'tsconfig.json'), JSON.stringify(tsconfig, null, 2)); -} diff --git a/packages/cli/src/commands/start.ts b/packages/cli/src/commands/start.ts new file mode 100644 index 00000000..5c20795b --- /dev/null +++ b/packages/cli/src/commands/start.ts @@ -0,0 +1,74 @@ +import express from 'express'; +import rateLimit from 'express-rate-limit'; +import { existsSync } from 'fs'; +import { join, resolve } from 'path'; +import chalk from 'chalk'; + +interface StartOptions { + port: string; + host: string; + dir?: string; +} + +export async function start(options: StartOptions) { + const cwd = process.cwd(); + const distDir = options.dir || 'dist'; + const distPath = resolve(cwd, distDir); + + // Check if dist directory exists + if (!existsSync(distPath)) { + throw new Error( + `Build directory not found: ${distDir}\n` + + `Run 'objectui build' first to create a production build.` + ); + } + + // Check if index.html exists + const indexPath = join(distPath, 'index.html'); + if (!existsSync(indexPath)) { + throw new Error( + `index.html not found in ${distDir}/\n` + + `Make sure you have a valid production build.` + ); + } + + console.log(chalk.blue('🚀 Starting production server...\n')); + + const app = express(); + const port = parseInt(options.port); + const host = options.host; + + // Configure rate limiting to prevent abuse + const limiter = rateLimit({ + windowMs: 15 * 60 * 1000, // 15 minutes + max: 1000, // Limit each IP to 1000 requests per windowMs + message: 'Too many requests from this IP, please try again later.', + standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers + legacyHeaders: false, // Disable the `X-RateLimit-*` headers + }); + + // Apply rate limiting to all routes + app.use(limiter); + + // Serve static files from dist directory + app.use(express.static(distPath)); + + // SPA fallback - serve index.html for all routes + app.get('*', (req, res) => { + res.sendFile(indexPath); + }); + + // Start server + app.listen(port, host, () => { + const protocol = 'http'; + const displayHost = host === '0.0.0.0' ? 'localhost' : host; + + console.log(chalk.green('✓ Production server started successfully!')); + console.log(); + console.log(chalk.bold(' Local: ') + chalk.cyan(`${protocol}://${displayHost}:${port}`)); + console.log(chalk.bold(' Serving: ') + chalk.dim(distDir + '/')); + console.log(); + console.log(chalk.dim(' Press Ctrl+C to stop the server')); + console.log(); + }); +} diff --git a/packages/cli/src/commands/test.ts b/packages/cli/src/commands/test.ts new file mode 100644 index 00000000..e1ab7bf7 --- /dev/null +++ b/packages/cli/src/commands/test.ts @@ -0,0 +1,71 @@ +import { execSync } from 'child_process'; +import { existsSync } from 'fs'; +import { join } from 'path'; +import chalk from 'chalk'; + +interface TestOptions { + watch?: boolean; + coverage?: boolean; + ui?: boolean; +} + +export async function test(options: TestOptions) { + const cwd = process.cwd(); + + console.log(chalk.blue('🧪 Running tests...\n')); + + // Check if the generated temp app exists + const tmpDir = join(cwd, '.objectui-tmp'); + const hasTempApp = existsSync(tmpDir); + + if (!hasTempApp) { + throw new Error( + 'No Object UI application found. Run \'objectui dev\' first to generate the application.' + ); + } + + // Check if package.json and node_modules exist + const packageJsonPath = join(tmpDir, 'package.json'); + const nodeModulesPath = join(tmpDir, 'node_modules'); + + if (!existsSync(packageJsonPath) || !existsSync(nodeModulesPath)) { + throw new Error( + 'Dependencies not installed. Run \'objectui dev\' first to set up the application.' + ); + } + + try { + let command = 'npx vitest'; + + if (options.watch) { + command += ' --watch'; + } else if (options.ui) { + command += ' --ui'; + } else { + command += ' run'; + } + + if (options.coverage) { + command += ' --coverage'; + } + + console.log(chalk.dim(` Running: ${command}\n`)); + + execSync(command, { + cwd: tmpDir, + stdio: 'inherit', + }); + + if (!options.watch && !options.ui) { + console.log(); + console.log(chalk.green('✓ Tests completed successfully!')); + console.log(); + } + } catch { + // Vitest returns non-zero exit code when tests fail + console.log(); + console.log(chalk.red('✗ Some tests failed.')); + console.log(); + process.exit(1); + } +} diff --git a/packages/cli/src/utils/app-generator.ts b/packages/cli/src/utils/app-generator.ts new file mode 100644 index 00000000..d329827d --- /dev/null +++ b/packages/cli/src/utils/app-generator.ts @@ -0,0 +1,620 @@ +import { readFileSync, writeFileSync, mkdirSync, readdirSync, statSync } from 'fs'; +import { join } from 'path'; +import chalk from 'chalk'; +import * as yaml from 'js-yaml'; + +export interface RouteInfo { + path: string; + filePath: string; + schema: unknown; + isDynamic: boolean; + paramName?: string; +} + +// Helper function to check if a file is a supported schema file +export function isSupportedSchemaFile(filename: string): boolean { + return filename.endsWith('.json') || + filename.endsWith('.yml') || + filename.endsWith('.yaml'); +} + +// Helper function to extract the base filename without extension +export function getBaseFileName(filename: string): string { + // Remove supported extensions + return filename + .replace(/\.(json|yml|yaml)$/, ''); +} + +// Helper function to parse schema file (JSON or YAML) +export function parseSchemaFile(filePath: string): unknown { + const content = readFileSync(filePath, 'utf-8'); + + if (filePath.endsWith('.json')) { + return JSON.parse(content); + } else if (filePath.endsWith('.yml') || filePath.endsWith('.yaml')) { + return yaml.load(content); + } + + throw new Error(`Unsupported file format: ${filePath}`); +} + +export function scanPagesDirectory(pagesDir: string): RouteInfo[] { + const routes: RouteInfo[] = []; + + const scanDir = (dir: string, routePrefix: string = '') => { + const entries = readdirSync(dir); + + for (const entry of entries) { + const fullPath = join(dir, entry); + const stat = statSync(fullPath); + + if (stat.isDirectory()) { + // Recursively scan subdirectories + const newPrefix = routePrefix + '/' + entry; + scanDir(fullPath, newPrefix); + } else if (isSupportedSchemaFile(entry)) { + // Process schema file + const fileName = getBaseFileName(entry); + let routePath: string; + let isDynamic = false; + let paramName: string | undefined; + + if (fileName === 'index') { + // index.schema.json or index.page.json maps to the directory path + routePath = routePrefix || '/'; + } else if (fileName.startsWith('[') && fileName.endsWith(']')) { + // Dynamic route: [id].schema.json -> /:id + paramName = fileName.slice(1, -1); + routePath = routePrefix + '/:' + paramName; + isDynamic = true; + } else { + // Regular file: about.schema.json -> /about + routePath = routePrefix + '/' + fileName; + } + + // Read and parse schema + try { + const schema = parseSchemaFile(fullPath); + + routes.push({ + path: routePath, + filePath: fullPath, + schema, + isDynamic, + paramName, + }); + } catch (error) { + console.warn(chalk.yellow(`⚠ Warning: Failed to parse ${fullPath}: ${error instanceof Error ? error.message : error}`)); + } + } + } + }; + + scanDir(pagesDir); + + // Sort routes: exact routes first, then dynamic routes + routes.sort((a, b) => { + if (a.isDynamic && !b.isDynamic) return 1; + if (!a.isDynamic && b.isDynamic) return -1; + return a.path.localeCompare(b.path); + }); + + return routes; +} + +export function createTempApp(tmpDir: string, schema: unknown) { + // Create index.html + const html = ` + + + + + Object UI App + + +
+ + +`; + + writeFileSync(join(tmpDir, 'index.html'), html); + + // Create src directory + const srcDir = join(tmpDir, 'src'); + mkdirSync(srcDir, { recursive: true }); + + // Create main.tsx + const mainTsx = `import React from 'react'; +import ReactDOM from 'react-dom/client'; +import App from './App'; +import './index.css'; + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + +);`; + + writeFileSync(join(srcDir, 'main.tsx'), mainTsx); + + // Create App.tsx + const appTsx = `import { SchemaRenderer } from '@object-ui/react'; +import '@object-ui/components'; + +const schema = ${JSON.stringify(schema, null, 2)}; + +function App() { + return ; +} + +export default App;`; + + writeFileSync(join(srcDir, 'App.tsx'), appTsx); + + // Create index.css + const indexCss = `@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + :root { + --background: 0 0% 100%; + --foreground: 222.2 84% 4.9%; + --card: 0 0% 100%; + --card-foreground: 222.2 84% 4.9%; + --popover: 0 0% 100%; + --popover-foreground: 222.2 84% 4.9%; + --primary: 222.2 47.4% 11.2%; + --primary-foreground: 210 40% 98%; + --secondary: 210 40% 96.1%; + --secondary-foreground: 222.2 47.4% 11.2%; + --muted: 210 40% 96.1%; + --muted-foreground: 215.4 16.3% 46.9%; + --accent: 210 40% 96.1%; + --accent-foreground: 222.2 47.4% 11.2%; + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 210 40% 98%; + --border: 214.3 31.8% 91.4%; + --input: 214.3 31.8% 91.4%; + --ring: 222.2 84% 4.9%; + --radius: 0.5rem; + --chart-1: 12 76% 61%; + --chart-2: 173 58% 39%; + --chart-3: 197 37% 24%; + --chart-4: 43 74% 66%; + --chart-5: 27 87% 67%; + } + + .dark { + --background: 222.2 84% 4.9%; + --foreground: 210 40% 98%; + --card: 222.2 84% 4.9%; + --card-foreground: 210 40% 98%; + --popover: 222.2 84% 4.9%; + --popover-foreground: 210 40% 98%; + --primary: 210 40% 98%; + --primary-foreground: 222.2 47.4% 11.2%; + --secondary: 217.2 32.6% 17.5%; + --secondary-foreground: 210 40% 98%; + --muted: 217.2 32.6% 17.5%; + --muted-foreground: 215 20.2% 65.1%; + --accent: 217.2 32.6% 17.5%; + --accent-foreground: 210 40% 98%; + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 210 40% 98%; + --border: 217.2 32.6% 17.5%; + --input: 217.2 32.6% 17.5%; + --ring: 212.7 26.8% 83.9%; + --chart-1: 220 70% 50%; + --chart-2: 160 60% 45%; + --chart-3: 30 80% 55%; + --chart-4: 280 65% 60%; + --chart-5: 340 75% 55%; + } +} + +@layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + } +}`; + + writeFileSync(join(srcDir, 'index.css'), indexCss); + + // Create tailwind.config.js + const tailwindConfig = `/** @type {import('tailwindcss').Config} */ +export default { + darkMode: ['class'], + content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'], + theme: { + extend: { + borderRadius: { + lg: 'var(--radius)', + md: 'calc(var(--radius) - 2px)', + sm: 'calc(var(--radius) - 4px)', + }, + colors: { + background: 'hsl(var(--background))', + foreground: 'hsl(var(--foreground))', + card: { + DEFAULT: 'hsl(var(--card))', + foreground: 'hsl(var(--card-foreground))', + }, + popover: { + DEFAULT: 'hsl(var(--popover))', + foreground: 'hsl(var(--popover-foreground))', + }, + primary: { + DEFAULT: 'hsl(var(--primary))', + foreground: 'hsl(var(--primary-foreground))', + }, + secondary: { + DEFAULT: 'hsl(var(--secondary))', + foreground: 'hsl(var(--secondary-foreground))', + }, + muted: { + DEFAULT: 'hsl(var(--muted))', + foreground: 'hsl(var(--muted-foreground))', + }, + accent: { + DEFAULT: 'hsl(var(--accent))', + foreground: 'hsl(var(--accent-foreground))', + }, + destructive: { + DEFAULT: 'hsl(var(--destructive))', + foreground: 'hsl(var(--destructive-foreground))', + }, + border: 'hsl(var(--border))', + input: 'hsl(var(--input))', + ring: 'hsl(var(--ring))', + chart: { + 1: 'hsl(var(--chart-1))', + 2: 'hsl(var(--chart-2))', + 3: 'hsl(var(--chart-3))', + 4: 'hsl(var(--chart-4))', + 5: 'hsl(var(--chart-5))', + }, + }, + }, + }, + plugins: [], +};`; + + writeFileSync(join(tmpDir, 'tailwind.config.js'), tailwindConfig); + + // Create postcss.config.js + const postcssConfig = `export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +};`; + + writeFileSync(join(tmpDir, 'postcss.config.js'), postcssConfig); + + // Create package.json + const packageJson = { + name: 'objectui-temp-app', + private: true, + type: 'module', + dependencies: { + react: '^18.3.1', + 'react-dom': '^18.3.1', + '@object-ui/react': '^0.1.0', + '@object-ui/components': '^0.1.0', + }, + devDependencies: { + '@types/react': '^18.3.12', + '@types/react-dom': '^18.3.1', + '@vitejs/plugin-react': '^4.2.1', + autoprefixer: '^10.4.23', + postcss: '^8.5.6', + tailwindcss: '^3.4.19', + typescript: '~5.7.3', + vite: '^5.0.0', + }, + }; + + writeFileSync(join(tmpDir, 'package.json'), JSON.stringify(packageJson, null, 2)); + + // Create tsconfig.json + const tsconfig = { + compilerOptions: { + target: 'ES2020', + useDefineForClassFields: true, + lib: ['ES2020', 'DOM', 'DOM.Iterable'], + module: 'ESNext', + skipLibCheck: true, + moduleResolution: 'bundler', + allowImportingTsExtensions: true, + resolveJsonModule: true, + isolatedModules: true, + noEmit: true, + jsx: 'react-jsx', + strict: true, + noUnusedLocals: true, + noUnusedParameters: true, + noFallthroughCasesInSwitch: true, + }, + include: ['src'], + }; + + writeFileSync(join(tmpDir, 'tsconfig.json'), JSON.stringify(tsconfig, null, 2)); +} + +export function createTempAppWithRouting(tmpDir: string, routes: RouteInfo[]) { + // Create index.html + const html = ` + + + + + Object UI App + + +
+ + +`; + + writeFileSync(join(tmpDir, 'index.html'), html); + + // Create src directory + const srcDir = join(tmpDir, 'src'); + mkdirSync(srcDir, { recursive: true }); + + // Create schemas directory and copy all schemas + const schemasDir = join(srcDir, 'schemas'); + mkdirSync(schemasDir, { recursive: true }); + + const schemaImports: string[] = []; + const routeComponents: string[] = []; + + routes.forEach((route, index) => { + const schemaVarName = `schema${index}`; + const schemaFileName = `page${index}.json`; + + // Write schema to schemas directory + writeFileSync( + join(schemasDir, schemaFileName), + JSON.stringify(route.schema, null, 2) + ); + + // Add import statement + schemaImports.push(`import ${schemaVarName} from './schemas/${schemaFileName}';`); + + // Add route component + routeComponents.push(` } />`); + }); + + // Create main.tsx + const mainTsx = `import React from 'react'; +import ReactDOM from 'react-dom/client'; +import App from './App'; +import './index.css'; + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + +);`; + + writeFileSync(join(srcDir, 'main.tsx'), mainTsx); + + // Create App.tsx with routing + const appTsx = `import { BrowserRouter, Routes, Route, Link } from 'react-router-dom'; +import { SchemaRenderer } from '@object-ui/react'; +import '@object-ui/components'; +${schemaImports.join('\n')} + +function App() { + return ( + + +${routeComponents.join('\n')} + + + ); +} + +export default App;`; + + writeFileSync(join(srcDir, 'App.tsx'), appTsx); + + // Create index.css with Tailwind + const indexCss = `@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + :root { + --background: 0 0% 100%; + --foreground: 222.2 84% 4.9%; + --card: 0 0% 100%; + --card-foreground: 222.2 84% 4.9%; + --popover: 0 0% 100%; + --popover-foreground: 222.2 84% 4.9%; + --primary: 222.2 47.4% 11.2%; + --primary-foreground: 210 40% 98%; + --secondary: 210 40% 96.1%; + --secondary-foreground: 222.2 47.4% 11.2%; + --muted: 210 40% 96.1%; + --muted-foreground: 215.4 16.3% 46.9%; + --accent: 210 40% 96.1%; + --accent-foreground: 222.2 47.4% 11.2%; + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 210 40% 98%; + --border: 214.3 31.8% 91.4%; + --input: 214.3 31.8% 91.4%; + --ring: 222.2 84% 4.9%; + --radius: 0.5rem; + --chart-1: 12 76% 61%; + --chart-2: 173 58% 39%; + --chart-3: 197 37% 24%; + --chart-4: 43 74% 66%; + --chart-5: 27 87% 67%; + } + + .dark { + --background: 222.2 84% 4.9%; + --foreground: 210 40% 98%; + --card: 222.2 84% 4.9%; + --card-foreground: 210 40% 98%; + --popover: 222.2 84% 4.9%; + --popover-foreground: 210 40% 98%; + --primary: 210 40% 98%; + --primary-foreground: 222.2 47.4% 11.2%; + --secondary: 217.2 32.6% 17.5%; + --secondary-foreground: 210 40% 98%; + --muted: 217.2 32.6% 17.5%; + --muted-foreground: 215 20.2% 65.1%; + --accent: 217.2 32.6% 17.5%; + --accent-foreground: 210 40% 98%; + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 210 40% 98%; + --border: 217.2 32.6% 17.5%; + --input: 217.2 32.6% 17.5%; + --ring: 212.7 26.8% 83.9%; + --chart-1: 220 70% 50%; + --chart-2: 160 60% 45%; + --chart-3: 30 80% 55%; + --chart-4: 280 65% 60%; + --chart-5: 340 75% 55%; + } +} + +@layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + } +}`; + + writeFileSync(join(srcDir, 'index.css'), indexCss); + + // Create tailwind.config.js + const tailwindConfig = `/** @type {import('tailwindcss').Config} */ +export default { + darkMode: ['class'], + content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'], + theme: { + extend: { + borderRadius: { + lg: 'var(--radius)', + md: 'calc(var(--radius) - 2px)', + sm: 'calc(var(--radius) - 4px)', + }, + colors: { + background: 'hsl(var(--background))', + foreground: 'hsl(var(--foreground))', + card: { + DEFAULT: 'hsl(var(--card))', + foreground: 'hsl(var(--card-foreground))', + }, + popover: { + DEFAULT: 'hsl(var(--popover))', + foreground: 'hsl(var(--popover-foreground))', + }, + primary: { + DEFAULT: 'hsl(var(--primary))', + foreground: 'hsl(var(--primary-foreground))', + }, + secondary: { + DEFAULT: 'hsl(var(--secondary))', + foreground: 'hsl(var(--secondary-foreground))', + }, + muted: { + DEFAULT: 'hsl(var(--muted))', + foreground: 'hsl(var(--muted-foreground))', + }, + accent: { + DEFAULT: 'hsl(var(--accent))', + foreground: 'hsl(var(--accent-foreground))', + }, + destructive: { + DEFAULT: 'hsl(var(--destructive))', + foreground: 'hsl(var(--destructive-foreground))', + }, + border: 'hsl(var(--border))', + input: 'hsl(var(--input))', + ring: 'hsl(var(--ring))', + chart: { + 1: 'hsl(var(--chart-1))', + 2: 'hsl(var(--chart-2))', + 3: 'hsl(var(--chart-3))', + 4: 'hsl(var(--chart-4))', + 5: 'hsl(var(--chart-5))', + }, + }, + }, + }, + plugins: [], +};`; + + writeFileSync(join(tmpDir, 'tailwind.config.js'), tailwindConfig); + + // Create postcss.config.js + const postcssConfig = `export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +};`; + + writeFileSync(join(tmpDir, 'postcss.config.js'), postcssConfig); + + // Create package.json with react-router-dom + const packageJson = { + name: 'objectui-temp-app', + private: true, + type: 'module', + dependencies: { + react: '^18.3.1', + 'react-dom': '^18.3.1', + 'react-router-dom': '^7.12.0', + '@object-ui/react': '^0.1.0', + '@object-ui/components': '^0.1.0', + }, + devDependencies: { + '@types/react': '^18.3.12', + '@types/react-dom': '^18.3.1', + '@vitejs/plugin-react': '^4.2.1', + autoprefixer: '^10.4.23', + postcss: '^8.5.6', + tailwindcss: '^3.4.19', + typescript: '~5.7.3', + vite: '^5.0.0', + }, + }; + + writeFileSync(join(tmpDir, 'package.json'), JSON.stringify(packageJson, null, 2)); + + // Create tsconfig.json + const tsconfig = { + compilerOptions: { + target: 'ES2020', + useDefineForClassFields: true, + lib: ['ES2020', 'DOM', 'DOM.Iterable'], + module: 'ESNext', + skipLibCheck: true, + moduleResolution: 'bundler', + allowImportingTsExtensions: true, + resolveJsonModule: true, + isolatedModules: true, + noEmit: true, + jsx: 'react-jsx', + strict: true, + noUnusedLocals: true, + noUnusedParameters: true, + noFallthroughCasesInSwitch: true, + }, + include: ['src'], + }; + + writeFileSync(join(tmpDir, 'tsconfig.json'), JSON.stringify(tsconfig, null, 2)); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a73bf83e..f98ccb4f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -275,6 +275,9 @@ importers: express: specifier: ^4.21.2 version: 4.22.1 + express-rate-limit: + specifier: ^7.4.1 + version: 7.5.1(express@4.22.1) js-yaml: specifier: ^4.1.0 version: 4.1.1 @@ -3405,6 +3408,12 @@ packages: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} + express-rate-limit@7.5.1: + resolution: {integrity: sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==} + engines: {node: '>= 16'} + peerDependencies: + express: '>= 4.11' + express@4.22.1: resolution: {integrity: sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==} engines: {node: '>= 0.10.0'} @@ -8272,6 +8281,10 @@ snapshots: expect-type@1.3.0: {} + express-rate-limit@7.5.1(express@4.22.1): + dependencies: + express: 4.22.1 + express@4.22.1: dependencies: accepts: 1.3.8