From a2edcdabe7861a2a9f4ebbc423a7411be1e47090 Mon Sep 17 00:00:00 2001 From: logikonline Date: Sat, 7 Mar 2026 12:47:28 -0500 Subject: [PATCH] feat(pages): add AI-powered landing page content generator Enable automatic landing page content generation using AI: - Generate hero, features, stats, and CTAs from README - Blog section configuration in settings UI - Extract repository metadata for AI context - Merge generated content while preserving existing settings - User-friendly generation button in settings panel --- options/locale/locale_en-US.json | 10 ++ routers/web/repo/setting/pages.go | 69 ++++++++ services/pages/generate.go | 175 +++++++++++++++++++++ templates/repo/settings/pages.tmpl | 15 ++ templates/repo/settings/pages_content.tmpl | 20 +++ 5 files changed, 289 insertions(+) create mode 100644 services/pages/generate.go diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index f7289742a1..4415bd6aa8 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -4502,6 +4502,16 @@ "repo.settings.pages.nav_show_repository": "Show Repository link (View Source button)", "repo.settings.pages.nav_show_releases": "Show Releases link", "repo.settings.pages.nav_show_issues": "Show Issues link", + "repo.settings.pages.blog_section": "Blog Section", + "repo.settings.pages.blog_enabled_desc": "Show recent blog posts on the landing page", + "repo.settings.pages.blog_headline": "Blog Headline", + "repo.settings.pages.blog_subheadline": "Blog Subheadline", + "repo.settings.pages.blog_max_posts": "Maximum Posts to Show", + "repo.settings.pages.ai_generate": "AI Content Generator", + "repo.settings.pages.ai_generate_desc": "Automatically generate landing page content (headline, features, stats, CTAs) from your repository's README and metadata using AI.", + "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.vault": "Vault", "repo.vault.secrets": "Secrets", "repo.vault.new_secret": "New Secret", diff --git a/routers/web/repo/setting/pages.go b/routers/web/repo/setting/pages.go index f82c755910..419ef6b229 100644 --- a/routers/web/repo/setting/pages.go +++ b/routers/web/repo/setting/pages.go @@ -9,7 +9,10 @@ import ( "strings" repo_model "code.gitcaddy.com/server/v3/models/repo" + "code.gitcaddy.com/server/v3/modules/ai" + "code.gitcaddy.com/server/v3/modules/git" "code.gitcaddy.com/server/v3/modules/json" + "code.gitcaddy.com/server/v3/modules/log" pages_module "code.gitcaddy.com/server/v3/modules/pages" "code.gitcaddy.com/server/v3/modules/templates" "code.gitcaddy.com/server/v3/services/context" @@ -84,6 +87,7 @@ func setCommonPagesData(ctx *context.Context) { ctx.Data["PagesURL"] = pages_service.GetPagesURL(ctx.Repo.Repository) ctx.Data["PagesTemplates"] = pages_module.ValidTemplates() ctx.Data["PagesTemplateNames"] = pages_module.TemplateDisplayNames() + ctx.Data["AIEnabled"] = ai.IsEnabled() } // Pages shows the repository pages settings (General page) @@ -193,6 +197,30 @@ func PagesPost(ctx *context.Context) { } else { ctx.Flash.Success(ctx.Tr("repo.settings.pages.domain_verified")) } + case "ai_generate": + readme := loadRawReadme(ctx, ctx.Repo.Repository) + generated, err := pages_service.GenerateLandingPageContent(ctx, ctx.Repo.Repository, readme) + if err != nil { + log.Error("AI landing page generation failed: %v", err) + ctx.Flash.Error(ctx.Tr("repo.settings.pages.ai_generate_failed")) + ctx.Redirect(ctx.Repo.Repository.Link() + "/settings/pages") + return + } + // Merge AI-generated content into existing config, preserving settings + config := getPagesLandingConfig(ctx) + config.Brand.Name = generated.Brand.Name + config.Brand.Tagline = generated.Brand.Tagline + config.Hero = generated.Hero + config.Stats = generated.Stats + config.ValueProps = generated.ValueProps + config.Features = generated.Features + config.CTASection = generated.CTASection + config.SEO = generated.SEO + if err := savePagesLandingConfig(ctx, config); err != nil { + ctx.ServerError("SavePagesConfig", err) + return + } + ctx.Flash.Success(ctx.Tr("repo.settings.pages.ai_generate_success")) default: ctx.NotFound(nil) return @@ -267,6 +295,12 @@ func PagesContentPost(ctx *context.Context) { config.Navigation.ShowRepository = ctx.FormBool("nav_show_repository") config.Navigation.ShowReleases = ctx.FormBool("nav_show_releases") config.Navigation.ShowIssues = ctx.FormBool("nav_show_issues") + config.Blog.Enabled = ctx.FormBool("blog_enabled") + config.Blog.Headline = ctx.FormString("blog_headline") + config.Blog.Subheadline = ctx.FormString("blog_subheadline") + if maxPosts := ctx.FormInt("blog_max_posts"); maxPosts > 0 { + config.Blog.MaxPosts = maxPosts + } config.Stats = nil for i := range 10 { value := ctx.FormString(fmt.Sprintf("stat_value_%d", i)) @@ -454,3 +488,38 @@ func PagesThemePost(ctx *context.Context) { ctx.Flash.Success(ctx.Tr("repo.settings.pages.saved")) ctx.Redirect(ctx.Repo.Repository.Link() + "/settings/pages/theme") } + +// 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()) + if err != nil { + return "" + } + defer gitRepo.Close() + + branch := repo.DefaultBranch + if branch == "" { + branch = "main" + } + + commit, err := gitRepo.GetBranchCommit(branch) + if err != nil { + return "" + } + + for _, name := range []string{"README.md", "readme.md", "README", "README.txt"} { + entry, err := commit.GetTreeEntryByPath(name) + if err != nil { + continue + } + reader, err := entry.Blob().DataAsync() + if err != nil { + continue + } + content := make([]byte, entry.Blob().Size()) + _, _ = reader.Read(content) + reader.Close() + return string(content) + } + return "" +} diff --git a/services/pages/generate.go b/services/pages/generate.go new file mode 100644 index 0000000000..fd04926424 --- /dev/null +++ b/services/pages/generate.go @@ -0,0 +1,175 @@ +// Copyright 2026 MarketAlly. All rights reserved. +// SPDX-License-Identifier: MIT + +package pages + +import ( + "context" + "errors" + "fmt" + "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": repo.PrimaryLanguage, + "stars": fmt.Sprintf("%d", repo.NumStars), + "forks": fmt.Sprintf("%d", 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 +} + +// 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)" +} diff --git a/templates/repo/settings/pages.tmpl b/templates/repo/settings/pages.tmpl index 612c801d2c..3e222bc05b 100644 --- a/templates/repo/settings/pages.tmpl +++ b/templates/repo/settings/pages.tmpl @@ -27,6 +27,21 @@ + {{if .AIEnabled}} +
+
+ {{.CsrfTokenHtml}} + +
+ +

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

+
+
+ +
+
+ {{end}} +
diff --git a/templates/repo/settings/pages_content.tmpl b/templates/repo/settings/pages_content.tmpl index 8d99bc8df0..eecdfef27a 100644 --- a/templates/repo/settings/pages_content.tmpl +++ b/templates/repo/settings/pages_content.tmpl @@ -45,6 +45,26 @@ +
{{ctx.Locale.Tr "repo.settings.pages.blog_section"}}
+
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
{{ctx.Locale.Tr "repo.settings.pages.stats"}}
{{range $i, $stat := .Config.Stats}}