diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 00000000..56d162a3 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,9 @@ +{ + "permissions": { + "allow": [ + "Bash(pnpm build:*)", + "Bash(find:*)" + ], + "deny": [] + } +} \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..eb751171 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,133 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Commands + +### Build Commands +```bash +# Build all packages (runs in parallel) +pnpm build + +# Run development mode with watch (builds everything in watch mode) +pnpm dev + +# Clean all node_modules +pnpm cleanNodeModules +``` + +### Testing +```bash +# Run all tests +pnpm test + +# Run specific framework tests +cd frameworks/react-cra && pnpm test +cd frameworks/solid && pnpm test + +# Test coverage in packages +cd packages/cta-engine && pnpm test:coverage +``` + +### Development Workflow +```bash +# 1. Install dependencies +pnpm install + +# 2. Build everything +pnpm build + +# 3. Create a test application (from outside the monorepo) +node [path-to-repo]/cli/create-tsrouter-app/dist/index.js my-app + +# 4. Run in development mode +pnpm dev +``` + +### Publishing (CI Only) +```bash +pnpm cipublish +``` + +## Architecture + +This is a monorepo for TanStack application builders, using pnpm workspaces and Nx for orchestration. + +### Key Concepts + +- **CTA (Create TanStack Application)**: The core system for creating TanStack applications +- **Frameworks**: Technology-specific implementations (React, Solid, etc.) +- **Add-ons**: Plugins that extend application capabilities (e.g., tanstack-query, clerk, sentry) +- **Starters**: Pre-configured application templates with modern defaults +- **Code Router vs File Router**: Two routing modes - code-based or file-based routing + +### Project Structure + +- **cli/**: CLI applications (create-tsrouter-app, create-tanstack, create-start-app) + - Each CLI delegates to @tanstack/cta-cli for core functionality + +- **packages/**: Core packages + - **cta-cli**: Command line interface logic + - **cta-engine**: Core engine for app generation and modification + - **cta-ui**: Web UI for interactive app creation + - **cta-ui-base**: Shared UI components + +- **frameworks/**: Framework implementations + - **react-cra**: React framework support + - **solid**: Solid framework support + - Each contains add-ons, toolchains, and project templates + +### Template System + +Uses EJS templating with these key variables: +- `typescript`, `tailwind`: Boolean flags +- `js`, `jsx`: File extensions based on TypeScript setting +- `fileRouter`, `codeRouter`: Routing mode flags +- `addOnEnabled`: Object of enabled add-ons +- `packageManager`: npm, yarn, or pnpm + +### Add-on System + +Add-ons modify the generated application by: +1. Adding dependencies via package.json +2. Copying asset files +3. Providing demo routes +4. Integrating with the build system + +Custom add-ons can be created as JSON files and loaded via URL. + +### Testing Add-ons and Starters + +```bash +# Serve add-on/starter locally +npx static-server + +# Test add-on +node [repo]/cli/create-tsrouter-app/dist/index.js app-test --add-ons http://localhost:9080/add-on.json + +# Test starter +node [repo]/cli/create-tsrouter-app/dist/index.js app-test --starter http://localhost:9080/starter.json +``` + +### UI Development + +The UI runs as both a web server and React app: + +```bash +# 1. Start API server (from empty directory) +CTA_DISABLE_UI=true node ../create-tsrouter-app/cli/create-tsrouter-app/dist/index.js --ui + +# 2. Start React dev server +cd packages/cta-ui && pnpm dev:ui + +# 3. Run monorepo in watch mode +pnpm dev +``` + +## Key Implementation Details + +- All workspace dependencies use `workspace:*` protocol +- EJS templates use special naming: `_dot_` prefix becomes `.` in output +- Add-ons can provide demo routes that integrate with the router +- The engine uses memfs for virtual file system operations during generation +- Special steps system handles post-generation tasks (e.g., shadcn setup) \ No newline at end of file diff --git a/CUSTOM_PROPERTIES_DESIGN.md b/CUSTOM_PROPERTIES_DESIGN.md new file mode 100644 index 00000000..eaf8c87a --- /dev/null +++ b/CUSTOM_PROPERTIES_DESIGN.md @@ -0,0 +1,467 @@ +# Custom Properties Design for CTA Framework + +## Overview + +This document outlines the design for replacing the hardcoded routes and integrations system with a generic `customProperties` approach using Zod schemas. This will allow framework definitions to declare their own custom properties that can be added to add-ons, providing a more flexible and extensible system. + +## Current System Analysis + +### Current Implementation +- **Routes**: Hardcoded array of route objects in `AddOnBaseSchema` +- **Integrations**: Hardcoded array of integration objects in `AddOnInfoSchema` +- **Template Processing**: Direct access to `routes` and `integrations` arrays in templates +- **Type Safety**: Fixed TypeScript types for routes and integrations + +### Limitations +1. Framework-specific concepts (routes/integrations) are baked into the core engine +2. No flexibility for frameworks to define their own custom properties +3. All frameworks must use the same structure for routes and integrations +4. Cannot easily add new property types without modifying core engine + +## Proposed Design + +### Core Concept + +Replace hardcoded `routes` and `integrations` with a generic `customProperties` system where: +1. Frameworks define their own custom property schemas using Zod +2. Add-ons provide values matching these schemas +3. Templates access properties through a unified interface +4. Type safety is maintained through Zod inference + +### Framework Definition Structure + +```typescript +// Example: React framework definition +export function createFrameworkDefinition(): FrameworkDefinition { + return { + id: 'react-cra', + name: 'React', + customProperties: { + routes: z.array( + z.object({ + url: z.string().optional(), + name: z.string().optional(), + path: z.string(), + jsName: z.string(), + }) + ).optional(), + integrations: z.array( + z.object({ + type: z.enum(['provider', 'root-provider', 'layout', 'header-user']), + path: z.string(), + jsName: z.string(), + }) + ).optional(), + }, + // ... rest of definition + } +} +``` + +### Type Updates + +```typescript +// packages/cta-engine/src/types.ts + +import type { ZodTypeAny } from 'zod' + +// Update FrameworkDefinition to include customProperties +export type FrameworkDefinition = { + id: string + name: string + description: string + version: string + + // New field for custom property schemas + customProperties?: Record + + base: Record + addOns: Array + basePackageJSON: Record + optionalPackages: Record + + supportedModes: Record< + string, + { + displayName: string + description: string + forceTypescript: boolean + } + > +} + +// Remove routes from AddOnBaseSchema +export const AddOnBaseSchema = z.object({ + id: z.string(), + name: z.string(), + description: z.string(), + author: z.string().optional(), + version: z.string().optional(), + link: z.string().optional(), + license: z.string().optional(), + warning: z.string().optional(), + type: z.enum(['add-on', 'example', 'starter', 'toolchain']), + command: z + .object({ + command: z.string(), + args: z.array(z.string()).optional(), + }) + .optional(), + // Remove routes - it will be in customProperties + packageAdditions: z + .object({ + dependencies: z.record(z.string(), z.string()).optional(), + devDependencies: z.record(z.string(), z.string()).optional(), + scripts: z.record(z.string(), z.string()).optional(), + }) + .optional(), + shadcnComponents: z.array(z.string()).optional(), + dependsOn: z.array(z.string()).optional(), + smallLogo: z.string().optional(), + logo: z.string().optional(), + addOnSpecialSteps: z.array(z.string()).optional(), + createSpecialSteps: z.array(z.string()).optional(), +}) + +// Update AddOnInfoSchema to remove integrations +export const AddOnInfoSchema = AddOnBaseSchema.extend({ + modes: z.array(z.string()), + phase: z.enum(['setup', 'add-on']), + readme: z.string().optional(), + // Add customProperties field + customProperties: z.record(z.string(), z.unknown()).optional(), +}) + +// Remove the separate Integration schema as it will be framework-specific +// export const IntegrationSchema = z.object({...}) - REMOVE +``` + +### Add-on Loading and Validation + +```typescript +// packages/cta-engine/src/add-on-loader.ts + +export async function loadAddOn( + addOnPath: string, + framework: Framework +): Promise { + const info = await loadAddOnInfo(addOnPath) + + // Validate custom properties against framework schema + if (framework.customProperties && info.customProperties) { + const validatedProperties: Record = {} + + for (const [key, schema] of Object.entries(framework.customProperties)) { + if (key in info.customProperties) { + try { + validatedProperties[key] = schema.parse(info.customProperties[key]) + } catch (error) { + throw new Error( + `Invalid custom property "${key}" in add-on "${info.id}": ${error.message}` + ) + } + } + } + + info.customProperties = validatedProperties + } + + return { + ...info, + getFiles, + getFileContents, + getDeletedFiles, + } +} +``` + +### Template Processing Updates + +```typescript +// packages/cta-engine/src/template-file.ts + +export function createTemplateFile(environment: Environment, options: Options) { + // Collect all custom properties from add-ons + const customProperties: Record> = {} + + // Initialize arrays for all framework custom properties + if (options.framework.customProperties) { + for (const key of Object.keys(options.framework.customProperties)) { + customProperties[key] = [] + } + } + + // Collect custom properties from each add-on + for (const addOn of options.chosenAddOns) { + if (addOn.customProperties) { + for (const [key, values] of Object.entries(addOn.customProperties)) { + if (customProperties[key] && Array.isArray(values)) { + customProperties[key].push(...values) + } else if (customProperties[key] && !Array.isArray(values)) { + customProperties[key].push(values) + } + } + } + } + + return async function templateFile(file: string, content: string) { + const templateValues = { + // ... existing values + packageManager: options.packageManager, + projectName: options.projectName, + typescript: options.typescript, + tailwind: options.tailwind, + js: options.typescript ? 'ts' : 'js', + jsx: options.typescript ? 'tsx' : 'jsx', + fileRouter: options.mode === 'file-router', + codeRouter: options.mode === 'code-router', + addOnEnabled, + addOns: options.chosenAddOns, + + // Add custom properties dynamically + ...customProperties, + + // Helper functions + getPackageManagerAddScript, + getPackageManagerRunScript, + relativePath: (path: string) => relativePath(file, path), + ignoreFile: () => { + throw new IgnoreFileError() + }, + } + + // ... rest of template processing + } +} +``` + +## Implementation Plan + +### 1. Core Engine Updates +- [ ] Add `customProperties` to `FrameworkDefinition` type +- [ ] Remove `routes` from `AddOnBaseSchema` +- [ ] Remove `integrations` and `IntegrationSchema` from types +- [ ] Add `customProperties` field to `AddOnInfoSchema` +- [ ] Update add-on loader to validate custom properties +- [ ] Update template processor to expose custom properties + +### 2. Framework Updates +- [ ] Update React framework to define routes/integrations schemas +- [ ] Update Solid framework similarly +- [ ] Update all framework templates to use customProperties + +### 3. Add-on Updates +- [ ] Update all add-on info.json files to use customProperties +- [ ] Update add-on documentation +- [ ] Remove routes/integrations from root level + +### 4. Template Updates +- [ ] Update all EJS templates to access properties from customProperties +- [ ] Update navigation generation to use customProperties.routes +- [ ] Update integration rendering to use customProperties.integrations + +## Benefits + +1. **Extensibility**: Frameworks can define any custom properties they need +2. **Type Safety**: Zod schemas provide runtime validation and TypeScript types +3. **Flexibility**: Different frameworks can have different property structures +4. **Future-Proof**: New property types can be added without core engine changes +5. **Framework-Specific**: Each framework can have its own domain-specific concepts +6. **Clean Architecture**: No framework-specific concepts in core engine + +## Example: Framework-Specific Properties + +### React Framework +```typescript +customProperties: { + routes: z.array( + z.object({ + url: z.string().optional(), + name: z.string().optional(), + path: z.string(), + jsName: z.string(), + }) + ).optional(), + integrations: z.array( + z.object({ + type: z.enum(['provider', 'root-provider', 'layout', 'header-user']), + path: z.string(), + jsName: z.string(), + }) + ).optional(), + contextProviders: z.array( + z.object({ + name: z.string(), + path: z.string(), + wrapperComponent: z.string(), + }) + ).optional(), + hooks: z.array( + z.object({ + name: z.string(), + path: z.string(), + category: z.enum(['state', 'effect', 'context', 'custom']), + }) + ).optional(), +} +``` + +### Solid Framework +```typescript +customProperties: { + routes: z.array( + z.object({ + url: z.string().optional(), + name: z.string().optional(), + path: z.string(), + jsName: z.string(), + }) + ).optional(), + integrations: z.array( + z.object({ + type: z.enum(['provider', 'root-provider', 'layout', 'header-user']), + path: z.string(), + jsName: z.string(), + }) + ).optional(), + signals: z.array( + z.object({ + name: z.string(), + path: z.string(), + global: z.boolean(), + }) + ).optional(), + stores: z.array( + z.object({ + name: z.string(), + path: z.string(), + type: z.enum(['store', 'mutable']), + }) + ).optional(), +} +``` + +### Vue Framework (hypothetical) +```typescript +customProperties: { + routes: z.array( + z.object({ + url: z.string().optional(), + name: z.string().optional(), + path: z.string(), + jsName: z.string(), + }) + ).optional(), + components: z.array( + z.object({ + name: z.string(), + path: z.string(), + global: z.boolean(), + }) + ).optional(), + composables: z.array( + z.object({ + name: z.string(), + path: z.string(), + autoImport: z.boolean(), + }) + ).optional(), + plugins: z.array( + z.object({ + name: z.string(), + path: z.string(), + options: z.record(z.string(), z.unknown()).optional(), + }) + ).optional(), +} +``` + +## Add-on info.json Structure + +### Before +```json +{ + "id": "tanstack-query", + "name": "Query", + "description": "Integrate TanStack Query into your application.", + "routes": [ + { + "url": "/demo/tanstack-query", + "name": "TanStack Query", + "path": "src/routes/demo.tanstack-query.tsx", + "jsName": "TanStackQueryDemo" + } + ], + "integrations": [ + { + "type": "root-provider", + "path": "src/integrations/tanstack-query/root-provider.tsx", + "jsName": "TanStackQueryProvider" + } + ] +} +``` + +### After +```json +{ + "id": "tanstack-query", + "name": "Query", + "description": "Integrate TanStack Query into your application.", + "customProperties": { + "routes": [ + { + "url": "/demo/tanstack-query", + "name": "TanStack Query", + "path": "src/routes/demo.tanstack-query.tsx", + "jsName": "TanStackQueryDemo" + } + ], + "integrations": [ + { + "type": "root-provider", + "path": "src/integrations/tanstack-query/root-provider.tsx", + "jsName": "TanStackQueryProvider" + } + ] + } +} +``` + +## Template Access Pattern + +### Before +```ejs +<% for(const route of routes) { %> + <%= route.name %> +<% } %> + +<% for(const integration of integrations.filter(i => i.type === 'provider')) { %> + <<%= integration.jsName %>> +<% } %> +``` + +### After +```ejs +<% for(const route of routes) { %> + <%= route.name %> +<% } %> + +<% for(const integration of integrations.filter(i => i.type === 'provider')) { %> + <<%= integration.jsName %>> +<% } %> +``` + +The template access remains the same because `customProperties` are spread into the template values, making `routes` and `integrations` available at the top level. + +## Testing Strategy + +1. **Unit Tests**: Test custom property validation and merging +2. **Integration Tests**: Test full app generation with custom properties +3. **Framework Tests**: Test each framework's custom properties +4. **Template Tests**: Verify templates render correctly with new system +5. **Add-on Tests**: Test all add-ons work with new structure + +## Conclusion + +The custom properties system provides a flexible, type-safe way for frameworks to extend the add-on system with their own domain-specific concepts. By removing hardcoded framework-specific properties from the core engine, we create a truly extensible system that can adapt to any framework's needs. \ No newline at end of file diff --git a/examples/custom-cli/create-qwik-app/add-ons/feather-icons/info.json b/examples/custom-cli/create-qwik-app/add-ons/feather-icons/info.json index 58c71fe1..e97ef786 100644 --- a/examples/custom-cli/create-qwik-app/add-ons/feather-icons/info.json +++ b/examples/custom-cli/create-qwik-app/add-ons/feather-icons/info.json @@ -2,7 +2,9 @@ "name": "Feather Icons", "description": "Add Feather Icons to your application.", "phase": "add-on", - "modes": ["default"], + "modes": [ + "default" + ], "type": "add-on", "link": "https://github.com/egmaleta/qwik-feather-icons", "routes": [ diff --git a/examples/custom-cli/create-qwik-app/project/base/src/components/header.tsx.ejs b/examples/custom-cli/create-qwik-app/project/base/src/components/header.tsx.ejs index 29b690a3..bbfe3cb1 100644 --- a/examples/custom-cli/create-qwik-app/project/base/src/components/header.tsx.ejs +++ b/examples/custom-cli/create-qwik-app/project/base/src/components/header.tsx.ejs @@ -12,9 +12,9 @@ export default component$(() => {
Home
-<% for(const addOn of addOns) { for(const route of (addOn?.routes||[])?.filter(r => r.url && r.name)) { %> -
<%= route.name %>
-<% } } %> +<% for(const route of (routes||[]).filter(r => r.url && r.name)) { %> +
<%= route.name %>
+ <% } %> <% if (integrations.filter(i => i.type === 'header-user').length > 0) { %>
diff --git a/frameworks/react-cra/add-ons/clerk/info.json b/frameworks/react-cra/add-ons/clerk/info.json index 1e8a647f..f07ace12 100644 --- a/frameworks/react-cra/add-ons/clerk/info.json +++ b/frameworks/react-cra/add-ons/clerk/info.json @@ -2,7 +2,9 @@ "name": "Clerk", "description": "Add Clerk authentication to your application.", "phase": "add-on", - "modes": ["file-router"], + "modes": [ + "file-router" + ], "type": "add-on", "link": "https://clerk.com", "routes": [ diff --git a/frameworks/react-cra/add-ons/convex/info.json b/frameworks/react-cra/add-ons/convex/info.json index 75a3443c..f06825c8 100644 --- a/frameworks/react-cra/add-ons/convex/info.json +++ b/frameworks/react-cra/add-ons/convex/info.json @@ -4,7 +4,9 @@ "link": "https://convex.dev", "phase": "add-on", "type": "add-on", - "modes": ["file-router"], + "modes": [ + "file-router" + ], "routes": [ { "url": "/demo/convex", diff --git a/frameworks/react-cra/add-ons/form/info.json b/frameworks/react-cra/add-ons/form/info.json index d06cb505..d2b9fde1 100644 --- a/frameworks/react-cra/add-ons/form/info.json +++ b/frameworks/react-cra/add-ons/form/info.json @@ -3,8 +3,20 @@ "description": "TanStack Form", "phase": "add-on", "type": "add-on", - "modes": ["file-router", "code-router"], + "modes": [ + "file-router", + "code-router" + ], "link": "https://tanstack.com/form/latest", + "shadcnComponents": [ + "button", + "select", + "input", + "textarea", + "slider", + "switch", + "label" + ], "routes": [ { "url": "/demo/form/simple", @@ -18,14 +30,5 @@ "path": "src/routes/demo.form.address.tsx", "jsName": "FormAddressDemo" } - ], - "shadcnComponents": [ - "button", - "select", - "input", - "textarea", - "slider", - "switch", - "label" ] } diff --git a/frameworks/react-cra/add-ons/neon/info.json b/frameworks/react-cra/add-ons/neon/info.json index 4ddc43ab..f9cdc2e0 100644 --- a/frameworks/react-cra/add-ons/neon/info.json +++ b/frameworks/react-cra/add-ons/neon/info.json @@ -4,7 +4,12 @@ "link": "https://neon.com", "phase": "add-on", "type": "add-on", - "modes": ["file-router"], + "modes": [ + "file-router" + ], + "dependsOn": [ + "start" + ], "routes": [ { "url": "/demo/neon", @@ -12,6 +17,5 @@ "path": "src/routes/demo.neon.tsx", "jsName": "NeonDemo" } - ], - "dependsOn": ["start"] + ] } diff --git a/frameworks/react-cra/add-ons/sentry/info.json b/frameworks/react-cra/add-ons/sentry/info.json index add3e6e8..f393e54a 100644 --- a/frameworks/react-cra/add-ons/sentry/info.json +++ b/frameworks/react-cra/add-ons/sentry/info.json @@ -3,8 +3,13 @@ "phase": "setup", "description": "Add Sentry for error monitoring, tracing, and session replays (requires Start).", "link": "https://sentry.com/", - "modes": ["file-router"], + "modes": [ + "file-router" + ], "type": "add-on", + "dependsOn": [ + "start" + ], "routes": [ { "url": "/demo/sentry/testing", @@ -12,6 +17,5 @@ "path": "src/routes/demo.sentry.testing.tsx", "jsName": "SentryDemo" } - ], - "dependsOn": ["start"] + ] } diff --git a/frameworks/react-cra/add-ons/start/info.json b/frameworks/react-cra/add-ons/start/info.json index b86e365d..a6771c71 100644 --- a/frameworks/react-cra/add-ons/start/info.json +++ b/frameworks/react-cra/add-ons/start/info.json @@ -3,9 +3,19 @@ "phase": "setup", "description": "Add TanStack Start for SSR, API endpoints, and more.", "link": "https://tanstack.com/start/latest", - "modes": ["file-router"], + "modes": [ + "file-router" + ], "type": "add-on", "warning": "TanStack Start is not yet at 1.0 and may change significantly or not be compatible with other add-ons.\nMigrating to Start might require deleting node_modules and re-installing.", + "deletedFiles": [ + "./index.html", + "./src/main.tsx", + "./src/App.css" + ], + "addOnSpecialSteps": [ + "rimraf-node-modules" + ], "routes": [ { "url": "/demo/start/server-funcs", @@ -19,11 +29,5 @@ "path": "src/routes/demo.start.api-request.tsx", "jsName": "StartApiRequestDemo" } - ], - "deletedFiles": [ - "./index.html", - "./src/main.tsx", - "./src/App.css" - ], - "addOnSpecialSteps": ["rimraf-node-modules"] + ] } diff --git a/frameworks/react-cra/add-ons/store/info.json b/frameworks/react-cra/add-ons/store/info.json index 27bac2fd..933622f8 100644 --- a/frameworks/react-cra/add-ons/store/info.json +++ b/frameworks/react-cra/add-ons/store/info.json @@ -4,7 +4,10 @@ "phase": "add-on", "link": "https://tanstack.com/store/latest", "type": "add-on", - "modes": ["file-router", "code-router"], + "modes": [ + "file-router", + "code-router" + ], "routes": [ { "url": "/demo/store", diff --git a/frameworks/react-cra/add-ons/table/info.json b/frameworks/react-cra/add-ons/table/info.json index 6dc0ca3e..fe90bf33 100644 --- a/frameworks/react-cra/add-ons/table/info.json +++ b/frameworks/react-cra/add-ons/table/info.json @@ -2,7 +2,10 @@ "name": "Table", "description": "Integrate TanStack Table into your application.", "phase": "add-on", - "modes": ["file-router", "code-router"], + "modes": [ + "file-router", + "code-router" + ], "link": "https://tanstack.com/table/latest", "type": "add-on", "routes": [ diff --git a/frameworks/react-cra/project/base/src/components/Header.tsx.ejs b/frameworks/react-cra/project/base/src/components/Header.tsx.ejs index f9e9d1cd..b4e927c5 100644 --- a/frameworks/react-cra/project/base/src/components/Header.tsx.ejs +++ b/frameworks/react-cra/project/base/src/components/Header.tsx.ejs @@ -10,10 +10,9 @@ export default function Header() {
Home
-<% for(const addOn of addOns) { - for(const route of (addOn?.routes||[])?.filter(r => r.url && r.name)) { %> -
<%= route.name %>
-<% } } %> +<% for(const route of (routes||[]).filter(r => r.url && r.name)) { %> +
<%= route.name %>
+ <% } %> <% if (integrations.filter(i => i.type === 'header-user').length > 0) { %>
diff --git a/frameworks/react-cra/src/index.ts b/frameworks/react-cra/src/index.ts index 6297064d..1cd240db 100644 --- a/frameworks/react-cra/src/index.ts +++ b/frameworks/react-cra/src/index.ts @@ -1,32 +1,84 @@ import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' - +import { z } from 'zod' import { registerFramework, scanAddOnDirectories, scanProjectDirectory, } from '@tanstack/cta-engine' -import type { FrameworkDefinition } from '@tanstack/cta-engine' +import type { ZodTypeAny } from 'zod' -export function createFrameworkDefinition(): FrameworkDefinition { +export function createFrameworkDefinition(): any { const baseDirectory = dirname(dirname(fileURLToPath(import.meta.url))) - const addOns = scanAddOnDirectories([ - join(baseDirectory, 'add-ons'), - join(baseDirectory, 'toolchains'), - join(baseDirectory, 'examples'), - ]) + // Define custom properties for React framework + const customProperties = { + routes: z + .array( + z.object({ + url: z.string().optional(), + name: z.string().optional(), + path: z.string(), + jsName: z.string(), + }), + ) + .optional(), + integrations: z + .array( + z.object({ + type: z.enum(['provider', 'root-provider', 'layout', 'header-user']), + path: z.string(), + jsName: z.string(), + }), + ) + .optional(), + } as Record + + const addOns = scanAddOnDirectories( + [ + join(baseDirectory, 'add-ons'), + join(baseDirectory, 'toolchains'), + join(baseDirectory, 'examples'), + ], + { customProperties }, + ) const { files, basePackageJSON, optionalPackages } = scanProjectDirectory( join(baseDirectory, 'project'), join(baseDirectory, 'project/base'), ) - return { + const framework = { id: 'react-cra', name: 'React', description: 'Templates for React CRA', version: '0.1.0', + customProperties: { + routes: z + .array( + z.object({ + url: z.string().optional(), + name: z.string().optional(), + path: z.string(), + jsName: z.string(), + }), + ) + .optional(), + integrations: z + .array( + z.object({ + type: z.enum([ + 'provider', + 'root-provider', + 'layout', + 'header-user', + ]), + path: z.string(), + jsName: z.string(), + }), + ) + .optional(), + }, base: files, addOns, basePackageJSON, @@ -44,6 +96,8 @@ export function createFrameworkDefinition(): FrameworkDefinition { }, }, } + + return framework } export function register() { diff --git a/frameworks/solid/add-ons/form/info.json b/frameworks/solid/add-ons/form/info.json index 78b9389d..29d757e1 100644 --- a/frameworks/solid/add-ons/form/info.json +++ b/frameworks/solid/add-ons/form/info.json @@ -3,7 +3,10 @@ "description": "TanStack Form", "phase": "add-on", "link": "https://tanstack.com/form/latest", - "modes": ["file-router", "code-router"], + "modes": [ + "file-router", + "code-router" + ], "type": "add-on", "routes": [ { @@ -12,5 +15,6 @@ "path": "src/routes/demo.form.tsx", "jsName": "FormDemo" } - ] + ], + "customProperties": {} } diff --git a/frameworks/solid/add-ons/sentry/info.json b/frameworks/solid/add-ons/sentry/info.json index 92cda812..9171ddee 100644 --- a/frameworks/solid/add-ons/sentry/info.json +++ b/frameworks/solid/add-ons/sentry/info.json @@ -3,7 +3,9 @@ "phase": "setup", "description": "Add Sentry for error monitoring and crash reporting (requires Start).", "link": "https://sentry.com/", - "modes": ["file-router"], + "modes": [ + "file-router" + ], "type": "add-on", "routes": [ { @@ -12,5 +14,6 @@ "path": "src/routes/demo.sentry.bad-event-handler.tsx", "jsName": "SentryBadEventHandlerDemo" } - ] + ], + "customProperties": {} } diff --git a/frameworks/solid/add-ons/start/info.json b/frameworks/solid/add-ons/start/info.json index 94ba8ada..b860dbdb 100644 --- a/frameworks/solid/add-ons/start/info.json +++ b/frameworks/solid/add-ons/start/info.json @@ -3,7 +3,9 @@ "phase": "setup", "description": "Add TanStack Start for SSR, API endpoints, and more.", "link": "https://tanstack.com/start/latest", - "modes": ["file-router"], + "modes": [ + "file-router" + ], "type": "add-on", "warning": "TanStack Start is not yet at 1.0 and may change significantly or not be compatible with other add-ons.", "routes": [ @@ -13,5 +15,6 @@ "path": "src/routes/demo.start.server-funcs.tsx", "jsName": "StartServerFuncsDemo" } - ] + ], + "customProperties": {} } diff --git a/frameworks/solid/add-ons/store/info.json b/frameworks/solid/add-ons/store/info.json index faac463e..1f590b45 100644 --- a/frameworks/solid/add-ons/store/info.json +++ b/frameworks/solid/add-ons/store/info.json @@ -2,7 +2,10 @@ "name": "Store", "description": "Add TanStack Store to your application.", "phase": "add-on", - "modes": ["file-router", "code-router"], + "modes": [ + "file-router", + "code-router" + ], "type": "add-on", "link": "https://tanstack.com/store/latest", "routes": [ @@ -12,5 +15,6 @@ "path": "src/routes/demo.store.tsx", "jsName": "StoreDemo" } - ] + ], + "customProperties": {} } diff --git a/frameworks/solid/add-ons/tanstack-query/info.json b/frameworks/solid/add-ons/tanstack-query/info.json index 23017a9e..aba2e208 100644 --- a/frameworks/solid/add-ons/tanstack-query/info.json +++ b/frameworks/solid/add-ons/tanstack-query/info.json @@ -2,7 +2,9 @@ "name": "TanStack Query", "description": "Integrate TanStack Query into your application.", "phase": "add-on", - "modes": ["file-router"], + "modes": [ + "file-router" + ], "type": "add-on", "link": "https://tanstack.com/query/latest", "routes": [ @@ -24,5 +26,6 @@ "path": "src/integrations/tanstack-query/header-user.tsx", "jsName": "TanStackQueryHeaderUser" } - ] + ], + "customProperties": {} } diff --git a/frameworks/solid/examples/tanchat/info.json b/frameworks/solid/examples/tanchat/info.json index b1a626b3..61b09108 100644 --- a/frameworks/solid/examples/tanchat/info.json +++ b/frameworks/solid/examples/tanchat/info.json @@ -8,7 +8,9 @@ "routes": [ { "url": "/example/chat", - "name": "Chat" + "name": "Chat", + "path": "src/routes/example.chat.tsx", + "jsName": "ChatDemo" } ], "dependsOn": ["solid-ui", "store"] diff --git a/frameworks/solid/project/base/src/components/Header.tsx.ejs b/frameworks/solid/project/base/src/components/Header.tsx.ejs index 1fbe677a..58643837 100644 --- a/frameworks/solid/project/base/src/components/Header.tsx.ejs +++ b/frameworks/solid/project/base/src/components/Header.tsx.ejs @@ -10,10 +10,9 @@ export default function Header() {
Home
- <% for(const addOn of addOns) { - for(const route of (addOn?.routes||[])?.filter(r => r.url && r.name) || []) { %> + <% for(const route of (routes||[]).filter(r => r.url && r.name)) { %>
<%= route.name %>
- <% } } %> + <% } %>
diff --git a/frameworks/solid/src/index.ts b/frameworks/solid/src/index.ts index 11d839b1..2e4f8beb 100644 --- a/frameworks/solid/src/index.ts +++ b/frameworks/solid/src/index.ts @@ -1,32 +1,59 @@ import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' - +import { z } from 'zod' import { registerFramework, scanAddOnDirectories, scanProjectDirectory, } from '@tanstack/cta-engine' -import type { FrameworkDefinition } from '@tanstack/cta-engine' +import type { ZodTypeAny } from 'zod' -export function createFrameworkDefinition(): FrameworkDefinition { +export function createFrameworkDefinition(): any { const baseDirectory = dirname(dirname(fileURLToPath(import.meta.url))) - const addOns = scanAddOnDirectories([ - join(baseDirectory, 'add-ons'), - join(baseDirectory, 'toolchains'), - join(baseDirectory, 'examples'), - ]) + // Define custom properties for Solid framework + const customProperties = { + routes: z + .array( + z.object({ + url: z.string().optional(), + name: z.string().optional(), + path: z.string(), + jsName: z.string(), + }), + ) + .optional(), + integrations: z + .array( + z.object({ + type: z.enum(['provider', 'root-provider', 'layout', 'header-user']), + path: z.string(), + jsName: z.string(), + }), + ) + .optional(), + } as Record + + const addOns = scanAddOnDirectories( + [ + join(baseDirectory, 'add-ons'), + join(baseDirectory, 'toolchains'), + join(baseDirectory, 'examples'), + ], + { customProperties }, + ) const { files, basePackageJSON, optionalPackages } = scanProjectDirectory( join(baseDirectory, 'project'), join(baseDirectory, 'project/base'), ) - return { + const framework = { id: 'solid', name: 'Solid', description: 'Solid templates for Tanstack Router Applications', version: '0.1.0', + customProperties, base: files, addOns, basePackageJSON, @@ -44,6 +71,8 @@ export function createFrameworkDefinition(): FrameworkDefinition { }, }, } + + return framework } export function register() { diff --git a/packages/cta-engine/ROUTES_AND_INTEGRATIONS.md b/packages/cta-engine/ROUTES_AND_INTEGRATIONS.md new file mode 100644 index 00000000..3026f810 --- /dev/null +++ b/packages/cta-engine/ROUTES_AND_INTEGRATIONS.md @@ -0,0 +1,448 @@ +# Routes and Integrations in CTA Engine + +This document explains how the routes and integrations systems work in the Create TanStack Application (CTA) engine. These two concepts are fundamental to how add-ons extend and enhance generated applications. + +## Table of Contents + +1. [Overview](#overview) +2. [Routes System](#routes-system) +3. [Integrations System](#integrations-system) +4. [Implementation Details](#implementation-details) +5. [Add-on Examples](#add-on-examples) +6. [Creating Custom Add-ons](#creating-custom-add-ons) + +## Overview + +The CTA engine uses two primary mechanisms for add-ons to extend applications: + +- **Routes**: Demo pages and API endpoints that showcase add-on functionality +- **Integrations**: Components and providers that integrate into the application's structure + +Both systems work together to provide a seamless way for add-ons to enhance applications without manual configuration. + +## Routes System + +### What are Routes? + +Routes are demo pages or API endpoints that add-ons provide to showcase their functionality. They are automatically registered with the TanStack Router when an add-on is selected during app creation. + +### Route Structure + +Routes are defined in an add-on's `info.json` file: + +```json +{ + "routes": [ + { + "url": "/demo/tanstack-query", + "name": "TanStack Query", + "path": "src/routes/demo.tanstack-query.tsx", + "jsName": "TanStackQueryDemo" + } + ] +} +``` + +#### Route Properties + +- **url** (optional): The URL path where the route will be accessible +- **name** (optional): Display name for navigation links +- **path**: File path relative to the add-on's assets directory +- **jsName**: JavaScript/TypeScript identifier used when importing the route + +### How Routes are Processed + +1. **Collection Phase**: During app generation, the template engine collects all routes from selected add-ons: + +```typescript +// From template-file.ts +const routes: Array['routes'][number]> = [] +for (const addOn of options.chosenAddOns) { + if (addOn.routes) { + routes.push(...addOn.routes) + } +} +``` + +2. **Template Processing**: Routes are made available to EJS templates via the `routes` variable + +3. **Router Integration**: Routes are integrated differently based on the routing mode: + +#### Code Router Mode + +In code router mode, routes are imported and registered directly in `main.tsx`: + +```typescript +// Import route components +<% for(const route of routes) { %> +import <%= route.jsName %> from "<%= relativePath(route.path) %>"; +<% } %> + +// Register routes with the router +const routeTree = rootRoute.addChildren([ + indexRoute<%= routes.map(route => `, ${route.jsName}(rootRoute)`).join('') %> +]); +``` + +#### File Router Mode + +In file router mode, route files are copied to the appropriate location in the routes directory, and the TanStack Router file-based routing system automatically discovers them. + +### Route File Format + +Route files use the TanStack Router format and are automatically templatized to work in both routing modes: + +```typescript +// Original route file +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/demo/my-feature')({ + component: MyFeatureDemo, +}) + +// After templatization (EJS) +import { <% if (fileRouter) { %>createFileRoute<% } else { %>createRoute<% } %> } from '@tanstack/react-router' + +<% if (codeRouter) { %> +import type { RootRoute } from '@tanstack/react-router' + +export default (parentRoute: RootRoute) => createRoute({ + path: '/demo/my-feature', + component: MyFeatureDemo, + getParentRoute: () => parentRoute, +}) +<% } else { %> +export const Route = createFileRoute('/demo/my-feature')({ + component: MyFeatureDemo, +}) +<% } %> +``` + +## Integrations System + +### What are Integrations? + +Integrations are components and providers that need to be injected into specific locations within the application structure. They allow add-ons to wrap the application with providers, add layout components, or inject UI elements into the header. + +### Integration Types + +The CTA engine supports four types of integrations: + +#### 1. Provider Integration + +Wraps the application or router with a context provider: + +```json +{ + "type": "provider", + "jsName": "ClerkProvider", + "path": "src/integrations/clerk/provider.tsx" +} +``` + +Providers are rendered in the root layout, wrapping the router outlet: + +```tsx +<% for(const integration of integrations.filter(i => i.type === 'provider')) { %> + <<%= integration.jsName %>> +<% } %> + +<% for(const integration of integrations.filter(i => i.type === 'provider').reverse()) { %> + > +<% } %> +``` + +#### 2. Root Provider Integration + +Special providers that wrap the entire application and provide context to the router: + +```json +{ + "type": "root-provider", + "path": "src/integrations/tanstack-query/root-provider.tsx", + "jsName": "TanStackQueryProvider" +} +``` + +Root providers must export: +- `Provider`: React component that wraps the app +- `getContext()`: Function that returns context for the router + +```tsx +// Import root providers +<% for(const integration of integrations.filter(i => i.type === 'root-provider')) { %> +import * as <%= integration.jsName %> from "<%= relativePath(integration.path) %>"; +<% } %> + +// Add context to router +const router = createRouter({ + routeTree, + context: { + <% for(const integration of integrations.filter(i => i.type === 'root-provider')) { %> + ...<%= integration.jsName %>.getContext(), + <% } %> + }, +}) + +// Wrap the application +<% for(const integration of integrations.filter(i => i.type === 'root-provider')) { %> + <<%= integration.jsName %>.Provider> +<% } %> + +<% for(const integration of integrations.filter(i => i.type === 'root-provider').reverse()) { %> + .Provider> +<% } %> +``` + +#### 3. Layout Integration + +Components that are rendered alongside the router outlet (e.g., devtools): + +```json +{ + "type": "layout", + "path": "src/integrations/tanstack-query/layout.tsx", + "jsName": "TanStackQueryLayout" +} +``` + +Layout components are rendered after the outlet: + +```tsx + +<% for(const integration of integrations.filter(i => i.type === 'layout')) { %> + <<%= integration.jsName %> /> +<% } %> +``` + +#### 4. Header User Integration + +Components that are injected into the application header (e.g., user menus): + +```json +{ + "type": "header-user", + "jsName": "ClerkHeader", + "path": "src/integrations/clerk/header-user.tsx" +} +``` + +Header user components are rendered in the header component: + +```tsx +<% if (integrations.filter(i => i.type === 'header-user').length > 0) { %> +
+ <% for(const integration of integrations.filter(i => i.type === 'header-user')) { %> + <<%= integration.jsName %> /> + <% } %> +
+<% } %> +``` + +## Implementation Details + +### Type Definitions + +The TypeScript types for routes and integrations are defined in `types.ts`: + +```typescript +// Integration type definition +export const IntegrationSchema = z.object({ + type: z.string(), + path: z.string(), + jsName: z.string(), +}) + +// Add-on schema includes both routes and integrations +export const AddOnInfoSchema = AddOnBaseSchema.extend({ + routes: z.array( + z.object({ + url: z.string().optional(), + name: z.string().optional(), + path: z.string(), + jsName: z.string(), + }), + ).optional(), + integrations: z.array(IntegrationSchema).optional(), + // ... other properties +}) +``` + +### Template Processing + +The template engine (`template-file.ts`) makes routes and integrations available to all EJS templates: + +```typescript +// Collect integrations from all add-ons +const integrations: Array['integrations'][number]> = [] +for (const addOn of options.chosenAddOns) { + if (addOn.integrations) { + for (const integration of addOn.integrations) { + integrations.push(integration) + } + } +} + +// Collect routes from all add-ons +const routes: Array['routes'][number]> = [] +for (const addOn of options.chosenAddOns) { + if (addOn.routes) { + routes.push(...addOn.routes) + } +} + +// Make available to templates +const templateValues = { + // ... other values + integrations, + routes, + // ... other values +} +``` + +### Navigation Generation + +The Header component automatically generates navigation links for all routes that have both `url` and `name` properties: + +```tsx +<% for(const addOn of addOns) { + for(const route of (addOn?.routes||[])?.filter(r => r.url && r.name)) { %> +
+ <%= route.name %> +
+<% } } %> +``` + +## Add-on Examples + +### TanStack Query Add-on + +This add-on demonstrates both routes and integrations: + +```json +{ + "name": "Query", + "description": "Integrate TanStack Query into your application.", + "routes": [ + { + "url": "/demo/tanstack-query", + "name": "TanStack Query", + "path": "src/routes/demo.tanstack-query.tsx", + "jsName": "TanStackQueryDemo" + } + ], + "integrations": [ + { + "type": "root-provider", + "path": "src/integrations/tanstack-query/root-provider.tsx", + "jsName": "TanStackQueryProvider" + }, + { + "type": "layout", + "path": "src/integrations/tanstack-query/layout.tsx", + "jsName": "TanStackQueryLayout" + } + ] +} +``` + +### Clerk Authentication Add-on + +This add-on uses provider and header integrations: + +```json +{ + "name": "Clerk", + "description": "Add Clerk authentication to your application.", + "routes": [ + { + "url": "/demo/clerk", + "name": "Clerk", + "path": "src/routes/demo.clerk.tsx", + "jsName": "ClerkDemo" + } + ], + "integrations": [ + { + "type": "header-user", + "jsName": "ClerkHeader", + "path": "src/integrations/clerk/header-user.tsx" + }, + { + "type": "provider", + "jsName": "ClerkProvider", + "path": "src/integrations/clerk/provider.tsx" + } + ] +} +``` + +## Creating Custom Add-ons + +### Best Practices for Routes + +1. **Naming Convention**: Use descriptive names prefixed with `demo.` for demo routes +2. **Path Structure**: Place route files in `assets/src/routes/` +3. **Component Naming**: Use PascalCase for `jsName` (e.g., `MyFeatureDemo`) +4. **URL Pattern**: Use `/demo/feature-name` for demo routes + +### Best Practices for Integrations + +1. **Provider Integrations**: + - Use for wrapping parts of the app with context + - Keep providers focused on a single concern + - Don't add UI elements in providers + +2. **Root Provider Integrations**: + - Use when you need to provide context to the router + - Always export both `Provider` and `getContext()` + - Initialize any required clients or stores + +3. **Layout Integrations**: + - Use for developer tools or persistent UI elements + - Keep layout components lightweight + - Position them appropriately (devtools, tooltips, etc.) + +4. **Header User Integrations**: + - Use for user-specific UI in the header + - Keep components small and focused + - Consider responsive design + +### File Structure + +Organize your add-on files consistently: + +``` +my-addon/ +├── info.json +├── package.json +├── assets/ +│ ├── src/ +│ │ ├── routes/ +│ │ │ └── demo.my-feature.tsx +│ │ └── integrations/ +│ │ └── my-feature/ +│ │ ├── provider.tsx +│ │ └── layout.tsx +│ └── _dot_env.local.append +``` + +### Testing Your Add-on + +1. Create a test project with your add-on +2. Verify routes appear in navigation +3. Check that integrations are properly injected +4. Test in both file-router and code-router modes +5. Ensure TypeScript types work correctly + +### Integration Dependencies + +If your integrations depend on other add-ons, use the `dependsOn` field: + +```json +{ + "dependsOn": ["tanstack-query", "start"] +} +``` + +This ensures required add-ons are included when your add-on is selected. \ No newline at end of file diff --git a/packages/cta-engine/src/custom-add-ons/add-on.ts b/packages/cta-engine/src/custom-add-ons/add-on.ts index 8dfb077a..86239b41 100644 --- a/packages/cta-engine/src/custom-add-ons/add-on.ts +++ b/packages/cta-engine/src/custom-add-ons/add-on.ts @@ -185,9 +185,12 @@ export async function buildAssetsDirectory( }) if (file.includes('/routes/')) { const { url, code, name, jsName } = templatize(changedFiles[file], file) - info.routes ||= [] - if (!info.routes.find((r) => r.url === url)) { - info.routes.push({ + if (!info.routes) { + info.routes = [] + } + const routes = info.routes as Array + if (!routes.find((r: any) => r.url === url)) { + routes.push({ url, name, jsName, diff --git a/packages/cta-engine/src/custom-add-ons/starter.ts b/packages/cta-engine/src/custom-add-ons/starter.ts index 4c193f68..c1e9d3e1 100644 --- a/packages/cta-engine/src/custom-add-ons/starter.ts +++ b/packages/cta-engine/src/custom-add-ons/starter.ts @@ -40,7 +40,6 @@ export async function readOrGenerateStarterInfo( shadcnComponents: [], framework: options.framework, mode: options.mode!, - routes: [], warning: '', type: 'starter', packageAdditions: { diff --git a/packages/cta-engine/src/frameworks.ts b/packages/cta-engine/src/frameworks.ts index ae162797..68614066 100644 --- a/packages/cta-engine/src/frameworks.ts +++ b/packages/cta-engine/src/frameworks.ts @@ -45,7 +45,10 @@ export function scanProjectDirectory( } } -export function scanAddOnDirectories(addOnsDirectories: Array) { +export function scanAddOnDirectories( + addOnsDirectories: Array, + framework?: { customProperties?: Record }, +) { const addOns: Array = [] for (const addOnsBase of addOnsDirectories) { @@ -93,6 +96,55 @@ export function scanAddOnDirectories(addOnsDirectories: Array) { return Promise.resolve(files[path]) } + // Validate custom properties if framework defines them + let validatedCustomProperties: Record | undefined + if (framework?.customProperties && info.customProperties) { + validatedCustomProperties = {} + for (const [key, schema] of Object.entries(framework.customProperties)) { + if (key in info.customProperties) { + try { + validatedCustomProperties[key] = schema.parse(info.customProperties[key]) + } catch (error: any) { + throw new Error( + `Invalid custom property "${key}" in add-on "${dir}": ${error.message}` + ) + } + } + } + } else if (info.customProperties) { + // If no framework validation, pass through as-is + validatedCustomProperties = info.customProperties + } + + // Also validate routes and integrations that are at root level in info.json + // but defined in framework's customProperties + let validatedRoutes = info.routes + let validatedIntegrations = info.integrations + + if (framework?.customProperties) { + // Validate routes if defined in framework customProperties + if (framework.customProperties.routes && info.routes) { + try { + validatedRoutes = framework.customProperties.routes.parse(info.routes) + } catch (error: any) { + throw new Error( + `Invalid routes in add-on "${dir}": ${error.message}` + ) + } + } + + // Validate integrations if defined in framework customProperties + if (framework.customProperties.integrations && info.integrations) { + try { + validatedIntegrations = framework.customProperties.integrations.parse(info.integrations) + } catch (error: any) { + throw new Error( + `Invalid integrations in add-on "${dir}": ${error.message}` + ) + } + } + } + addOns.push({ ...info, id: dir, @@ -100,6 +152,9 @@ export function scanAddOnDirectories(addOnsDirectories: Array) { readme, files, smallLogo, + routes: validatedRoutes, + integrations: validatedIntegrations, + customProperties: validatedCustomProperties, getFiles, getFileContents, getDeletedFiles: () => Promise.resolve(info.deletedFiles ?? []), diff --git a/packages/cta-engine/src/index.ts b/packages/cta-engine/src/index.ts index 253ac72e..1d4a3d72 100644 --- a/packages/cta-engine/src/index.ts +++ b/packages/cta-engine/src/index.ts @@ -68,7 +68,6 @@ export { StopEvent, AddOnCompiledSchema, AddOnInfoSchema, - IntegrationSchema, } from './types.js' export type { diff --git a/packages/cta-engine/src/template-file.ts b/packages/cta-engine/src/template-file.ts index 80bfd32c..b813fcda 100644 --- a/packages/cta-engine/src/template-file.ts +++ b/packages/cta-engine/src/template-file.ts @@ -9,7 +9,7 @@ import { } from './package-manager.js' import { relativePath } from './file-helpers.js' -import type { AddOn, Environment, Options } from './types.js' +import type { Environment, Options } from './types.js' function convertDotFilesAndPaths(path: string) { return path @@ -50,19 +50,52 @@ export function createTemplateFile(environment: Environment, options: Options) { } } - const integrations: Array['integrations'][number]> = [] + // Collect all custom properties from add-ons + const customProperties: Record> = {} + + // Initialize arrays for all framework custom properties + if (options.framework.customProperties) { + for (const key of Object.keys(options.framework.customProperties)) { + customProperties[key] = [] + } + } + + // Collect custom properties from each add-on for (const addOn of options.chosenAddOns) { - if (addOn.integrations) { - for (const integration of addOn.integrations) { - integrations.push(integration) + if (addOn.customProperties) { + for (const [key, values] of Object.entries(addOn.customProperties)) { + // Initialize array if it doesn't exist (for properties not defined in framework) + if (!(key in customProperties)) { + customProperties[key] = [] + } + + // Add values to the array + if (Array.isArray(values)) { + customProperties[key].push(...values) + } else { + customProperties[key].push(values) + } } } } - const routes: Array['routes'][number]> = [] - for (const addOn of options.chosenAddOns) { - if (addOn.routes) { - routes.push(...addOn.routes) + // Collect routes and integrations from add-ons (they're at root level in info.json) + // but they're defined in customProperties in the framework schema + if (options.framework.customProperties?.routes) { + customProperties.routes = [] + for (const addOn of options.chosenAddOns) { + if (addOn.routes) { + customProperties.routes.push(...addOn.routes) + } + } + } + + if (options.framework.customProperties?.integrations) { + customProperties.integrations = [] + for (const addOn of options.chosenAddOns) { + if ('integrations' in addOn && addOn.integrations) { + customProperties.integrations.push(...(addOn as any).integrations) + } } } @@ -86,8 +119,9 @@ export function createTemplateFile(environment: Environment, options: Options) { codeRouter: options.mode === 'code-router', addOnEnabled, addOns: options.chosenAddOns, - integrations, - routes, + + // Spread custom properties to make them available at top level + ...customProperties, getPackageManagerAddScript, getPackageManagerRunScript, diff --git a/packages/cta-engine/src/types.ts b/packages/cta-engine/src/types.ts index 5f4173f4..d594c5c8 100644 --- a/packages/cta-engine/src/types.ts +++ b/packages/cta-engine/src/types.ts @@ -1,4 +1,5 @@ import z from 'zod' +import type { ZodTypeAny } from 'zod' import type { PackageManager } from './package-manager.js' @@ -63,17 +64,20 @@ export const StarterCompiledSchema = StarterSchema.extend({ deletedFiles: z.array(z.string()), }) -export const IntegrationSchema = z.object({ - type: z.string(), - path: z.string(), - jsName: z.string(), -}) - export const AddOnInfoSchema = AddOnBaseSchema.extend({ modes: z.array(z.string()), - integrations: z.array(IntegrationSchema).optional(), phase: z.enum(['setup', 'add-on']), readme: z.string().optional(), + integrations: z + .array( + z.object({ + type: z.string(), + path: z.string(), + jsName: z.string(), + }), + ) + .optional(), + customProperties: z.record(z.string(), z.unknown()).optional(), }) export const AddOnCompiledSchema = AddOnInfoSchema.extend({ @@ -81,8 +85,6 @@ export const AddOnCompiledSchema = AddOnInfoSchema.extend({ deletedFiles: z.array(z.string()), }) -export type Integration = z.infer - export type AddOnBase = z.infer export type StarterInfo = z.infer @@ -109,6 +111,8 @@ export type FrameworkDefinition = { description: string version: string + customProperties?: Record + base: Record addOns: Array basePackageJSON: Record @@ -127,6 +131,7 @@ export type FrameworkDefinition = { export type Framework = Omit & FileBundleHandler & { getAddOns: () => Array + customProperties?: Record } export interface Options { diff --git a/packages/cta-engine/tests/template-file.test.ts b/packages/cta-engine/tests/template-file.test.ts index 4887867c..6a3e7ed0 100644 --- a/packages/cta-engine/tests/template-file.test.ts +++ b/packages/cta-engine/tests/template-file.test.ts @@ -102,14 +102,16 @@ describe('createTemplateFile', () => { { id: 'test', name: 'Test', - routes: [ - { - path: '/test', - name: 'Test', - url: '/test', - jsName: 'test', - }, - ], + customProperties: { + routes: [ + { + path: '/test', + name: 'Test', + url: '/test', + jsName: 'test', + }, + ], + }, } as AddOn, ], }) @@ -132,13 +134,15 @@ describe('createTemplateFile', () => { { id: 'test', name: 'Test', - integrations: [ - { - type: 'header-user', - path: '/test', - jsName: 'test', - } as Integration, - ], + customProperties: { + integrations: [ + { + type: 'header-user', + path: '/test', + jsName: 'test', + } as Integration, + ], + }, } as AddOn, ], })