2
0
Files
gitcaddy-server/services/pages/generate.go
logikonline 965ef8966f
All checks were successful
Build and Release / Create Release (push) Successful in 0s
Build and Release / Integration Tests (PostgreSQL) (push) Successful in 4m4s
Build and Release / Unit Tests (push) Successful in 4m38s
Build and Release / Lint (push) Successful in 6m26s
Build and Release / Build Binaries (amd64, darwin, macos) (push) Successful in 3m52s
Build and Release / Build Binaries (amd64, linux, linux-latest) (push) Successful in 4m52s
Build and Release / Build Binaries (amd64, windows, windows-latest) (push) Successful in 9h5m22s
Build and Release / Build Binaries (arm64, darwin, macos) (push) Successful in 3m38s
Build and Release / Build Binary (linux/arm64) (push) Successful in 7m46s
feat(pages): add navigation label translation support
Add translation support for navigation section labels (Value Props, Features, Pricing, Blog, Gallery, Compare, etc.). Adds TemplateDefaultLabels function that returns template-specific creative names (e.g., "Systems Analysis" for value props in Architecture Deep Dive). Auto-applies defaults when enabling pages or changing templates. Includes UI fields in languages settings and translation JSON serialization. Enables full localization of section headings.
2026-03-17 23:34:29 -04:00

379 lines
11 KiB
Go

// Copyright 2026 MarketAlly. All rights reserved.
// SPDX-License-Identifier: MIT
package pages
import (
"context"
"errors"
"fmt"
"strconv"
"strings"
repo_model "code.gitcaddy.com/server/v3/models/repo"
"code.gitcaddy.com/server/v3/modules/ai"
"code.gitcaddy.com/server/v3/modules/json"
pages_module "code.gitcaddy.com/server/v3/modules/pages"
)
// aiGeneratedConfig holds the AI-generated landing page content
type aiGeneratedConfig struct {
Brand struct {
Name string `json:"name"`
Tagline string `json:"tagline"`
} `json:"brand"`
Hero struct {
Headline string `json:"headline"`
Subheadline string `json:"subheadline"`
PrimaryCTA struct {
Label string `json:"label"`
URL string `json:"url"`
} `json:"primary_cta"`
SecondaryCTA struct {
Label string `json:"label"`
URL string `json:"url"`
} `json:"secondary_cta"`
} `json:"hero"`
Stats []pages_module.StatConfig `json:"stats"`
ValueProps []pages_module.ValuePropConfig `json:"value_props"`
Features []pages_module.FeatureConfig `json:"features"`
CTASection struct {
Headline string `json:"headline"`
Subheadline string `json:"subheadline"`
ButtonLabel string `json:"button_label"`
ButtonURL string `json:"button_url"`
} `json:"cta_section"`
SEO struct {
Title string `json:"title"`
Description string `json:"description"`
} `json:"seo"`
}
// GenerateLandingPageContent uses AI to auto-generate landing page content from repo metadata.
func GenerateLandingPageContent(ctx context.Context, repo *repo_model.Repository, readme string) (*pages_module.LandingConfig, error) {
if !ai.IsEnabled() {
return nil, errors.New("AI service is not enabled")
}
repoURL := repo.HTMLURL()
topics := strings.Join(repo.Topics, ", ")
client := ai.GetClient()
resp, err := client.ExecuteTask(ctx, &ai.ExecuteTaskRequest{
RepoID: repo.ID,
Task: "landing_page_generate",
Context: map[string]string{
"repo_name": repo.Name,
"repo_description": repo.Description,
"repo_url": repoURL,
"topics": topics,
"primary_language": getPrimaryLanguageName(repo),
"stars": strconv.Itoa(repo.NumStars),
"forks": strconv.Itoa(repo.NumForks),
"readme": truncateReadme(readme),
},
})
if err != nil {
return nil, fmt.Errorf("AI generation failed: %w", err)
}
if !resp.Success {
return nil, fmt.Errorf("AI generation error: %s", resp.Error)
}
var generated aiGeneratedConfig
if err := json.Unmarshal([]byte(extractJSON(resp.Result)), &generated); err != nil {
return nil, fmt.Errorf("failed to parse AI response: %w", err)
}
// Build LandingConfig from AI response
config := pages_module.DefaultConfig()
config.Brand.Name = generated.Brand.Name
config.Brand.Tagline = generated.Brand.Tagline
config.Hero.Headline = generated.Hero.Headline
config.Hero.Subheadline = generated.Hero.Subheadline
config.Hero.PrimaryCTA = pages_module.CTAButton{
Label: generated.Hero.PrimaryCTA.Label,
URL: repoURL,
}
config.Hero.SecondaryCTA = pages_module.CTAButton{
Label: generated.Hero.SecondaryCTA.Label,
URL: repoURL,
}
if len(generated.Stats) > 0 {
config.Stats = generated.Stats
}
if len(generated.ValueProps) > 0 {
config.ValueProps = generated.ValueProps
}
if len(generated.Features) > 0 {
config.Features = generated.Features
}
config.CTASection = pages_module.CTASectionConfig{
Headline: generated.CTASection.Headline,
Subheadline: generated.CTASection.Subheadline,
Button: pages_module.CTAButton{
Label: generated.CTASection.ButtonLabel,
URL: repoURL,
},
}
if generated.SEO.Title != "" {
config.SEO.Title = generated.SEO.Title
}
if generated.SEO.Description != "" {
config.SEO.Description = generated.SEO.Description
}
return config, nil
}
// getPrimaryLanguageName returns the primary language name for the repo, or empty string
func getPrimaryLanguageName(repo *repo_model.Repository) string {
if repo.PrimaryLanguage != nil {
return repo.PrimaryLanguage.Language
}
return ""
}
// extractJSON extracts JSON from an AI response that may be wrapped in markdown code fences.
func extractJSON(s string) string {
s = strings.TrimSpace(s)
// Strip ```json ... ``` or ``` ... ``` wrapper
if strings.HasPrefix(s, "```") {
// Remove opening fence (```json or ```)
if idx := strings.Index(s, "\n"); idx != -1 {
s = s[idx+1:]
}
// Remove closing fence
if idx := strings.LastIndex(s, "```"); idx != -1 {
s = s[:idx]
}
s = strings.TrimSpace(s)
}
// If still not starting with {, try to find first { and last }
if !strings.HasPrefix(s, "{") {
start := strings.Index(s, "{")
end := strings.LastIndex(s, "}")
if start != -1 && end != -1 && end > start {
s = s[start : end+1]
}
}
return s
}
// truncateReadme limits README content to avoid sending too much to the AI
func truncateReadme(readme string) string {
const maxLen = 4000
if len(readme) <= maxLen {
return readme
}
return readme[:maxLen] + "\n... (truncated)"
}
// TranslateLandingPageContent uses AI to translate landing page content to a target language.
// Returns a JSON overlay string that can be deep-merged onto the base config.
func TranslateLandingPageContent(ctx context.Context, repo *repo_model.Repository, config *pages_module.LandingConfig, targetLang string) (string, error) {
if !ai.IsEnabled() {
return "", errors.New("AI service is not enabled")
}
langNames := pages_module.LanguageDisplayNames()
langName := langNames[targetLang]
if langName == "" {
langName = targetLang
}
// Build the source content to translate
sourceContent := buildTranslatableContent(config)
client := ai.GetClient()
resp, err := client.ExecuteTask(ctx, &ai.ExecuteTaskRequest{
RepoID: repo.ID,
Task: "landing_page_translate",
Context: map[string]string{
"target_language": langName,
"target_code": targetLang,
"source_content": sourceContent,
},
})
if err != nil {
return "", fmt.Errorf("AI translation failed: %w", err)
}
if !resp.Success {
return "", fmt.Errorf("AI translation error: %s", resp.Error)
}
// Validate it's valid JSON
result := extractJSON(resp.Result)
var check map[string]any
if err := json.Unmarshal([]byte(result), &check); err != nil {
return "", fmt.Errorf("AI returned invalid JSON: %w", err)
}
return result, nil
}
// buildTranslatableContent extracts translatable text from a config for the AI
func buildTranslatableContent(config *pages_module.LandingConfig) string {
content := map[string]any{}
// Brand
if config.Brand.Name != "" || config.Brand.Tagline != "" {
content["brand"] = map[string]any{
"name": config.Brand.Name,
"tagline": config.Brand.Tagline,
}
}
// Hero
content["hero"] = map[string]any{
"headline": config.Hero.Headline,
"subheadline": config.Hero.Subheadline,
"primary_cta": map[string]string{"label": config.Hero.PrimaryCTA.Label},
"secondary_cta": map[string]string{"label": config.Hero.SecondaryCTA.Label},
}
// Stats, Value Props, Features (already struct-serializable)
if len(config.Stats) > 0 {
content["stats"] = config.Stats
}
if len(config.ValueProps) > 0 {
content["value_props"] = config.ValueProps
}
if len(config.Features) > 0 {
content["features"] = config.Features
}
// Testimonials
if len(config.SocialProof.Testimonials) > 0 {
testimonials := make([]map[string]string, 0, len(config.SocialProof.Testimonials))
for _, t := range config.SocialProof.Testimonials {
testimonials = append(testimonials, map[string]string{
"quote": t.Quote,
"role": t.Role,
})
}
content["social_proof"] = map[string]any{"testimonials": testimonials}
}
// Pricing
if config.Pricing.Headline != "" || len(config.Pricing.Plans) > 0 {
pricing := map[string]any{
"headline": config.Pricing.Headline,
"subheadline": config.Pricing.Subheadline,
}
if len(config.Pricing.Plans) > 0 {
plans := make([]map[string]string, 0, len(config.Pricing.Plans))
for _, p := range config.Pricing.Plans {
plans = append(plans, map[string]string{
"name": p.Name,
"period": p.Period,
"cta": p.CTA,
})
}
pricing["plans"] = plans
}
content["pricing"] = pricing
}
// CTA Section
content["cta_section"] = map[string]any{
"headline": config.CTASection.Headline,
"subheadline": config.CTASection.Subheadline,
"button": map[string]string{"label": config.CTASection.Button.Label},
}
// Blog
if config.Blog.Enabled {
blogHeadline := config.Blog.Headline
if blogHeadline == "" {
blogHeadline = "Latest Posts"
}
blog := map[string]any{
"headline": blogHeadline,
"subheadline": config.Blog.Subheadline,
}
if config.Blog.CTAButton.Label != "" {
blog["cta_button"] = map[string]string{"label": config.Blog.CTAButton.Label}
}
content["blog"] = blog
}
// Gallery
if config.Gallery.Enabled {
galleryHeadline := config.Gallery.Headline
if galleryHeadline == "" {
galleryHeadline = "Gallery"
}
content["gallery"] = map[string]any{
"headline": galleryHeadline,
"subheadline": config.Gallery.Subheadline,
}
}
// Comparison
if config.Comparison.Enabled {
compHeadline := config.Comparison.Headline
if compHeadline == "" {
compHeadline = "How We Compare"
}
content["comparison"] = map[string]any{
"headline": compHeadline,
"subheadline": config.Comparison.Subheadline,
}
}
// Footer
if config.Footer.Copyright != "" || len(config.Footer.Links) > 0 {
footer := map[string]any{
"copyright": config.Footer.Copyright,
}
if len(config.Footer.Links) > 0 {
links := make([]map[string]string, 0, len(config.Footer.Links))
for _, l := range config.Footer.Links {
links = append(links, map[string]string{"label": l.Label})
}
footer["links"] = links
}
content["footer"] = footer
}
// SEO
if config.SEO.Title != "" || config.SEO.Description != "" {
content["seo"] = map[string]any{
"title": config.SEO.Title,
"description": config.SEO.Description,
}
}
// Navigation labels (for translating nav items and section headers)
// Use template-specific defaults so AI translates the correct terms
// (e.g. "Systems Analysis" for architecture-deep-dive, not generic "Value Props")
defaults := pages_module.TemplateDefaultLabels(config.Template)
labelOrDefault := func(label, def string) string {
if label != "" {
return label
}
return def
}
content["navigation"] = map[string]any{
"label_value_props": labelOrDefault(config.Navigation.LabelValueProps, defaults.LabelValueProps),
"label_features": labelOrDefault(config.Navigation.LabelFeatures, defaults.LabelFeatures),
"label_pricing": labelOrDefault(config.Navigation.LabelPricing, defaults.LabelPricing),
"label_blog": labelOrDefault(config.Navigation.LabelBlog, defaults.LabelBlog),
"label_gallery": labelOrDefault(config.Navigation.LabelGallery, defaults.LabelGallery),
"label_compare": labelOrDefault(config.Navigation.LabelCompare, defaults.LabelCompare),
"label_docs": labelOrDefault(config.Navigation.LabelDocs, "Docs"),
"label_releases": labelOrDefault(config.Navigation.LabelReleases, "Releases"),
"label_api": labelOrDefault(config.Navigation.LabelAPI, "API"),
"label_issues": labelOrDefault(config.Navigation.LabelIssues, "Issues"),
}
data, _ := json.Marshal(content)
return string(data)
}