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
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.
379 lines
11 KiB
Go
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)
|
|
}
|