Implement internationalization system for landing pages: - Database model for storing language-specific translations - Language configuration with default and enabled languages - Language switcher in navigation across all templates - Translation management UI in settings - Support for 15 languages including English, Spanish, German, French, Japanese, Chinese - Auto-detection and manual language selection - AI-powered translation generation capability
285 lines
9.3 KiB
Go
285 lines
9.3 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),
|
|
"instruction": `You are a landing page copywriter. Analyze this open-source repository and generate compelling landing page content.
|
|
|
|
Return valid JSON with this exact structure:
|
|
{
|
|
"brand": {"name": "Project Name", "tagline": "Short tagline"},
|
|
"hero": {
|
|
"headline": "Compelling headline (max 10 words)",
|
|
"subheadline": "Supporting text explaining the value (1-2 sentences)",
|
|
"primary_cta": {"label": "Get Started", "url": "` + repoURL + `"},
|
|
"secondary_cta": {"label": "View on GitHub", "url": "` + repoURL + `"}
|
|
},
|
|
"stats": [
|
|
{"value": "...", "label": "..."}
|
|
],
|
|
"value_props": [
|
|
{"title": "...", "description": "...", "icon": "one of: zap,shield,rocket,check,star,heart,lock,globe,clock,gear,code,terminal,package,database,cloud,cpu,graph,people,tools,light-bulb"}
|
|
],
|
|
"features": [
|
|
{"title": "...", "description": "...", "icon": "same icon options as value_props"}
|
|
],
|
|
"cta_section": {
|
|
"headline": "Ready to get started?",
|
|
"subheadline": "...",
|
|
"button_label": "Get Started Free",
|
|
"button_url": "` + repoURL + `"
|
|
},
|
|
"seo": {
|
|
"title": "SEO title (50-60 chars)",
|
|
"description": "Meta description (150-160 chars)"
|
|
}
|
|
}
|
|
|
|
Guidelines:
|
|
- Generate 3-4 stats based on actual repo data (stars, forks, etc.) or compelling project metrics
|
|
- Generate exactly 3 value propositions that highlight the project's key strengths
|
|
- Generate 3-6 features based on what the README describes
|
|
- Use action-oriented, benefit-focused copy
|
|
- Keep it professional but engaging
|
|
- Icon choices should match the content semantically`,
|
|
},
|
|
})
|
|
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(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: generated.Hero.PrimaryCTA.URL,
|
|
}
|
|
config.Hero.SecondaryCTA = pages_module.CTAButton{
|
|
Label: generated.Hero.SecondaryCTA.Label,
|
|
URL: generated.Hero.SecondaryCTA.URL,
|
|
}
|
|
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: generated.CTASection.ButtonURL,
|
|
},
|
|
}
|
|
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 ""
|
|
}
|
|
|
|
// 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,
|
|
"instruction": `You are a professional translator. Translate the following landing page content to ` + langName + ` (` + targetLang + `).
|
|
|
|
Return valid JSON as a partial config overlay. Only include the fields that have translatable text content.
|
|
Do NOT translate URLs, brand names, or technical terms. Keep the same JSON structure.
|
|
|
|
Return this exact JSON structure (only include fields with actual translated text):
|
|
{
|
|
"hero": {
|
|
"headline": "translated headline",
|
|
"subheadline": "translated subheadline",
|
|
"primary_cta": {"label": "translated label"},
|
|
"secondary_cta": {"label": "translated label"}
|
|
},
|
|
"stats": [{"value": "keep original", "label": "translated label"}],
|
|
"value_props": [{"title": "translated", "description": "translated"}],
|
|
"features": [{"title": "translated", "description": "translated"}],
|
|
"cta_section": {
|
|
"headline": "translated",
|
|
"subheadline": "translated",
|
|
"button": {"label": "translated"}
|
|
},
|
|
"seo": {
|
|
"title": "translated",
|
|
"description": "translated"
|
|
}
|
|
}
|
|
|
|
Important:
|
|
- Maintain the exact same number of items in arrays (stats, value_props, features)
|
|
- Keep icon names, URLs, and image_urls unchanged
|
|
- Use natural, marketing-quality translations (not literal/robotic)
|
|
- Adapt idioms and expressions for the target culture`,
|
|
},
|
|
})
|
|
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
|
|
var check map[string]any
|
|
if err := json.Unmarshal([]byte(resp.Result), &check); err != nil {
|
|
return "", fmt.Errorf("AI returned invalid JSON: %w", err)
|
|
}
|
|
|
|
return resp.Result, nil
|
|
}
|
|
|
|
// buildTranslatableContent extracts translatable text from a config for the AI
|
|
func buildTranslatableContent(config *pages_module.LandingConfig) string {
|
|
data, _ := json.Marshal(map[string]any{
|
|
"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": config.Stats,
|
|
"value_props": config.ValueProps,
|
|
"features": config.Features,
|
|
"cta_section": map[string]any{
|
|
"headline": config.CTASection.Headline,
|
|
"subheadline": config.CTASection.Subheadline,
|
|
"button": map[string]string{"label": config.CTASection.Button.Label},
|
|
},
|
|
"seo": map[string]any{
|
|
"title": config.SEO.Title,
|
|
"description": config.SEO.Description,
|
|
},
|
|
})
|
|
return string(data)
|
|
}
|