From 5788123e00634c4bd7537b705143d9c7dce1c6dc Mon Sep 17 00:00:00 2001 From: logikonline Date: Sat, 7 Mar 2026 13:09:46 -0500 Subject: [PATCH] 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 --- models/migrations/migrations.go | 1 + models/migrations/v1_26/v367.go | 24 ++ models/pages/translation.go | 70 +++++ modules/pages/config.go | 30 +++ options/locale/locale_en-US.json | 22 ++ routers/web/pages/pages.go | 102 +++++++- routers/web/repo/setting/pages.go | 261 ++++++++++++++++++- routers/web/web.go | 1 + services/pages/generate.go | 119 ++++++++- templates/pages/base_head.tmpl | 48 +++- templates/pages/bold-marketing.tmpl | 12 + templates/pages/header.tmpl | 12 + templates/pages/minimalist-docs.tmpl | 12 + templates/pages/open-source-hero.tmpl | 12 + templates/pages/saas-conversion.tmpl | 12 + templates/repo/settings/pages_languages.tmpl | 118 +++++++++ templates/repo/settings/pages_nav.tmpl | 3 + 17 files changed, 844 insertions(+), 15 deletions(-) create mode 100644 models/migrations/v1_26/v367.go create mode 100644 models/pages/translation.go create mode 100644 templates/repo/settings/pages_languages.tmpl diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go index ff2fd8673f..f04960baac 100644 --- a/models/migrations/migrations.go +++ b/models/migrations/migrations.go @@ -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 } diff --git a/models/migrations/v1_26/v367.go b/models/migrations/v1_26/v367.go new file mode 100644 index 0000000000..d9afc314f3 --- /dev/null +++ b/models/migrations/v1_26/v367.go @@ -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)) +} diff --git a/models/pages/translation.go b/models/pages/translation.go new file mode 100644 index 0000000000..a5389327b7 --- /dev/null +++ b/models/pages/translation.go @@ -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 +} diff --git a/modules/pages/config.go b/modules/pages/config.go index 819c0b5e72..89b8801ad1 100644 --- a/modules/pages/config.go +++ b/modules/pages/config.go @@ -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"` diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index 4415bd6aa8..9739652fda 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -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", diff --git a/routers/web/pages/pages.go b/routers/web/pages/pages.go index 1ba4d63385..e2dadde9c3 100644 --- a/routers/web/pages/pages.go +++ b/routers/web/pages/pages.go @@ -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) diff --git a/routers/web/repo/setting/pages.go b/routers/web/repo/setting/pages.go index 419ef6b229..7245c989d5 100644 --- a/routers/web/repo/setting/pages.go +++ b/routers/web/repo/setting/pages.go @@ -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()) diff --git a/routers/web/web.go b/routers/web/web.go index 080e3aef43..dcf3d98319 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -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) diff --git a/services/pages/generate.go b/services/pages/generate.go index fd04926424..de77034e2f 100644 --- a/services/pages/generate.go +++ b/services/pages/generate.go @@ -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) +} diff --git a/templates/pages/base_head.tmpl b/templates/pages/base_head.tmpl index c9cb407fa3..b35f0e91f0 100644 --- a/templates/pages/base_head.tmpl +++ b/templates/pages/base_head.tmpl @@ -1,10 +1,13 @@ - + {{if .Config.Hero.Headline}}{{.Config.Hero.Headline}}{{else}}{{.Repository.Name}}{{end}} - {{.Repository.Owner.Name}} + {{if .LangSwitcherEnabled}}{{range .AvailableLanguages}} + {{end}} + {{end}} {{if .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); } diff --git a/templates/pages/bold-marketing.tmpl b/templates/pages/bold-marketing.tmpl index 25a1912cc1..e59565e97f 100644 --- a/templates/pages/bold-marketing.tmpl +++ b/templates/pages/bold-marketing.tmpl @@ -1142,6 +1142,18 @@ Repo {{end}} + {{if .LangSwitcherEnabled}} +
+ +
+ {{range .AvailableLanguages}} + {{index $.LanguageNames .}} + {{end}} +
+
+ {{end}} {{if .Config.Hero.PrimaryCTA.Label}} {{.Config.Hero.PrimaryCTA.Label}} diff --git a/templates/pages/header.tmpl b/templates/pages/header.tmpl index 52e0ebfce9..335a0499ca 100644 --- a/templates/pages/header.tmpl +++ b/templates/pages/header.tmpl @@ -21,6 +21,18 @@ GitCaddy View Source {{end}} + {{if .LangSwitcherEnabled}} +
+ +
+ {{range .AvailableLanguages}} + {{index $.LanguageNames .}} + {{end}} +
+
+ {{end}} diff --git a/templates/pages/minimalist-docs.tmpl b/templates/pages/minimalist-docs.tmpl index a2553af75d..2c4cd44342 100644 --- a/templates/pages/minimalist-docs.tmpl +++ b/templates/pages/minimalist-docs.tmpl @@ -1009,6 +1009,18 @@ Repository {{end}} + {{if .LangSwitcherEnabled}} +
+ +
+ {{range .AvailableLanguages}} + {{index $.LanguageNames .}} + {{end}} +
+
+ {{end}} diff --git a/templates/pages/open-source-hero.tmpl b/templates/pages/open-source-hero.tmpl index 67ab15f7ea..cabab30635 100644 --- a/templates/pages/open-source-hero.tmpl +++ b/templates/pages/open-source-hero.tmpl @@ -1000,6 +1000,18 @@ Repository {{end}} + {{if .LangSwitcherEnabled}} +
+ +
+ {{range .AvailableLanguages}} + {{index $.LanguageNames .}} + {{end}} +
+
+ {{end}} +
+ {{range .AvailableLanguages}} + {{index $.LanguageNames .}} + {{end}} +
+ + {{end}} {{if .Config.Hero.PrimaryCTA.Label}}{{.Config.Hero.PrimaryCTA.Label}}{{else}}Get Started{{end}} diff --git a/templates/repo/settings/pages_languages.tmpl b/templates/repo/settings/pages_languages.tmpl new file mode 100644 index 0000000000..efd4a97eb3 --- /dev/null +++ b/templates/repo/settings/pages_languages.tmpl @@ -0,0 +1,118 @@ +{{template "repo/settings/layout_head" (dict "ctxData" . "pageClass" "repository settings pages")}} +
+

+ {{ctx.Locale.Tr "repo.settings.pages.languages"}} +

+
+
+ {{.CsrfTokenHtml}} + + +
+ + +

{{ctx.Locale.Tr "repo.settings.pages.default_lang_help"}}

+
+ +
+ +

{{ctx.Locale.Tr "repo.settings.pages.enabled_languages_help"}}

+
+ {{range $code, $name := .LanguageNames}} +
+
+ + +
+
+ {{end}} +
+
+ +
+ +
+
+
+ + {{if .Config.I18n.Languages}} +

+ {{ctx.Locale.Tr "repo.settings.pages.translations"}} +

+
+ {{range .Config.I18n.Languages}} + {{if ne . $.Config.I18n.DefaultLang}} +
+
+
{{index $.LanguageNames .}} ({{.}})
+
+ {{if $.AIEnabled}} +
+ {{$.CsrfTokenHtml}} + + + +
+ {{end}} + {{if index $.TranslationMap .}} +
+ {{$.CsrfTokenHtml}} + + + +
+ {{end}} +
+
+ +
+ {{$.CsrfTokenHtml}} + + + + {{$trans := index $.TranslationMap .}} + +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ +
+
+
+ {{end}} + {{end}} +
+ {{end}} +
+{{template "repo/settings/layout_footer" .}} diff --git a/templates/repo/settings/pages_nav.tmpl b/templates/repo/settings/pages_nav.tmpl index 70d8f32fb5..1bae72548c 100644 --- a/templates/repo/settings/pages_nav.tmpl +++ b/templates/repo/settings/pages_nav.tmpl @@ -24,5 +24,8 @@ {{ctx.Locale.Tr "repo.settings.pages.theme"}} + + {{ctx.Locale.Tr "repo.settings.pages.languages"}} + {{end}}