package templates import ( "context" "fmt" "io/fs" "os" "path/filepath" "strings" "github.com/orchard9/rdev/internal/domain" "github.com/orchard9/rdev/internal/port" ) // availableComponentTemplates lists all supported component templates. var availableComponentTemplates = []port.ComponentTemplateInfo{ { Type: "service", Description: "Go API service using pkg/ shared packages", Stack: "go", DefaultPort: 8080, DestDir: "services", }, { Type: "worker", Description: "Go background worker for async job processing", Stack: "go", DefaultPort: 0, // Workers don't expose ports DestDir: "workers", }, { Type: "app-astro", Description: "Astro landing page with Tailwind CSS", Stack: "astro", DefaultPort: 4321, DestDir: "apps", }, { Type: "app-react", Description: "React SPA with Vite, TypeScript, and Tailwind", Stack: "react", DefaultPort: 5173, DestDir: "apps", }, { Type: "app-nextjs", Description: "Next.js 14 dashboard with App Router and design system", Stack: "nextjs", DefaultPort: 3000, DestDir: "apps", }, { Type: "cli", Description: "Go CLI tool using Cobra", Stack: "go", DefaultPort: 0, // CLIs don't expose ports DestDir: "cli", }, } // GetComponentTemplate returns info about a specific component template. func (p *Provider) GetComponentTemplate(ctx context.Context, componentType string) (*port.ComponentTemplateInfo, error) { // Check for context cancellation select { case <-ctx.Done(): return nil, ctx.Err() default: } for _, t := range availableComponentTemplates { if t.Type == componentType { result := t files, err := listComponentTemplateFiles(componentType) if err == nil { result.Files = files } return &result, nil } } return nil, fmt.Errorf("%w: component type %s", domain.ErrTemplateNotFound, componentType) } // ListComponentTemplates returns available component templates. // If componentType is empty, returns all templates; otherwise filters by type. func (p *Provider) ListComponentTemplates(ctx context.Context, componentType string) ([]port.ComponentTemplateInfo, error) { // Check for context cancellation select { case <-ctx.Done(): return nil, ctx.Err() default: } var result []port.ComponentTemplateInfo for _, t := range availableComponentTemplates { if componentType != "" && t.Type != componentType { continue } info := t files, err := listComponentTemplateFiles(t.Type) if err == nil { info.Files = files } result = append(result, info) } return result, nil } // GetComponentFiles returns the files for a component template with variables interpolated. func (p *Provider) GetComponentFiles(ctx context.Context, componentType string, destPath string, vars map[string]string) ([]port.ComponentFile, error) { // Check for context cancellation select { case <-ctx.Done(): return nil, ctx.Err() default: } // Validate component type exists found := false for _, t := range availableComponentTemplates { if t.Type == componentType { found = true break } } if !found { return nil, fmt.Errorf("%w: component type %s", domain.ErrTemplateNotFound, componentType) } templateDir := "templates/components/" + componentType var files []port.ComponentFile err := fs.WalkDir(templatesFS, templateDir, func(path string, d fs.DirEntry, err error) error { if err != nil { return err } if d.IsDir() { return nil } // Read file content content, err := templatesFS.ReadFile(path) if err != nil { return fmt.Errorf("failed to read component template file %s: %w", path, err) } // Interpolate variables interpolated := interpolateVars(string(content), vars) // Calculate relative path from component template root relPath, err := filepath.Rel(templateDir, path) if err != nil { return fmt.Errorf("failed to get relative path: %w", err) } // Strip .tmpl extension relPath = strings.TrimSuffix(relPath, ".tmpl") // Prepend destination path fullPath := filepath.Join(destPath, relPath) files = append(files, port.ComponentFile{ Path: fullPath, Content: interpolated, }) return nil }) if err != nil { return nil, fmt.Errorf("failed to collect component template files: %w", err) } if len(files) == 0 { return nil, fmt.Errorf("component template %s contains no files", componentType) } p.logger.Debug("prepared component files", "component_type", componentType, "dest_path", destPath, "file_count", len(files), ) return files, nil } // listComponentTemplateFiles returns the list of files in a component template. func listComponentTemplateFiles(componentType string) ([]string, error) { templateDir := "templates/components/" + componentType var files []string err := fs.WalkDir(templatesFS, templateDir, func(path string, d fs.DirEntry, err error) error { if err != nil { return err } if d.IsDir() { return nil } relPath, err := filepath.Rel(templateDir, path) if err != nil { return err } // Strip .tmpl extension for display relPath = strings.TrimSuffix(relPath, ".tmpl") files = append(files, relPath) return nil }) return files, err } // GetComponentWoodpeckerStep returns the .woodpecker.step.yml content for a component. // This is the CI step that should be inserted into the main .woodpecker.yml file. func (p *Provider) GetComponentWoodpeckerStep(ctx context.Context, componentType string, vars map[string]string) (string, error) { // Check for context cancellation select { case <-ctx.Done(): return "", ctx.Err() default: } stepPath := "templates/components/" + componentType + "/.woodpecker.step.yml.tmpl" content, err := templatesFS.ReadFile(stepPath) if err != nil { return "", fmt.Errorf("failed to read woodpecker step template: %w", err) } return interpolateVars(string(content), vars), nil } // RenderSkeletonToDir renders the monorepo skeleton template to a local directory. // This is used for testing templates locally without needing Gitea. func RenderSkeletonToDir(outputDir string, vars map[string]string) error { templateDir := "templates/skeleton" return fs.WalkDir(templatesFS, templateDir, func(path string, d fs.DirEntry, err error) error { if err != nil { return err } if d.IsDir() { return nil } // Read file content content, err := templatesFS.ReadFile(path) if err != nil { return fmt.Errorf("failed to read template file %s: %w", path, err) } // Interpolate variables interpolated := interpolateVars(string(content), vars) // Calculate relative path from template root relPath, err := filepath.Rel(templateDir, path) if err != nil { return fmt.Errorf("failed to get relative path: %w", err) } // Strip .tmpl extension relPath = strings.TrimSuffix(relPath, ".tmpl") // Create output path outPath := filepath.Join(outputDir, relPath) // Create parent directories if err := os.MkdirAll(filepath.Dir(outPath), 0755); err != nil { return fmt.Errorf("failed to create directory for %s: %w", outPath, err) } // Write file if err := os.WriteFile(outPath, []byte(interpolated), 0644); err != nil { return fmt.Errorf("failed to write file %s: %w", outPath, err) } return nil }) } // RenderComponentToDir renders a component template to a local directory. // The destPath is relative to outputDir (e.g., "services/my-api"). func RenderComponentToDir(outputDir, componentType, destPath string, vars map[string]string) error { // Validate component type exists found := false for _, t := range availableComponentTemplates { if t.Type == componentType { found = true break } } if !found { return fmt.Errorf("unknown component type: %s", componentType) } templateDir := "templates/components/" + componentType return fs.WalkDir(templatesFS, templateDir, func(path string, d fs.DirEntry, err error) error { if err != nil { return err } if d.IsDir() { return nil } // Skip .woodpecker.step.yml.tmpl - it's for CI insertion, not file creation if strings.HasSuffix(path, ".woodpecker.step.yml.tmpl") { return nil } // Read file content content, err := templatesFS.ReadFile(path) if err != nil { return fmt.Errorf("failed to read component template file %s: %w", path, err) } // Interpolate variables interpolated := interpolateVars(string(content), vars) // Calculate relative path from component template root relPath, err := filepath.Rel(templateDir, path) if err != nil { return fmt.Errorf("failed to get relative path: %w", err) } // Strip .tmpl extension relPath = strings.TrimSuffix(relPath, ".tmpl") // Create output path under destPath outPath := filepath.Join(outputDir, destPath, relPath) // Create parent directories if err := os.MkdirAll(filepath.Dir(outPath), 0755); err != nil { return fmt.Errorf("failed to create directory for %s: %w", outPath, err) } // Write file if err := os.WriteFile(outPath, []byte(interpolated), 0644); err != nil { return fmt.Errorf("failed to write file %s: %w", outPath, err) } return nil }) }