2
0

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
This commit is contained in:
2026-03-07 12:47:28 -05:00
parent 3a8bdd936c
commit a2edcdabe7
5 changed files with 289 additions and 0 deletions

View File

@@ -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",

View File

@@ -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 ""
}

175
services/pages/generate.go Normal file
View File

@@ -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)"
}

View File

@@ -27,6 +27,21 @@
</div>
</form>
{{if .AIEnabled}}
<div class="divider"></div>
<form class="ui form" method="post">
{{.CsrfTokenHtml}}
<input type="hidden" name="action" value="ai_generate">
<div class="field">
<label>{{ctx.Locale.Tr "repo.settings.pages.ai_generate"}}</label>
<p class="help">{{ctx.Locale.Tr "repo.settings.pages.ai_generate_desc"}}</p>
</div>
<div class="field">
<button class="ui purple button">{{ctx.Locale.Tr "repo.settings.pages.ai_generate_button"}}</button>
</div>
</form>
{{end}}
<div class="divider"></div>
<form class="ui form" method="post">

View File

@@ -45,6 +45,26 @@
</div>
</div>
<h5 class="ui dividing header">{{ctx.Locale.Tr "repo.settings.pages.blog_section"}}</h5>
<div class="inline field">
<div class="ui toggle checkbox">
<input type="checkbox" name="blog_enabled" {{if .Config.Blog.Enabled}}checked{{end}}>
<label>{{ctx.Locale.Tr "repo.settings.pages.blog_enabled_desc"}}</label>
</div>
</div>
<div class="field">
<label>{{ctx.Locale.Tr "repo.settings.pages.blog_headline"}}</label>
<input name="blog_headline" value="{{.Config.Blog.Headline}}" placeholder="Latest Posts">
</div>
<div class="field">
<label>{{ctx.Locale.Tr "repo.settings.pages.blog_subheadline"}}</label>
<input name="blog_subheadline" value="{{.Config.Blog.Subheadline}}" placeholder="Stay up to date with our latest news">
</div>
<div class="field">
<label>{{ctx.Locale.Tr "repo.settings.pages.blog_max_posts"}}</label>
<input name="blog_max_posts" type="number" min="1" max="12" value="{{if .Config.Blog.MaxPosts}}{{.Config.Blog.MaxPosts}}{{else}}3{{end}}">
</div>
<h5 class="ui dividing header">{{ctx.Locale.Tr "repo.settings.pages.stats"}}</h5>
<div id="stats-container">
{{range $i, $stat := .Config.Stats}}