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:
@@ -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",
|
||||
|
||||
@@ -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
175
services/pages/generate.go
Normal 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)"
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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}}
|
||||
|
||||
Reference in New Issue
Block a user