2
0

feat(pages): add multi-language support for landing pages

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
This commit is contained in:
2026-03-07 13:09:46 -05:00
parent a2edcdabe7
commit 5788123e00
17 changed files with 844 additions and 15 deletions

View File

@@ -441,6 +441,7 @@ func prepareMigrationTasks() []*migration {
newMigration(364, "Add view_count to blog_post", v1_26.AddViewCountToBlogPost),
newMigration(365, "Add public_app_integration to repository", v1_26.AddPublicAppIntegrationToRepository),
newMigration(366, "Add page experiment tables for A/B testing", v1_26.AddPageExperimentTables),
newMigration(367, "Add pages translation table for multi-language support", v1_26.AddPagesTranslationTable),
}
return preparedMigrations
}

View File

@@ -0,0 +1,24 @@
// Copyright 2026 MarketAlly. All rights reserved.
// SPDX-License-Identifier: MIT
package v1_26
import (
"code.gitcaddy.com/server/v3/modules/timeutil"
"xorm.io/xorm"
)
func AddPagesTranslationTable(x *xorm.Engine) error {
type Translation struct {
ID int64 `xorm:"pk autoincr"`
RepoID int64 `xorm:"INDEX NOT NULL"`
Lang string `xorm:"VARCHAR(10) NOT NULL"`
ConfigJSON string `xorm:"TEXT"`
AutoGenerated bool `xorm:"NOT NULL DEFAULT false"`
CreatedUnix timeutil.TimeStamp `xorm:"created"`
UpdatedUnix timeutil.TimeStamp `xorm:"updated"`
}
return x.Sync(new(Translation))
}

View File

@@ -0,0 +1,70 @@
// Copyright 2026 MarketAlly. All rights reserved.
// SPDX-License-Identifier: MIT
package pages
import (
"context"
"code.gitcaddy.com/server/v3/models/db"
"code.gitcaddy.com/server/v3/modules/timeutil"
)
func init() {
db.RegisterModel(new(Translation))
}
// Translation stores a language-specific translation overlay for a landing page.
type Translation struct {
ID int64 `xorm:"pk autoincr"`
RepoID int64 `xorm:"INDEX NOT NULL"`
Lang string `xorm:"VARCHAR(10) NOT NULL"`
ConfigJSON string `xorm:"TEXT"`
AutoGenerated bool `xorm:"NOT NULL DEFAULT false"`
CreatedUnix timeutil.TimeStamp `xorm:"created"`
UpdatedUnix timeutil.TimeStamp `xorm:"updated"`
}
// TableName returns the table name for Translation.
func (t *Translation) TableName() string {
return "pages_translation"
}
// GetTranslationsByRepoID returns all translations for a repository.
func GetTranslationsByRepoID(ctx context.Context, repoID int64) ([]*Translation, error) {
translations := make([]*Translation, 0, 10)
return translations, db.GetEngine(ctx).Where("repo_id = ?", repoID).
Asc("lang").Find(&translations)
}
// GetTranslation returns a specific language translation for a repository.
func GetTranslation(ctx context.Context, repoID int64, lang string) (*Translation, error) {
t := new(Translation)
has, err := db.GetEngine(ctx).Where("repo_id = ? AND lang = ?", repoID, lang).Get(t)
if err != nil {
return nil, err
}
if !has {
return nil, nil
}
return t, nil
}
// CreateTranslation creates a new translation.
func CreateTranslation(ctx context.Context, t *Translation) error {
_, err := db.GetEngine(ctx).Insert(t)
return err
}
// UpdateTranslation updates an existing translation.
func UpdateTranslation(ctx context.Context, t *Translation) error {
_, err := db.GetEngine(ctx).ID(t.ID).Cols("config_json", "auto_generated").Update(t)
return err
}
// DeleteTranslation deletes a translation by repo ID and language.
func DeleteTranslation(ctx context.Context, repoID int64, lang string) error {
_, err := db.GetEngine(ctx).Where("repo_id = ? AND lang = ?", repoID, lang).
Delete(new(Translation))
return err
}

View File

@@ -67,6 +67,9 @@ type LandingConfig struct {
// A/B testing experiments
Experiments ExperimentConfig `yaml:"experiments,omitempty"`
// Multi-language support
I18n I18nConfig `yaml:"i18n,omitempty"`
}
// BrandConfig represents brand/identity settings
@@ -233,6 +236,33 @@ type ExperimentConfig struct {
ApprovalRequired bool `yaml:"approval_required,omitempty"`
}
// I18nConfig represents multi-language settings for the landing page
type I18nConfig struct {
DefaultLang string `yaml:"default_lang,omitempty" json:"default_lang,omitempty"`
Languages []string `yaml:"languages,omitempty" json:"languages,omitempty"`
}
// LanguageDisplayNames returns a map of language codes to display names
func LanguageDisplayNames() map[string]string {
return map[string]string{
"en": "English",
"es": "Espanol",
"de": "Deutsch",
"fr": "Francais",
"ja": "Japanese",
"zh": "Chinese",
"pt": "Portugues",
"ru": "Russian",
"ko": "Korean",
"it": "Italiano",
"hi": "Hindi",
"ar": "Arabic",
"nl": "Nederlands",
"pl": "Polski",
"tr": "Turkish",
}
}
// AdvancedConfig represents advanced settings
type AdvancedConfig struct {
CustomCSS string `yaml:"custom_css,omitempty"`

View File

@@ -4512,6 +4512,28 @@
"repo.settings.pages.ai_generate_button": "Generate Content with AI",
"repo.settings.pages.ai_generate_success": "Landing page content has been generated successfully. Review and customize it in the other tabs.",
"repo.settings.pages.ai_generate_failed": "Failed to generate content with AI. Please try again later or configure the content manually.",
"repo.settings.pages.languages": "Languages",
"repo.settings.pages.default_lang": "Default Language",
"repo.settings.pages.default_lang_help": "The primary language of your landing page content",
"repo.settings.pages.enabled_languages": "Enabled Languages",
"repo.settings.pages.enabled_languages_help": "Select which languages your landing page should support. Visitors will see a language switcher in the navigation.",
"repo.settings.pages.save_languages": "Save Language Settings",
"repo.settings.pages.languages_saved": "Language settings saved successfully.",
"repo.settings.pages.translations": "Translations",
"repo.settings.pages.ai_translate": "AI Translate",
"repo.settings.pages.ai_translate_success": "Translation has been generated successfully by AI. Review and edit as needed.",
"repo.settings.pages.delete_translation": "Delete",
"repo.settings.pages.save_translation": "Save Translation",
"repo.settings.pages.translation_saved": "Translation saved successfully.",
"repo.settings.pages.translation_deleted": "Translation deleted.",
"repo.settings.pages.translation_empty": "No translation content provided.",
"repo.settings.pages.trans_headline": "Headline",
"repo.settings.pages.trans_subheadline": "Subheadline",
"repo.settings.pages.trans_primary_cta": "Primary CTA Label",
"repo.settings.pages.trans_secondary_cta": "Secondary CTA Label",
"repo.settings.pages.trans_cta_headline": "CTA Section Headline",
"repo.settings.pages.trans_cta_subheadline": "CTA Section Subheadline",
"repo.settings.pages.trans_cta_button": "CTA Button Label",
"repo.vault": "Vault",
"repo.vault.secrets": "Secrets",
"repo.vault.new_secret": "New Secret",

View File

@@ -13,6 +13,7 @@ import (
"math/big"
"net/http"
"path"
"slices"
"strconv"
"strings"
"time"
@@ -74,18 +75,21 @@ func ServeLandingPage(ctx *context.Context) {
idStr = strings.TrimRight(idStr, "/")
blogID, err := strconv.ParseInt(idStr, 10, 64)
if err == nil && blogID > 0 {
config = applyLanguageOverlay(ctx, repo, config)
serveBlogDetail(ctx, repo, config, blogID, "/blog")
return
}
} else if requestPath == "/blog" || requestPath == "/blog/" {
config = applyLanguageOverlay(ctx, repo, config)
serveBlogList(ctx, repo, config, "/blog")
return
}
}
// Render the landing page with A/B test variant
// Render the landing page with A/B test variant and language overlay
ctx.Data["BlogBaseURL"] = "/blog"
config = assignVariant(ctx, repo, config)
config = applyLanguageOverlay(ctx, repo, config)
renderLandingPage(ctx, repo, config)
}
@@ -487,6 +491,7 @@ func ServeRepoLandingPage(ctx *context.Context) {
ctx.Data["BlogBaseURL"] = fmt.Sprintf("/%s/%s/pages/blog", repo.OwnerName, repo.Name)
config = assignVariant(ctx, repo, config)
config = applyLanguageOverlay(ctx, repo, config)
renderLandingPage(ctx, repo, config)
}
@@ -510,6 +515,7 @@ func ServeRepoBlogList(ctx *context.Context) {
}
blogBaseURL := fmt.Sprintf("/%s/%s/pages/blog", repo.OwnerName, repo.Name)
config = applyLanguageOverlay(ctx, repo, config)
serveBlogList(ctx, repo, config, blogBaseURL)
}
@@ -539,6 +545,7 @@ func ServeRepoBlogDetail(ctx *context.Context) {
}
blogBaseURL := fmt.Sprintf("/%s/%s/pages/blog", repo.OwnerName, repo.Name)
config = applyLanguageOverlay(ctx, repo, config)
serveBlogDetail(ctx, repo, config, blogID, blogBaseURL)
}
@@ -828,6 +835,99 @@ func deepMerge(dst, src map[string]any) map[string]any {
return dst
}
// detectPageLanguage determines the active language for a landing page.
// Priority: ?lang= query param > pages_lang cookie > Accept-Language header > default.
func detectPageLanguage(ctx *context.Context, config *pages_module.LandingConfig) string {
langs := config.I18n.Languages
if len(langs) == 0 {
return ""
}
defaultLang := config.I18n.DefaultLang
if defaultLang == "" {
defaultLang = "en"
}
// 1. Explicit ?lang= query parameter
if qLang := ctx.FormString("lang"); qLang != "" {
if slices.Contains(langs, qLang) {
ctx.SetSiteCookie("pages_lang", qLang, 86400*365)
return qLang
}
}
// 2. Cookie
if cLang := ctx.GetSiteCookie("pages_lang"); cLang != "" {
if slices.Contains(langs, cLang) {
return cLang
}
}
// 3. Accept-Language header
accept := ctx.Req.Header.Get("Accept-Language")
if accept != "" {
for part := range strings.SplitSeq(accept, ",") {
tag := strings.TrimSpace(strings.SplitN(part, ";", 2)[0])
// Try exact match first
if slices.Contains(langs, tag) {
return tag
}
// Try base language (e.g. "en-US" → "en")
if base, _, found := strings.Cut(tag, "-"); found {
if slices.Contains(langs, base) {
return base
}
}
}
}
return defaultLang
}
// applyLanguageOverlay loads the translation for the detected language and merges it onto config.
// Sets template data for the language switcher and returns the (possibly merged) config.
func applyLanguageOverlay(ctx *context.Context, repo *repo_model.Repository, config *pages_module.LandingConfig) *pages_module.LandingConfig {
if len(config.I18n.Languages) == 0 {
return config
}
activeLang := detectPageLanguage(ctx, config)
defaultLang := config.I18n.DefaultLang
if defaultLang == "" {
defaultLang = "en"
}
// Set template data for language switcher
ctx.Data["LangSwitcherEnabled"] = true
ctx.Data["ActiveLang"] = activeLang
ctx.Data["AvailableLanguages"] = config.I18n.Languages
ctx.Data["LanguageNames"] = pages_module.LanguageDisplayNames()
// If active language is the default, no overlay needed
if activeLang == defaultLang || activeLang == "" {
return config
}
// Load translation overlay from DB
translation, err := pages_model.GetTranslation(ctx, repo.ID, activeLang)
if err != nil {
log.Error("Failed to load translation for %s: %v", activeLang, err)
return config
}
if translation == nil || translation.ConfigJSON == "" {
return config
}
// Deep-merge translation overlay onto config
merged, err := deepMergeConfig(config, translation.ConfigJSON)
if err != nil {
log.Error("Failed to merge translation config for %s: %v", activeLang, err)
return config
}
return merged
}
// ApproveExperiment handles the email approval link for an A/B test experiment
func ApproveExperiment(ctx *context.Context) {
handleExperimentAction(ctx, true)

View File

@@ -6,8 +6,10 @@ package setting
import (
"fmt"
"net/http"
"slices"
"strings"
pages_model "code.gitcaddy.com/server/v3/models/pages"
repo_model "code.gitcaddy.com/server/v3/models/repo"
"code.gitcaddy.com/server/v3/modules/ai"
"code.gitcaddy.com/server/v3/modules/git"
@@ -20,14 +22,15 @@ import (
)
const (
tplRepoSettingsPages templates.TplName = "repo/settings/pages"
tplRepoSettingsPagesBrand templates.TplName = "repo/settings/pages_brand"
tplRepoSettingsPagesHero templates.TplName = "repo/settings/pages_hero"
tplRepoSettingsPagesContent templates.TplName = "repo/settings/pages_content"
tplRepoSettingsPagesSocial templates.TplName = "repo/settings/pages_social"
tplRepoSettingsPagesPricing templates.TplName = "repo/settings/pages_pricing"
tplRepoSettingsPagesFooter templates.TplName = "repo/settings/pages_footer"
tplRepoSettingsPagesTheme templates.TplName = "repo/settings/pages_theme"
tplRepoSettingsPages templates.TplName = "repo/settings/pages"
tplRepoSettingsPagesBrand templates.TplName = "repo/settings/pages_brand"
tplRepoSettingsPagesHero templates.TplName = "repo/settings/pages_hero"
tplRepoSettingsPagesContent templates.TplName = "repo/settings/pages_content"
tplRepoSettingsPagesSocial templates.TplName = "repo/settings/pages_social"
tplRepoSettingsPagesPricing templates.TplName = "repo/settings/pages_pricing"
tplRepoSettingsPagesFooter templates.TplName = "repo/settings/pages_footer"
tplRepoSettingsPagesTheme templates.TplName = "repo/settings/pages_theme"
tplRepoSettingsPagesLanguages templates.TplName = "repo/settings/pages_languages"
)
// getPagesLandingConfig loads the landing page configuration
@@ -489,6 +492,248 @@ func PagesThemePost(ctx *context.Context) {
ctx.Redirect(ctx.Repo.Repository.Link() + "/settings/pages/theme")
}
// TranslationView is a flattened view of a translation for the settings UI
type TranslationView struct {
Headline string
Subheadline string
PrimaryCTA string
SecondaryCTA string
CTAHeadline string
CTASubheadline string
CTAButton string
}
// parseTranslationView parses a PagesTranslation into a flat view for the template
func parseTranslationView(t *pages_model.Translation) *TranslationView {
if t == nil || t.ConfigJSON == "" {
return nil
}
var overlay map[string]any
if err := json.Unmarshal([]byte(t.ConfigJSON), &overlay); err != nil {
return nil
}
view := &TranslationView{}
if hero, ok := overlay["hero"].(map[string]any); ok {
if v, ok := hero["headline"].(string); ok {
view.Headline = v
}
if v, ok := hero["subheadline"].(string); ok {
view.Subheadline = v
}
if cta, ok := hero["primary_cta"].(map[string]any); ok {
if v, ok := cta["label"].(string); ok {
view.PrimaryCTA = v
}
}
if cta, ok := hero["secondary_cta"].(map[string]any); ok {
if v, ok := cta["label"].(string); ok {
view.SecondaryCTA = v
}
}
}
if ctaSec, ok := overlay["cta_section"].(map[string]any); ok {
if v, ok := ctaSec["headline"].(string); ok {
view.CTAHeadline = v
}
if v, ok := ctaSec["subheadline"].(string); ok {
view.CTASubheadline = v
}
if btn, ok := ctaSec["button"].(map[string]any); ok {
if v, ok := btn["label"].(string); ok {
view.CTAButton = v
}
}
}
return view
}
// buildTranslationJSON builds a JSON overlay string from form values
func buildTranslationJSON(ctx *context.Context) string {
overlay := map[string]any{}
hero := map[string]any{}
if v := ctx.FormString("trans_headline"); v != "" {
hero["headline"] = v
}
if v := ctx.FormString("trans_subheadline"); v != "" {
hero["subheadline"] = v
}
if v := ctx.FormString("trans_primary_cta"); v != "" {
hero["primary_cta"] = map[string]any{"label": v}
}
if v := ctx.FormString("trans_secondary_cta"); v != "" {
hero["secondary_cta"] = map[string]any{"label": v}
}
if len(hero) > 0 {
overlay["hero"] = hero
}
ctaSec := map[string]any{}
if v := ctx.FormString("trans_cta_headline"); v != "" {
ctaSec["headline"] = v
}
if v := ctx.FormString("trans_cta_subheadline"); v != "" {
ctaSec["subheadline"] = v
}
if v := ctx.FormString("trans_cta_button"); v != "" {
ctaSec["button"] = map[string]any{"label": v}
}
if len(ctaSec) > 0 {
overlay["cta_section"] = ctaSec
}
if len(overlay) == 0 {
return ""
}
data, _ := json.Marshal(overlay)
return string(data)
}
func PagesLanguages(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("repo.settings.pages.languages")
ctx.Data["PageIsSettingsPages"] = true
ctx.Data["PageIsSettingsPagesLanguages"] = true
setCommonPagesData(ctx)
config := getPagesLandingConfig(ctx)
ctx.Data["LanguageNames"] = pages_module.LanguageDisplayNames()
// Build a function to check if a language is enabled
enabledLangs := config.I18n.Languages
ctx.Data["IsLangEnabled"] = func(code string) bool {
return slices.Contains(enabledLangs, code)
}
// Load translations into a map[lang]*TranslationView
translationMap := make(map[string]*TranslationView)
translations, err := pages_model.GetTranslationsByRepoID(ctx, ctx.Repo.Repository.ID)
if err == nil {
for _, t := range translations {
translationMap[t.Lang] = parseTranslationView(t)
}
}
ctx.Data["TranslationMap"] = translationMap
ctx.HTML(http.StatusOK, tplRepoSettingsPagesLanguages)
}
func PagesLanguagesPost(ctx *context.Context) {
action := ctx.FormString("action")
config := getPagesLandingConfig(ctx)
switch action {
case "update_i18n":
config.I18n.DefaultLang = ctx.FormString("default_lang")
if config.I18n.DefaultLang == "" {
config.I18n.DefaultLang = "en"
}
selectedLangs := ctx.Req.Form["languages"]
// Ensure default language is always in the list
if !slices.Contains(selectedLangs, config.I18n.DefaultLang) {
selectedLangs = append([]string{config.I18n.DefaultLang}, selectedLangs...)
}
config.I18n.Languages = selectedLangs
if err := savePagesLandingConfig(ctx, config); err != nil {
ctx.ServerError("SavePagesConfig", err)
return
}
ctx.Flash.Success(ctx.Tr("repo.settings.pages.languages_saved"))
case "save_translation":
targetLang := ctx.FormString("target_lang")
if targetLang == "" {
ctx.Flash.Error("Language is required")
break
}
configJSON := buildTranslationJSON(ctx)
if configJSON == "" {
ctx.Flash.Warning(ctx.Tr("repo.settings.pages.translation_empty"))
break
}
existing, err := pages_model.GetTranslation(ctx, ctx.Repo.Repository.ID, targetLang)
if err != nil {
ctx.ServerError("GetTranslation", err)
return
}
if existing != nil {
existing.ConfigJSON = configJSON
existing.AutoGenerated = false
if err := pages_model.UpdateTranslation(ctx, existing); err != nil {
ctx.ServerError("UpdateTranslation", err)
return
}
} else {
t := &pages_model.Translation{
RepoID: ctx.Repo.Repository.ID,
Lang: targetLang,
ConfigJSON: configJSON,
}
if err := pages_model.CreateTranslation(ctx, t); err != nil {
ctx.ServerError("CreateTranslation", err)
return
}
}
ctx.Flash.Success(ctx.Tr("repo.settings.pages.translation_saved"))
case "delete_translation":
targetLang := ctx.FormString("target_lang")
if err := pages_model.DeleteTranslation(ctx, ctx.Repo.Repository.ID, targetLang); err != nil {
ctx.ServerError("DeleteTranslation", err)
return
}
ctx.Flash.Success(ctx.Tr("repo.settings.pages.translation_deleted"))
case "ai_translate":
targetLang := ctx.FormString("target_lang")
if targetLang == "" {
ctx.Flash.Error("Language is required")
break
}
translated, err := pages_service.TranslateLandingPageContent(ctx, ctx.Repo.Repository, config, targetLang)
if err != nil {
log.Error("AI translation failed: %v", err)
ctx.Flash.Error(fmt.Sprintf("AI translation failed: %v", err))
break
}
// Save or update the translation
existing, err := pages_model.GetTranslation(ctx, ctx.Repo.Repository.ID, targetLang)
if err != nil {
ctx.ServerError("GetTranslation", err)
return
}
if existing != nil {
existing.ConfigJSON = translated
existing.AutoGenerated = true
if err := pages_model.UpdateTranslation(ctx, existing); err != nil {
ctx.ServerError("UpdateTranslation", err)
return
}
} else {
t := &pages_model.Translation{
RepoID: ctx.Repo.Repository.ID,
Lang: targetLang,
ConfigJSON: translated,
AutoGenerated: true,
}
if err := pages_model.CreateTranslation(ctx, t); err != nil {
ctx.ServerError("CreateTranslation", err)
return
}
}
ctx.Flash.Success(ctx.Tr("repo.settings.pages.ai_translate_success"))
}
ctx.Redirect(ctx.Repo.Repository.Link() + "/settings/pages/languages")
}
// loadRawReadme loads the raw README content from the repository for AI consumption
func loadRawReadme(ctx *context.Context, repo *repo_model.Repository) string {
gitRepo, err := git.OpenRepository(ctx, repo.RepoPath())

View File

@@ -1339,6 +1339,7 @@ func registerWebRoutes(m *web.Router) {
m.Combo("/pricing").Get(repo_setting.PagesPricing).Post(repo_setting.PagesPricingPost)
m.Combo("/footer").Get(repo_setting.PagesFooter).Post(repo_setting.PagesFooterPost)
m.Combo("/theme").Get(repo_setting.PagesTheme).Post(repo_setting.PagesThemePost)
m.Combo("/languages").Get(repo_setting.PagesLanguages).Post(repo_setting.PagesLanguagesPost)
})
m.Group("/actions/general", func() {
m.Get("", repo_setting.ActionsGeneralSettings)

View File

@@ -7,6 +7,7 @@ import (
"context"
"errors"
"fmt"
"strconv"
"strings"
repo_model "code.gitcaddy.com/server/v3/models/repo"
@@ -34,8 +35,8 @@ type aiGeneratedConfig struct {
} `json:"secondary_cta"`
} `json:"hero"`
Stats []pages_module.StatConfig `json:"stats"`
ValueProps []pages_module.ValuePropConfig `json:"value_props"`
Features []pages_module.FeatureConfig `json:"features"`
ValueProps []pages_module.ValuePropConfig `json:"value_props"`
Features []pages_module.FeatureConfig `json:"features"`
CTASection struct {
Headline string `json:"headline"`
Subheadline string `json:"subheadline"`
@@ -66,9 +67,9 @@ func GenerateLandingPageContent(ctx context.Context, repo *repo_model.Repository
"repo_description": repo.Description,
"repo_url": repoURL,
"topics": topics,
"primary_language": repo.PrimaryLanguage,
"stars": fmt.Sprintf("%d", repo.NumStars),
"forks": fmt.Sprintf("%d", repo.NumForks),
"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.
@@ -165,6 +166,14 @@ Guidelines:
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
@@ -173,3 +182,103 @@ func truncateReadme(readme string) string {
}
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)
}

View File

@@ -1,10 +1,13 @@
<!DOCTYPE html>
<html lang="{{ctx.Locale.Lang}}" data-theme="{{ctx.CurrentWebTheme.InternalName}}">
<html lang="{{if .ActiveLang}}{{.ActiveLang}}{{else}}{{ctx.Locale.Lang}}{{end}}" data-theme="{{ctx.CurrentWebTheme.InternalName}}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{if .Config.Hero.Headline}}{{.Config.Hero.Headline}}{{else}}{{.Repository.Name}}{{end}} - {{.Repository.Owner.Name}}</title>
<meta name="description" content="{{if .Config.Hero.Subheadline}}{{.Config.Hero.Subheadline}}{{else}}{{.Repository.Description}}{{end}}">
{{if .LangSwitcherEnabled}}{{range .AvailableLanguages}}
<link rel="alternate" hreflang="{{.}}" href="?lang={{.}}">{{end}}
{{end}}
{{if .Config.Brand.FaviconURL}}
<link rel="icon" href="{{.Config.Brand.FaviconURL}}">
{{else}}
@@ -169,6 +172,49 @@
margin: 4px 0;
}
.pages-footer-powered a { color: #79b8ff; }
/* Language switcher */
.pages-lang-switcher {
position: relative;
display: inline-block;
}
.pages-lang-btn {
background: none;
border: 1px solid currentColor;
border-radius: 6px;
padding: 4px 10px;
font-size: 0.8rem;
cursor: pointer;
color: inherit;
display: flex;
align-items: center;
gap: 6px;
opacity: 0.8;
}
.pages-lang-btn:hover { opacity: 1; }
.pages-lang-dropdown {
display: none;
position: absolute;
top: 100%;
right: 0;
background: #fff;
border: 1px solid #e1e4e8;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
min-width: 140px;
z-index: 200;
margin-top: 4px;
overflow: hidden;
}
.pages-lang-dropdown.open { display: block; }
.pages-lang-option {
display: block;
padding: 8px 14px;
color: #24292e;
text-decoration: none;
font-size: 0.85rem;
}
.pages-lang-option:hover { background: #f6f8fa; }
.pages-lang-option.active { font-weight: 600; color: var(--pages-primary); }
</style>
</head>
<body class="pages-body">

View File

@@ -1142,6 +1142,18 @@
Repo
</a>
{{end}}
{{if .LangSwitcherEnabled}}
<div class="pages-lang-switcher">
<button class="pages-lang-btn" onclick="this.nextElementSibling.classList.toggle('open')">
{{svg "octicon-globe" 14}} {{index $.LanguageNames .ActiveLang}}
</button>
<div class="pages-lang-dropdown">
{{range .AvailableLanguages}}
<a href="?lang={{.}}" class="pages-lang-option{{if eq . $.ActiveLang}} active{{end}}">{{index $.LanguageNames .}}</a>
{{end}}
</div>
</div>
{{end}}
{{if .Config.Hero.PrimaryCTA.Label}}
<a href="{{.Config.Hero.PrimaryCTA.URL}}" class="nb-btn-primary small">
{{.Config.Hero.PrimaryCTA.Label}}

View File

@@ -21,6 +21,18 @@
<img src="/assets/img/gitcaddy-icon.svg" width="16" height="16" alt="GitCaddy"> View Source
</a>
{{end}}
{{if .LangSwitcherEnabled}}
<div class="pages-lang-switcher">
<button class="pages-lang-btn" onclick="this.nextElementSibling.classList.toggle('open')">
{{svg "octicon-globe" 14}} {{index $.LanguageNames .ActiveLang}}
</button>
<div class="pages-lang-dropdown">
{{range .AvailableLanguages}}
<a href="?lang={{.}}" class="pages-lang-option{{if eq . $.ActiveLang}} active{{end}}">{{index $.LanguageNames .}}</a>
{{end}}
</div>
</div>
{{end}}
</div>
</nav>
</div>

View File

@@ -1009,6 +1009,18 @@
Repository
</a>
{{end}}
{{if .LangSwitcherEnabled}}
<div class="pages-lang-switcher">
<button class="pages-lang-btn" onclick="this.nextElementSibling.classList.toggle('open')">
{{svg "octicon-globe" 14}} {{index $.LanguageNames .ActiveLang}}
</button>
<div class="pages-lang-dropdown">
{{range .AvailableLanguages}}
<a href="?lang={{.}}" class="pages-lang-option{{if eq . $.ActiveLang}} active{{end}}">{{index $.LanguageNames .}}</a>
{{end}}
</div>
</div>
{{end}}
</div>
<button class="ea-mobile-toggle" onclick="document.getElementById('ea-mobile-nav').classList.toggle('open')">Menu</button>
</nav>

View File

@@ -1000,6 +1000,18 @@
Repository
</a>
{{end}}
{{if .LangSwitcherEnabled}}
<div class="pages-lang-switcher">
<button class="pages-lang-btn" onclick="this.nextElementSibling.classList.toggle('open')">
{{svg "octicon-globe" 14}} {{index $.LanguageNames .ActiveLang}}
</button>
<div class="pages-lang-dropdown">
{{range .AvailableLanguages}}
<a href="?lang={{.}}" class="pages-lang-option{{if eq . $.ActiveLang}} active{{end}}">{{index $.LanguageNames .}}</a>
{{end}}
</div>
</div>
{{end}}
</div>
<button class="osh-menu-toggle" onclick="this.parentElement.querySelector('.osh-nav-links').style.display=this.parentElement.querySelector('.osh-nav-links').style.display==='flex'?'none':'flex'">
{{svg "octicon-three-bars" 20}}

View File

@@ -1117,6 +1117,18 @@
Repository
</a>
{{end}}
{{if .LangSwitcherEnabled}}
<div class="pages-lang-switcher">
<button class="pages-lang-btn" onclick="this.nextElementSibling.classList.toggle('open')">
{{svg "octicon-globe" 14}} {{index $.LanguageNames .ActiveLang}}
</button>
<div class="pages-lang-dropdown">
{{range .AvailableLanguages}}
<a href="?lang={{.}}" class="pages-lang-option{{if eq . $.ActiveLang}} active{{end}}">{{index $.LanguageNames .}}</a>
{{end}}
</div>
</div>
{{end}}
<a href="{{if .Config.Hero.PrimaryCTA.URL}}{{.Config.Hero.PrimaryCTA.URL}}{{else}}{{.RepoURL}}{{end}}" class="gm-btn-primary" data-cta="primary" style="padding: 10px 20px; font-size: 13px;">
<span>{{if .Config.Hero.PrimaryCTA.Label}}{{.Config.Hero.PrimaryCTA.Label}}{{else}}Get Started{{end}}</span>
</a>

View File

@@ -0,0 +1,118 @@
{{template "repo/settings/layout_head" (dict "ctxData" . "pageClass" "repository settings pages")}}
<div class="user-main-content twelve wide column">
<h4 class="ui top attached header">
{{ctx.Locale.Tr "repo.settings.pages.languages"}}
</h4>
<div class="ui attached segment">
<form class="ui form" method="post">
{{.CsrfTokenHtml}}
<input type="hidden" name="action" value="update_i18n">
<div class="field">
<label>{{ctx.Locale.Tr "repo.settings.pages.default_lang"}}</label>
<select name="default_lang" class="ui dropdown">
{{range $code, $name := .LanguageNames}}
<option value="{{$code}}" {{if eq $code $.Config.I18n.DefaultLang}}selected{{end}}>{{$name}}</option>
{{end}}
</select>
<p class="help">{{ctx.Locale.Tr "repo.settings.pages.default_lang_help"}}</p>
</div>
<div class="field">
<label>{{ctx.Locale.Tr "repo.settings.pages.enabled_languages"}}</label>
<p class="help">{{ctx.Locale.Tr "repo.settings.pages.enabled_languages_help"}}</p>
<div class="grouped fields">
{{range $code, $name := .LanguageNames}}
<div class="field">
<div class="ui checkbox">
<input type="checkbox" name="languages" value="{{$code}}" {{if $.IsLangEnabled $code}}checked{{end}}>
<label>{{$name}} ({{$code}})</label>
</div>
</div>
{{end}}
</div>
</div>
<div class="field">
<button class="ui primary button">{{ctx.Locale.Tr "repo.settings.pages.save_languages"}}</button>
</div>
</form>
</div>
{{if .Config.I18n.Languages}}
<h4 class="ui top attached header tw-mt-4">
{{ctx.Locale.Tr "repo.settings.pages.translations"}}
</h4>
<div class="ui attached segment">
{{range .Config.I18n.Languages}}
{{if ne . $.Config.I18n.DefaultLang}}
<div class="ui segment">
<div class="tw-flex tw-justify-between tw-items-center tw-mb-4">
<h5 class="tw-m-0">{{index $.LanguageNames .}} ({{.}})</h5>
<div>
{{if $.AIEnabled}}
<form method="post" class="tw-inline-block">
{{$.CsrfTokenHtml}}
<input type="hidden" name="action" value="ai_translate">
<input type="hidden" name="target_lang" value="{{.}}">
<button class="ui purple tiny button">{{ctx.Locale.Tr "repo.settings.pages.ai_translate"}}</button>
</form>
{{end}}
{{if index $.TranslationMap .}}
<form method="post" class="tw-inline-block">
{{$.CsrfTokenHtml}}
<input type="hidden" name="action" value="delete_translation">
<input type="hidden" name="target_lang" value="{{.}}">
<button class="ui red tiny button">{{ctx.Locale.Tr "repo.settings.pages.delete_translation"}}</button>
</form>
{{end}}
</div>
</div>
<form class="ui form" method="post">
{{$.CsrfTokenHtml}}
<input type="hidden" name="action" value="save_translation">
<input type="hidden" name="target_lang" value="{{.}}">
{{$trans := index $.TranslationMap .}}
<div class="field">
<label>{{ctx.Locale.Tr "repo.settings.pages.trans_headline"}}</label>
<input name="trans_headline" value="{{if $trans}}{{$trans.Headline}}{{end}}" placeholder="{{$.Config.Hero.Headline}}">
</div>
<div class="field">
<label>{{ctx.Locale.Tr "repo.settings.pages.trans_subheadline"}}</label>
<input name="trans_subheadline" value="{{if $trans}}{{$trans.Subheadline}}{{end}}" placeholder="{{$.Config.Hero.Subheadline}}">
</div>
<div class="field">
<label>{{ctx.Locale.Tr "repo.settings.pages.trans_primary_cta"}}</label>
<input name="trans_primary_cta" value="{{if $trans}}{{$trans.PrimaryCTA}}{{end}}" placeholder="{{$.Config.Hero.PrimaryCTA.Label}}">
</div>
<div class="field">
<label>{{ctx.Locale.Tr "repo.settings.pages.trans_secondary_cta"}}</label>
<input name="trans_secondary_cta" value="{{if $trans}}{{$trans.SecondaryCTA}}{{end}}" placeholder="{{$.Config.Hero.SecondaryCTA.Label}}">
</div>
<div class="field">
<label>{{ctx.Locale.Tr "repo.settings.pages.trans_cta_headline"}}</label>
<input name="trans_cta_headline" value="{{if $trans}}{{$trans.CTAHeadline}}{{end}}" placeholder="{{$.Config.CTASection.Headline}}">
</div>
<div class="field">
<label>{{ctx.Locale.Tr "repo.settings.pages.trans_cta_subheadline"}}</label>
<input name="trans_cta_subheadline" value="{{if $trans}}{{$trans.CTASubheadline}}{{end}}" placeholder="{{$.Config.CTASection.Subheadline}}">
</div>
<div class="field">
<label>{{ctx.Locale.Tr "repo.settings.pages.trans_cta_button"}}</label>
<input name="trans_cta_button" value="{{if $trans}}{{$trans.CTAButton}}{{end}}" placeholder="{{$.Config.CTASection.Button.Label}}">
</div>
<div class="field">
<button class="ui primary tiny button">{{ctx.Locale.Tr "repo.settings.pages.save_translation"}}</button>
</div>
</form>
</div>
{{end}}
{{end}}
</div>
{{end}}
</div>
{{template "repo/settings/layout_footer" .}}

View File

@@ -24,5 +24,8 @@
<a class="{{if .PageIsSettingsPagesTheme}}active {{end}}item" href="{{.RepoLink}}/settings/pages/theme">
{{ctx.Locale.Tr "repo.settings.pages.theme"}}
</a>
<a class="{{if .PageIsSettingsPagesLanguages}}active {{end}}item" href="{{.RepoLink}}/settings/pages/languages">
{{ctx.Locale.Tr "repo.settings.pages.languages"}}
</a>
</div>
{{end}}